#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Mutation-side Python API for the scitex-todo task store.
Sits on top of ``_model.load_tasks`` / ``_model.save_tasks`` to give agents,
the CLI verbs, the MCP tools, and (later) the web GUI write handlers a
single, well-tested surface for:
add_task Append a new task to the store.
update_task Mutate fields of an existing task by id.
complete_task Convenience: set status='done' + stamp completion meta.
list_tasks Filter by scope / assignee / status.
summary Count tasks per status (and by scope/assignee).
Every mutation runs through :func:`_model.save_tasks`, which holds an
``fcntl.flock``-based mutex on a sibling lock file so two concurrent writers
cannot interleave (PHASE 1 prereq for the cross-host sync substrate — see
``GITIGNORED/ARCHITECTURE.md`` Req 2).
The filter functions are read-only and do NOT lock — they snapshot the
store via :func:`_model.load_tasks` and apply the filter in memory.
Design constraints
------------------
- **Generic** (Req 8): scope/assignee/status are free-form strings. The
helpers don't know what an "agent" is.
- **Centralized** (Req 3): the default store is whatever
:func:`_paths.resolve_tasks_path` returns; callers can override with an
explicit ``store=`` path. The user-scope default
(``~/.scitex/todo/tasks.yaml``) covers Req 7.
- **Shared with scopes** (Req 1): ``$SCITEX_TODO_SCOPE`` provides the
default value for ``list_tasks(scope=...)`` when the caller doesn't pass
one explicitly. Pass ``scope=""`` (empty string) to ignore the env
default and see everything.
"""
from __future__ import annotations
import datetime as _dt
import getpass
import os
from pathlib import Path
from ._model import (
VALID_STATUSES,
TaskValidationError,
_save_tasks_unlocked,
_store_lock,
load_tasks,
save_tasks,
)
from ._paths import resolve_tasks_path
#: Env var name an agent sets to scope its default `list_tasks` / `summary`
#: view. The CLI's `--scope` flag overrides this; pass `scope=""` in the
#: Python API to see the unfiltered store.
ENV_SCOPE = "SCITEX_TODO_SCOPE"
#: Env var name carrying the agent's identity. Used as the default
#: `completed_by` when :func:`complete_task` doesn't get an explicit `by=`.
ENV_AGENT = "SCITEX_TODO_AGENT"
[docs]
class TaskNotFoundError(KeyError):
"""Raised when an update/complete target id is not in the store."""
# --------------------------------------------------------------------------- #
# Internal helpers #
# --------------------------------------------------------------------------- #
def _resolved_store(store: str | Path | None) -> Path:
"""Resolve a store path argument through the precedence chain.
``None`` ⇒ apply the full resolution chain (`_paths.resolve_tasks_path`).
Explicit path ⇒ used as-is (must exist for reads; will be created for
fresh writes by :func:`_model.save_tasks`).
"""
return resolve_tasks_path(store) if store is None else Path(store).expanduser()
def _default_scope(arg: str | None) -> str | None:
"""Resolve a scope argument, honoring ``$SCITEX_TODO_SCOPE`` as the
default.
``None`` (caller didn't pass anything) → env var if set, else ``None``
(no filter).
Empty string ``""`` → caller explicitly opted out of filtering.
Non-empty string → used as-is.
"""
if arg is None:
env = os.environ.get(ENV_SCOPE)
return env if env else None
if arg == "":
return None
return arg
def _default_agent(arg: str | None) -> str:
"""Resolve a `completed_by` argument with the env→login→unknown chain.
Per ``GITIGNORED/QUESTIONS.md`` #2 the precedence is
``$SCITEX_TODO_AGENT`` → ``getpass.getuser()`` → ``"unknown"`` (final
fallback handles environments where login info isn't available).
"""
if arg:
return arg
env = os.environ.get(ENV_AGENT)
if env:
return env
try:
return getpass.getuser()
except Exception: # pragma: no cover — extremely rare environments
return "unknown"
def _utc_now_iso() -> str:
"""ISO-8601 UTC timestamp with second resolution and the ``Z`` suffix.
Trims the microseconds (the operator reads these on the board; second
resolution is plenty) and uses the canonical ``Z`` suffix rather than
``+00:00`` so the string round-trips losslessly through YAML.
"""
return (
_dt.datetime.now(_dt.timezone.utc)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z")
)
def _match(
task: dict,
*,
scope: str | None = None,
assignee: str | None = None,
status: str | None = None,
statuses: list[str] | None = None,
agent: str | None = None,
project: str | None = None,
host: str | None = None,
blocker: str | None = None,
kind: str | None = None,
id_prefix: str | None = None,
blocking_me: bool = False,
overdue: bool = False,
) -> bool:
"""String-equality + predicate filter. ``None`` / empty = no constraint.
Filter semantics (per PR #66 / ADR-0008 D2 + D10):
- ``status`` (single) and ``statuses`` (multi) are OR-combined: a
row matches if its status is in ``set([status]) ∪ set(statuses)``
(after dropping ``None``).
- ``blocker="__none"`` matches rows with no blocker field (the
explicit "no blocker named" filter the board's `/graph` uses).
- ``kind=None`` is NO filter; ``kind="task"`` matches both
explicit ``"task"`` AND ``absent`` rows (since absent ≡ "task"
per ADR-0002).
- ``id_prefix`` is a substring match on the front of ``id`` —
the cheap "find my project's rows" without remembering exact
ids.
- ``blocking_me`` is the BLOCKING-YOU predicate (board-v3 panel):
``status == "blocked" AND blocker == "operator-decision"``.
Composes with the other filters via AND.
"""
if scope is not None and task.get("scope") != scope:
return False
if assignee is not None and task.get("assignee") != assignee:
return False
if agent is not None and task.get("agent") != agent:
return False
if project is not None and task.get("project") != project:
return False
if host is not None and task.get("host") != host:
return False
if blocker is not None:
if blocker == "__none":
if task.get("blocker"):
return False
elif task.get("blocker") != blocker:
return False
if kind is not None:
eff = task.get("kind") or "task"
if eff != kind:
return False
if id_prefix and not str(task.get("id", "")).startswith(id_prefix):
return False
# Union the single + multi status constraints. None and empty list
# collapse to "no constraint." When BOTH are provided, the union is
# checked (so callers can extend an existing single-status default).
allowed: set[str] = set()
if status is not None:
allowed.add(status)
if statuses:
allowed.update(statuses)
if allowed and task.get("status") not in allowed:
return False
if blocking_me and not (
task.get("status") == "blocked"
and task.get("blocker") == "operator-decision"
):
return False
if overdue:
from ._model import is_overdue as _is_overdue
if not _is_overdue(task):
return False
return True
# --------------------------------------------------------------------------- #
# Public API #
# --------------------------------------------------------------------------- #
[docs]
def add_task(
store: str | Path | None = None,
*,
id: str,
title: str,
status: str = "pending",
scope: str | None = None,
assignee: str | None = None,
priority: int | None = None,
parent: str | None = None,
note: str | None = None,
depends_on: list[str] | None = None,
blocks: list[str] | None = None,
repo: str | None = None,
**extras,
) -> dict:
"""Append a new task to ``store`` and persist via :func:`save_tasks`.
Returns the inserted task mapping (a fresh dict, not the underlying
YAML node) for convenient round-trip use by callers — the CLI prints
it, the MCP tools serialize it as the JSON result.
The ``**extras`` keyword catches operator-co-designed Task dataclass
fields (``task`` / ``project`` / ``host`` / ``agent`` / ``goal`` /
``last_activity`` / ``blocker`` / ``pr_url`` / ``issue_url`` / ``kind``
+ compute metadata ``job_id`` / ``command`` / ``started_at`` /
``finished_at``) without an explosion of named parameters. ``None``
values are dropped; non-``None`` values flow into the new task dict
and the writer's validator gates closed enums (``status`` / ``kind``
/ ``blocker``) — typos raise ``TaskValidationError`` with the bad
value and the valid set. Unknown keys are accepted at this layer
(forward-compat); the validator decides whether they're shape-valid.
Raises
------
TaskValidationError
On duplicate id or any other structural fault — `save_tasks`
re-runs the full validation gate before touching disk.
"""
resolved = _resolved_store(store)
resolved.parent.mkdir(parents=True, exist_ok=True)
new: dict = {"id": id, "title": title, "status": status}
# D11 partial-fix (ADR-0008): auto-stamp ``created_at`` +
# ``last_activity`` at insert time. ``created_at`` is the immutable
# insert stamp; ``last_activity`` starts equal and ticks on every
# subsequent successful update_task. Callers can override by passing
# the field explicitly (e.g. importers replaying historical state).
_stamp = _utc_now_iso()
new["created_at"] = _stamp
new["last_activity"] = _stamp
if scope is not None:
new["scope"] = scope
if assignee is not None:
new["assignee"] = assignee
if priority is not None:
new["priority"] = priority
if parent is not None:
new["parent"] = parent
if note is not None:
new["note"] = note
if depends_on is not None:
new["depends_on"] = list(depends_on)
if blocks is not None:
new["blocks"] = list(blocks)
if repo is not None:
new["repo"] = repo
# Operator-co-designed surface (TG 9667) + compute metadata
# (ADR-0002). Forwarded through **extras so callers don't have to
# match a long explicit parameter list and the writer's validator
# gates the closed enums.
for key, value in extras.items():
if value is None:
continue
new[key] = value
# Lock for the FULL read-modify-write — without this, two concurrent
# writers each load a stale snapshot and the second `save_tasks` call
# silently clobbers the first writer's insert. See
# tests/scitex_todo/test__store.py::test_two_concurrent_writers...
with _store_lock(resolved):
tasks = load_tasks(resolved) if resolved.exists() else []
# WIP-validation gate (operator standing direction via lead a2a
# `d99b8de6839d46e586e4ee692f43c1d9` + ``5acfbb5d0db44db8a7fa4f70c399d539``,
# 2026-06-12). Count the new task's agent's open tasks (status NOT
# in {done, goal}) BEFORE the append; WARN to stderr at the
# threshold, HARD REFUSE (raise) at 2x. Goal-tier umbrellas are
# excluded — they accumulate by design, not by WIP-failure.
# Direct YAML hand-edits bypass this gate by design (CLI/MCP
# path enforcement only — operator wants the CLI/MCP path made
# fat so hand-edits are unnecessary, not policed).
agent_for_wip = new.get("agent")
if agent_for_wip and new.get("status") not in _wip_excluded_statuses():
from ._throughput import evaluate_wip
rep = evaluate_wip(tasks, agent_for_wip)
if rep is not None and rep.is_refuse:
raise TaskValidationError(
f"WIP gate refuses add: {rep.agent} already has "
f"{rep.open_count} open tasks (>= 2 × limit "
f"{rep.limit}). Close existing tasks before adding "
f"more — see SCITEX_TODO_WIP_LIMIT env."
)
if rep is not None and rep.is_warn:
import sys
print(
f"WARN: WIP gate — {rep.agent} now has "
f"{rep.open_count + 1} open tasks (limit {rep.limit}). "
f"Completion is not keeping up with creation; close "
f"existing before adding more.",
file=sys.stderr,
)
tasks.append(new)
_save_tasks_unlocked(tasks, resolved)
return dict(new)
def _wip_excluded_statuses() -> frozenset[str]:
"""Re-export from ``_throughput`` so the gate's exclusion list stays
a single source of truth (lead-confirmed ``5acfbb5d`` — exclude
``done`` + ``goal``)."""
from ._throughput import WIP_EXCLUDED_STATUSES
return WIP_EXCLUDED_STATUSES
[docs]
def update_task(
store: str | Path | None = None,
task_id: str | None = None,
**fields,
) -> dict:
"""Update fields of the task with id ``task_id``; return the merged dict.
Any keyword argument becomes a field on the task. Passing ``None`` for
a field DELETES it (matches the operator's mental model: "clear the
scope" = `update_task(..., scope=None)`). To leave a field untouched,
just omit it.
Raises
------
TaskNotFoundError
If no task matches ``task_id``.
TaskValidationError
If the resulting mutation is structurally invalid.
"""
if not task_id:
raise TypeError("update_task() requires a non-empty task_id")
resolved = _resolved_store(store)
with _store_lock(resolved):
tasks = load_tasks(resolved)
for task in tasks:
if task.get("id") == task_id:
for key, value in fields.items():
if value is None:
task.pop(key, None)
else:
task[key] = value
# D11 partial-fix (ADR-0008): auto-stamp ``last_activity``
# on every successful mutation (drives the recency-color
# signal on the board). Skip if the caller passed an
# explicit ``last_activity`` field this call — their
# value wins over the auto-stamp.
if "last_activity" not in fields:
task["last_activity"] = _utc_now_iso()
_save_tasks_unlocked(tasks, resolved)
return dict(task)
raise TaskNotFoundError(f"task id {task_id!r} not found in {resolved}")
[docs]
def complete_task(
store: str | Path | None = None,
task_id: str | None = None,
*,
by: str | None = None,
) -> dict:
"""Mark ``task_id`` as ``done`` and stamp ``_log_meta.completed_{at,by}``.
Idempotent per ``GITIGNORED/QUESTIONS.md`` #3: re-completing a
``done`` task is a no-op (timestamps stay frozen from the first
completion). Pass ``by=`` to override the
``$SCITEX_TODO_AGENT`` → ``$USER`` → ``"unknown"`` precedence chain.
Returns the (post-mutation) task mapping.
Raises
------
TaskNotFoundError
If no task matches ``task_id``.
"""
if not task_id:
raise TypeError("complete_task() requires a non-empty task_id")
resolved = _resolved_store(store)
with _store_lock(resolved):
tasks = load_tasks(resolved)
for task in tasks:
if task.get("id") == task_id:
if task.get("status") == "done":
# Idempotent: don't refresh the stamp, just return.
return dict(task)
task["status"] = "done"
log_meta = task.get("_log_meta")
if not isinstance(log_meta, dict):
log_meta = {}
task["_log_meta"] = log_meta
log_meta["completed_at"] = _utc_now_iso()
log_meta["completed_by"] = _default_agent(by)
_save_tasks_unlocked(tasks, resolved)
return dict(task)
raise TaskNotFoundError(f"task id {task_id!r} not found in {resolved}")
[docs]
def list_tasks(
store: str | Path | None = None,
*,
scope: str | None = None,
assignee: str | None = None,
status: str | None = None,
# PR #66 additions per ADR-0008 D2 / D10:
statuses: list[str] | None = None,
agent: str | None = None,
project: str | None = None,
host: str | None = None,
blocker: str | None = None,
kind: str | None = None,
id_prefix: str | None = None,
blocking_me: bool = False,
overdue: bool = False,
) -> list[dict]:
"""Snapshot the store, then filter by any combination of fields.
Filter semantics:
- ``scope=None`` (default): use ``$SCITEX_TODO_SCOPE`` if set, else
no filter. ``scope=""`` opts out of the env default explicitly.
- ``assignee`` / ``agent`` / ``project`` / ``host`` / ``status``:
``None`` = no filter; any string = exact match. (Generic Req 8 —
no fuzzy / glob; callers compose.)
- ``statuses`` (list) AND ``status`` (single) are OR-combined.
- ``blocker="__none"`` matches rows with no blocker field; any other
value is an exact match (closed-enum gating at the CLI layer).
- ``kind="task"`` matches both explicit ``"task"`` AND absent rows
(since absent ≡ ``"task"`` per ADR-0002).
- ``id_prefix`` matches the front of ``id`` (cheap project-rollup
lookup without exact id).
- ``blocking_me=True`` is the board's BLOCKING-YOU predicate
(``status == "blocked" AND blocker == "operator-decision"``);
composes with the other filters via AND.
The returned list contains fresh dicts, safe to mutate without
affecting the on-disk store (no save here).
"""
resolved = _resolved_store(store)
tasks = load_tasks(resolved)
scope_eff = _default_scope(scope)
return [
dict(t)
for t in tasks
if _match(
t,
scope=scope_eff,
assignee=assignee,
status=status,
statuses=statuses,
agent=agent,
project=project,
host=host,
blocker=blocker,
kind=kind,
id_prefix=id_prefix,
blocking_me=blocking_me,
overdue=overdue,
)
]
[docs]
def summarize_tasks(
store: str | Path | None = None,
*,
scope: str | None = None,
assignee: str | None = None,
) -> dict:
"""Return numeric progress counts grouped by status, scope, assignee.
Output shape (always present keys):
::
{
"store": "/abs/path/to/tasks.yaml",
"total": int,
"by_status": {<status>: int, ...}, # one key per VALID_STATUSES
"by_scope": {<scope|"">: int, ...},
"by_assignee": {<assignee|"">: int, ...},
}
Tasks with no scope / assignee bucket under the empty string ``""``.
The ``by_status`` map is densified to all :data:`VALID_STATUSES` so
consumers (web UI, progress widgets) don't have to special-case
zero-count keys.
"""
resolved = _resolved_store(store)
tasks = load_tasks(resolved)
scope_eff = _default_scope(scope)
by_status: dict[str, int] = {s: 0 for s in VALID_STATUSES}
by_scope: dict[str, int] = {}
by_assignee: dict[str, int] = {}
total = 0
for task in tasks:
if not _match(task, scope=scope_eff, assignee=assignee, status=None):
continue
total += 1
st = task.get("status")
if st in by_status:
by_status[st] += 1
sc = task.get("scope") or ""
by_scope[sc] = by_scope.get(sc, 0) + 1
asg = task.get("assignee") or ""
by_assignee[asg] = by_assignee.get(asg, 0) + 1
return {
"store": str(resolved),
"total": total,
"by_status": by_status,
"by_scope": by_scope,
"by_assignee": by_assignee,
}
[docs]
def resolve_store(store: str | Path | None = None) -> dict:
"""Return the resolved task store path and the precedence chain.
Mirrors the data the `scitex-todo resolve-store` CLI verb and the
`resolve_store` MCP tool emit. Keeping a Python API by the same name
as the MCP tool satisfies audit §6 (Convention A: tool_name == api_name).
Output shape::
{
"resolved": "/abs/path/to/tasks.yaml",
"explicit": <the `store` arg you passed, or None>,
"env_tasks": <value of $SCITEX_TODO_TASKS, or None>,
"user_store": "/abs/path/to/~/.scitex/todo/tasks.yaml",
"bundled_example": "/abs/path/to/bundled/example.yaml",
"pkg_short": "scitex_todo",
"exists": bool,
}
"""
import os
from ._paths import (
ENV_TASKS,
PKG_SHORT,
_user_root,
bundled_example,
resolve_tasks_path,
)
resolved = resolve_tasks_path(store if isinstance(store, (str, type(None))) else str(store))
return {
"resolved": str(resolved),
"explicit": str(store) if store is not None else None,
"env_tasks": os.environ.get(ENV_TASKS),
"user_store": str(_user_root() / "tasks.yaml"),
"bundled_example": str(bundled_example()),
"pkg_short": PKG_SHORT,
"exists": Path(resolved).exists(),
}
def get_task(
store: str | Path | None = None,
task_id: str | None = None,
) -> dict:
"""Return a single task by id, or raise ``TaskNotFoundError``.
Companion to ``add_task`` / ``update_task`` / ``list_tasks`` — the
natural "read one" verb every CRUD surface expects but the Python
API was missing (PR #56 audit gap). The MCP wrapper exposes this as
``get_task`` per Convention A.
"""
from . import _model
tasks_path = _resolved_store(store)
if not task_id:
raise ValueError("get_task: 'task_id' is required")
with _model._store_lock(tasks_path):
tasks = _model.load_tasks(tasks_path)
for t in tasks:
if t.get("id") == task_id:
return dict(t)
raise TaskNotFoundError(f"task id {task_id!r} not found in {tasks_path}")
def delete_task(
store: str | Path | None = None,
task_id: str | None = None,
) -> dict:
"""Remove a task + scrub references to it. Returns the lossless
payload the client can pass to ``restore_task`` for Undo.
The board v3 Delete-with-Undo flow uses this via ``handlers/crud.py``;
exposing the same operation here lets MCP agents do the same delete +
later undo without round-tripping HTTP.
Returns ``{"removed": <full task dict>, "refs": [<refs scrubbed>]}``
where each ref is the id of another task whose depends_on / blocks /
parent pointed at the deleted task (the client passes this back to
restore_task to lossless-revert).
"""
from . import _model
tasks_path = _resolved_store(store)
if not task_id:
raise ValueError("delete_task: 'task_id' is required")
with _model._store_lock(tasks_path):
tasks = _model.load_tasks(tasks_path)
target = None
keep: list = []
for t in tasks:
if t.get("id") == task_id:
target = dict(t)
else:
keep.append(t)
if target is None:
raise TaskNotFoundError(f"task id {task_id!r} not found in {tasks_path}")
refs: list[str] = []
for t in keep:
mutated = False
if isinstance(t.get("depends_on"), list) and task_id in t["depends_on"]:
t["depends_on"] = [d for d in t["depends_on"] if d != task_id]
if not t["depends_on"]:
t.pop("depends_on", None)
mutated = True
if isinstance(t.get("blocks"), list) and task_id in t["blocks"]:
t["blocks"] = [b for b in t["blocks"] if b != task_id]
if not t["blocks"]:
t.pop("blocks", None)
mutated = True
if t.get("parent") == task_id:
t.pop("parent", None)
mutated = True
if mutated:
refs.append(t.get("id"))
_model._save_tasks_unlocked(keep, tasks_path)
return {"removed": target, "refs": refs}
def restore_task(
store: str | Path | None = None,
task: dict | None = None,
refs: list[str] | None = None,
) -> dict:
"""Undo a ``delete_task``: re-insert the task at its original id.
Idempotent on duplicate id — raises ``ValueError`` if the id is
already present (use ``update_task`` to mutate; this verb is the
Delete-Undo partner only).
"""
from . import _model
tasks_path = _resolved_store(store)
if not isinstance(task, dict) or not task.get("id"):
raise ValueError("restore_task: 'task' must be a dict with 'id'")
tid = task["id"]
with _model._store_lock(tasks_path):
tasks = _model.load_tasks(tasks_path)
if any(t.get("id") == tid for t in tasks):
raise ValueError(f"restore_task: id {tid!r} already present")
tasks.append(dict(task))
_model._save_tasks_unlocked(tasks, tasks_path)
# refs are descriptive (the client passes them through so callers can
# see which tasks had been mutated; we don't reverse-apply them since
# the depends_on / blocks values were just stripped, not stored).
return {"task": task, "refs": list(refs or [])}
def comment_task(
store: str | Path | None = None,
task_id: str | None = None,
text: str | None = None,
by: str | None = None,
) -> dict:
"""Append an entry to ``task.comments[]`` (the established Issue-
activity-log shape from skill 30, Gitea-compatible field).
`by` overrides the $SCITEX_TODO_AGENT → $USER precedence used by
add_task / complete_task.
"""
from . import _model
tasks_path = _resolved_store(store)
if not task_id:
raise ValueError("comment_task: 'task_id' is required")
if not text or not str(text).strip():
raise ValueError("comment_task: 'text' is required")
author = _default_agent(by)
entry = {
"author": author,
"ts": _utc_now_iso(),
"text": str(text),
}
with _model._store_lock(tasks_path):
tasks = _model.load_tasks(tasks_path)
target = None
for t in tasks:
if t.get("id") == task_id:
target = t
break
if target is None:
raise TaskNotFoundError(f"task id {task_id!r} not found in {tasks_path}")
comments = target.setdefault("comments", [])
comments.append(entry)
_model._save_tasks_unlocked(tasks, tasks_path)
return {"task_id": task_id, "comment": entry}
def set_edge(
store: str | Path | None = None,
action: str | None = None,
kind: str | None = None,
source: str | None = None,
target: str | None = None,
) -> dict:
"""Add or remove a depends_on / blocks edge.
``action`` in {"add", "remove"}. ``kind`` in {"depends_on", "blocks"}.
Mutates ``tasks[source][kind]`` (adding/removing ``target``).
"""
from . import _model
if action not in ("add", "remove"):
raise ValueError("set_edge: action must be 'add' or 'remove'")
if kind not in ("depends_on", "blocks"):
raise ValueError("set_edge: kind must be 'depends_on' or 'blocks'")
if not source or not target:
raise ValueError("set_edge: 'source' and 'target' are required")
if source == target:
raise ValueError("set_edge: self-edge is forbidden")
tasks_path = _resolved_store(store)
with _model._store_lock(tasks_path):
tasks = _model.load_tasks(tasks_path)
src_task = next((t for t in tasks if t.get("id") == source), None)
tgt_task = next((t for t in tasks if t.get("id") == target), None)
if src_task is None:
raise TaskNotFoundError(f"set_edge: unknown source id {source!r}")
if tgt_task is None:
raise TaskNotFoundError(f"set_edge: unknown target id {target!r}")
edges = src_task.get(kind) or []
if action == "add" and target not in edges:
edges = list(edges) + [target]
elif action == "remove":
edges = [e for e in edges if e != target]
if edges:
src_task[kind] = edges
else:
src_task.pop(kind, None)
_model._save_tasks_unlocked(tasks, tasks_path)
return {"action": action, "kind": kind, "source": source, "target": target}
def resolve_task(
store: str | Path | None = None,
task_id: str | None = None,
actor: str | None = None,
) -> dict:
"""Flip a task from ``status=blocked`` (typically ``blocker=operator-
decision``) to ``done`` and clear the blocker. Appends an audit
comment naming the actor.
Idempotent on already-resolved tasks (re-resolves are no-ops, just
log a "noop" comment).
"""
from . import _model
if not task_id:
raise ValueError("resolve_task: 'task_id' is required")
who = _default_agent(actor)
tasks_path = _resolved_store(store)
with _model._store_lock(tasks_path):
tasks = _model.load_tasks(tasks_path)
target = next((t for t in tasks if t.get("id") == task_id), None)
if target is None:
raise TaskNotFoundError(f"resolve_task: unknown id {task_id!r}")
was_done = target.get("status") == "done"
target["status"] = "done"
target.pop("blocker", None)
comments = target.setdefault("comments", [])
comments.append(
{
"author": who,
"ts": _utc_now_iso(),
"text": (
"[resolve (noop — already done)]"
if was_done
else "[RESOLVED via mcp.resolve_task] flipped status='blocked'->done, blocker cleared."
),
}
)
_model._save_tasks_unlocked(tasks, tasks_path)
return {"task_id": task_id, "actor": who, "task": dict(target)}
def reopen_task(
store: str | Path | None = None,
task_id: str | None = None,
by: str | None = None,
) -> dict:
"""Un-resolve a task — flip ``status=done`` back to ``blocked`` with
``blocker=operator-decision`` (the original LOUD halo state). Used
by the board v3 Resolve→Undo loop.
"""
from . import _model
if not task_id:
raise ValueError("reopen_task: 'task_id' is required")
who = _default_agent(by)
tasks_path = _resolved_store(store)
with _model._store_lock(tasks_path):
tasks = _model.load_tasks(tasks_path)
target = next((t for t in tasks if t.get("id") == task_id), None)
if target is None:
raise TaskNotFoundError(f"reopen_task: unknown id {task_id!r}")
target["status"] = "blocked"
target["blocker"] = "operator-decision"
comments = target.setdefault("comments", [])
comments.append(
{
"author": who,
"ts": _utc_now_iso(),
"text": "[REOPENED via mcp.reopen_task] flipped status='done'->blocked, blocker=operator-decision restored.",
}
)
_model._save_tasks_unlocked(tasks, tasks_path)
return {"task_id": task_id, "by": who, "task": dict(target)}
__all__ = [
"ENV_AGENT",
"ENV_SCOPE",
"TaskNotFoundError",
"TaskValidationError",
"add_task",
"comment_task",
"complete_task",
"delete_task",
"get_task",
"list_tasks",
"reopen_task",
"resolve_store",
"resolve_task",
"restore_task",
"set_edge",
"summarize_tasks",
"update_task",
]
# EOF