From 1d7a6d5ff2ea40e0138fd74451b4a27ebadbb497 Mon Sep 17 00:00:00 2001 From: John Preston Date: Fri, 7 Aug 2026 19:00:20 +0400 Subject: [PATCH] [ai] Consolidate discovered follow-ups. --- .agents/skills/continue/SKILL.md | 96 ++- .../references/consolidate-pending-tasks.md | 244 ++++++++ .agents/skills/perform-task/SKILL.md | 4 +- .agents/skills/process-inbox/SKILL.md | 12 +- .../skills/process-inbox/scripts/workspace.py | 579 +++++++++++++++++- .../process-inbox/scripts/workspace_test.py | 246 +++++++- .claude/ai-workflow-adapter.md | 21 +- .claude/commands/continue.md | 17 +- .claude/commands/perform-task.md | 3 +- 9 files changed, 1183 insertions(+), 39 deletions(-) create mode 100644 .agents/skills/continue/references/consolidate-pending-tasks.md diff --git a/.agents/skills/continue/SKILL.md b/.agents/skills/continue/SKILL.md index 934d99b039..966db4d25e 100644 --- a/.agents/skills/continue/SKILL.md +++ b/.agents/skills/continue/SKILL.md @@ -1,6 +1,6 @@ --- name: continue -description: Continue autonomous Telegram Desktop development from the shared ai-tdesktop repository. Use when the user invokes $continue or /continue, asks Codex to keep working through the AI queue, or wants one command to resume the active task at the head of a frozen startup batch, drain matching queued work, or process the local inbox only when startup has no task work, while including follow-ups discovered from the batch but deferring unrelated tasks added mid-run. +description: Continue autonomous Telegram Desktop development from the shared ai-tdesktop repository. Use when the user invokes $continue or /continue, asks Codex to keep working through the AI queue, or wants one command to resume the active task at the head of a frozen startup batch, drain matching queued work, or process the local inbox only when startup has no task work, while including and consolidating follow-ups discovered from the batch but deferring unrelated tasks added mid-run. --- # Continue AI Work @@ -8,8 +8,9 @@ description: Continue autonomous Telegram Desktop development from the shared ai Act as the checkout-level scheduler. Choose one invocation mode at startup, freeze its task batch, and keep looping only through that batch and follow-ups discovered from its results. Do not drain unrelated tasks added while the run -is in progress. Delegate inbox planning and one-task execution; do not plan or -implement Telegram changes in this scheduler session. +is in progress. After routing new follow-ups, consolidate compatible unclaimed +work in a fresh leaf worker. Delegate inbox planning and one-task execution; do +not plan or implement Telegram changes in this scheduler session. This is the default development command and the successor to the old `task` and `implement` workflows. Inbox processing may bootstrap an otherwise idle @@ -39,7 +40,9 @@ Restore those exact tracked paths to the slot branch head, delete their untracked files, and continue. Stop instead of cleaning when any other slot path changed, and never clean the main worktree or stash anywhere. Unpublished clean AI slot commits are incomplete publication; run the -helper's `publish` command before selecting work. +helper's `publish` command before selecting work. It recognizes a consolidation +commit and revalidates its aliases and complete dependency graph after every +rebase before pushing; never publish such a commit manually. The canonical lifecycle is deliberately small: @@ -61,6 +64,18 @@ checkout. Moving an unfinished task to another checkout is a rare explicit human reassignment that may restart the task and discard checkout-local phase artifacts; it is never automatic scheduler behavior. +Before freezing a new invocation batch when no task is `in-progress`, handle +each entry in the queue JSON's `pending_consolidations` once. These durable +markers mean discovery routing published new tasks but its separate +consolidation pass did not finish. Spawn the consolidation worker described +below with the source task and batch ids recorded in the marker, then refresh +the queue. A repeated pre-commit race may remain pending for the next +invocation; record that marker as attempted and do not spin. If a task is +already active, its expected local phase files make the shared AI slot unsafe +for consolidation: freeze and finish that active task first, then recover all +pending markers at its clean canonical `Approve` or `Block` boundary before +selecting more work. + ## Interpret scope hints Treat text after `$continue` or `/continue` as optional natural-language @@ -94,6 +109,7 @@ these invocation-local values in the scheduler plan: - ordered `initial_batch_task_ids`; - ordered `batch_task_ids`, initially equal to the initial batch; - empty `discovered_task_ids`; +- empty `consolidation_mappings` and `consolidation_receipts`; - empty `attempted_blocked`. Do not write a batch file, claim the whole batch, or publish reservations. @@ -316,14 +332,33 @@ Spawn one disposable routing worker with `fork_turns: "none"`. Tell it to read the routing, splitting, task-path, artifact, validation, and publication rules in `.agents/skills/process-inbox/SKILL.md`, but not to call `prepare`, `finalize`, or `abort`. Its immutable input is the result, not the inbox. It -must not edit Telegram source, start tasks, or implement work. +must not edit Telegram source, start tasks, or implement work. Give it the +current ordered `batch_task_ids` for the pending consolidation marker. The worker must deduplicate existing tasks, create independently testable unclaimed `todo` tasks and justified project updates, write a discovery -receipt, and write the source task's routing marker. It stages only those -paths, commits `Route follow-ups from `, and publishes with the -workspace helper. Retry ordinary concurrent-master races; preserve a semantic -conflict or unavailable-remote slot commit and stop. +receipt, and write the source task's routing marker. When it creates at least +one task, it must also write `work/consolidation-pending.md` under the source +task, recording the source id, newly created ids, and the post-routing batch: +the scheduler's current ordered `batch_task_ids` followed by the newly created +ids in routing order with duplicates removed. The marker is part of the routing +commit and makes the separate pass resumable after a crash. It stages only +those paths, commits +`Route follow-ups from `, and publishes with the workspace +helper. Retry ordinary concurrent-master races; preserve a semantic conflict +or unavailable-remote slot commit and stop. + +Use this stable marker shape so a context-free worker can recover it: + +```markdown +# Pending task consolidation + +Source: +Created: +- +Batch: +- +``` Project assignment has a strong source-project bias. When the source task has a project, assign each discovered implementation or verification task to that @@ -407,6 +442,46 @@ transitively when a discovered task later reports its own follow-ups. Deduplicated references to pre-existing tasks and unrelated tasks observed in queue refreshes do not join the batch. +## Consolidate pending tasks after discovery + +Whenever discovery routing publishes `work/consolidation-pending.md`, run one +fresh consolidation pass before selecting the next task. Do not run it for a +receipt-only routing or a routing that only reused existing tasks. Do not reuse +the performer or routing worker: spawn one disposable worker with +`fork_turns: "none"`, instruct it not to delegate, and give it `source_root`, +`slot_worktree`, `checkout_tag`, the pending marker, and effective batch ids. +Use the current frozen `batch_task_ids` when one exists; only recovery before a +new batch is frozen uses the marker's Batch list. The marker always supplies the +source task and newly created ids after scheduler context is lost. + +Tell it to read +`.agents/skills/continue/references/consolidate-pending-tasks.md` completely and +own exactly one queue-wide consolidation pass. The worker may edit and publish +AI task, project, and receipt state, but must not touch Telegram source, build, +test, claim, start, approve, or block work. Wait and validate it like the routing +worker; keep its task-description scan and merge reasoning out of the scheduler +context. + +A no-merge result still publishes `work/consolidation-complete.md` and removes +the pending marker, so it cannot be repeated after a restart. A pre-commit race +leaves the pending marker intact; refresh the queue, record it as attempted for +this invocation, and continue without treating the optimization as a blocker. +If the worker created a commit that cannot be published safely, preserve it and +hard-stop exactly as for discovery routing. + +At every clean canonical `Approve` or `Block` boundary, process any older +pending marker deferred by an active startup task before selecting more work, +then process the marker just created by that task's routing. Attempt each marker +at most once per invocation. + +For each published old-to-new mapping, rewrite `batch_task_ids` by placing the +replacement at the earliest position occupied by any of its sources and removing +the other source ids and duplicate replacement ids. Do not add a replacement +when none of its sources was in the batch. Apply the same replacement and +deduplication to `discovered_task_ids`; leave `initial_batch_task_ids` unchanged +as the startup record. Append the mapping and receipt to the invocation-local +consolidation records, refresh canonical state, and only then select more work. + ## Report Return one compact summary: invocation mode, initial batch ids, discovered ids @@ -414,7 +489,8 @@ added to the batch, inbox receipt if processed, tasks approved, exceptionally blocked tasks with exact unverified behavior and retry status, recorded tasks left queued, unrelated new tasks deferred to the next invocation, routed discoveries, infrastructure-limited coverage gaps recorded but not routed, -archived projects, any discarded interrupted-worker leftovers, +consolidation no-merge results or receipts, old-to-new mappings, the net +task-count saving, archived projects, any discarded interrupted-worker leftovers, elapsed time, and why the loop stopped. Make any global hard stop or unsafe state unmistakable. Never include source or AI commit hashes; task ids are the only durable locators. diff --git a/.agents/skills/continue/references/consolidate-pending-tasks.md b/.agents/skills/continue/references/consolidate-pending-tasks.md new file mode 100644 index 0000000000..cd6d29b596 --- /dev/null +++ b/.agents/skills/continue/references/consolidate-pending-tasks.md @@ -0,0 +1,244 @@ +# Consolidate pending AI tasks + +Use one fresh leaf worker to reduce fixed per-task execution cost after newly +discovered follow-ups reach canonical AI state. Prefer a smaller queue when one +context pass, plan, review, build, fixture, and test run can prove several close +requests without weakening any of them. + +## Contents + +- [Inputs and boundary](#inputs-and-boundary) +- [Inventory and eligibility](#inventory-and-eligibility) +- [Aggressive merge decision](#aggressive-merge-decision) +- [Build replacement tasks](#build-replacement-tasks) +- [Traceability and validation](#traceability-and-validation) +- [Publish and return](#publish-and-return) + +## Inputs and boundary + +Receive the source checkout, AI slot worktree, checkout tag, the source task's +`work/consolidation-pending.md`, and the scheduler's effective ordered batch +ids. When a current invocation batch is already frozen, its `batch_task_ids` +are authoritative. Only recovery before a new batch is frozen uses the marker's +Batch list, which already includes every Created id. Read the discovery source +task and newly routed ids from the marker. Do not delegate. Do not read or +modify Telegram source, build, test, inbox, claim, or task execution state. +Work only in the clean checkout-specific AI slot after the discovery-routing +commit has published. + +This is one queue-wide optimization pass, not another discovery planner. It may +replace compatible unclaimed tasks and update their direct dependents, project +indexes, and one new consolidation receipt. It must not invent new work, alter +approved history, create or move projects, or change the meaning of a request. + +## Inventory and eligibility + +Refresh canonical AI state. Inventory every task's id, status, ownership, type, +project, dependencies, and tracked task-directory contents. Partition all +mergeable work by this exact key: + +1. the same project slug, or `project: null` for every member; +2. the same `type` (`implement` or `verify`); +3. the same scheduler membership: every member is in the current batch, or no + member is in it. + +Never merge across projects, between a project and standalone work, across task +types, or across the frozen-batch boundary. Named projects and standalone work +are separate lineages even when their files overlap. + +A candidate must be `status: todo`, `claimed_by: null`, with `claimed_at`, +`claim_order`, `lease_until`, and `phase` all `null`. It must have no tracked +`work/` or `evidence/` and no unexpected task-local artifact suggesting someone +has begun it. Inputs are allowed only when every pertinent file can be copied +and relinked without loss. + +Read `task.md` and `state.yaml` completely for every member of each partition +containing at least two candidates. Read that project's `project.md` and +`tasks.md`, or the complete standalone candidate set. Inspect dependency and +reverse-dependency state. A candidate referenced by an `in-progress`, `blocked`, +approved, claimed, or otherwise non-mergeable task is ineligible; unclaimed +`todo` dependents may be rewritten atomically with the replacement. + +Before selecting a cluster, simulate its replacement in the complete dependency +graph: remove the cluster, add the stable union of its external dependencies, +and rewrite every eligible dependent. Reject a cluster if the replacement would +depend directly or transitively on one of those dependents or if the simulated +graph contains any cycle. Dependency similarity never overrides this rule. + +## Aggressive merge decision + +Within an eligible partition, bias toward merging. Two or more tasks belong in +one replacement when they are close enough that a single execution can reuse a +material part of its context, implementation or measurement setup. Strong merge +signals include: + +- the same component, control flow, surface, source seam, or expected files; +- one fixture, account state, overlay, process lifetime, or UI navigation can + exercise all acceptance criteria; +- one implementation naturally establishes several requested invariants; +- verification tasks measure the same parent diff, state machine, or tightly + related surfaces from one instrumented run; +- the combined work can use one coherent plan, review, Debug build, and test + loop instead of merely running unrelated jobs back to back. + +Merge more than two tasks whenever those signals hold for the whole cluster. +The default question is why the tasks still need separate multi-hour pipelines, +not why they happen to resemble each other. + +Keep tasks separate when the merged title and plan would be artificial, the +parts need independent designs or incompatible fixtures, one part materially +interferes with another's measurement, the work has a real sequential product +boundary, or the result would no longer fit one normal implementation or +verification pass. Same project alone is not enough. Record close candidates +left separate and the concrete reason; do not use vague labels such as +"unrelated" or "too large". + +For `verify` clusters, retain each claim's own parent-diff boundary and revert +test. A union of dependencies or paths is not a new scope boundary. Combining +verification saves setup; it never widens what shipped behavior is owed and +never permits a source change. Do not combine `verify` with `implement`; a +verification finding routes its repair through the normal later pipeline. + +## Build replacement tasks + +Create one new dated task for each selected cluster. There is no `superseded` +status, but old task ids are durable: never delete their directories. Use a +concise imperative slug with the normal same-day collision suffix and write +canonical `task.md` plus `state.yaml`: + +- `status: todo`, the shared source `type` and `project`, and all ownership and + phase fields `null`; +- `created` equal to the local consolidation date; +- `depends_on` equal to the stable union of external source dependencies, with + duplicates and every internally superseded id removed; +- `inbox_receipt` pointing to the new consolidation receipt. + +Immediately before retirement, obtain each source specification fingerprint +from the helper while its live `state.yaml` still exists: + +```bash +python3 .agents/skills/process-inbox/scripts/workspace.py task-content-digest \ + --task +``` + +Retire each source by removing only its `state.yaml`, retaining its original +`task.md` and `input/`, and adding this exact `superseded.yaml`: + +```yaml +superseded_by: YYYY/MM/DD/replacement-task +receipt: receipts/YYYY/MM/DD/consolidation-receipt.md +type: implement +project: project-slug +content_sha256: +``` + +Use the actual shared type and `project: null` where appropriate. The queue sees +only directories with `state.yaml`, while the workspace resolver follows +`superseded.yaml` chains. This preserves old receipt links, deduplication paths, +human bookmarks, original wording, and inputs without leaving duplicate live +work. The digest covers every retained file path and byte except `state.yaml` +and `superseded.yaml`; post-rebase validation rejects a late task or input edit. +Never put execution artifacts into a retired directory. + +Organize the combined body and acceptance by named parts when that keeps source +boundaries visible. Preserve every unique acceptance criterion and every +load-bearing constraint, prerequisite, safety rule, visual basis, fixture fact, +and input. Exact duplicate criteria may collapse to the strongest version only +when the receipt maps every source criterion to it and explains why nothing was +lost. Never generalize precise readings into a weaker umbrella criterion. + +For a combined verification with several parent tasks, give each part its own +scope boundary and dependency statement. Explicitly state that their union is +not the boundary of any individual claim. Preserve the no-implementation and +no-Telegram-commit contract once for the whole task. + +Copy supplied files into the replacement's `input/` using collision-safe names, +rewrite all links, and map every old file to the new path in the receipt. If an +input cannot be preserved, exclude that cluster. Rewrite eligible external +dependents' dependency lists and prerequisite prose from old ids to the new id. + +For a named project, replace the old links with the new link in `tasks.md` and +make nearby durable narrative coherent. Do not store live status there. Do not +edit earlier receipts, approved task artifacts, or the discovery-routing marker; +they are immutable history. Repeat the complete old-to-new mapping in the +replacement task, each durable alias, and the consolidation receipt. + +## Traceability and validation + +Write one concise receipt under `receipts/YYYY/MM/DD/` named for the checkout +and consolidation time. Include: + +- trigger source task, local time, checkout tag, and newly routed ids; +- the eligible inventory and every selected or rejected close cluster; +- exact old-to-new mapping, project, type, and batch-membership class; +- shared setup that justifies each merge and the fixed-cost saving it creates; +- per-criterion and load-bearing-context accounting; +- dependency unions, dependent rewrites, inputs, project-index changes, and all + paths created, changed, or removed. + +Keep the proposal in worker context or an ignored temporary file. Immediately +before writing tracked files, refresh canonical state and re-read every source +and rewritten dependent. If any status, owner, type, project, dependency, or +tracked task contents changed, write nothing and return `RACED`, leaving the +pending marker for a later invocation. Apply all selected clusters only after +this safety check; never produce a partial consolidation. + +Validate that every replacement is an unclaimed `todo`, every source criterion +and input is accounted for, all dependencies and links exist, project/type +boundaries hold, no old id remains as a live dependency or project link, every +alias chain reaches a live task, every retained-content fingerprint still +matches, and the complete live dependency graph is acyclic. Preserve native +line endings without a BOM. Never record source or AI commit hashes. + +Whether or not anything merged, remove `work/consolidation-pending.md` and write +`work/consolidation-complete.md` under the source task. It must name the source, +newly routed ids, local time, examined partitions, concrete reasons for close +candidates left separate, receipt or `none`, mappings or `none`, and exactly one +of `STATUS: MERGED` or `STATUS: NO_MERGE`. This marker is the durable no-repeat +boundary. Only explicit task, project, receipt, and source-marker paths may +change. + +## Publish and return + +Do not stage or commit manually. Publish through the dedicated helper, passing +every changed source task, retired task, replacement task, rewritten dependent, +project, and receipt path explicitly. Pass one mapping for every retired id: + +```bash +python3 .agents/skills/process-inbox/scripts/workspace.py consolidate-publish \ + --source-task \ + --receipt \ + --mapping = \ + --path \ + --path \ + --path \ + --path +``` + +For `NO_MERGE`, omit `--receipt` and `--mapping` and pass the source task path +covering the marker change. The helper rejects changes outside the explicit +paths, validates aliases, retained-content fingerprints, and the full dependency +graph, stages the paths, and commits once as +`Consolidate pending tasks after `. After every fetch and rebase +it repeats validation immediately before its push; the push is the +compare-and-swap boundary. A concurrent task that still names a retired id, a +late source-specification edit, a new cycle, a semantic conflict, or a remote +outage therefore preserves the commit and returns `BLOCKED`; never force-push. +The generic `publish` command recognizes and applies the same validator when +resuming this commit. + +Return only this compact contract to the scheduler: + +```text +STATUS: MERGED | NO_MERGE | RACED | BLOCKED +Receipt: +Mappings: new pairs or none> +Created: +Retired: +Net: +``` + +`NO_MERGE` publishes only the completion boundary. Pre-commit `RACED` leaves the +pending marker intact and is safe to defer. `BLOCKED` is reserved for a committed +or otherwise unsafe publication state that the scheduler must preserve and +report. diff --git a/.agents/skills/perform-task/SKILL.md b/.agents/skills/perform-task/SKILL.md index 9277870c0c..9600a0a2b5 100644 --- a/.agents/skills/perform-task/SKILL.md +++ b/.agents/skills/perform-task/SKILL.md @@ -7,7 +7,9 @@ description: Resolve, start or resume, implement, commit, and verify exactly one Own exactly one task through a Telegram commit and a canonical AI `Approve` or exceptional `Block`. Do not process the inbox, split the task, drain the queue, -or select a follow-up afterward. +select a follow-up, or consolidate pending tasks afterward. The `continue` +scheduler isolates discovery routing and queue consolidation in fresh workers +after this performer returns. ## Read the complete engine diff --git a/.agents/skills/process-inbox/SKILL.md b/.agents/skills/process-inbox/SKILL.md index 3074a5fbcc..ce099e6ee7 100644 --- a/.agents/skills/process-inbox/SKILL.md +++ b/.agents/skills/process-inbox/SKILL.md @@ -54,6 +54,13 @@ Read these before planning: `projects/archive/`, and relevant task states from ``; - the transaction's `inbox.md` and every file it references. +Some retained task directories have `superseded.yaml` instead of `state.yaml`. +They are durable aliases created by queue consolidation, not missing or reusable +paths. Follow `superseded_by` chains to their live task when deduplicating, +resolving prior receipt references, or checking whether a same-digest result +still exists. New dependencies and project links must name the final live task, +never an alias. A dated slug occupied by an alias still counts as a collision. + Use one disposable leaf planner when the harness supports delegation; instruct it not to delegate. Otherwise perform the same work locally. The planner may write a proposed routing file inside the ignored transaction, but only the @@ -209,8 +216,9 @@ Create one tracked Markdown receipt under `receipts/YYYY/MM/DD/`. Include: - deduplication decisions. Before writing, search receipts for the same digest. If it was already fully -processed and all referenced tasks still exist, create nothing and reuse that -receipt for finalization. +processed and every referenced task either has live state or has a durable alias +chain reaching live state, create nothing and reuse that receipt for +finalization. ## Validate and publish diff --git a/.agents/skills/process-inbox/scripts/workspace.py b/.agents/skills/process-inbox/scripts/workspace.py index 0ec95c4e35..28c6ec9ffe 100755 --- a/.agents/skills/process-inbox/scripts/workspace.py +++ b/.agents/skills/process-inbox/scripts/workspace.py @@ -18,10 +18,13 @@ TAG_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]*") TASK_ID_PATTERN = re.compile( r"[0-9]{4}/[0-9]{2}/[0-9]{2}/[a-z0-9][a-z0-9-]*" ) +SHA256_PATTERN = re.compile(r"[0-9a-f]{64}") VALID_STATUSES = {"todo", "in-progress", "approved", "blocked"} DEFAULT_TASK_TYPE = "implement" VALID_TASK_TYPES = {DEFAULT_TASK_TYPE, "verify"} VALID_FINDINGS = {"confirmed", "deviation", "inconclusive"} +CONSOLIDATION_PENDING = "work/consolidation-pending.md" +CONSOLIDATION_COMPLETE = "work/consolidation-complete.md" COMMIT_HASH_PATTERN = re.compile( r"(?i)\b(?:commit|revision|sha(?:-1)?)\b[^\r\n]{0,32}(? ".join(visited + [current]) + ) + visited.append(current) + alias = superseded.get(current) + if alias is None: + raise WorkspaceError(f"Task does not exist: {task_id}") + current = alias["superseded_by"] + result = dict(states[current]) + if current != task_id: + result["superseded_from"] = task_id + return result + + +def resolve_task(root, states, value): + superseded = load_superseded(root) if TASK_ID_PATTERN.fullmatch(value): - if value not in states: - raise WorkspaceError(f"Task does not exist: {value}") - return states[value] + return resolve_task_id(states, superseded, value) name = normalized_task_name(value) if not name: raise WorkspaceError("Task name is empty") @@ -635,6 +743,16 @@ def resolve_task(states, value): task for task in states.values() if normalized_task_name(task["title"]) == name ] + if not exact: + alias_ids = [ + task_id for task_id in superseded + if task_id.rsplit("/", 1)[-1] == name + ] + exact = [ + resolve_task_id(states, superseded, task_id) + for task_id in alias_ids + ] + exact = list({task["id"]: task for task in exact}.values()) unfinished = [ task for task in exact if task["status"] in ("todo", "in-progress", "blocked") @@ -650,6 +768,25 @@ def resolve_task(states, value): return candidates[0] +def pending_consolidations(root): + tasks = root / "tasks" + if not tasks.is_dir(): + return [] + result = [] + for marker in sorted(tasks.glob( + "*/*/*/*/" + CONSOLIDATION_PENDING + )): + parts = marker.relative_to(root).parts + task_id = "/".join(parts[1:5]) + if not TASK_ID_PATTERN.fullmatch(task_id): + raise WorkspaceError(f"Invalid consolidation marker path: {marker}") + result.append({ + "source_task": task_id, + "marker": marker.relative_to(root).as_posix(), + }) + return result + + def command_queue(args): config = worktree_config(args, create=True) main = Path(config["ai_main"]) @@ -710,6 +847,7 @@ def command_queue(args): "own_todo": own_todo, "own_blocked": own_blocked, "unclaimed_todo": unclaimed_todo, + "pending_consolidations": pending_consolidations(slot), "other_claimed_unfinished": sum( 1 for task in values if task["claimed_by"] not in (None, tag) @@ -735,7 +873,7 @@ def command_resolve(args): if not changed_paths(slot) and not unpublished_counts(config)["slot_only"]: sync_canonical(config) states = load_states(slot) - task = task_summary(resolve_task(states, args.name), states) + task = task_summary(resolve_task(slot, states, args.name), states) active = sorted( value["id"] for value in states.values() if value["claimed_by"] == config["checkout_tag"] @@ -853,9 +991,14 @@ def command_retry(args): "phase": "resume", "lease_until": None, }) - routed = path.parent / "work" / "discovered-routed.md" - if routed.is_file(): - routed.unlink() + for marker in ( + "work/discovered-routed.md", + CONSOLIDATION_PENDING, + CONSOLIDATION_COMPLETE, + ): + marker_path = path.parent / marker + if marker_path.is_file(): + marker_path.unlink() print(json.dumps({ "task": args.task, "status": "in-progress", @@ -2421,7 +2564,9 @@ def command_finish(args): def command_publish(args): config = worktree_config(args, create=True) - published = publish_slot(config) + slot = Path(config["slot_worktree"]) + validate = consolidation_validation_for_head(slot) + published = publish_slot(config, validate=validate) print(json.dumps({"published": bool(published)}, indent=2, sort_keys=True)) @@ -2452,6 +2597,395 @@ def path_is_covered(path, roots): return any(path == root or path.startswith(root + "/") for root in roots) +def parse_consolidation_mapping(value): + parts = value.split("=", 1) + if ( + len(parts) != 2 + or not TASK_ID_PATTERN.fullmatch(parts[0]) + or not TASK_ID_PATTERN.fullmatch(parts[1]) + or parts[0] == parts[1] + ): + raise WorkspaceError( + f"Invalid consolidation mapping {value!r}; use old-task=new-task" + ) + return tuple(parts) + + +def validate_dependency_graph(states): + dependents = {task_id: [] for task_id in states} + remaining = {} + for task in states.values(): + missing = [ + dependency for dependency in task["depends_on"] + if dependency not in states + ] + if missing: + raise WorkspaceError( + f"{task['id']} has missing dependencies: " + ", ".join(missing) + ) + dependencies = set(task["depends_on"]) + remaining[task["id"]] = len(dependencies) + for dependency in dependencies: + dependents[dependency].append(task["id"]) + ready = [task_id for task_id, count in remaining.items() if count == 0] + processed = 0 + while ready: + task_id = ready.pop() + processed += 1 + for dependent in dependents[task_id]: + remaining[dependent] -= 1 + if remaining[dependent] == 0: + ready.append(dependent) + if processed != len(states): + cycle_members = sorted( + task_id for task_id, count in remaining.items() + if count + ) + raise WorkspaceError( + "Task dependency cycle includes: " + ", ".join(cycle_members) + ) + + +def validate_superseded_graph(root, states, superseded): + for task_id, alias in superseded.items(): + if task_id in states: + raise WorkspaceError( + f"Superseded task still has live state.yaml: {task_id}" + ) + directory = root / "tasks" / task_id + if not (directory / "task.md").is_file(): + raise WorkspaceError(f"Superseded task lost task.md: {task_id}") + actual_digest = retained_task_digest(directory) + if actual_digest != alias["content_sha256"]: + raise WorkspaceError( + f"Superseded task retained content changed: {task_id}" + ) + current = task_id + visited = [] + while current not in states: + if current in visited: + raise WorkspaceError( + "Superseded task cycle: " + " -> ".join(visited + [current]) + ) + visited.append(current) + current_alias = superseded.get(current) + if current_alias is None: + raise WorkspaceError( + f"Superseded task {task_id} targets missing task {current}" + ) + current = current_alias["superseded_by"] + + +def validate_consolidation_tree(root, source_task, mappings, receipt): + states = load_states(root) + superseded = load_superseded(root) + if source_task not in states: + raise WorkspaceError( + f"Consolidation source task does not exist: {source_task}" + ) + if states[source_task]["status"] not in ("approved", "blocked"): + raise WorkspaceError( + f"Consolidation source task is not finished: {source_task}" + ) + source = root / "tasks" / source_task + pending = source / CONSOLIDATION_PENDING + complete = source / CONSOLIDATION_COMPLETE + if pending.exists(): + raise WorkspaceError(f"Consolidation pending marker remains: {pending}") + if not complete.is_file(): + raise WorkspaceError(f"Missing consolidation completion marker: {complete}") + complete_text = complete.read_text(encoding="utf-8-sig") + expected_status = "MERGED" if mappings else "NO_MERGE" + if source_task not in complete_text or f"STATUS: {expected_status}" not in complete_text: + raise WorkspaceError( + f"Consolidation completion marker must name {source_task} and " + f"contain STATUS: {expected_status}" + ) + validate_dependency_graph(states) + validate_superseded_graph(root, states, superseded) + if not mappings: + if receipt is not None: + raise WorkspaceError("A no-merge consolidation must not publish a receipt") + return + if receipt is None: + raise WorkspaceError("A merged consolidation requires a receipt") + receipt_path = root / receipt + if not receipt_path.is_file(): + raise WorkspaceError(f"Missing consolidation receipt: {receipt}") + receipt_text = receipt_path.read_text(encoding="utf-8-sig") + targets = {} + for old_id, new_id in mappings.items(): + alias = superseded.get(old_id) + if alias is None or alias["superseded_by"] != new_id: + raise WorkspaceError( + f"Missing exact superseded mapping {old_id} -> {new_id}" + ) + if alias["receipt"] != receipt: + raise WorkspaceError( + f"Superseded task {old_id} names the wrong receipt" + ) + new_task = states.get(new_id) + if new_task is None: + raise WorkspaceError(f"Replacement task does not exist: {new_id}") + if ( + new_task["status"] != "todo" + or any(new_task[field] is not None for field in ( + "claimed_by", + "claimed_at", + "claim_order", + "lease_until", + "phase", + )) + ): + raise WorkspaceError( + f"Replacement task is not pristine unclaimed todo work: {new_id}" + ) + if ( + alias["type"] != new_task["type"] + or alias["project"] != new_task["project"] + ): + raise WorkspaceError( + f"Replacement changes type or project for {old_id}: {new_id}" + ) + if new_task["inbox_receipt"] != receipt: + raise WorkspaceError(f"Replacement task names the wrong receipt: {new_id}") + if old_id not in receipt_text or new_id not in receipt_text: + raise WorkspaceError( + f"Consolidation receipt omits mapping {old_id} -> {new_id}" + ) + targets.setdefault(new_id, []).append(old_id) + for new_id, old_ids in targets.items(): + if len(old_ids) < 2: + raise WorkspaceError( + f"Replacement {new_id} consolidates fewer than two tasks" + ) + for project_path in sorted((root / "projects").glob("*/tasks.md")): + text = project_path.read_text(encoding="utf-8-sig") + for old_id in mappings: + if f"tasks/{old_id}/task.md" in text: + raise WorkspaceError( + f"Project index still links superseded task {old_id}: {project_path}" + ) + + +def consolidation_validation_for_head(slot): + subject = run_git(slot, "show", "-s", "--format=%s", "HEAD").stdout.strip() + prefix = "Consolidate pending tasks after " + if not subject.startswith(prefix): + return None + source_task = subject[len(prefix):] + if not TASK_ID_PATTERN.fullmatch(source_task): + raise WorkspaceError(f"Invalid consolidation commit subject: {subject!r}") + changed = run_git( + slot, + "diff-tree", + "--no-commit-id", + "--name-only", + "-r", + "HEAD^", + "HEAD", + ).stdout.splitlines() + superseded = load_superseded(slot) + mappings = {} + for path in changed: + if not path.endswith("/superseded.yaml"): + continue + parts = PurePosixPath(path).parts + if len(parts) != 6 or parts[0] != "tasks": + raise WorkspaceError( + f"Invalid superseded path in consolidation commit: {path}" + ) + old_id = "/".join(parts[1:5]) + alias = superseded.get(old_id) + if alias is None: + raise WorkspaceError( + f"Consolidation commit removed superseded alias: {old_id}" + ) + mappings[old_id] = alias["superseded_by"] + receipts = { + superseded[old_id]["receipt"] + for old_id in mappings + } + if len(receipts) > 1: + raise WorkspaceError("Consolidation mappings name different receipts") + receipt = next(iter(receipts), None) + return lambda root: validate_consolidation_tree( + root, + source_task, + mappings, + receipt, + ) + + +def command_task_content_digest(args): + config = worktree_config(args, create=True) + slot = Path(config["slot_worktree"]) + path = state_path(slot, args.task).parent + print(json.dumps({ + "content_sha256": retained_task_digest(path), + "task": args.task, + }, indent=2, sort_keys=True)) + + +def command_consolidate_publish(args): + config = worktree_config(args, create=True) + slot = Path(config["slot_worktree"]) + if not TASK_ID_PATTERN.fullmatch(args.source_task): + raise WorkspaceError(f"Invalid source task: {args.source_task!r}") + mappings = {} + for value in args.mappings: + old_id, new_id = parse_consolidation_mapping(value) + if old_id in mappings: + raise WorkspaceError(f"Duplicate consolidation source task: {old_id}") + mappings[old_id] = new_id + receipt = normalized_publish_path(args.receipt) if args.receipt else None + if receipt is not None and not receipt.startswith("receipts/"): + raise WorkspaceError("The consolidation receipt must be below receipts/") + if mappings and receipt is None: + raise WorkspaceError("A merged consolidation requires a receipt") + if not mappings and receipt is not None: + raise WorkspaceError("A no-merge consolidation must not publish a receipt") + paths = sorted({normalized_publish_path(value) for value in args.paths}) + complete = f"tasks/{args.source_task}/{CONSOLIDATION_COMPLETE}" + if not path_is_covered(complete, paths): + raise WorkspaceError( + "The consolidation completion marker is not covered by a publication path" + ) + if receipt is not None and not path_is_covered(receipt, paths): + raise WorkspaceError( + "The consolidation receipt is not covered by a publication path" + ) + changes = changed_paths(slot) + unexpected = [path for path in changes if not path_is_covered(path, paths)] + if unexpected: + raise WorkspaceError( + "Consolidation changes are outside the explicit publication paths: " + + ", ".join(unexpected) + ) + changed_aliases = { + "/".join(PurePosixPath(path).parts[1:5]) + for path in changes + if path.endswith("/superseded.yaml") + } + if changed_aliases != set(mappings): + raise WorkspaceError( + "Changed superseded aliases do not match explicit mappings: " + + ", ".join(sorted(changed_aliases ^ set(mappings))) + ) + for old_id in mappings: + required = { + f"tasks/{old_id}/state.yaml", + f"tasks/{old_id}/superseded.yaml", + } + if not required.issubset(changes): + raise WorkspaceError( + f"Consolidation does not retire {old_id} with state and alias changes" + ) + old_prefix = f"tasks/{old_id}/" + unexpected_old = [ + path for path in changes + if path.startswith(old_prefix) and path not in required + ] + if unexpected_old: + raise WorkspaceError( + f"Consolidation modifies retained content for {old_id}: " + + ", ".join(unexpected_old) + ) + source_allowed = { + f"tasks/{args.source_task}/{CONSOLIDATION_PENDING}", + f"tasks/{args.source_task}/{CONSOLIDATION_COMPLETE}", + } + if changes and not source_allowed.issubset(changes): + raise WorkspaceError( + "Consolidation must replace the pending marker with a completion marker" + ) + unexpected_source = [ + path for path in changes + if path.startswith(f"tasks/{args.source_task}/") + and path not in source_allowed + ] + if unexpected_source: + raise WorkspaceError( + "Consolidation modifies discovery source content: " + + ", ".join(unexpected_source) + ) + if not mappings: + unexpected_no_merge = [ + path for path in changes + if path not in source_allowed + ] + if unexpected_no_merge: + raise WorkspaceError( + "No-merge consolidation changes non-marker paths: " + + ", ".join(unexpected_no_merge) + ) + else: + for new_id in set(mappings.values()): + required = { + f"tasks/{new_id}/task.md", + f"tasks/{new_id}/state.yaml", + } + if not required.issubset(changes): + raise WorkspaceError( + f"Replacement task is not newly created: {new_id}" + ) + if receipt not in changes: + raise WorkspaceError( + f"Consolidation receipt is not newly written: {receipt}" + ) + counts = unpublished_counts(config) + if changes and counts["slot_only"]: + raise WorkspaceError( + "The AI slot has unpublished commits before consolidation staging" + ) + validate = lambda root: validate_consolidation_tree( + root, + args.source_task, + mappings, + receipt, + ) + validate(slot) + committed = False + if changes: + for path in paths: + if ( + (slot / path).exists() + or any(path_is_covered(change, [path]) for change in changes) + ): + run_git(slot, "add", "-A", "--", path) + unstaged = run_git(slot, "diff", "--name-only").stdout.splitlines() + untracked = run_git( + slot, + "ls-files", + "--others", + "--exclude-standard", + ).stdout.splitlines() + if unstaged or untracked: + raise WorkspaceError( + "Consolidation changes remain unstaged: " + + ", ".join(sorted(set(unstaged + untracked))) + ) + if run_git(slot, "diff", "--cached", "--quiet", check=False).returncode == 0: + raise WorkspaceError("No consolidation state changed") + run_git( + slot, + "commit", + "-m", + f"Consolidate pending tasks after {args.source_task}", + ) + committed = True + elif not counts["slot_only"]: + raise WorkspaceError("No consolidation changes or unpublished commit") + published = publish_slot(config, validate=validate) + print(json.dumps({ + "committed": committed, + "mappings": mappings, + "published": bool(published), + "receipt": receipt, + "source_task": args.source_task, + }, indent=2, sort_keys=True)) + + def validate_receipt_text(text, metadata, label): if metadata["digest"] not in text: raise WorkspaceError( @@ -3030,6 +3564,29 @@ def parse_args(): ) inbox_publish.set_defaults(handler=command_inbox_publish) + consolidate_publish = subparsers.add_parser("consolidate-publish") + add_common_arguments(consolidate_publish) + consolidate_publish.add_argument("--source-task", required=True) + consolidate_publish.add_argument("--receipt") + consolidate_publish.add_argument( + "--mapping", + action="append", + dest="mappings", + default=[], + ) + consolidate_publish.add_argument( + "--path", + action="append", + dest="paths", + required=True, + ) + consolidate_publish.set_defaults(handler=command_consolidate_publish) + + task_content_digest = subparsers.add_parser("task-content-digest") + add_common_arguments(task_content_digest) + task_content_digest.add_argument("--task", required=True) + task_content_digest.set_defaults(handler=command_task_content_digest) + archive_stale = subparsers.add_parser("archive-stale") add_common_arguments(archive_stale) archive_stale.add_argument("--days", type=int, default=90) diff --git a/.agents/skills/process-inbox/scripts/workspace_test.py b/.agents/skills/process-inbox/scripts/workspace_test.py index 275bb89e75..dbf6f622bd 100644 --- a/.agents/skills/process-inbox/scripts/workspace_test.py +++ b/.agents/skills/process-inbox/scripts/workspace_test.py @@ -523,16 +523,258 @@ class WorkspaceTest(unittest.TestCase): } states = {task["id"]: task for task in (approved, blocked)} - resolved = workspace.resolve_task(states, "correct-recent-search-peer-actions") + with tempfile.TemporaryDirectory() as temporary: + resolved = workspace.resolve_task( + Path(temporary), + states, + "correct-recent-search-peer-actions", + ) self.assertEqual(resolved["id"], TASK_ID) + def test_resolve_follows_durable_superseded_task_alias(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + write_task(root, status="todo", claimed_by=None) + old_id = "2026/07/18/old-recent-search-task" + old = root / "tasks" / old_id + old.mkdir(parents=True) + (old / "task.md").write_text("# Old task\n", encoding="utf-8") + content_digest = workspace.retained_task_digest(old) + (old / "superseded.yaml").write_text( + f"""superseded_by: {TASK_ID} +receipt: receipts/2026/07/20/consolidation.md +type: implement +project: null +content_sha256: {content_digest} +""", + encoding="utf-8", + ) + states = workspace.load_states(root) + + resolved = workspace.resolve_task(root, states, old_id) + + self.assertEqual(resolved["id"], TASK_ID) + self.assertEqual(resolved["superseded_from"], old_id) + + def test_dependency_validation_rejects_missing_and_cyclic_edges(self): + first = {**task_state("todo", None), "depends_on": ["missing"]} + with self.assertRaisesRegex(workspace.WorkspaceError, "missing dependencies"): + workspace.validate_dependency_graph({TASK_ID: first}) + + other_id = "2026/07/20/other-task" + first = {**task_state("todo", None), "depends_on": [other_id]} + other = { + **task_state("todo", None), + "id": other_id, + "depends_on": [TASK_ID], + } + with self.assertRaisesRegex(workspace.WorkspaceError, "dependency cycle"): + workspace.validate_dependency_graph({TASK_ID: first, other_id: other}) + + def test_queue_inventory_finds_pending_consolidation_markers(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + directory = write_task(root, status="approved") + marker = directory / workspace.CONSOLIDATION_PENDING + marker.write_text("Created: one\n", encoding="utf-8") + + self.assertEqual(workspace.pending_consolidations(root), [{ + "source_task": TASK_ID, + "marker": f"tasks/{TASK_ID}/{workspace.CONSOLIDATION_PENDING}", + }]) + + def test_generic_publish_recognizes_consolidation_validation(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + git_repo(root) + directory = write_task(root, status="approved") + pending = directory / workspace.CONSOLIDATION_PENDING + pending.write_text("pending\n", encoding="utf-8") + git(root, "add", ".") + git(root, "commit", "-m", "Seed completed task") + pending.unlink() + complete = directory / workspace.CONSOLIDATION_COMPLETE + complete.write_text( + f"# Consolidation\n\nSource: {TASK_ID}\nSTATUS: NO_MERGE\n", + encoding="utf-8", + ) + git(root, "add", "-A") + git( + root, + "commit", + "-m", + f"Consolidate pending tasks after {TASK_ID}", + ) + + validate = workspace.consolidation_validation_for_head(root) + + self.assertIsNotNone(validate) + validate(root) + + def test_no_merge_consolidation_publishes_durable_completion(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + config = inbox_worktrees(root) + main = Path(config["ai_main"]) + slot = Path(config["slot_worktree"]) + source_task = "2026/07/18/active-task" + directory = main / "tasks" / source_task + state = directory / "state.yaml" + state.write_text( + state.read_text(encoding="utf-8") + .replace("status: todo", "status: approved") + .replace("phase: null", "phase: complete"), + encoding="utf-8", + ) + pending = directory / workspace.CONSOLIDATION_PENDING + pending.parent.mkdir() + pending.write_text("# Pending task consolidation\n", encoding="utf-8") + git(main, "add", f"tasks/{source_task}") + git(main, "commit", "-m", "Route follow-ups") + git(slot, "merge", "--ff-only", "master") + slot_directory = slot / "tasks" / source_task + (slot_directory / workspace.CONSOLIDATION_PENDING).unlink() + (slot_directory / workspace.CONSOLIDATION_COMPLETE).write_text( + f"# Consolidation\n\nSource: {source_task}\nSTATUS: NO_MERGE\n", + encoding="utf-8", + ) + out = io.StringIO() + with ( + mock.patch.object(workspace, "worktree_config", return_value=config), + contextlib.redirect_stdout(out), + ): + workspace.command_consolidate_publish(SimpleNamespace( + source_task=source_task, + mappings=[], + receipt=None, + paths=[f"tasks/{source_task}"], + )) + + result = json.loads(out.getvalue()) + self.assertTrue(result["committed"]) + self.assertTrue(result["published"]) + self.assertFalse( + (main / "tasks" / source_task / workspace.CONSOLIDATION_PENDING).exists() + ) + self.assertTrue( + (main / "tasks" / source_task / workspace.CONSOLIDATION_COMPLETE).is_file() + ) + + def test_merged_consolidation_validates_aliases_and_dependencies(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = write_task(root, status="approved") + (source / workspace.CONSOLIDATION_COMPLETE).write_text( + f"# Consolidation\n\nSource: {TASK_ID}\nSTATUS: MERGED\n", + encoding="utf-8", + ) + old_ids = ["2026/07/18/first-task", "2026/07/18/second-task"] + new_id = "2026/07/20/combined-task" + receipt = "receipts/2026/07/20/consolidation.md" + for old_id in old_ids: + directory = root / "tasks" / old_id + directory.mkdir(parents=True) + (directory / "task.md").write_text( + f"# {old_id}\n", encoding="utf-8", + ) + content_digest = workspace.retained_task_digest(directory) + (directory / "superseded.yaml").write_text( + f"""superseded_by: {new_id} +receipt: {receipt} +type: implement +project: null +content_sha256: {content_digest} +""", + encoding="utf-8", + ) + new = root / "tasks" / new_id + new.mkdir(parents=True) + (new / "task.md").write_text("# Combined task\n", encoding="utf-8") + (new / "state.yaml").write_text( + f"""status: todo +type: implement +created: 2026-07-20 +project: null +depends_on: [] +claimed_by: null +claimed_at: null +claim_order: null +lease_until: null +phase: null +inbox_receipt: {receipt} +""", + encoding="utf-8", + ) + receipt_path = root / receipt + receipt_path.parent.mkdir(parents=True) + receipt_path.write_text( + "\n".join(old_ids + [new_id]) + "\n", + encoding="utf-8", + ) + mappings = {old_id: new_id for old_id in old_ids} + + workspace.validate_consolidation_tree( + root, + TASK_ID, + mappings, + receipt, + ) + (root / "tasks" / old_ids[0] / "task.md").write_text( + "# Late changed acceptance\n", encoding="utf-8", + ) + with self.assertRaisesRegex( + workspace.WorkspaceError, + "retained content changed", + ): + workspace.validate_consolidation_tree( + root, + TASK_ID, + mappings, + receipt, + ) + (root / "tasks" / old_ids[0] / "task.md").write_text( + f"# {old_ids[0]}\n", encoding="utf-8", + ) + + dependent = root / "tasks" / "2026/07/20/racing-dependent" + dependent.mkdir(parents=True) + (dependent / "task.md").write_text( + "# Racing dependent\n", encoding="utf-8", + ) + (dependent / "state.yaml").write_text( + f"""status: todo +type: implement +created: 2026-07-20 +project: null +depends_on: [{old_ids[0]}] +claimed_by: null +claimed_at: null +claim_order: null +lease_until: null +phase: null +inbox_receipt: receipts/2026/07/20/race.md +""", + encoding="utf-8", + ) + with self.assertRaisesRegex(workspace.WorkspaceError, "missing dependencies"): + workspace.validate_consolidation_tree( + root, + TASK_ID, + mappings, + receipt, + ) + def test_retry_reopens_owned_blocked_task_and_resets_routing_marker(self): with tempfile.TemporaryDirectory() as temporary: slot = Path(temporary) directory = write_task(slot) routed = directory / "work" / "discovered-routed.md" routed.write_text("routed\n", encoding="utf-8") + pending = directory / workspace.CONSOLIDATION_PENDING + pending.write_text("pending\n", encoding="utf-8") + complete = directory / workspace.CONSOLIDATION_COMPLETE + complete.write_text("complete\n", encoding="utf-8") config = { "checkout_tag": "macbook-twork", "slot_worktree": str(slot), @@ -549,6 +791,8 @@ class WorkspaceTest(unittest.TestCase): self.assertEqual(state["status"], "in-progress") self.assertEqual(state["phase"], "resume") self.assertFalse(routed.exists()) + self.assertFalse(pending.exists()) + self.assertFalse(complete.exists()) commit.assert_not_called() def test_start_atomically_assigns_unclaimed_todo(self): diff --git a/.claude/ai-workflow-adapter.md b/.claude/ai-workflow-adapter.md index 5d05c2703e..6572eb0d19 100644 --- a/.claude/ai-workflow-adapter.md +++ b/.claude/ai-workflow-adapter.md @@ -12,8 +12,11 @@ This file adapts harness mechanics and removes unnecessary text normalization. - Treat `fork_turns: "none"` as a fresh Agent invocation with a self-contained prompt containing exact repository, task, artifact, and input paths. Do not rely on the parent conversation being inherited. -- Tell every leaf Agent not to delegate and never to commit. Preserve the - shared workflow's single-writer and one-stateful-performer constraints. +- Tell every disposable phase leaf Agent not to delegate and never to commit. + Publication-owning orchestrators — `process-inbox`, `perform-task`, discovered + routing, and pending-task consolidation — follow the shared workflow's exact + helper, commit, and publication contract instead. Preserve its single-writer + and one-stateful-performer constraints. - Every phase leaf and the performer inherit the parent model, as the shared workflow says. Do not pass a model override on the Agent call: its family aliases already resolve to the newest permitted model of that family, so an @@ -44,13 +47,13 @@ This file adapts harness mechanics and removes unnecessary text normalization. - If the first real leaf Agent is rejected before work begins because nested delegation is unavailable, use the shared same-session fallback. Do not treat mere presence of the Agent tool as a successful delegation probe. -- Whenever an Agent is asked to run `process-inbox`, `perform-task`, or - discovered-task routing — the orchestrating roles — explicitly tell it to - read this adapter completely before the applicable shared skill or - reference. Do NOT tell leaf phase agents to read this adapter: their phase - prompts are self-contained and already carry the leaf rules (no delegation, - no commits, progress and reply contracts); an adapter read there is wasted - context. +- Whenever an Agent is asked to run `process-inbox`, `perform-task`, + discovered-task routing, or pending-task consolidation — the orchestrating + roles — explicitly tell it to read this adapter completely before the + applicable shared skill or reference. Do NOT tell leaf phase agents to read + this adapter: their phase prompts are self-contained and already carry the + leaf rules (no delegation, no commits, progress and reply contracts); an + adapter read there is wasted context. ## Text handling diff --git a/.claude/commands/continue.md b/.claude/commands/continue.md index b4d310517f..92bb253ad9 100644 --- a/.claude/commands/continue.md +++ b/.claude/commands/continue.md @@ -19,10 +19,19 @@ follow-up requires its affirmative independence test. Continue until that frozen-and-derived batch reaches the scheduler's normal stop condition or a global hard stop. -Every inbox worker, performer, and discovered-task routing Agent must be told -in its initial prompt to read `.claude/ai-workflow-adapter.md` completely before -the applicable shared skill or reference. Use the Agent tool for those workers; -do not start Claude subprocesses through Bash. +Apply the shared skill's pending-task consolidation phase too. At each eligible +clean AI-slot boundary, recover an older `pending_consolidations` marker or +consolidate newly routed tasks in one fresh foreground Agent. Give it the +effective frozen batch, keep its queue scan and merge reasoning outside this +scheduler context, and require it to read the complete shared consolidation +reference. Never run consolidation inside the performer or discovery-routing +Agent, and defer it while the active task owns dirty local phase state. + +Every inbox worker, performer, discovered-task routing Agent, and pending-task +consolidation Agent must be told in its initial prompt to read +`.claude/ai-workflow-adapter.md` completely before the applicable shared skill +or reference. Use the Agent tool for those workers; do not start Claude +subprocesses through Bash. Arguments, when present, are natural-language hints for new shared work; their wording decides whether they merely prioritize or strictly restrict selection: diff --git a/.claude/commands/perform-task.md b/.claude/commands/perform-task.md index 241b469809..55ab59d8ed 100644 --- a/.claude/commands/perform-task.md +++ b/.claude/commands/perform-task.md @@ -9,7 +9,8 @@ Read `.claude/ai-workflow-adapter.md` and `.agents/skills/perform-task/SKILL.md` completely. Follow the shared skill with the Claude adapter's delegation and text-handling substitutions. Resolve, start or resume, implement, verify, and publish only the named task. Do not -continue with other queue work afterward. +continue with other queue work, route discoveries, or consolidate pending tasks +afterward; those post-result phases belong to the `continue` scheduler. Apply the shared pipeline's conditional `[ai] ` commit-subject rule exactly; the prefix is required for permanent test-helper or agent harness/documentation