diff --git a/.env.example b/.env.example index 6bf37d1..dc9264d 100644 --- a/.env.example +++ b/.env.example @@ -139,6 +139,11 @@ CLK_ROBUSTNESS_REFINE_ACCEPT_THRESHOLD=0.8 CLK_ROBUSTNESS_QA_PARALLEL_JUDGES=1 CLK_ROBUSTNESS_MAX_QA_DEPTH=3 +# Context-isolated DELEGATE sub-agents (DELEGATE: TO: TASK: ...). +# max_delegate_depth caps nesting; 1 = a worker may delegate but the child +# cannot itself delegate (one level deep). +CLK_ROBUSTNESS_MAX_DELEGATE_DEPTH=1 + # Ralph / autoresearch plateau detection. After plateau_window # consecutive iterations without improvement, the loop escalates + # reframes; failing that, it terminates gracefully with done.md. diff --git a/.gitignore b/.gitignore index 8f7709f..ea1c608 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,12 @@ Thumbs.db # Kickoff workspace (kickoff dirs created by kickoff.sh land here) workspace/ +# Context-offload scratch space: agents park large command output / generated +# data / logs here and read back only slices (grep/head/sed). Disposable +# working memory, never a deliverable — kept out of git so it can't pollute +# history via the per-run `git add -A` auto-commit. +scratch/ + # Local env overrides (kickoff.sh reads .env if present) .env diff --git a/clk_harness/config.py b/clk_harness/config.py index 707ac66..c9a44c0 100644 --- a/clk_harness/config.py +++ b/clk_harness/config.py @@ -261,6 +261,10 @@ def save_json(path: Path, data: Dict[str, Any], *, backup: bool = True) -> None: # Inter-agent Q&A bounds. "qa_parallel_judges": 1, "max_qa_depth": 6, + # Context-isolated DELEGATE sub-agents. Depth 1 = a worker may spawn an + # isolated child for a bounded subtask, but that child cannot itself + # delegate (one level deep) — bounds token cost and recursion. + "max_delegate_depth": 1, # Ralph / autoresearch plateau detection. # A large window means the team runs many more iterations before the # harness decides there is no more improvement to extract. diff --git a/clk_harness/kickoff.py b/clk_harness/kickoff.py index 804239f..4b003d8 100644 --- a/clk_harness/kickoff.py +++ b/clk_harness/kickoff.py @@ -106,6 +106,7 @@ def _csv(s) -> List[str]: ("CLK_ROBUSTNESS_REFINE_ACCEPT_THRESHOLD", ("robustness", "refine_accept_threshold"), float), ("CLK_ROBUSTNESS_QA_PARALLEL_JUDGES", ("robustness", "qa_parallel_judges"), int), ("CLK_ROBUSTNESS_MAX_QA_DEPTH", ("robustness", "max_qa_depth"), int), + ("CLK_ROBUSTNESS_MAX_DELEGATE_DEPTH", ("robustness", "max_delegate_depth"), int), ("CLK_ROBUSTNESS_PLATEAU_WINDOW", ("robustness", "plateau_window"), int), ("CLK_ROBUSTNESS_PLATEAU_ACTION", ("robustness", "plateau_action"), str), ("CLK_ROBUSTNESS_DEBATE", ("robustness", "debate"), str), diff --git a/clk_harness/orchestration/agent/prompts.py b/clk_harness/orchestration/agent/prompts.py index e0012d2..c8df37e 100644 --- a/clk_harness/orchestration/agent/prompts.py +++ b/clk_harness/orchestration/agent/prompts.py @@ -17,6 +17,7 @@ from ...utils.activity_log import log_event from .. import blackboard as _blackboard from .. import casting as _casting +from .. import todos as _todos if TYPE_CHECKING: import threading @@ -94,7 +95,11 @@ def _observer_log(self, line: str) -> None: ... def render_prompt(self, agent: "AgentSpec", objective: str, extra: Optional[Dict[str, Any]] = None) -> str: try: template = self._load_prompt_template(agent.prompt_file) - ctx = self._collect_context(objective, extra or {}) + # Thread the dispatched agent's name into context so the per-author + # $todos checklist (and $agent) resolve on the normal dispatch path, + # where ``extra`` would otherwise carry no agent name. + merged = {**(extra or {}), "agent": (extra or {}).get("agent") or agent.name} + ctx = self._collect_context(objective, merged) return self._safe_substitute(template, ctx) except Exception as exc: log_exception("orchestration.agent.render_prompt", exc) @@ -407,22 +412,33 @@ def _collect_context(self, objective: str, extra: Dict[str, Any]) -> Dict[str, A # provided, otherwise show the most recent global posts so the # agent has at least some peer context. Capped to keep prompts # bounded; tunable via clk.config.json::blackboard. - bb_cfg = (self.clk_cfg.get("blackboard") or {}) - bb_inputs = list(extra.get("blackboard_inputs") or []) - # Allow the chief to widen the digest via stage metadata flag - # ``include_full_blackboard`` (carried through ``extra``). - if extra.get("include_full_blackboard"): - bb_inputs = [] - try: - bb_digest = _blackboard.digest( - self.paths, - selectors=bb_inputs, - max_posts=int(bb_cfg.get("digest_max_posts") or 20), - max_chars_per_post=int(bb_cfg.get("digest_max_chars_per_post") or 800), + # Context isolation for DELEGATE children: a delegated subtask must NOT + # inherit the caller's blackboard. Note an empty selector list does + # NOT isolate (digest() returns recent global posts when selectors are + # falsy), so this needs its own explicit branch. + if extra.get("delegate_isolated") or str(extra.get("phase") or "") == "delegate": + bb_digest = ( + "Blackboard digest: (isolated — this is a delegated subtask; " + "peer context is intentionally withheld. Work only from the " + "task described in your objective.)" ) - except Exception as exc: - log_exception("orchestration.agent._collect_context.blackboard", exc) - bb_digest = "Blackboard digest: (unavailable)" + else: + bb_cfg = (self.clk_cfg.get("blackboard") or {}) + bb_inputs = list(extra.get("blackboard_inputs") or []) + # Allow the chief to widen the digest via stage metadata flag + # ``include_full_blackboard`` (carried through ``extra``). + if extra.get("include_full_blackboard"): + bb_inputs = [] + try: + bb_digest = _blackboard.digest( + self.paths, + selectors=bb_inputs, + max_posts=int(bb_cfg.get("digest_max_posts") or 20), + max_chars_per_post=int(bb_cfg.get("digest_max_chars_per_post") or 800), + ) + except Exception as exc: + log_exception("orchestration.agent._collect_context.blackboard", exc) + bb_digest = "Blackboard digest: (unavailable)" # Casting-rejection feedback: surface duplicate-prevention misses # so the chief learns from them on the next dispatch. @@ -444,6 +460,17 @@ def _collect_context(self, objective: str, extra: Dict[str, Any]) -> Dict[str, A except Exception as exc: log_exception("orchestration.agent._collect_context.notes", exc) + # Working checklist: inject THIS author's own mutable TODOS list so it + # can review and re-emit an updated checklist this turn. Per-author, so + # peers' lists stay out of the way (that is what the blackboard is for). + try: + todos = _todos.render_todos( + _todos.todos_for(self.paths, str(extra.get("agent") or "")) + ) + except Exception as exc: + log_exception("orchestration.agent._collect_context.todos", exc) + todos = "(no todos yet)" + # Outputs contract: convert the stage_outputs list into a concrete, # agent-visible instruction block so workers know BEFORE they write # their first response which POST PRODUCES keys are required. Without @@ -483,6 +510,7 @@ def _collect_context(self, objective: str, extra: Dict[str, Any]) -> Dict[str, A "blackboard_digest": bb_digest, "casting_feedback": casting_feedback or "(none)", "notes": notes, + "todos": todos, "outputs_contract": outputs_contract, } # ``telemetry`` is a live counter object threaded through ``extra`` for diff --git a/clk_harness/orchestration/agent/runner.py b/clk_harness/orchestration/agent/runner.py index c0c7696..ebe2b17 100644 --- a/clk_harness/orchestration/agent/runner.py +++ b/clk_harness/orchestration/agent/runner.py @@ -52,6 +52,9 @@ def __init__( # Lock around meta-prompt cache reads/writes so parallel stages # racing to draft the same dispatch prompt don't corrupt the file. self._meta_cache_lock = threading.Lock() + # Serialises the mutable per-author TODOS store (.clk/state/todos.json) + # so parallel dispatches sharing one agent name can't tear the write. + self._todos_lock = threading.Lock() # -- public ------------------------------------------------------------ @@ -103,6 +106,9 @@ def get_provider(self, name: Optional[str]) -> AgentProvider: "qa_answer", "refine_critic", "refine_worker", + # A context-isolated DELEGATE child: single-shot, must not recurse + # into consensus / quality / QA / further delegation. + "delegate", # Mission-level chief dispatches: single-shot planning / gating that # must not recurse into consensus or the quality-retry loop. "charter", @@ -631,6 +637,10 @@ def _on_progress(kind: str, message: str) -> None: # apply hooks. Posting is cheap and uncommitted, so it happens # even for dry-runs to keep the digest accurate during planning. self._apply_posts(run, extra or {}) + # Persist any TODOS: checklist block. Mutable per-author working + # memory; like posts it runs even for dry-runs so the checklist + # stays live during planning. + self._apply_todos(run, extra or {}) # Apply any PROPOSE_ROLE / PROPOSE_WORKFLOW blocks the agent # emitted. Mutates ``self.agents_cfg`` in place so the very next # stage that names a freshly-proposed role can dispatch to it. @@ -643,6 +653,9 @@ def _on_progress(kind: str, message: str) -> None: # files_written list so the TUI / commit logic see them. if not is_dry: self._apply_actions(run, extra or {}) + # Spawn any DELEGATE children AFTER the parent's own actions/commit + # land, so the parent's work is a distinct unit from the child's. + self._apply_delegate(run, extra or {}) if self.observer is not None: try: self.observer.end(agent.name, run) @@ -689,7 +702,9 @@ def _apply_consensus(self, run: AgentRun, extra: Dict[str, Any]) -> None: text = run.response.text or "" if not text or "PROPOSE_CONSENSUS" not in text: return - if str(extra.get("phase") or "") == "consensus": + # Don't fan out consensus from a consensus sample (recursion) or from a + # context-isolated DELEGATE child (it must stay single-shot). + if str(extra.get("phase") or "") in {"consensus", "delegate"}: return proposals = _casting.parse_consensus_proposals(text) if not proposals: diff --git a/clk_harness/orchestration/agent/transcript.py b/clk_harness/orchestration/agent/transcript.py index 8385d1f..c6a4b70 100644 --- a/clk_harness/orchestration/agent/transcript.py +++ b/clk_harness/orchestration/agent/transcript.py @@ -10,6 +10,7 @@ import json from dataclasses import dataclass, field +from datetime import datetime from typing import TYPE_CHECKING, Any, Dict, FrozenSet, List, Optional from ...git_ops import add_all, has_changes, head_sha, is_repo @@ -20,6 +21,7 @@ from .. import actions as _actions from .. import blackboard as _blackboard from .. import casting as _casting +from .. import todos as _todos if TYPE_CHECKING: import threading @@ -125,6 +127,7 @@ class TranscriptMixin: clk_cfg: Dict[str, Any] observer: Optional[AgentObserver] _proposals_lock: "threading.RLock" + _todos_lock: "threading.Lock" _META_PHASES: FrozenSet[str] if TYPE_CHECKING: @@ -184,7 +187,14 @@ def _apply_posts(self, run: AgentRun, extra: Dict[str, Any]) -> None: That makes the asker's worker effectively block on the answer, which gets posted back to the blackboard with ``post_type: answer`` and ``consumes: []``. + + A context-isolated DELEGATE child runs with ``suppress_posts`` (and + ``phase == "delegate"``): its own POST blocks are NOT persisted to the + shared board — only its distilled result returns to the caller as a + single ``delegate_result`` post created by ``_apply_delegate``. """ + if str(extra.get("phase") or "") == "delegate" or extra.get("suppress_posts"): + return text = (run.response.text or "") if not text or "POST:" not in text: return @@ -312,6 +322,158 @@ def _route_blocking_questions( dry_run=self.clk_cfg.get("dry_run", False), ) + def _apply_todos(self, run: AgentRun, extra: Dict[str, Any]) -> None: + """Persist a ``TODOS:`` block the agent emitted as its live checklist. + + The checklist is mutable and per-author: the latest block overwrites + this author's previous list (last-write-wins), and is re-injected into + this author's next prompt via the ``$todos`` placeholder. Skipped for + meta phases (consensus / qa_answer / delegate / etc.) so a subtask's + stray block can't clobber the driving agent's checklist. + """ + if str(extra.get("phase") or "") in self._META_PHASES: + return + text = run.response.text or "" + if "TODOS:" not in text: + return + try: + with self._todos_lock: + _todos.apply_todos_blocks( + self.paths, + text, + author=run.agent, + stage_id=str(extra.get("stage_id") or ""), + workflow=str(extra.get("workflow") or ""), + ) + except Exception as exc: + log_exception("orchestration.agent._apply_todos", exc) + + def _apply_delegate(self, run: AgentRun, extra: Dict[str, Any]) -> None: + """Spawn context-isolated DELEGATE children for a bounded subtask. + + Each ``DELEGATE:`` block dispatches the named target once, with the + caller's blackboard withheld (``delegate_isolated``) and the child's + own POST blocks suppressed. The child MAY do real work — its ACTION + blocks execute and commit under its own name. The child's distilled + result returns to the caller as a single ``delegate_result`` post it + sees on its next turn. + + Skipped from any meta phase (so a child cannot itself delegate) and + bounded by ``max_delegate_depth`` (default 1), a per-turn cap, and + unknown-target / self / cycle guards — mirroring the blocking-Q&A + routing in ``_route_blocking_questions``. + """ + text = run.response.text or "" + if "DELEGATE:" not in text: + return + if str(extra.get("phase") or "") in self._META_PHASES: + return + props = _casting.parse_delegate_proposals(text) + if not props: + return + cfg = self.clk_cfg.get("robustness") or {} + max_depth = int(cfg.get("max_delegate_depth") or 1) + chain: List[str] = list(extra.get("delegate_chain") or []) + if len(chain) >= max_depth: + log_event( + self.paths, + "delegate_chain_capped", + agent=run.agent, + depth=len(chain), + max_depth=max_depth, + chain=list(chain), + ) + return + max_per_turn = int(cfg.get("max_delegates_per_turn") or 2) + result_cap = int(cfg.get("delegate_result_max_chars") or 2000) + known = set((self.agents_cfg.get("agents") or {}).keys()) + next_chain = chain + [run.agent] + for prop in props[:max_per_turn]: + target = prop.target + if not target or target not in known: + log_event( + self.paths, + "delegate_target_unknown", + agent=run.agent, + target=target, + name=prop.name, + ) + continue + if target == run.agent or target in chain: + log_event( + self.paths, + "delegate_chain_cycle", + agent=run.agent, + target=target, + chain=list(chain), + ) + continue + req_id = ( + f"deleg-{run.agent}-" + f"{datetime.now().strftime('%Y%m%dT%H%M%S%f')}-{prop.name}" + ) + child_obj = ( + "Delegated, context-isolated subtask.\n\n" + f"Requested by `{run.agent}`. You do NOT see the caller's " + "blackboard — work only from what is written here. You MAY do " + "real work (emit ACTION blocks to change files); they will be " + "committed under your name. When done, end with a concise, " + "self-contained summary of the result the caller needs — that " + "summary is all that is returned to them.\n" + ) + if prop.context: + child_obj += f"\nContext:\n{prop.context}\n" + child_obj += f"\nTask:\n{prop.objective}\n" + log_event( + self.paths, + "delegate_dispatch", + agent=run.agent, + target=target, + name=prop.name, + req_id=req_id, + chain=next_chain, + ) + self._observer_log(f"delegate :: {run.agent} → {target} :: {prop.name}") + try: + child = self._dispatch_once( + target, + child_obj, + extra={ + "phase": "delegate", + "delegate_chain": next_chain, + "delegate_isolated": True, + "suppress_posts": True, + "agent": target, + "stage_id": extra.get("stage_id"), + "workflow": extra.get("workflow"), + }, + dry_run=self.clk_cfg.get("dry_run", False), + ) + except Exception as exc: + log_exception("orchestration.agent._apply_delegate.dispatch", exc) + continue + body = "" + if child is not None and child.response is not None: + body = (child.response.text or "").strip() + if len(body) > result_cap: + body = body[:result_cap].rstrip() + " …" + try: + p = _blackboard.post( + self.paths, + author=run.agent, + body=body or "(delegate produced no output)", + post_type="delegate_result", + consumes=[req_id], + produces=[f"delegate:{prop.name}"], + stage_id=str(extra.get("stage_id") or ""), + workflow=str(extra.get("workflow") or ""), + slug_hint=f"delegate-{prop.name}", + ) + if p.id and p.id not in run.posts: + run.posts.append(p.id) + except Exception as exc: + log_exception("orchestration.agent._apply_delegate.post", exc) + def _apply_actions(self, run: AgentRun, extra: Optional[Dict[str, Any]] = None) -> None: """Execute ACTION blocks; merge harness-written files back into the run.""" extra = extra or {} diff --git a/clk_harness/orchestration/casting/__init__.py b/clk_harness/orchestration/casting/__init__.py index c8d3c90..aebab23 100644 --- a/clk_harness/orchestration/casting/__init__.py +++ b/clk_harness/orchestration/casting/__init__.py @@ -15,6 +15,7 @@ CASTING_PROTOCOL, CharterProposal, ConsensusProposal, + DelegateProposal, PlanProposal, RoleProposal, WorkflowProposal, @@ -24,6 +25,7 @@ casting_objective, parse_charter_proposal, parse_consensus_proposals, + parse_delegate_proposals, parse_plan_proposal, parse_role_proposals, parse_workflow_proposals, @@ -57,6 +59,7 @@ "CastingResult", "CharterProposal", "ConsensusProposal", + "DelegateProposal", "DEFAULT_MAX_DYNAMIC_ROLES", "DEFAULT_PROMPT_SIM_THRESHOLD", "PlanProposal", @@ -80,6 +83,7 @@ "list_roles", "parse_charter_proposal", "parse_consensus_proposals", + "parse_delegate_proposals", "parse_plan_proposal", "parse_role_proposals", "parse_workflow_proposals", diff --git a/clk_harness/orchestration/casting/director.py b/clk_harness/orchestration/casting/director.py index 6d83c7b..f960e81 100644 --- a/clk_harness/orchestration/casting/director.py +++ b/clk_harness/orchestration/casting/director.py @@ -88,6 +88,20 @@ class ConsensusProposal: objective: str = "" +@dataclass +class DelegateProposal: + """A context-isolated sub-task handed to an ephemeral child agent. + + The child does NOT inherit the caller's blackboard; it runs a bounded + ``objective`` and its distilled result returns to the caller as a single + ``delegate_result`` post. + """ + name: str + target: str = "" # agent/role that runs the subtask + context: str = "" # optional one-line context handed in + objective: str = "" # the bounded task (TASK: body) + + @dataclass class CharterProposal: """A chief-authored mission charter (the up-front commitment). @@ -124,6 +138,11 @@ class PlanProposal: _CONS_OBJECTIVE_RE = re.compile(r"^\s*OBJECTIVE\s*:\s*$", re.IGNORECASE) _CONS_END_RE = re.compile(r"^\s*END_CONSENSUS\s*$", re.IGNORECASE) +_DELEG_HEAD_RE = re.compile(r"^\s*DELEGATE\s*:\s*([A-Za-z][A-Za-z0-9_\-]*)\s*$", re.MULTILINE) +_DELEG_FIELD_RE = re.compile(r"^(TO|CONTEXT)\s*:\s*(.*)$", re.IGNORECASE) +_DELEG_TASK_RE = re.compile(r"^\s*TASK\s*:\s*$", re.IGNORECASE) +_DELEG_END_RE = re.compile(r"^\s*END_DELEGATE\s*$", re.IGNORECASE) + _CHARTER_HEAD_RE = re.compile(r"^\s*PROPOSE_CHARTER\s*:?\s*$", re.IGNORECASE) _CHARTER_END_RE = re.compile(r"^\s*END_CHARTER\s*$", re.IGNORECASE) _CHARTER_FIELD_RE = re.compile( @@ -422,6 +441,64 @@ def parse_consensus_proposals(text: str) -> List[ConsensusProposal]: return out +def parse_delegate_proposals(text: str) -> List[DelegateProposal]: + """Extract ``DELEGATE:`` blocks from an agent's response. + + Block grammar (mirrors PROPOSE_CONSENSUS):: + + DELEGATE: + TO: + CONTEXT: + TASK: + + END_DELEGATE + + A proposal is only emitted when it names a target and a task. + """ + if not text: + return [] + out: List[DelegateProposal] = [] + lines = text.splitlines() + i = 0 + while i < len(lines): + m = _DELEG_HEAD_RE.match(lines[i]) + if not m: + i += 1 + continue + prop = DelegateProposal(name=m.group(1).strip()) + i += 1 + task_lines: List[str] = [] + in_task = False + while i < len(lines): + line = lines[i] + if _DELEG_END_RE.match(line): + i += 1 + break + if not in_task: + fm = _DELEG_FIELD_RE.match(line) + if fm: + key = fm.group(1).upper() + val = fm.group(2).strip() + if key == "TO": + prop.target = _normalize_name(val) + elif key == "CONTEXT": + prop.context = val + i += 1 + continue + if _DELEG_TASK_RE.match(line): + in_task = True + i += 1 + continue + i += 1 + continue + task_lines.append(line) + i += 1 + prop.objective = "\n".join(task_lines).strip() + if prop.name and prop.target and prop.objective: + out.append(prop) + return out + + def apply_response_proposals( paths: Paths, response_text: str, diff --git a/clk_harness/orchestration/response_quality.py b/clk_harness/orchestration/response_quality.py index 78c2b91..fbd65ee 100644 --- a/clk_harness/orchestration/response_quality.py +++ b/clk_harness/orchestration/response_quality.py @@ -112,6 +112,8 @@ def repair_hint(self) -> str: _END_ACTION_RE = re.compile(r"^\s*END_ACTION\s*$", re.IGNORECASE | re.MULTILINE) _POST_HEAD_RE = re.compile(r"^\s*POST\s*:\s*([A-Za-z][A-Za-z0-9_]*)\s*$", re.IGNORECASE | re.MULTILINE) _POST_END_RE = re.compile(r"^\s*END_POST\s*$", re.IGNORECASE | re.MULTILINE) +_TODOS_HEAD_RE = re.compile(r"^\s*TODOS\s*:\s*$", re.IGNORECASE | re.MULTILINE) +_TODOS_END_RE = re.compile(r"^\s*END_TODOS\s*$", re.IGNORECASE | re.MULTILINE) _PROGRESS_RE = re.compile(r"^\s*PROGRESS\s*:\s*(yes|no|true|false)\s*$", re.IGNORECASE | re.MULTILINE) @@ -182,6 +184,14 @@ def _post_block_balance(text: str) -> int: return heads - ends +def _todos_block_balance(text: str) -> int: + heads = len(_TODOS_HEAD_RE.findall(text or "")) + ends = len(_TODOS_END_RE.findall(text or "")) + if heads == 0: + return 0 + return heads - ends + + def _missing_outputs(text: str, expected: Sequence[str]) -> List[str]: """Return the subset of ``expected`` keys not present in any POST block's ``PRODUCES:`` list.""" @@ -279,6 +289,15 @@ def score( "Every POST block must terminate with a line `END_POST`." ) + # 4b. Malformed TODOS blocks + todos_balance = _todos_block_balance(text or "") + if todos_balance > 0: + q.flags.append("malformed_todos") + q.reasons.append( + f"{todos_balance} TODOS header(s) had no matching END_TODOS. " + "Every TODOS checklist block must terminate with a line `END_TODOS`." + ) + # 5. Missing declared outputs missing = _missing_outputs(text or "", list(expected_outputs or [])) if missing: @@ -336,6 +355,7 @@ def score( "refusal": 0.5, "malformed_action": 0.4, "malformed_post": 0.3, + "malformed_todos": 0.3, "outputs_missing": 0.4, "noop": 0.5, "low_confidence": 0.3, diff --git a/clk_harness/orchestration/todos.py b/clk_harness/orchestration/todos.py new file mode 100644 index 0000000..7abe485 --- /dev/null +++ b/clk_harness/orchestration/todos.py @@ -0,0 +1,271 @@ +"""Per-agent mutable checklist ("TODOS"). + +A lightweight, mutable per-turn checklist that fills the gap between the +**append-only** ``PROGRESS.md`` (a running log) and the **heavyweight** +charter / mission plan (chief-authored, phase-gated, durable). An agent +maintains a short list of checklist items, each in one of three states:: + + - [ ] not started (status "todo") + - [~] in progress (status "doing") + - [x] done (status "done") + +Agents emit the whole checklist as a ``TODOS:`` block in their response and +re-emit it every turn; the latest block **overwrites** the agent's previous +checklist (last-write-wins), unlike blackboard posts which are immutable. + +Storage is a single JSON file, ``.clk/state/todos.json``, keyed by author so +parallel agents never clobber one another's list:: + + { + "version": 1, + "authors": { + "engineer": { + "items": [{"status": "doing", "text": "wire the parser"}], + "ts": "2026-05-01T12:34:56", + "stage_id": "build_a", + "workflow": "engineering" + } + } + } + +The file lives under ``.clk/`` (harness state, hidden from agents' direct +ACTION:write reach). The runner reads an author's list back into that +author's next prompt under the ``$todos`` placeholder. This mirrors the +``POST:`` block pipeline (parse -> persist -> re-inject) almost exactly; the +one deliberate difference is the mutable, per-author, last-write-wins store +instead of one immutable JSON file per entry. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +from ..config import Paths, load_json, save_json +from ..log import get_logger, log_exception +from ..utils.activity_log import log_event + +logger = get_logger(__name__) + +# Keep injected checklists bounded so they can't bloat a prompt. +_MAX_ITEMS = 50 +_MAX_TEXT = 200 + +_MARK_TO_STATUS = {" ": "todo", "~": "doing", "x": "done", "X": "done"} +_STATUS_TO_MARK = {"todo": " ", "doing": "~", "done": "x"} + + +def todos_path(paths: Paths) -> Path: + """Path to the single mutable checklist store.""" + return paths.state / "todos.json" + + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + + +@dataclass +class Todo: + status: str = "todo" # one of: todo | doing | done + text: str = "" + + def to_dict(self) -> Dict[str, Any]: + return {"status": self.status, "text": self.text} + + @classmethod + def from_dict(cls, raw: Dict[str, Any]) -> "Todo": + status = str(raw.get("status") or "todo").lower() + if status not in _STATUS_TO_MARK: + status = "todo" + return cls(status=status, text=str(raw.get("text") or "")) + + +@dataclass +class TodoList: + author: str + items: List[Todo] = field(default_factory=list) + ts: str = "" + stage_id: str = "" + workflow: str = "" + + def to_dict(self) -> Dict[str, Any]: + return { + "items": [t.to_dict() for t in self.items], + "ts": self.ts, + "stage_id": self.stage_id, + "workflow": self.workflow, + } + + @classmethod + def from_dict(cls, author: str, raw: Dict[str, Any]) -> "TodoList": + return cls( + author=author, + items=[Todo.from_dict(x) for x in (raw.get("items") or []) if isinstance(x, dict)], + ts=str(raw.get("ts") or ""), + stage_id=str(raw.get("stage_id") or ""), + workflow=str(raw.get("workflow") or ""), + ) + + +# --------------------------------------------------------------------------- +# TODOS: block parsing +# --------------------------------------------------------------------------- + + +_TODOS_HEAD_RE = re.compile(r"^\s*TODOS\s*:\s*$", re.IGNORECASE) +_TODOS_END_RE = re.compile(r"^\s*END_TODOS\s*$", re.IGNORECASE) +_TODOS_ITEM_RE = re.compile(r"^\s*[-*]\s*\[(?P[ ~xX])\]\s*(?P.*\S)\s*$") + + +def parse_todos_blocks(text: str) -> Optional[List[Dict[str, str]]]: + """Extract the items of the LAST complete ``TODOS:`` block in ``text``. + + Block grammar:: + + TODOS: + - [ ] not started + - [~] in progress + - [x] done + END_TODOS + + The agent re-emits its full checklist each turn, so when several + ``TODOS:`` blocks appear the last complete one wins (overwrite + semantics). Non-item lines inside a block are ignored. Returns the + parsed items (possibly empty for an intentionally-cleared list), or + ``None`` when no complete block is present. + """ + if not text or "TODOS:" not in text: + return None + lines = text.splitlines() + result: Optional[List[Dict[str, str]]] = None + i = 0 + while i < len(lines): + if not _TODOS_HEAD_RE.match(lines[i]): + i += 1 + continue + i += 1 + items: List[Dict[str, str]] = [] + closed = False + while i < len(lines): + line = lines[i] + if _TODOS_END_RE.match(line): + closed = True + i += 1 + break + m = _TODOS_ITEM_RE.match(line) + if m: + status = _MARK_TO_STATUS.get(m.group("mark"), "todo") + items.append({"status": status, "text": m.group("text").strip()}) + i += 1 + if closed: + result = items # last complete block wins + return result + + +# --------------------------------------------------------------------------- +# Persistence (mutable, per-author, last-write-wins) +# --------------------------------------------------------------------------- + + +def load_todos(paths: Paths) -> Dict[str, Any]: + """Load the raw todos store (``{"version", "authors": {...}}``).""" + return load_json(todos_path(paths), {"version": 1, "authors": {}}) + + +def todos_for(paths: Paths, author: str) -> Optional[TodoList]: + """Return ``author``'s current checklist, or ``None`` if they have none.""" + if not author: + return None + try: + store = load_todos(paths) + except Exception as exc: + log_exception("orchestration.todos.todos_for", exc) + return None + entry = (store.get("authors") or {}).get(author) + if not isinstance(entry, dict): + return None + return TodoList.from_dict(author, entry) + + +def apply_todos_blocks( + paths: Paths, + text: str, + *, + author: str, + stage_id: str = "", + workflow: str = "", +) -> Optional[TodoList]: + """Parse the last ``TODOS:`` block and OVERWRITE ``author``'s list. + + Returns the new :class:`TodoList` when a block was applied, else + ``None``. Only the calling author's slot is touched — other authors' + checklists are preserved. + """ + items_raw = parse_todos_blocks(text) + if items_raw is None: + return None + items = [Todo.from_dict(x) for x in items_raw][:_MAX_ITEMS] + todolist = TodoList( + author=author, + items=items, + ts=datetime.now().isoformat(timespec="seconds"), + stage_id=stage_id, + workflow=workflow, + ) + try: + store = load_todos(paths) + if not isinstance(store.get("authors"), dict): + store = {"version": 1, "authors": {}} + store["authors"][author] = todolist.to_dict() + paths.state.mkdir(parents=True, exist_ok=True) + save_json(todos_path(paths), store) + except Exception as exc: + log_exception("orchestration.todos.apply_todos_blocks", exc) + return None + try: + counts = _counts(items) + log_event( + paths, + "todos_updated", + author=author, + total=len(items), + todo=counts["todo"], + doing=counts["doing"], + done=counts["done"], + stage_id=stage_id, + ) + except Exception as exc: + logger.debug("todos log_event failed: %s", exc) + return todolist + + +# --------------------------------------------------------------------------- +# Rendering for the $todos prompt placeholder +# --------------------------------------------------------------------------- + + +def _counts(items: List[Todo]) -> Dict[str, int]: + out = {"todo": 0, "doing": 0, "done": 0} + for t in items: + out[t.status] = out.get(t.status, 0) + 1 + return out + + +def render_todos(todolist: Optional[TodoList]) -> str: + """Render a checklist as markdown for the ``$todos`` placeholder.""" + if todolist is None or not todolist.items: + return "(no todos yet)" + c = _counts(todolist.items) + open_n = c["todo"] + c["doing"] + lines = [f"Working checklist ({open_n} open, {c['done']} done):"] + for t in todolist.items[:_MAX_ITEMS]: + mark = _STATUS_TO_MARK.get(t.status, " ") + text = t.text.strip() + if len(text) > _MAX_TEXT: + text = text[:_MAX_TEXT].rstrip() + " …" + lines.append(f"- [{mark}] {text}") + return "\n".join(lines) diff --git a/clk_harness/templates/prompts.py b/clk_harness/templates/prompts.py index bfe165b..b2033ac 100644 --- a/clk_harness/templates/prompts.py +++ b/clk_harness/templates/prompts.py @@ -30,6 +30,51 @@ """ +_TODOS_PROTOCOL_BLOCK = """ +Working checklist (TODOS — your private, mutable per-turn scratch plan): +$todos + +- This is lightweight working memory that sits between the append-only + PROGRESS.md (a permanent log) and the heavyweight charter/plan. Use it to + track the concrete steps of the task you are on right now. +- To update it, emit a TODOS block. Re-emit your FULL checklist each turn — it + OVERWRITES your previous one (last-write-wins), so include every item you + still care about, not just the changed ones. Omit the block to leave your + checklist unchanged. +- Item grammar — mark is a space (todo), ~ (in progress), or x (done): + TODOS: + - [ ] not started + - [~] in progress + - [x] done + END_TODOS +- Keep it short (a handful of items) and end the block with END_TODOS on its + own line. It is yours alone; to share findings with peers use a POST block. +""" + + +_DELEGATE_PROTOCOL_BLOCK = """ +Delegation (DELEGATE — hand a bounded subtask to a context-isolated child): +- Use ONLY for a self-contained subtask whose result you can consume as a + short summary (e.g. "investigate X and report back", "implement and commit + the Y helper"). The child runs in a FRESH context: it does NOT see your + blackboard or history — it gets only the task you write here. +- The child MAY do real work (emit ACTION blocks to change/commit files under + its own name). Its distilled result returns to you as a `delegate_result` + post on your NEXT turn (not inline) — so delegate, then continue other work + or end your turn; read the result next round. +- Grammar: + DELEGATE: + TO: + CONTEXT: + TASK: + + END_DELEGATE +- Delegation depth is capped (a delegated child cannot itself delegate). This + is not a substitute for a POST: question to a peer — use it to offload work, + not to ask a quick question. +""" + + _BASE_FOOTER = _CONFIDENCE_BLOCK + """ $outputs_contract Blackboard (shared context with peer agents) @@ -74,9 +119,22 @@ may mention it; if you emit ``PATH: workspace/foo``, the harness strips the prefix and writes to ``$project_root/foo``. +- Context offload — keep big blobs out of your reply. When a command + produces a lot of output, or you generate a large dataset/log, DO NOT + paste it into your response. Park it under ``scratch/`` (git-ignored, + disposable) and then read back only the slice you need. Idiom: + ACTION: run + CMD: > scratch/run.log 2>&1 + END_ACTION + ACTION: run + CMD: grep -n scratch/run.log # or: head -n 40 / sed -n '80,120p' + END_ACTION + This keeps your context small and focused. ``scratch/`` is never a + deliverable — do not put project output there. + Cross-iteration notes (shared memory — read AND update every cycle): $notes - +""" + _TODOS_PROTOCOL_BLOCK + _DELEGATE_PROTOCOL_BLOCK + """ Iteration discipline (inspired by incremental autonomous loops): - Identify the SMALLEST verifiable unit of work that makes measurable progress toward the objective. Do that unit well and completely. @@ -107,6 +165,9 @@ welcome when it has a clear job; otherwise use or extend what exists. - Avoid duplicate files, duplicate directories, and alternate implementations of the same thing. +- ``scratch/`` is disposable working memory for context offload (see + Filesystem), not project output — never treat scratch files as + deliverables. FINAL COMPLIANCE CHECK — verify every item before you end your response. The harness validates these mechanically and re-dispatches you on any miss: diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 50b2222..8412bc1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -56,6 +56,7 @@ quality. Use this table to pick a regime: | `robustness.debate_lenses` | Adversarial lenses (one parallel critic each) — more lenses = more critic dispatches per round | `[correctness, security, simplicity]` | | `robustness.debate_max_rounds` | Cap on debate rounds (panel critique + worker revision) per stage | 2 (default) | | `robustness.max_qa_depth` | Cap on inter-agent Q&A chain depth (each peer answer can ask one peer) | 3 (default) | +| `robustness.max_delegate_depth` | Cap on DELEGATE sub-agent nesting; 1 = a worker may spawn one isolated child, but that child cannot itself delegate (each level can add a bounded child dispatch) | 1 (default) | | `robustness.plateau_window` | How many no-improvement Ralph/autoresearch iterations before escalation | 3 (default) | | `robustness.plateau_action` | `off` disables adaptive loop termination entirely | `escalate_then_reframe` | diff --git a/pi-extension/.gitignore b/pi-extension/.gitignore index 552f221..e91e027 100644 --- a/pi-extension/.gitignore +++ b/pi-extension/.gitignore @@ -1,2 +1,3 @@ node_modules/ *.log +scratch/ diff --git a/pi-extension/package.json b/pi-extension/package.json index b4cd38e..6292515 100644 --- a/pi-extension/package.json +++ b/pi-extension/package.json @@ -10,7 +10,7 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "test:unit": "tsx --test tests/errors.test.ts tests/prompts.test.ts tests/state.test.ts tests/git.test.ts tests/index.test.ts tests/runtime_smoke.test.ts tests/safety_nets.test.ts tests/quality.test.ts tests/consensus.test.ts tests/watchdog.test.ts tests/autonomy.test.ts", + "test:unit": "tsx --test tests/errors.test.ts tests/prompts.test.ts tests/state.test.ts tests/git.test.ts tests/index.test.ts tests/runtime_smoke.test.ts tests/safety_nets.test.ts tests/quality.test.ts tests/consensus.test.ts tests/watchdog.test.ts tests/autonomy.test.ts tests/todos.test.ts tests/delegate.test.ts", "test": "npm run test:unit", "test:strict": "npm run typecheck && npm run test:unit" }, diff --git a/pi-extension/src/consensus.ts b/pi-extension/src/consensus.ts index 1827a33..858e03f 100644 --- a/pi-extension/src/consensus.ts +++ b/pi-extension/src/consensus.ts @@ -40,6 +40,69 @@ import { */ export type SpawnFn = (opts: SpawnOptions) => Promise<{ output: string; sessionId: string }>; +/** Cap on the distilled result returned from a delegated subtask. */ +export const MAX_DELEGATE_RESULT_CHARS = 8000; + +export interface DelegateOptions { + /** Target role label for the child. */ + agent: string; + /** The bounded subtask. */ + task: string; + /** Optional one-line context handed to the child. */ + context?: string; + preferredModel?: string; + cwd: string; + signal?: AbortSignal; + onUpdate?: (text: string) => void; + /** Injectable spawn (tests pass a stub); defaults to the tmux impl. */ + spawn?: SpawnFn; + /** Override the result cap (mainly for tests). */ + maxResultChars?: number; +} + +export interface DelegateResult { + output: string; + sessionId: string; +} + +/** + * Hand a bounded subtask to a context-isolated child agent and return + * only its distilled result. The child runs as a fresh pi session + * (spawnSubagent) — it does NOT inherit the caller's conversation or + * blackboard — but MAY do real work (write/commit files) in the repo. + * Distillation is by instruction: the child is told to reply with only a + * concise summary, mirroring the Python harness's DELEGATE child + * objective. Depth is capped structurally — spawnSubagent's preamble + * forbids the child from spawning further subagents or calling clk_*. + */ +export async function runDelegate(opts: DelegateOptions): Promise { + const spawn = opts.spawn ?? defaultSpawnSubagent; + const preamble = + "Delegated, context-isolated subtask. You do NOT share the caller's " + + "conversation or blackboard — work only from the task below. You MAY do " + + "real work (read/write/edit files, run bash, commit with git). When " + + "finished, reply with ONLY a concise, self-contained summary of the " + + "result the caller needs — that summary is all that is returned."; + const composed = + preamble + + (opts.context ? `\n\nContext:\n${opts.context}` : "") + + `\n\nTask:\n${opts.task}`; + const { output, sessionId } = await spawn({ + agent: opts.agent, + task: composed, + preferredModel: opts.preferredModel, + cwd: opts.cwd, + signal: opts.signal, + onUpdate: opts.onUpdate, + }); + const cap = opts.maxResultChars ?? MAX_DELEGATE_RESULT_CHARS; + let distilled = output || "(delegate produced no output)"; + if (distilled.length > cap) { + distilled = distilled.slice(0, cap) + `\n\n[result truncated at ${cap} chars]`; + } + return { output: distilled, sessionId }; +} + export interface QualityDispatchOptions extends SpawnOptions { /** * Extra spawn attempts after the initial one. Default 1 (so up to diff --git a/pi-extension/src/git.ts b/pi-extension/src/git.ts index d04d3d6..b5ea23f 100644 --- a/pi-extension/src/git.ts +++ b/pi-extension/src/git.ts @@ -31,6 +31,8 @@ export async function isRepo(cwd: string): Promise { */ const HARDENED_GITIGNORE = `# CLK harness state — ignore entirely. .clk/ +# Context-offload scratch space — disposable working memory, never committed. +scratch/ # Secrets — these patterns are also checked by the pre-push hook. /.env /.env.example diff --git a/pi-extension/src/prompts.ts b/pi-extension/src/prompts.ts index c826f1e..e15707c 100644 --- a/pi-extension/src/prompts.ts +++ b/pi-extension/src/prompts.ts @@ -25,6 +25,12 @@ You have four dispatch tools — pick the one that matches the situation: one subagent **scored by the harness's quality detector**, with up to \`maxRetries\` automatic repair re-rolls. Default everywhere a single worker is enough but you want bad output caught before it propagates. +* \`clk_delegate({ agent, task, context?, preferredModel? })\` — hand a + bounded, self-contained subtask to a **context-isolated** child that does + NOT see your conversation/blackboard and returns ONLY a distilled summary. + The child MAY do real work (write/commit files). Use to offload a discrete + chunk of work whose result you can consume as a short summary; it cannot + itself delegate (depth capped one level). * \`clk_consensus({ agent, task, samples?, preferredModel? })\` — fan-out N parallel samples (default 3, max 6), each scored, returns the winner plus all candidates. Use **liberally** for any decision that benefits @@ -456,6 +462,13 @@ ${idea} transition: cast updated, dispatch started, consensus reached, Ralph iteration complete, autoresearch learning captured, validation gate passed/failed. The user watches this log to know what's happening. +- **Working checklist.** Keep a short, mutable checklist of the concrete + steps for the task you are on right now via \`clk_todos({ items: [...] })\`, + each item \`{ status: "todo"|"doing"|"done", text }\`. Re-send the FULL list + whenever it changes — it OVERWRITES the previous one (last-write-wins). + This is lightweight per-turn planning that sits between the append-only + \`clk_progress\` log and the heavyweight charter/plan; it is not a substitute + for either. Keep it to a handful of items. - **Direct edits are fine.** You may write files via the built-in \`write\`/\`edit\` tools, or delegate file writes to subagents — your call. Either way, checkpoint after. @@ -471,6 +484,14 @@ ${idea} Calling \`bash\` with no arguments or an empty object will also fail. If you see a validation error ("tool not found" or "must have required properties command"), retry as \`bash({ command: "" })\`. +- **Context offload.** Don't let big output flood your context. When a + command prints a lot, or you generate a large dataset/log, redirect it to a + file under \`scratch/\` and read back only the slice you need: + bash({ command: " > scratch/run.log 2>&1" }) + bash({ command: "grep -n scratch/run.log" }) // or head/sed -n + \`scratch/\` is disposable working memory — leave it untracked and NEVER + \`git add\` it (same discipline as \`run.log\`/\`results.tsv\`). It is not a + deliverable. - **Loop invariant.** After every \`clk_merge\` or \`clk_revert\`, you are back on the home branch. Immediately begin the next Ralph iteration (rule 4) without waiting for user input. diff --git a/pi-extension/src/state.ts b/pi-extension/src/state.ts index 9dd5806..554dc37 100644 --- a/pi-extension/src/state.ts +++ b/pi-extension/src/state.ts @@ -8,6 +8,7 @@ import type { ProgressKind, RalphOutcome, SuperviseState, + TodoItem, } from "./types.js"; const ROOT = ".clk"; @@ -115,6 +116,25 @@ export async function setRoster(cwd: string, roster: Roster, pi: ExtensionAPI): ); } +/** + * Overwrite the mutable per-turn checklist (last-write-wins). Mirrors + * setRoster; the whole list is replaced each call so the chief re-emits + * its full checklist as it changes. Distinct from appendProgress, which + * is append-only. + */ +export async function setTodos(cwd: string, todos: TodoItem[], pi: ExtensionAPI): Promise { + memory.todos = todos; + await persist(cwd, pi); + await atomicWrite( + join(cwd, ROOT, "state", "todos.json"), + JSON.stringify(todos, null, 2), + ); +} + +export function getTodos(): TodoItem[] { + return memory.todos ?? []; +} + export async function appendProgress( cwd: string, entry: { kind: ProgressKind; message: string }, diff --git a/pi-extension/src/tools.ts b/pi-extension/src/tools.ts index 3736100..1116237 100644 --- a/pi-extension/src/tools.ts +++ b/pi-extension/src/tools.ts @@ -3,6 +3,7 @@ import { StringEnum } from "@earendil-works/pi-ai"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { setRoster, + setTodos, appendProgress, markDone, setHomeBranch, @@ -26,7 +27,7 @@ import { } from "./git.js"; import { activeSignal, mergeSignals, endRun } from "./abort.js"; import { classifyError, looksRedacted, recoveryHint, withRetry } from "./errors.js"; -import { dispatchWithQuality, runConsensus } from "./consensus.js"; +import { dispatchWithQuality, runConsensus, runDelegate } from "./consensus.js"; import { tmuxAvailable } from "./subagent.js"; import { summarise } from "./quality.js"; @@ -176,6 +177,49 @@ export function registerClkTools(pi: ExtensionAPI): void { }, }); + pi.registerTool({ + name: "clk_todos", + label: "CLK Todos", + description: + "Overwrite your working checklist (mutable, last-write-wins). Re-send the FULL list each " + + "time it changes — it replaces the previous one. Lightweight per-turn planning that sits " + + "between the append-only progress log and the heavyweight charter/plan.", + promptSnippet: + "Maintain a short mutable checklist ([ ]/[~]/[x]); call with the full updated list whenever it changes.", + parameters: Type.Object({ + items: Type.Array( + Type.Object({ + status: StringEnum(["todo", "doing", "done"] as const), + text: Type.String(), + }), + { description: "The full checklist; replaces the previous one." }, + ), + }), + async execute(_id, params, _signal, _onUpdate, ctx) { + const items = params.items; + if (items.some((i) => looksRedacted(i.text))) { + return { + content: [{ type: "text", text: `clk_todos skipped: an item's text appears redacted. ${recoveryHint("redaction")}` }], + details: {}, + }; + } + await setTodos(ctx.cwd, items, pi); + const marks: Record = { todo: " ", doing: "~", done: "x" }; + const rendered = items.map((i) => `- [${marks[i.status] ?? " "}] ${i.text}`).join("\n"); + const open = items.filter((i) => i.status !== "done").length; + ctx.ui.setStatus("clk-todos", `todos: ${open} open / ${items.length}`); + return { + content: [ + { + type: "text", + text: items.length ? `checklist updated (${open} open):\n${rendered}` : "checklist cleared", + }, + ], + details: { count: items.length, open }, + }; + }, + }); + pi.registerTool({ name: "clk_checkpoint", label: "CLK Checkpoint", @@ -657,6 +701,64 @@ export function registerClkTools(pi: ExtensionAPI): void { }, }); + // --------------------------------------------------------------------- + // clk_delegate — context-isolated bounded subtask, distilled result + // --------------------------------------------------------------------- + pi.registerTool({ + name: "clk_delegate", + label: "CLK Delegate (context-isolated)", + description: + "Hand a bounded, self-contained subtask to a context-isolated child agent and get back " + + "ONLY a distilled result. The child runs in a fresh session — it does NOT see your " + + "conversation or blackboard — but MAY do real work (write/commit files). Use to offload a " + + "chunk of work whose result you can consume as a short summary. Depth is capped: the child " + + "cannot itself delegate.", + promptSnippet: "Delegate a bounded subtask to an isolated child; returns only a distilled result.", + parameters: Type.Object({ + agent: Type.String({ description: "Short role label for the child." }), + task: Type.String({ description: "The bounded, self-contained subtask (include any persona)." }), + context: Type.Optional(Type.String({ description: "Optional one-line context handed to the child." })), + preferredModel: Type.Optional(Type.String()), + }), + async execute(_id, params, signal, onUpdate, ctx) { + if (signal?.aborted || activeSignal()?.aborted) { + return { content: [{ type: "text", text: "clk_delegate cancelled before start." }], details: {} }; + } + if (!(await tmuxAvailable())) { + return { content: [{ type: "text", text: "tmux not installed; cannot delegate." }], details: {} }; + } + if (looksRedacted(params.task)) { + return { + content: [{ type: "text", text: `clk_delegate skipped: 'task' appears redacted. ${recoveryHint("redaction")}` }], + details: {}, + }; + } + const sig = mergeSignals(signal, activeSignal()); + try { + const { output, sessionId } = await runDelegate({ + agent: params.agent, + task: params.task, + context: params.context, + preferredModel: params.preferredModel, + cwd: ctx.cwd, + signal: sig, + onUpdate: (text) => onUpdate?.({ content: [{ type: "text", text }], details: {} }), + }); + ctx.ui.setStatus("clk-last", `delegate → ${params.agent}`); + return { + content: [{ type: "text", text: output }], + details: { agent: params.agent, sessionId }, + }; + } catch (err) { + const cls = classifyError(err); + return { + content: [{ type: "text", text: `clk_delegate failed: ${(err as Error).message}. ${recoveryHint(cls)}` }], + details: { error: String(err) }, + }; + } + }, + }); + // --------------------------------------------------------------------- // clk_autoresearch — survey → investigate → critique loop // --------------------------------------------------------------------- diff --git a/pi-extension/src/types.ts b/pi-extension/src/types.ts index 97a179e..1d316a6 100644 --- a/pi-extension/src/types.ts +++ b/pi-extension/src/types.ts @@ -30,6 +30,19 @@ export type ProgressEntry = { message: string; }; +/** + * A single item on the mutable per-turn checklist (TODOS). + * todo -> [ ] doing -> [~] done -> [x] + * The checklist is overwritten wholesale each time the chief updates it + * (last-write-wins), unlike the append-only progress log. + */ +export type TodoStatus = "todo" | "doing" | "done"; + +export type TodoItem = { + status: TodoStatus; + text: string; +}; + /** * Watchdog counters — the supervise loop's memory between chief turns. * Mirrors the no-progress / rescue ladder in the Python harness's @@ -63,4 +76,5 @@ export type ClkState = { homeBranch?: string; supervise?: SuperviseState; ralphOutcomes?: RalphOutcome[]; + todos?: TodoItem[]; }; diff --git a/pi-extension/tests/delegate.test.ts b/pi-extension/tests/delegate.test.ts new file mode 100644 index 0000000..c428333 --- /dev/null +++ b/pi-extension/tests/delegate.test.ts @@ -0,0 +1,61 @@ +/** + * Tests for runDelegate (context-isolated sub-agent). A fake spawn is + * injected so no tmux / pi is needed — we verify the isolation + distill + * preamble, context hand-in, and result truncation, not the real + * subprocess plumbing. + */ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; + +import { runDelegate, type SpawnFn } from "../src/consensus.ts"; + +describe("runDelegate", () => { + test("hands the child an isolation + distill preamble, context, and task", async () => { + let seen: any = null; + const spawn: SpawnFn = async (opts) => { + seen = opts; + return { output: "the distilled answer", sessionId: "s1" }; + }; + const res = await runDelegate({ + agent: "engineer", + task: "build the helper", + context: "focus on X", + cwd: "/tmp", + spawn, + }); + assert.equal(res.output, "the distilled answer"); + assert.equal(res.sessionId, "s1"); + assert.equal(seen.agent, "engineer"); + assert.ok(seen.task.includes("context-isolated"), "carries the isolation preamble"); + assert.ok( + seen.task.includes("concise, self-contained summary"), + "instructs the child to return only a distilled result", + ); + assert.ok(seen.task.includes("Context:\nfocus on X"), "hands in the context"); + assert.ok(seen.task.includes("Task:\nbuild the helper"), "includes the task body"); + }); + + test("context is optional", async () => { + let seen: any = null; + const spawn: SpawnFn = async (opts) => { + seen = opts; + return { output: "ok", sessionId: "s" }; + }; + await runDelegate({ agent: "qa", task: "run checks", cwd: "/tmp", spawn }); + assert.ok(!seen.task.includes("Context:"), "no Context section when omitted"); + assert.ok(seen.task.includes("Task:\nrun checks")); + }); + + test("empty child output yields a placeholder", async () => { + const spawn: SpawnFn = async () => ({ output: "", sessionId: "s" }); + const res = await runDelegate({ agent: "x", task: "t", cwd: "/tmp", spawn }); + assert.ok(res.output.includes("no output")); + }); + + test("caps the distilled result at maxResultChars", async () => { + const spawn: SpawnFn = async () => ({ output: "z".repeat(50), sessionId: "s" }); + const res = await runDelegate({ agent: "x", task: "t", cwd: "/tmp", spawn, maxResultChars: 10 }); + assert.ok(res.output.startsWith("zzzzzzzzzz")); + assert.ok(res.output.includes("truncated")); + }); +}); diff --git a/pi-extension/tests/prompts.test.ts b/pi-extension/tests/prompts.test.ts index 4a2345f..85fb26e 100644 --- a/pi-extension/tests/prompts.test.ts +++ b/pi-extension/tests/prompts.test.ts @@ -24,11 +24,32 @@ describe("clkChiefPrimer", () => { "clk_ralph", "clk_checkpoint", "clk_done", + "clk_todos", + "clk_delegate", ]) { assert.ok(out.includes(tool), `primer should reference ${tool}`); } }); + test("teaches the mutable TODOS checklist convention", () => { + const out = clkChiefPrimer("anything"); + assert.ok(out.includes("clk_todos"), "primer should mention the clk_todos tool"); + assert.ok(/overwrite/i.test(out), "primer should explain last-write-wins semantics"); + }); + + test("teaches the context-offload scratch/ convention", () => { + const out = clkChiefPrimer("anything"); + assert.ok(out.includes("scratch/"), "primer should mention the scratch/ convention"); + assert.ok(/offload/i.test(out), "primer should mention context offload"); + }); + + test("describes clk_delegate as context-isolated", () => { + const out = clkChiefPrimer("anything").toLowerCase(); + assert.ok(out.includes("clk_delegate")); + assert.ok(out.includes("context-isolated") || out.includes("isolated"), + "primer should describe clk_delegate isolation"); + }); + test("references casting and completion criteria", () => { const out = clkChiefPrimer("anything").toLowerCase(); assert.ok(out.includes("cast"), "primer should mention casting"); diff --git a/pi-extension/tests/todos.test.ts b/pi-extension/tests/todos.test.ts new file mode 100644 index 0000000..1d6c00e --- /dev/null +++ b/pi-extension/tests/todos.test.ts @@ -0,0 +1,99 @@ +/** + * Tests for the TODOS checklist: setTodos persistence (last-write-wins) + * and the clk_todos tool handler. + */ +import { test, describe, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { registerClkTools } from "../src/tools.ts"; +import { reset, setTodos, getTodos } from "../src/state.ts"; +import { endRun } from "../src/abort.ts"; + +const fakePi = { + registerTool: (def: any) => { toolDefs[def.name] = def; }, + sendUserMessage: () => {}, + appendEntry: () => {}, +} as never; +let toolDefs: Record = {}; + +function fakeCtx(cwd: string) { + const statuses: Record = {}; + return { + cwd, + ui: { + setStatus: (k: string, v: string) => { statuses[k] = v; }, + notify: () => {}, + }, + _statuses: statuses, + }; +} +function resultText(res: any): string { + return (res.content ?? []).map((c: any) => c.text).join("\n"); +} + +let cwd: string; +beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "clk-todos-")); + toolDefs = {}; + reset(); + endRun("test setup"); + registerClkTools(fakePi); +}); +afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); +}); + +describe("setTodos", () => { + test("persists the checklist to .clk/state/todos.json", async () => { + const items = [ + { status: "todo" as const, text: "a" }, + { status: "doing" as const, text: "b" }, + ]; + await setTodos(cwd, items, fakePi); + const raw = JSON.parse(await readFile(join(cwd, ".clk", "state", "todos.json"), "utf8")); + assert.deepEqual(raw, items); + assert.deepEqual(getTodos(), items); + }); + + test("overwrites last-write-wins", async () => { + await setTodos(cwd, [ + { status: "todo", text: "one" }, + { status: "todo", text: "two" }, + ], fakePi); + await setTodos(cwd, [{ status: "done", text: "one" }], fakePi); + assert.deepEqual(getTodos(), [{ status: "done", text: "one" }]); + }); +}); + +describe("clk_todos tool", () => { + test("registers and overwrites via the handler, rendering marks", async () => { + const res = await toolDefs["clk_todos"].execute( + "id", + { items: [ + { status: "todo", text: "wire parser" }, + { status: "done", text: "read docs" }, + ] }, + undefined, undefined, fakeCtx(cwd), + ); + const text = resultText(res); + assert.ok(text.includes("- [ ] wire parser")); + assert.ok(text.includes("- [x] read docs")); + assert.equal(res.details.count, 2); + assert.equal(res.details.open, 1); + assert.deepEqual(getTodos(), [ + { status: "todo", text: "wire parser" }, + { status: "done", text: "read docs" }, + ]); + }); + + test("clearing the list is allowed", async () => { + const res = await toolDefs["clk_todos"].execute( + "id", { items: [] }, undefined, undefined, fakeCtx(cwd), + ); + assert.ok(resultText(res).includes("cleared")); + assert.deepEqual(getTodos(), []); + }); +}); diff --git a/tests/test_actions_sandbox.py b/tests/test_actions_sandbox.py index 6a7be4c..e62f32d 100644 --- a/tests/test_actions_sandbox.py +++ b/tests/test_actions_sandbox.py @@ -141,3 +141,10 @@ def test_apply_actions_writes_at_project_root(paths: Paths) -> None: result = actions.apply_actions(paths, text, agent_name="test") assert (paths.root / "README.md").exists() assert any(f.endswith("README.md") for f in result.files_written) + + +def test_resolve_safe_accepts_scratch_path(paths: Paths) -> None: + """scratch/ (context-offload) needs no sandbox exception; it resolves like + any other non-.clk path under the project root.""" + target = actions._resolve_safe(paths.root, "scratch/run.log") + assert target == paths.root / "scratch" / "run.log" diff --git a/tests/test_delegate_parser.py b/tests/test_delegate_parser.py new file mode 100644 index 0000000..52382f7 --- /dev/null +++ b/tests/test_delegate_parser.py @@ -0,0 +1,57 @@ +"""Unit tests for the DELEGATE: block parser (casting.director).""" + +from __future__ import annotations + +from clk_harness.orchestration import casting + + +def test_parse_full_block() -> None: + props = casting.parse_delegate_proposals( + "noise\n" + "DELEGATE: probe\n" + "TO: engineer\n" + "CONTEXT: focus on the parser\n" + "TASK:\n" + "investigate the failing case\n" + "and report the root cause\n" + "END_DELEGATE\n" + "trailing noise" + ) + assert len(props) == 1 + p = props[0] + assert p.name == "probe" + assert p.target == "engineer" + assert p.context == "focus on the parser" + assert p.objective == "investigate the failing case\nand report the root cause" + + +def test_context_is_optional() -> None: + props = casting.parse_delegate_proposals( + "DELEGATE: x\nTO: qa\nTASK:\nrun the checks\nEND_DELEGATE" + ) + assert len(props) == 1 + assert props[0].context == "" + assert props[0].objective == "run the checks" + + +def test_missing_target_is_dropped() -> None: + assert casting.parse_delegate_proposals("DELEGATE: x\nTASK:\ny\nEND_DELEGATE") == [] + + +def test_missing_task_is_dropped() -> None: + assert casting.parse_delegate_proposals("DELEGATE: x\nTO: engineer\nEND_DELEGATE") == [] + + +def test_no_block_returns_empty() -> None: + assert casting.parse_delegate_proposals("just prose, no delegate") == [] + + +def test_multiple_blocks_all_parsed() -> None: + props = casting.parse_delegate_proposals( + "DELEGATE: a\nTO: engineer\nTASK:\nfirst\nEND_DELEGATE\n" + "DELEGATE: b\nTO: qa\nTASK:\nsecond\nEND_DELEGATE\n" + ) + assert [(p.name, p.target, p.objective) for p in props] == [ + ("a", "engineer", "first"), + ("b", "qa", "second"), + ] diff --git a/tests/test_response_quality.py b/tests/test_response_quality.py index b254320..7168336 100644 --- a/tests/test_response_quality.py +++ b/tests/test_response_quality.py @@ -223,3 +223,17 @@ def test_require_confidence_off_by_default() -> None: q = rq.score(text) assert "confidence_missing" not in q.flags assert q.ok + + +def test_malformed_todos_flagged() -> None: + q = rq.score("TODOS:\n- [ ] x\n(no closing marker)", min_chars=1) + assert "malformed_todos" in q.flags + assert not q.ok + + +def test_balanced_todos_not_flagged() -> None: + q = rq.score( + "TODOS:\n- [ ] x\nEND_TODOS\nplus a substantive sentence of real content.", + min_chars=1, + ) + assert "malformed_todos" not in q.flags diff --git a/tests/test_robustness_integration.py b/tests/test_robustness_integration.py index 943dd14..313b819 100644 --- a/tests/test_robustness_integration.py +++ b/tests/test_robustness_integration.py @@ -315,3 +315,164 @@ def test_should_auto_consensus_always(paths: Paths) -> None: assert runner._should_auto_consensus("engineer", {}) assert runner._should_auto_consensus("ralph", {}) assert not runner._should_auto_consensus("chief", {}) + + +# --------------------------------------------------------------------------- +# TODOS: mutable per-turn checklist wiring +# --------------------------------------------------------------------------- + + +def test_todos_persisted_on_dispatch(paths: Paths) -> None: + from clk_harness.orchestration import todos as td + + provider = _FakeProvider([ + AgentResponse(ok=True, text="TODOS:\n- [ ] step one\n- [~] step two\nEND_TODOS") + ]) + runner = _make_runner(paths, provider) + runner._dispatch_once("engineer", "obj", extra={}, dry_run=False) + tl = td.todos_for(paths, "engineer") + assert tl is not None + assert [(i.status, i.text) for i in tl.items] == [("todo", "step one"), ("doing", "step two")] + + +def test_todos_skipped_in_meta_phase(paths: Paths) -> None: + from clk_harness.orchestration import todos as td + + provider = _FakeProvider([AgentResponse(ok=True, text="TODOS:\n- [ ] x\nEND_TODOS")]) + runner = _make_runner(paths, provider) + runner._dispatch_once("engineer", "obj", extra={"phase": "qa_answer"}, dry_run=False) + assert td.todos_for(paths, "engineer") is None + + +def test_todos_reinjected_into_next_context(paths: Paths) -> None: + """A persisted checklist is surfaced in that author's next $todos context.""" + from clk_harness.orchestration import todos as td + + provider = _FakeProvider([]) + runner = _make_runner(paths, provider) + td.apply_todos_blocks(paths, "TODOS:\n- [~] wire parser\nEND_TODOS", author="engineer") + ctx = runner._collect_context("obj", {"agent": "engineer"}) + assert "wire parser" in ctx["todos"] + assert "[~]" in ctx["todos"] + # A different author sees their own (empty) checklist, not the engineer's. + other = runner._collect_context("obj", {"agent": "qa"}) + assert "wire parser" not in other["todos"] + + +def test_todos_end_to_end_prompt_when_template_seeded(paths: Paths) -> None: + """With the shipped engineer template on disk, the checklist reaches the + final rendered prompt (the $todos placeholder lives in _BASE_FOOTER).""" + from clk_harness.orchestration import todos as td + from clk_harness.templates.prompts import PROMPTS + + (paths.prompts / "engineer.md").write_text(PROMPTS["engineer.md"], encoding="utf-8") + td.apply_todos_blocks(paths, "TODOS:\n- [~] wire parser\nEND_TODOS", author="engineer") + provider = _FakeProvider([]) + runner = _make_runner(paths, provider) + prompt = runner.render_prompt(runner.get_agent("engineer"), "obj", extra={}) + assert "wire parser" in prompt + + +# --------------------------------------------------------------------------- +# DELEGATE: context-isolated sub-agent wiring +# --------------------------------------------------------------------------- + + +def _delegate_run(agent: str, body: str): + from clk_harness.orchestration.agent import AgentRun + + return AgentRun( + agent=agent, objective="o", + response=AgentResponse(ok=True, text=body), + started_at="t0", finished_at="t1", + ) + + +def test_delegate_spawns_isolated_child_and_returns_one_result(paths: Paths) -> None: + child_text = "POST: finding\nBODY:\nsecret child detail\nEND_POST\nSummary: built the helper" + provider = _FakeProvider([AgentResponse(ok=True, text=child_text)]) + runner = _make_runner(paths, provider) + parent = _delegate_run( + "architect", "DELEGATE: h\nTO: engineer\nTASK:\nbuild helper\nEND_DELEGATE" + ) + runner._apply_delegate(parent, {}) + # Exactly one child dispatch, to the named target. + assert [c["agent"] for c in provider.calls] == ["engineer"] + posts = bb.list_posts(paths) + # The child's own POST block was suppressed (not persisted to the board). + assert all(p.post_type != "finding" for p in posts) + # Exactly one delegate_result, authored by the parent, carrying the child's + # distilled output, and recorded on the parent run. + results = [p for p in posts if p.post_type == "delegate_result"] + assert len(results) == 1 + assert results[0].author == "architect" + assert "child detail" in results[0].body + assert results[0].id in parent.posts + + +def test_delegate_child_can_do_real_work(paths: Paths) -> None: + child_text = ( + "ACTION: write\nPATH: scratch/out.txt\nCONTENT:\nhello from child\nEND_ACTION\nDONE" + ) + provider = _FakeProvider([AgentResponse(ok=True, text=child_text)]) + runner = _make_runner(paths, provider, {"auto_commit": False}) + parent = _delegate_run( + "architect", "DELEGATE: w\nTO: engineer\nTASK:\nwrite it\nEND_DELEGATE" + ) + runner._apply_delegate(parent, {}) + assert (paths.root / "scratch" / "out.txt").read_text(encoding="utf-8").strip() == "hello from child" + + +def test_delegate_depth_cap_blocks_grandchild(paths: Paths) -> None: + provider = _FakeProvider([AgentResponse(ok=True, text="should-not-run")]) + runner = _make_runner(paths, provider) # max_delegate_depth default 1 + parent = _delegate_run( + "architect", "DELEGATE: x\nTO: engineer\nTASK:\nt\nEND_DELEGATE" + ) + # Already one level deep -> at the cap, so no child is spawned. + runner._apply_delegate(parent, {"delegate_chain": ["ralph"]}) + assert provider.calls == [] + assert [p for p in bb.list_posts(paths) if p.post_type == "delegate_result"] == [] + + +def test_delegate_unknown_target_skipped(paths: Paths) -> None: + provider = _FakeProvider([AgentResponse(ok=True, text="x")]) + runner = _make_runner(paths, provider) + parent = _delegate_run( + "architect", "DELEGATE: x\nTO: nonexistent\nTASK:\nt\nEND_DELEGATE" + ) + runner._apply_delegate(parent, {}) + assert provider.calls == [] + + +def test_delegate_self_and_cycle_skipped(paths: Paths) -> None: + provider = _FakeProvider([AgentResponse(ok=True, text="x")]) + # self: architect delegating to architect (depth ok, self-skip fires). + runner = _make_runner(paths, provider) + runner._apply_delegate( + _delegate_run("architect", "DELEGATE: x\nTO: architect\nTASK:\nt\nEND_DELEGATE"), + {"delegate_chain": []}, + ) + # cycle: target already in the chain (raise depth so the cycle guard is reached). + runner2 = _make_runner(paths, provider, { + "robustness": {**DEFAULT_CLK_CONFIG["robustness"], "max_delegate_depth": 5} + }) + runner2._apply_delegate( + _delegate_run("architect", "DELEGATE: x\nTO: engineer\nTASK:\nt\nEND_DELEGATE"), + {"delegate_chain": ["engineer"]}, + ) + assert provider.calls == [] + + +def test_delegate_isolation_withholds_blackboard(paths: Paths) -> None: + provider = _FakeProvider([]) + runner = _make_runner(paths, provider) + bb.post(paths, author="researcher", body="PEER-SECRET-XYZ", + post_type="finding", slug_hint="f") + normal = runner._collect_context("obj", {"agent": "engineer"})["blackboard_digest"] + isolated = runner._collect_context( + "obj", {"agent": "engineer", "phase": "delegate"} + )["blackboard_digest"] + assert "PEER-SECRET-XYZ" in normal + assert "PEER-SECRET-XYZ" not in isolated + assert "isolated" in isolated.lower() diff --git a/tests/test_todos.py b/tests/test_todos.py new file mode 100644 index 0000000..e5ba2bd --- /dev/null +++ b/tests/test_todos.py @@ -0,0 +1,158 @@ +"""TODOS module: parsing, mutable per-author persistence, rendering.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from clk_harness.config import Paths +from clk_harness.orchestration import todos as td + + +@pytest.fixture +def paths(tmp_path: Path) -> Paths: + p = Paths(root=tmp_path) + p.ensure() + return p + + +# --------------------------------------------------------------------------- +# Parsing +# --------------------------------------------------------------------------- + + +def test_parse_grammar_all_three_states() -> None: + items = td.parse_todos_blocks( + "noise before\n" + "TODOS:\n" + "- [ ] not started\n" + "- [~] in progress\n" + "- [x] done lower\n" + "- [X] done upper\n" + "END_TODOS\n" + "noise after" + ) + assert items == [ + {"status": "todo", "text": "not started"}, + {"status": "doing", "text": "in progress"}, + {"status": "done", "text": "done lower"}, + {"status": "done", "text": "done upper"}, + ] + + +def test_parse_no_block_returns_none() -> None: + assert td.parse_todos_blocks("just some prose, no checklist") is None + # A bare TODOS: mention without a closing END_TODOS is not a complete block. + assert td.parse_todos_blocks("TODOS: inline mention") is None + + +def test_parse_multiple_blocks_last_wins() -> None: + """The agent re-emits its full checklist each turn; the last complete + block overwrites earlier ones.""" + items = td.parse_todos_blocks( + "TODOS:\n- [ ] first draft\nEND_TODOS\n" + "then reconsidered\n" + "TODOS:\n- [x] final a\n- [~] final b\nEND_TODOS\n" + ) + assert items == [ + {"status": "done", "text": "final a"}, + {"status": "doing", "text": "final b"}, + ] + + +def test_parse_empty_block_is_intentional_clear() -> None: + """A complete but empty block clears the list (returns [], not None).""" + assert td.parse_todos_blocks("TODOS:\nEND_TODOS") == [] + + +def test_parse_ignores_non_item_lines_in_block() -> None: + items = td.parse_todos_blocks( + "TODOS:\n- [ ] keep\nrandom commentary line\n* [x] star bullet\nEND_TODOS" + ) + assert items == [ + {"status": "todo", "text": "keep"}, + {"status": "done", "text": "star bullet"}, + ] + + +# --------------------------------------------------------------------------- +# Persistence (mutable, per-author, last-write-wins) +# --------------------------------------------------------------------------- + + +def test_apply_persists_to_state_json(paths: Paths) -> None: + tl = td.apply_todos_blocks( + paths, + "TODOS:\n- [ ] a\n- [~] b\nEND_TODOS", + author="engineer", + stage_id="build_a", + workflow="engineering", + ) + assert tl is not None + store_file = paths.state / "todos.json" + assert store_file.exists() + raw = json.loads(store_file.read_text(encoding="utf-8")) + assert set(raw["authors"].keys()) == {"engineer"} + entry = raw["authors"]["engineer"] + assert entry["stage_id"] == "build_a" + assert [i["text"] for i in entry["items"]] == ["a", "b"] + + +def test_apply_no_block_is_noop(paths: Paths) -> None: + assert td.apply_todos_blocks(paths, "no checklist here", author="qa") is None + assert not (paths.state / "todos.json").exists() + + +def test_apply_overwrites_same_author(paths: Paths) -> None: + td.apply_todos_blocks( + paths, "TODOS:\n- [ ] one\n- [ ] two\n- [ ] three\nEND_TODOS", author="engineer" + ) + # Re-emit a shorter, updated list — it fully replaces the prior one. + td.apply_todos_blocks( + paths, "TODOS:\n- [x] one\nEND_TODOS", author="engineer" + ) + tl = td.todos_for(paths, "engineer") + assert tl is not None + assert [(i.status, i.text) for i in tl.items] == [("done", "one")] + + +def test_apply_preserves_other_authors(paths: Paths) -> None: + td.apply_todos_blocks(paths, "TODOS:\n- [ ] eng task\nEND_TODOS", author="engineer") + td.apply_todos_blocks(paths, "TODOS:\n- [~] qa task\nEND_TODOS", author="qa") + # Engineer re-emits — must not clobber qa's slot. + td.apply_todos_blocks(paths, "TODOS:\n- [x] eng task\nEND_TODOS", author="engineer") + + raw = json.loads((paths.state / "todos.json").read_text(encoding="utf-8")) + assert set(raw["authors"].keys()) == {"engineer", "qa"} + eng = td.todos_for(paths, "engineer") + qa = td.todos_for(paths, "qa") + assert [(i.status, i.text) for i in eng.items] == [("done", "eng task")] + assert [(i.status, i.text) for i in qa.items] == [("doing", "qa task")] + + +def test_todos_for_unknown_author_returns_none(paths: Paths) -> None: + assert td.todos_for(paths, "nobody") is None + assert td.todos_for(paths, "") is None + + +# --------------------------------------------------------------------------- +# Rendering +# --------------------------------------------------------------------------- + + +def test_render_empty() -> None: + assert td.render_todos(None) == "(no todos yet)" + assert td.render_todos(td.TodoList(author="x", items=[])) == "(no todos yet)" + + +def test_render_non_empty_marks_and_counts(paths: Paths) -> None: + td.apply_todos_blocks( + paths, "TODOS:\n- [ ] a\n- [~] b\n- [x] c\nEND_TODOS", author="engineer" + ) + out = td.render_todos(td.todos_for(paths, "engineer")) + assert "2 open, 1 done" in out + assert "- [ ] a" in out + assert "- [~] b" in out + assert "- [x] c" in out