diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e585ba8c..be8df3ad 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "agent-engineering-toolkit", - "version": "1.10.0", + "version": "1.11.0", "description": "Multi-plugin marketplace for evidence-based Claude Code artifact engineering. Provides plugins for configuration initialization (AGENTS.md, CLAUDE.md), artifact creation/optimization (skills, hooks, rules, subagents), and autonomous backlog orchestration.", "owner": { "name": "rodrigorjsf", @@ -33,7 +33,7 @@ { "name": "orchestrate", "description": "Autonomously orchestrate a backlog of ready-for-agent GitHub issues — dependency-ordered waves, implementer and reviewer subagents in isolated git worktrees, checkpointed and resumable.", - "version": "1.6.0", + "version": "1.7.0", "author": { "name": "rodrigorjsf", "email": "rodrigo_rjsf@hotmail.com" diff --git a/.claude/rules/agent-files.md b/.claude/rules/agent-files.md index 1e1e1931..3770a375 100644 --- a/.claude/rules/agent-files.md +++ b/.claude/rules/agent-files.md @@ -11,4 +11,4 @@ paths: - `tools:` default to read-only (`Read, Grep, Glob, Bash`) — new agent types may require additional tools when justified - `maxTurns:` defaults: 15 for codebase/scope agents, 20 for evaluator agents — adjust per agent when justified - Prompt must request structured output format -- Agents cannot spawn other agents (Task tool unavailable in agent context) +- Agents spawn other agents only when `Agent` is listed in `tools:` — omit it unless the agent's role is to delegate diff --git a/CONTEXT.md b/CONTEXT.md index 17a4d4d1..f3691292 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -172,7 +172,7 @@ Removal of a concluded run's run directory, worktrees, and umbrella/slice branch _Avoid_: purge, garbage collection, prune **Pre-flight pass** (`/orchestrate preflight `): -The third orchestrate mode — alongside the normal run and **Run cleanup** — that performs a run's one-time setup (config bootstrap, backlog fetch, **Run partition**, wave planning, umbrella branch, and the first validated `run-state.json` checkpoint) and then **stops before the wave loop**, leaving an `in-progress` run whose slices are all `pending` and `completedWaves: 0` — so the operator can review the partition and wave plan at the checkpoint before committing the expensive wave loop. A later `/orchestrate ` in a fresh session resumes from that checkpoint via the unchanged exactly-one-match resume path — no new resume semantics. Its value is this **staged-inspection gate**, not token relocation (post-#293 the bootstrap residue it shifts is ~2% of a ~1M execution window). The pass is **detect-and-stop**: when a run already exists for PRD `` it reports that run's state and stops rather than re-deriving scope, entering the wave loop, or creating a duplicate (so a concluded-but-uncleaned run blocks a re-bootstrap until `/orchestrate clean`). Distinct from the **Config bootstrapper** (`bootstrap_config`), which is only the first setup step of the pass; the pre-flight pass is the whole steps-1–6 setup that ends at the resumable checkpoint. The pass is an orchestrator mode, never a privileged subagent — see ADR-0014. +The third orchestrate mode — alongside the normal run and **Run cleanup** — that performs a run's one-time setup (config bootstrap, backlog fetch, **Run partition**, wave planning, umbrella branch, and the first validated `run-state.json` checkpoint), then **probes the capability gate** — executing the install step and each configured verb once in a throwaway dependency-free detached checkout, and reporting each verb as passed, failed, broken configuration, or not configured — and then **stops before the wave loop**, leaving an `in-progress` run whose slices are all `pending` and `completedWaves: 0` — so the operator can review the partition, the wave plan, and what the gate will actually verify at the checkpoint before committing the expensive wave loop. The probe runs **after** the checkpoint is written, so a failing probe reports loudly without blocking resumption; it creates no branch and removes its checkout. A later `/orchestrate ` in a fresh session resumes from that checkpoint via the unchanged exactly-one-match resume path — no new resume semantics. Its value is this **staged-inspection gate**, not token relocation (post-#293 the bootstrap residue it shifts is ~2% of a ~1M execution window). The pass is **detect-and-stop**: when a run already exists for PRD `` it reports that run's state and stops rather than re-deriving scope, entering the wave loop, or creating a duplicate (so a concluded-but-uncleaned run blocks a re-bootstrap until `/orchestrate clean`). Distinct from the **Config bootstrapper** (`bootstrap_config`), which is only the first setup step of the pass; the pre-flight pass is the whole steps-1–6 setup that ends at the resumable checkpoint. The pass is an orchestrator mode, never a privileged subagent — see ADR-0014. _Avoid_: bootstrap, bootstrap mode (bootstrap names the `bootstrap_config` config step, not the whole pre-flight pass) **Backlog partitioner**: @@ -200,7 +200,7 @@ A plain object with the four auto-detected capability verb keys (`tests`, `typec _Avoid_: command config, verb table, command dictionary **Config bootstrapper** (`bootstrap_config` MCP tool): -The integration module (`orchestrate-mcp/src/tools/bootstrap-config.ts`) that makes a first-ever orchestrate run set up its own `.orchestrate/` configuration. It composes the **Capability detector** to write a project-aware `commands.json`, derives `handoff.json`'s context-window size from the running model (passed as a tool input, since the MCP process cannot see the calling model — unknown or absent falls back to 200000), writes `routing.json` from shipped defaults, creates the **Run directory** parent `.orchestrate/runs/`, and idempotently appends `.orchestrate/runs/` to the target repository's `.gitignore`. Every step is individually idempotent — a committed config file is never overwritten. +The integration module (`orchestrate-mcp/src/tools/bootstrap-config.ts`) that makes an orchestrate run set up its own `.orchestrate/` configuration. It composes the **Capability detector** to write a project-aware `commands.json`, derives `handoff.json`'s context-window size from the running model (passed as a tool input, since the MCP process cannot see the calling model — unknown or absent falls back to 200000), writes `routing.json` from shipped defaults, creates the **Run directory** parent `.orchestrate/runs/`, and idempotently appends `.orchestrate/runs/` to the target repository's `.gitignore`. Every step is individually idempotent — a committed config file is never overwritten. _Avoid_: config generator, init tool, setup wizard **Result envelope**: @@ -220,11 +220,11 @@ A slice whose own work creates the project's build manifest — e.g. the first s _Avoid_: bootstrap slice, scaffold slice (skeleton-first names the temporal property: the manifest is created by the work, not pre-existing) **Implementer `incomplete` status**: -The third value of the implementer **Result envelope**'s `status` enum — alongside `completed` and `blocked`, and unique to the implementer role. It is the implementer's *graceful* turn-budget self-report: when the implementer foresees it cannot finish every acceptance criterion within its remaining turns, it stops cleanly and emits `status: "incomplete"` with the partial work recorded, rather than being cut off mid-sentence. Distinct from `blocked` (an unrecoverable obstacle — more turns would not help) and from a hard turn-limit cutoff (which truncates the envelope into an unclosed fence the **Envelope validator** reports `invalid`). A single `incomplete` does **not** FAIL the slice immediately: the envelope carries a `remainingWork` handoff, and the orchestrator re-spawns the implementer in the same preserved worktree — the bounded continue-in-place loop — until it returns `completed` or the run-wide continuation budget is exhausted. The slice FAILs from `incomplete` only when the budget runs out (resumable → `needs-info`) or a continuation makes no worktree progress (the no-progress guard → `needs-triage`). It is never a new `run-state.json` slice `state` value — the continuation counter and fingerprint are within-session loop state, never persisted. +The third value of the implementer **Result envelope**'s `status` enum — alongside `completed` and `blocked`, and unique to the implementer role. It is the implementer's *graceful* turn-budget self-report: when the implementer foresees it cannot finish every acceptance criterion within its remaining turns, it stops cleanly and emits `status: "incomplete"` with the partial work recorded, rather than being cut off mid-sentence. Distinct from `blocked` (an unrecoverable obstacle — more turns would not help) and from a hard turn-limit cutoff (which truncates the envelope into an unclosed fence the **Envelope validator** reports `invalid`). A single `incomplete` does **not** FAIL the slice immediately: the envelope carries a `remainingWork` handoff, and the **Slice executor** re-spawns the implementer in the same preserved worktree — the bounded continue-in-place loop — until it returns `completed` or the run-wide continuation budget is exhausted. The slice FAILs from `incomplete` only when the budget runs out (resumable → `needs-info`) or a continuation makes no worktree progress (the no-progress guard → `needs-triage`). It is never a new `run-state.json` slice `state` value; the continuation counter and worktree fingerprint are persisted to the executor's own **Slice progress record**, so the bound survives an executor resume. _Avoid_: partial status, timed-out status (it is a proactive self-report, not a passively-observed timeout) **Changeset scope check**: -The orchestrator's post-implementer verification, the `verify_changeset` MCP tool, run after every implementer returns and before a `completed` envelope is trusted. It inspects the slice worktree directly with `git status` and compares the implementer's declared `filesChanged` against what actually changed on disk, returning a `match` verdict — `matched`, `clean`, `mismatch`, `empty-but-declared` (the implementer's edits never landed), or `suspiciously-empty` (the work was under-reported). It is a cheap set comparison, not a semantic scope check: it never parses the issue body and never judges whether the changed files are the *right* files. +The **Slice executor**'s post-implementer verification, the `verify_changeset` MCP tool, run after every implementer returns and before a `completed` envelope is trusted. It inspects the slice worktree directly with `git status` and compares the implementer's declared `filesChanged` against what actually changed on disk, returning a `match` verdict — `matched`, `clean`, `mismatch`, `empty-but-declared` (the implementer's edits never landed), or `suspiciously-empty` (the work was under-reported). It is a cheap set comparison, not a semantic scope check: it never parses the issue body and never judges whether the changed files are the *right* files. _Avoid_: scope validator, diff checker (it compares declared-vs-actual file sets, it does not validate semantic scope) **Envelope validator**: @@ -232,7 +232,7 @@ The deterministic `validate_envelope` MCP tool that classifies a subagent's retu _Avoid_: envelope parser, schema checker (validator is the contract name; it classifies, it does not merely parse) **Worktree fallback**: -The orchestrator's recovery path, the `recover_changed_files` MCP tool, for when a subagent's **Result envelope** is missing or invalid: it inspects the slice worktree directly with `git status` and returns the full changed-file set (build artifacts included), treating the worktree as the source of truth. Applies to the implementer, reviewer, and conflict-resolver only — the read-only investigator leaves no worktree changes to recover. +The recovery path for when a worker's **Result envelope** is missing or invalid, the `recover_changed_files` MCP tool: it inspects the slice worktree directly with `git status` and returns the full changed-file set (build artifacts included), treating the worktree as the source of truth. Applies to the implementer, reviewer, and conflict-resolver only — the read-only investigator leaves no worktree changes to recover. It belongs to whoever holds the worker's envelope: the orchestrator on the conflict-resolver path it still runs itself. When a **Slice executor**'s own envelope is missing or invalid, the orchestrator's recovery path is `recover_slice_progress` against the **Slice progress record**, not this tool. _Avoid_: git-status recovery, changed-file scan (worktree fallback is the precise term — it is the fallback, not the primary path) **Subagent advisor policy** (superseded): @@ -244,7 +244,7 @@ The standing constraint — expressed as a `## Scope-boundary guard` section in _Avoid_: scope check, brief filter (the guard is a positive constraint on what the brief may contain, enforced at two points — inside the investigator definition and at the orchestrator boundary) **Orchestrator judgment spine**: -The irreducible body of the orchestrate `SKILL.md` that remains after **MCP-first decomposition** — the roles & safety boundary, the two-axis complexity-tier assessment, wave-concurrency policy, failure-cause narration, and checkpoint/resume semantics. It is the residue that cannot be extracted to an `orchestrate-mcp` tool or a subagent because it is non-mechanizable orchestration judgment. After the #275 procedural-prose relocation the spine lands near ~425 lines — refined down from the ~750 the decomposition first projected — which is now *within* the project's 500-line `SKILL.md` body cap; the documented over-cap exception for this spine remains on record (ADR-0013) so it is never flagged as bloat should its judgment grow back over the cap, and is orchestrate-specific (not generalized to other skills). Deterministic procedure is extracted to MCP tools (no execution-permission prompt); judgment-bearing procedure is relocated to on-demand `references/` (loaded only when its phase runs, outside the smart zone); only judgment stays in the always-loaded spine. See ADR-0013. +The irreducible body of the orchestrate `SKILL.md` that remains after **MCP-first decomposition** — the roles & safety boundary, the two-axis complexity-tier assessment, wave-concurrency policy, the slice-executor briefing contract, failure-cause narration, the read boundary on slice-internal artifacts, and checkpoint/resume semantics. It is the residue that cannot be extracted to an `orchestrate-mcp` tool or a subagent because it is non-mechanizable orchestration judgment. The #275 procedural-prose relocation refined it to ~425 lines — down from the ~750 the decomposition first projected — but the ADR-0017 delegation then moved the intra-slice procedure out to the slice executor while adding the briefing contract, the structured envelope-recovery path, and the read boundary, leaving the spine at **542 lines of body**. It therefore *exercises* the documented over-cap exception recorded in ADR-0013, explicitly and at that measured figure, rather than merely holding it in reserve; the exception is orchestrate-specific (not generalized to other skills). Deterministic procedure is extracted to MCP tools (no execution-permission prompt); judgment-bearing procedure is relocated to on-demand `references/` (loaded only when its phase runs, outside the smart zone); only judgment stays in the always-loaded spine. See ADR-0013. _Avoid_: orchestrator core, skill body (the spine is specifically what remains after extraction, not the whole file or its runtime) **Routing variant**: @@ -275,6 +275,10 @@ _Avoid_: task-executor (task collides with the harness's own task tools, and **s The per-slice record a **Slice executor** writes at each completed stage, and the anchor an interrupted slice resumes from. It preserves the resume granularity that per-transition `subState` writes give today, now that one executor spawn spans several stages. The executor owns the record and reads it back to resume itself; the orchestrator never opens it, obtaining its contents only through a dedicated MCP tool when an envelope is missing or invalid — the same structured-recovery path as the **Worktree fallback**. It also carries the once-only **Model fallback** guard, which is why that guard survives a handoff without living in the orchestrator's checkpoint. _Avoid_: slice state, slice checkpoint (the run-state checkpoint is the orchestrator's; this record is the executor's) +**Read guard**: +The plugin-level `PreToolUse` **Hook** that mechanically denies the orchestrator a read of a slice-internal artifact — the **Slice progress record** and the slice report — and returns a reason naming the correct behaviour instead. It tells the orchestrator from a subagent by the agent identity the hook event carries, is scoped to `Read` and `Bash`, and is a silent no-op with no run in progress. It is **defence in depth for the prose rule, never a replacement**: policy can disable plugin hooks, and `@`-referenced files reach the model without any tool call at all, so the boundary stated in the orchestrator's own instructions stays load-bearing. See ADR-0017. +_Avoid_: read block, permission rule (a permission rule was the rejected alternative — it would restrict the executor too and carries no corrective message) + **Failure class**: The closed enum a **Slice executor** returns naming *why* a slice failed, alongside the prose failure reason. It exists because the layer holding the evidence and the layer holding tracker authority are no longer the same one: the executor observes the failure and classifies it, and the orchestrator maps the class to a triage label and writes it. Classification follows the evidence; labelling policy stays with the single writer. _Avoid_: failure reason (that is the prose companion, not the enum), error code diff --git a/README.md b/README.md index 026077cf..19eea420 100644 --- a/README.md +++ b/README.md @@ -149,9 +149,9 @@ agent-engineering-toolkit/ │ ├── .claude-plugin/plugin.json │ ├── .mcp.json # Registers the orchestrate-mcp server │ ├── README.md # Full plugin documentation -│ ├── hooks/ # context-watchdog PostToolUse hook -│ ├── skills/ # 1 skill: orchestrate -│ ├── agents/ # 8 subagents: {investigator,implementer,reviewer,conflict-resolver}-{standard,deep} +│ ├── hooks/ # 3 hooks: SessionStart, PreToolUse read guard, PostToolUse watchdog +│ ├── skills/ # 2 skills: orchestrate, slice-pipeline +│ ├── agents/ # 10 subagents: {investigator,implementer,reviewer,conflict-resolver,slice-executor}-{standard,deep} │ ├── templates/ # commands.json, routing.json, handoff.json │ └── orchestrate-mcp/ # MCP server (worktree, capability, routing, render, search tools) ├── skills/ # Standalone distribution — npx skills add compatible diff --git a/plugins/orchestrate/.claude-plugin/plugin.json b/plugins/orchestrate/.claude-plugin/plugin.json index 399871a4..0f8e16ff 100644 --- a/plugins/orchestrate/.claude-plugin/plugin.json +++ b/plugins/orchestrate/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "orchestrate", - "version": "1.6.0", + "version": "1.7.0", "description": "Autonomously orchestrate a backlog of ready-for-agent GitHub issues — dependency-ordered waves, implementer and reviewer subagents in isolated git worktrees, checkpointed and resumable.", "author": { "name": "rodrigorjsf" diff --git a/plugins/orchestrate/README.md b/plugins/orchestrate/README.md index c240388b..44f55efa 100644 --- a/plugins/orchestrate/README.md +++ b/plugins/orchestrate/README.md @@ -4,7 +4,7 @@ Autonomously drive a backlog of `ready-for-agent` GitHub issues from open to rev ## Cost and Model Guidance -A single orchestrate run drives an entire backlog — for every issue it spawns an implementer and a reviewer subagent (and an investigator for standard- and complex-tier issues), each in its own context. Cost scales with the size of the backlog and how many issues route to the `deep` routing variant. +A single orchestrate run drives an entire backlog — for every issue it spawns a slice-executor subagent, which in turn spawns an implementer and a reviewer (and an investigator for standard- and complex-tier issues), each in its own context. Cost scales with the size of the backlog and how many issues route to the `deep` routing variant. **Recommended model:** Claude Opus for the orchestrator — wave planning, complexity assessment, and conflict handling are judgment-heavy. Per-role models are set per complexity tier in `routing.json` (see the configuration reference). @@ -19,7 +19,7 @@ Given a repository with open issues labelled `ready-for-agent`, one `/orchestrat 1. **Reads the backlog** — every open `ready-for-agent` issue, with its **Blocked by** dependencies and an assessed complexity tier (`trivial`, `standard`, `complex`). The tier weighs two axes — conceptual difficulty and *fan-out* (the number of independent targets the slice touches) — so a wide-but-simple slice is tiered up purely for the larger turn budget and an investigation pass. Investigator is skipped for trivial-tier slices only. 2. **Plans dependency waves** — a topological sort so every issue's blockers resolve in an earlier wave; a dependency cycle is reported and stops the run cleanly. 3. **Cuts an umbrella branch** from `development` — every slice's pull request merges into it, never directly into `development`. -4. **Processes each slice** in its own isolated worktree — routed investigator (standard and complex tiers; skipped for trivial), implementer, then reviewer; then commit, push, open a slice pull request, and squash-merge it into the umbrella branch. +4. **Processes each slice** in its own isolated worktree — it spawns a routed `slice-executor` subagent that runs the investigator (standard and complex tiers; skipped for trivial), implementer, and reviewer inside that worktree, then commits, pushes, opens a slice pull request, and squash-merges it into the umbrella branch. 5. **Resolves merge conflicts** once per conflicting slice via a dedicated conflict-resolver subagent. 6. **Checkpoints** the run's `run-state.json` (under the per-run directory `.orchestrate/runs//`) after every step — an interrupted run re-invoked with `/orchestrate` skips every completed slice and continues. 7. **Hands off** to a fresh Claude Code session when the orchestrator's context window fills, so a long run survives without degrading. @@ -39,8 +39,9 @@ Every other role is a **subagent**, spawned with the standard Agent tool by its | `implementer` | standard, deep | Edits code inside one worktree; verifies via the capability tools. No Bash, no git. | | `reviewer` | standard, deep | Reviews the slice in the worktree, fixes issues inline, re-runs the capability tools, gates the merge. No Bash, no git. | | `conflict-resolver` | standard, deep | Edits conflicted files to a correct merged state. No Bash, no git. | +| `slice-executor` | standard, deep | Owns one slice end to end — spawns the investigator, implementer, and reviewer, validates each result envelope, and runs the Capability gate. Its operating procedure is the preloaded `slice-pipeline` skill. No Bash, no git. The orchestrator spawns it as step 3 of processing each slice. `routing.json` routes the trivial and standard tiers to the `standard` variant and the complex tier to `deep`; each variant's effort is fixed in its agent definition, not chosen per invocation — `standard` runs at `high`, `deep` at `xhigh`. | -This is the **no-Bash safety model**: the subagents have no shell and no git access. They are sandboxed to a single worktree, and the investigator cannot write at all. Only the orchestrator runs commands, touches branches and remotes, and writes to the issue tracker. A subagent cannot push, cannot merge, cannot edit an issue, and cannot reach outside its worktree — so the blast radius of any one subagent is one directory. +This is the **no-Bash safety model**: the subagents have no shell and no git access. They are sandboxed to a single worktree, and the investigator cannot write at all. Only the orchestrator runs commands, touches branches and remotes, and writes to the issue tracker. A subagent cannot push, cannot merge, cannot edit an issue, and cannot reach outside its worktree — so the blast radius of any one subagent is one directory. The `slice-executor` is the one subagent granted the `Agent` tool, so that it can spawn the workers its slice needs; the four worker roles are granted no `Agent` tool at all, which is what keeps them leaves of the agent tree. Every subagent ends its turn with a machine-checkable **result envelope** — a fenced ` ```orchestrate-envelope ` JSON block conforming to a per-role schema. The orchestrator reads a subagent's status and changed-file set only from this validated envelope (via the `validate_envelope` tool), never from its prose — so a turn that was truncated or cut short is detected, never silently accepted. When an envelope is missing or invalid, the orchestrator recovers the worktree's changed-file set by inspecting it directly with `recover_changed_files`, treating the worktree as the source of truth. @@ -50,17 +51,18 @@ The plugin bundles `orchestrate-mcp`, a Model Context Protocol server providing | Tool | Purpose | |------|---------| -| `bootstrap_config` | Set up a repository's `.orchestrate/` config on a first-ever run — project-aware `commands.json`, model-derived `handoff.json`, default `routing.json`, the run directory, and the `.gitignore` entry | +| `bootstrap_config` | Complete a repository's `.orchestrate/` config — project-aware `commands.json`, model-derived `handoff.json`, default `routing.json`, the run directory, and the `.gitignore` entry — writing only whichever files are missing, and reporting `capabilities`/`falseGreenRisk` for the config's final state | | `create_worktree` / `remove_worktree` | Git worktree lifecycle — isolated per-slice checkouts | | `push_and_verify` | Push a slice branch and verify it actually landed on the remote (SHA-match `git ls-remote` check + bounded backoff) — fails loud when an exit-0 push never lands; git-only, never shells `gh` | | `finalize_slice` | Land one reviewed slice's git + run-state mechanics in two phases: `commit-push` (stage exactly the named files, guard an empty changeset, commit with `Closes #`, compose `push_and_verify`, checkpoint `subState: pushed`) and `post-merge` (checkpoint `subState: merged`, remove the worktree, reclaim the local branch). Forge ops and the `pr-open` checkpoint stay in the spine; git-only, never shells `gh` | | `run_tests` / `run_typecheck` / `run_build` / `run_lint` | Run the project's configured capability commands | -| `run_wave` | A family of bracketed deterministic wave-loop operations behind one tool, selected by the `operation` discriminant — so only the higher-level policy that decides how a wave processes its slices stays the orchestrator's concern. `refresh-base` (fast-forward the local umbrella ref to its remote tip via a `git merge-base` ancestor proof, or report `diverged` and leave the ref untouched), `select-processable` (gate one slice on its in-partition and out-of-partition blocker states — consumed from state passed in, never read with `gh`), `reverify-slice` (no-op for the first merged slice; else fetch + merge the umbrella into the worktree and run the tests + build verbs, returning `passed`/`failed{which}`/`conflict` — a conflict is flagged in place, never resolved), and `integration-gate` (run the per-wave integration suite → `proceed`/`halt`/`tolerate`). Git-only, run-scoped, never throws | +| `run_wave` | A family of bracketed deterministic wave-loop operations behind one tool, selected by the `operation` discriminant — so only the higher-level policy that decides how a wave processes its slices stays the orchestrator's concern. `refresh-base` (fast-forward the local umbrella ref to its remote tip via a `git merge-base` ancestor proof, or report `diverged` and leave the ref untouched), `select-processable` (gate one slice on its in-partition and out-of-partition blocker states — consumed from state passed in, never read with `gh`), `reverify-slice` (no-op for the first merged slice; else fetch + merge the umbrella into the worktree and run the tests + build verbs, returning `passed`/`failed{which}`/`conflict` — a conflict is flagged in place, never resolved), `integration-gate` (run the per-wave integration suite → `proceed`/`halt`/`tolerate`), `plan-wave-width` (cap a wave's in-flight slices against the session's concurrent-subagent limit — two agent slots per slice, so half the limit, floored at 1 — returning `waveWidth` + a `deferredCount` that keeps its state), and `classify-spawn-outcome` (classify an observed spawn refusal as `backpressure` — requeue the slice unchanged, never fail it — or `spawn-error`, keeping a spent session spawn budget distinguishable). Git-only, run-scoped, never throws | | `resolve_merge_conflict` | The two deterministic git operations around the conflict-resolver spawn, behind one tool selected by the `operation` discriminant — the resolver spawn, envelope validation, clean-path re-verify, and attempt-once policy stay in the spine. `prepare` (re-entrant recovery first — abort a stale in-progress merge before the fresh fetch + merge of the umbrella into the worktree, returning `clean` (auto-committed, nothing to resolve) or `conflicted{conflictedFiles}`, a rename-conflict emitting both paths) and `finalize` (stage the resolved set, scan the staged diff for residual conflict markers, complete the merge commit → `completed`, or `markers_remain` with the merge aborted leaving the worktree clean). Git-only, run-scoped, never shells `gh`, never throws | | `plan_waves` | Topologically sort issues into dependency waves; detects cycles | -| `resolve_routing` | Resolve the model and routing variant for each role from a complexity tier | -| `validate_envelope` | Validate a subagent's result envelope against its role schema — distinguishes a valid, a truncated/invalid, and a missing envelope. The implementer status carries `completed`, `incomplete` (a graceful turn-budget self-report), and `blocked` | +| `resolve_routing` | Resolve the model and routing variant for each role — investigator, implementer, reviewer, conflict-resolver, and slice-executor (ADR-0017, #356) — from a complexity tier. A routing.json predating the role still resolves: `slice-executor` defaults to the tier's own `implementer` entry, flagged with a warning | +| `validate_envelope` | Validate a subagent's result envelope against its role schema — distinguishes a valid, a truncated/invalid, and a missing envelope. The implementer status carries `completed`, `incomplete` (a graceful turn-budget self-report), and `blocked`. The schema also defines the `slice-executor` envelope shape (ADR-0017) describing a whole slice's outcome, with a `failureClass` drawn from a closed set | | `recover_changed_files` | Recover a worktree's changed-file set by inspecting it directly — the orchestrator's fallback when an envelope is missing or invalid | +| `recover_slice_progress` | Read and validate one slice's progress record at `.orchestrate/runs//slice--progress.json` — the resume anchor a slice executor writes at each completed stage (ADR-0017), carrying the last completed stage, the investigator brief, the continuation count, the worktree fingerprint, and the once-only model-fallback guard. The orchestrator's structured recovery when a slice-executor envelope is missing or invalid: it obtains the record's contents through this tool instead of opening the file, so the data is validated and the read boundary holds. The path is derived from `runId` + `issue` — no file path is accepted — so a read can never leave the run's own directory; reports a missing record distinctly from a malformed one, and never throws. Reads only | | `verify_changeset` | Compare a worktree's actual changeset against the file set an implementer declared — the post-implementer scope check before a `completed` envelope is trusted | | `resolve_cleanup_verdicts` | The PURE verdict logic of the start-of-run cleanup sweep, in two phases: `enumerate` (apply the `completed && finalPullRequest != null` eligibility gate to the parsed run-states, return the deduplicated final-PR identifiers the spine fetches with `gh pr view`) and `classify` (turn the fetched `{state, mergedAt}` facts into the four-way `merged`/`open`/`closed-unmerged`/`unknown` verdict `clean_runs` consumes, capturing each merged run's passed-slice close-set in the same pass). No fs/git/`gh` — the fetch loop, `gh issue close`, and `clean_runs`' removal stay in the spine | | `clean_runs` | Remove a concluded run's worktrees, branches, and run directory once its final pull request has merged — git + filesystem only | @@ -86,57 +88,36 @@ The `detect-project` module (`orchestrate-mcp/src/tools/detect-project.ts`) auto | `Makefile` | Make | `make test`, `make typecheck`, `make build`, `make lint` | | _(none found)_ | none | empty map — no capability tool is wired to a failing command | -**Usage example** (TypeScript): - -```typescript -import { detectCommandMap } from "./tools/detect-project.js"; - -// Detect from a repository root — returns the command map or {} if unrecognized. -const map = detectCommandMap("/path/to/repo"); -// For a repo with package.json: -// { tests: ["npm", "test"], typecheck: ["npm", "run", "typecheck"], -// build: ["npm", "run", "build"], lint: ["npm", "run", "lint"] } - -// Or use the pure functions directly (no I/O): -import { detectProjectType, buildCommandMap } from "./tools/detect-project.js"; -const type = detectProjectType(["Cargo.toml", "Makefile"]); // "cargo" -const commands = buildCommandMap(type); // cargo argv arrays -``` - -A manifest-less repository yields `{}` — never an npm fallback — so no capability tool is ever wired to a command guaranteed to fail. +A manifest-less repository yields `{}` — never an npm fallback — so no capability tool is ever wired to a command guaranteed to fail. `detectCommandMap(repoPath)` is the module's entry point; `detectProjectType` and `buildCommandMap` are the underlying pure functions for a caller that already has the manifest list. ### Config bootstrapping -The `bootstrap_config` MCP tool makes a first-ever run set up its own `.orchestrate/` configuration with no manual steps. On a fresh run — when the repository has no `.orchestrate/` directory — the orchestrator calls it before planning the backlog. It: +The `bootstrap_config` MCP tool completes a repository's `.orchestrate/` configuration with no manual steps — writing whichever of its three files are missing. **The orchestrator calls it unconditionally at the start of every run, never gated on whether `.orchestrate/` already exists**: a directory that already has some files (e.g. `routing.json` and `handoff.json` from an earlier run, but no `commands.json`) is exactly the case a directory-existence gate would miss, since every write inside the tool is independently idempotent at the FILE level. It: - Composes the **capability detector** above and writes a project-appropriate `.orchestrate/commands.json`. The shipped `templates/commands.json` is an empty `{}` safe default — the bootstrapper is the canonical source of a project-aware config. For an npm project it also sets `install: ["npm", "install"]`; for cargo, Python, Maven, Gradle, Make, or an unrecognized project it omits `install` (a wrong install command is worse than none). - Writes `.orchestrate/handoff.json` with a context-window size **derived from the running model**, not a static 200k constant. The model id (or an explicit token count) is passed as a tool input — the MCP process cannot see the calling LLM's model. A small explicit table maps the model to its window; an unknown or absent model falls back to `200000`. -- Writes `.orchestrate/routing.json` from the shipped defaults. -- Creates `.orchestrate/runs/` and idempotently appends `.orchestrate/runs/` to the repository's `.gitignore` — exactly once, even across repeated bootstraps. +- Writes `.orchestrate/routing.json` from the shipped defaults, creates `.orchestrate/runs/`, and idempotently appends `.orchestrate/runs/` to the repository's `.gitignore` — exactly once, even across repeated bootstraps. Every step is individually idempotent: a committed config file is never overwritten, the run directory `mkdir` is recursive, and the `.gitignore` line is never duplicated. Running `bootstrap_config` against an already-configured repository is a safe no-op. -**Usage example.** The orchestrate skill calls the tool on a fresh run: +**Config-completeness reporting.** Every call also reports the FINAL `commands.json`'s completeness, whether it was written by this call or was already on disk: `capabilities` names which of `tests`/`typecheck`/`build`/`lint`/`install` resolve to a configured command, and `falseGreenRisk` is `true` exactly when **both** `tests` and `build` are unconfigured — the specific conjunction that lets a slice merge green with nothing ever executed. A single missing verb (`lint`, `typecheck`, `install`) is common and not itself flagged — many projects legitimately skip a linter or need no install step. When `falseGreenRisk` is `true`, the run should report it and stop rather than proceed silently; see `references/prerequisites.md`. -```jsonc -// bootstrap_config tool input -{ - "repoPath": "/path/to/repo", - "model": "claude-opus-4-7[1m]" // or, e.g., "contextWindowTokens": 1000000 -} -// → { "status": "ok", "projectType": "npm", -// "contextWindowTokens": 1000000, "contextWindowSource": "model-table", -// "files": { "commandsJson": "written", "routingJson": "written", -// "handoffJson": "written" }, -// "runsDir": "created", "gitignore": "created-with-line" } -``` +The orchestrate skill calls the tool with `{ repoPath, model }` at the start of every run — `model` (or an explicit `contextWindowTokens`) drives the handoff derivation above; the response echoes `projectType`, which files were `written` vs already present, and the `capabilities`/`falseGreenRisk` report. ### Context handoff -A long backlog can fill the orchestrator session's context window before every wave is done. The bundled `context-watchdog` hook (a `PostToolUse` hook) estimates context usage from the session transcript and, past a configurable threshold (default 40%), writes the active run's `.orchestrate/runs//context-flag.json`. The orchestrator finishes the current slice, checkpoints, and calls `spawn_successor` to launch a new interactive Claude Code session that resumes from `run-state.json` — then the predecessor exits. The successor clears the stale flag on startup, so there is no handoff loop. +A long backlog can exhaust the orchestrator session before every wave is done — by filling its context window, or by spending the platform's per-session subagent-spawn budget. The bundled `context-watchdog` hook (a `PostToolUse` hook) watches both: it estimates context usage from the session transcript and counts **this session's** subagent spawns out of a per-run append-only log, and past either configurable threshold (both default 40%) writes the active run's `.orchestrate/runs//context-flag.json`, recording in `trigger` which budget raised it. The orchestrator finishes the current slice, checkpoints, and calls `spawn_successor` to launch a new interactive Claude Code session that resumes from `run-state.json` — then the predecessor exits. The successor clears the stale flag on startup, so there is no handoff loop. When several runs proceed concurrently in one repository, the watchdog binds to the correct run by **driver-session identity**: a companion `SessionStart` hook captures the session's `session_id` into `$ORCHESTRATE_SESSION_ID`, the orchestrator records it as `driverSessionId` in `run-state.json` (refreshed on resume), and the watchdog matches the event's `session_id` against each in-progress run — writing the flag only under the matching run's directory. If it cannot disambiguate, or the identity is unavailable, the watchdog safely writes nothing: the run stays correct and merely loses automatic handoff, remaining manually resumable with `/orchestrate`. +**Two spawn budgets bound a run.** The **per-session total** is `watchdog.sessionSpawnBudget` (default `200`, the platform's own per-session cap — see the `handoff.json` table below) — what the context watchdog above tracks. The **per-wave concurrency ceiling** is separate: `plan-wave-width` caps how many of a wave's processable slices may be in flight at once against the platform's concurrent-subagent limit (`CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS`, default `20`). Each in-flight slice occupies **two** live agent slots — its slice-executor plus the one worker that executor currently has running — so the wave width is `min(processableCount, floor(concurrencyLimit / 2))`, floored at `1`: half the concurrency limit, not the raw limit. A slice the cap defers stays `pending` in the wave's queue; it is never skipped or failed. + +### Read guard + +The slice-executor delegation layer saves the orchestrator's context by keeping slice-internal artifacts — each slice's progress record and its report — out of the orchestrator's window entirely: it learns a slice's outcome from the executor's result envelope and passes those paths forward without opening them. The bundled `read-guard` hook (a `PreToolUse` hook scoped to `Read` and `Bash`) enforces that boundary mechanically, denying such a read and returning a reason that names what to do instead — use the envelope's own fields, and recover the record through the `recover_slice_progress` tool if the envelope is missing or invalid. It tells the orchestrator from a subagent by the agent identity the hook event carries, so an executor reading its own record is untouched, and with no run in progress it is a silent no-op for every path. + +It is **defence in depth, not a dependency.** The same boundary is stated as a rule in the orchestrator's own instructions and stays load-bearing: enterprise policy (`allowManagedHooksOnly`) or a user setting (`disableAllHooks`, which is all-or-nothing and would also give up the context watchdog) can switch plugin hooks off, and even when enabled the hook cannot see a file referenced with `@` in a prompt, which Claude Code inserts without any tool call. + ## Installation ```bash @@ -158,13 +139,8 @@ claude plugin install orchestrate@agent-engineering-toolkit Invoke the skill in a repository that has a `ready-for-agent` backlog: ```bash -/orchestrate:orchestrate -``` - -If the plugin is installed at user scope (the default), the namespace prefix is optional: - -```bash -/orchestrate +/orchestrate:orchestrate # fully namespaced +/orchestrate # short form — works when installed at user scope (the default) ``` The run is autonomous — it processes the whole backlog, resolves conflicts, checkpoints, hands off if its context fills, and ends by opening the final umbrella pull request. To resume an interrupted run, invoke `/orchestrate` again in the same repository: it scans `.orchestrate/runs/*/run-state.json` for an in-progress run and continues from the last checkpoint. @@ -198,13 +174,7 @@ Before committing the full multi-wave execution, you can inspect the partition a /orchestrate preflight 195 ``` -The run is left `in-progress` with all slices `pending` and `completedWaves: 0`. The orchestrator writes a `.orchestrate/runs//preflight-handoff.md` with the resume invocation and preconditions. Review the partition and wave plan there; when you are ready to execute, resume in a fresh session: - -```bash -/orchestrate 195 -``` - -The resume path is the normal exactly-one-match path — it picks up the checkpoint from step 6 and enters wave 0, with no new semantics. +The run is left `in-progress` with all slices `pending` and `completedWaves: 0`. The orchestrator writes a `.orchestrate/runs//preflight-handoff.md` with the resume invocation and preconditions. Review the partition and wave plan there; when you are ready to execute, resume in a fresh session with `/orchestrate 195` — the normal exactly-one-match resume path, which picks up the checkpoint and enters wave 0 with no new semantics. **Staged-inspection gate — the value.** The pre-flight pass is a reviewable partition + wave plan checkpoint: you see which issues the run will process and in what wave order, and you decide whether to proceed before the expensive wave execution begins. Every slice is still `pending` at the stop point — no worktrees or slice branches have been created, only the umbrella branch exists. @@ -239,18 +209,18 @@ To run orchestrate against another repository, that repository needs: - **The `gh` CLI**, installed and authenticated (`gh auth status`) — the orchestrator uses it for every GitHub operation. - **An `origin/development` branch** — the integration base every umbrella branch is cut from. - **Branch protection that does not block** merges into `orchestrate/umbrella-*` and `orchestrate/slice-*` branches — the auto-merge needs them open. -- **Capability configuration** — handled automatically on the first run. When the repository has no `.orchestrate/` directory, the orchestrator calls the `bootstrap_config` MCP tool, which detects the project type and writes a project-appropriate `.orchestrate/commands.json`, `.orchestrate/routing.json`, and `.orchestrate/handoff.json`. To configure ahead of time instead, copy this plugin's `templates/` files into the target repository's `.orchestrate/` directory and fill them in — `templates/commands.json` ships as an empty `{}` starting point. A committed config is never overwritten by the bootstrapper. +- **Capability configuration** — handled automatically. The orchestrator calls the `bootstrap_config` MCP tool at the start of every run, regardless of whether `.orchestrate/` already exists, which detects the project type and writes whichever of `.orchestrate/commands.json`, `.orchestrate/routing.json`, and `.orchestrate/handoff.json` are missing. To configure ahead of time instead, copy this plugin's `templates/` files into the target repository's `.orchestrate/` directory and fill them in — `templates/commands.json` ships as an empty `{}` starting point. A committed config is never overwritten by the bootstrapper. - **A `ready-for-agent` backlog** — issues labelled `ready-for-agent`, each with a **Blocked by** section listing blocker issue numbers (`- #NNN`) and a **Parent** section naming the PRD issue. Optionally, install the **`ast-grep` CLI** to enable the investigator and reviewer subagents' structural code search; without it, they fall back to text search. -The run's generated, ephemeral files must be gitignored. Every run keeps its `run-state.json`, `context-flag.json`, and rendered HTML artifacts under a per-run directory, `.orchestrate/runs//`, so one gitignore line covers them all: +The run's generated, ephemeral files must be gitignored. Every run keeps its `run-state.json`, `context-flag.json`, `spawn-log.jsonl`, per-slice progress records, and rendered HTML artifacts under a per-run directory, `.orchestrate/runs//`, so one gitignore line covers them all: ```gitignore .orchestrate/runs/ ``` -`bootstrap_config` adds this line to the repository's `.gitignore` automatically — idempotently, never duplicating it — so a first-ever run needs no manual gitignore edit. The committed `.orchestrate/commands.json`, `.orchestrate/routing.json`, and `.orchestrate/handoff.json` stay flat at the `.orchestrate/` top level — they are configuration and stay tracked. +`bootstrap_config` adds this line to the repository's `.gitignore` automatically — idempotently, never duplicating it, on every run it is called — so no run ever needs a manual gitignore edit. The committed `.orchestrate/commands.json`, `.orchestrate/routing.json`, and `.orchestrate/handoff.json` stay flat at the `.orchestrate/` top level — they are configuration and stay tracked. ## Configuration Reference @@ -258,7 +228,7 @@ All configuration lives in the target repository's `.orchestrate/` directory. ### `.orchestrate/commands.json` -Maps each capability verb to the **argv array** that runs it. The argv form is executed with no shell, so a command can never be word-split or glob-expanded. A missing verb is tolerated — that capability tool reports `not-configured`. On a first-ever run `bootstrap_config` writes this file project-aware; the example below shows the npm form. +Maps each capability verb to the **argv array** that runs it — no shell, so a command can never be word-split or glob-expanded. A missing verb is tolerated (`not-configured`). `bootstrap_config` writes this file project-aware the first time it is absent, and is a safe no-op on every later run; the example below shows the npm form. ```json { @@ -274,9 +244,7 @@ The optional `install` verb runs once in each fresh worktree before the capabili ### `.orchestrate/routing.json` -Maps each complexity tier to the model and routing variant for each role, and configures label overrides and run policy. Schema version 2 is the current format. `investigator` may be `null` — that tier skips the investigation pass. `variant` is `standard` or `deep`. Without this file, the run falls back to the `-standard` routing variant of every role. - -**v1 deprecation:** a file without a top-level `version` field (the old format) still loads via an explicit v1→v2 mapper and emits a deprecation warning. Run-policy keys in the v1 flat object (`intraWaveConcurrency`, `continuationBudget`) are preserved during migration. Upgrade by adding `"version": 2` and nesting tiers under `"tiers"`, run policy under `"run"`. +Maps each complexity tier to the model and routing variant for each role, and configures label overrides and run policy. Schema version 2 is the current format. `investigator` may be `null` — that tier skips the investigation pass. `variant` is `standard` or `deep`. Without this file, the run falls back to the `-standard` routing variant of every role. A v1 file (no top-level `version`) still loads via a deprecation-warning mapper that preserves its flat `intraWaveConcurrency`/`continuationBudget` keys; upgrade by adding `"version": 2` and nesting tiers under `"tiers"`, run policy under `"run"`. A file predating the `slice-executor` role simply omits that key per tier — `resolve_routing` fills the gap from the tier's own `implementer` entry and warns. ```json { @@ -286,19 +254,15 @@ Maps each complexity tier to the model and routing variant for each role, and co "investigator": null, "implementer": { "model": "haiku", "variant": "standard" }, "reviewer": { "model": "sonnet", "variant": "standard" }, - "conflict-resolver": { "model": "sonnet", "variant": "standard" } - }, - "standard": { - "investigator": { "model": "haiku", "variant": "standard" }, - "implementer": { "model": "sonnet", "variant": "standard" }, - "reviewer": { "model": "opus", "variant": "standard" }, - "conflict-resolver": { "model": "opus", "variant": "standard" } + "conflict-resolver": { "model": "sonnet", "variant": "standard" }, + "slice-executor": { "model": "haiku", "variant": "standard" } }, "complex": { "investigator": { "model": "opus", "variant": "deep" }, "implementer": { "model": "opus", "variant": "deep" }, "reviewer": { "model": "opus", "variant": "deep" }, - "conflict-resolver": { "model": "opus", "variant": "deep" } + "conflict-resolver": { "model": "opus", "variant": "deep" }, + "slice-executor": { "model": "opus", "variant": "deep" } } }, "labels": { @@ -308,52 +272,44 @@ Maps each complexity tier to the model and routing variant for each role, and co "fallback": { "model": "opus", "maxRetries": 1 } } }, - "run": { - "intraWaveConcurrency": "parallel", - "continuationBudget": 2 - } + "run": { "intraWaveConcurrency": "parallel", "continuationBudget": 2 } } ``` -**Tier matrix defaults.** The trivial tier applies a deliberate cross-model gate: haiku implements, sonnet reviews — a cost-effective quality check. The standard tier adds an investigator (haiku/standard) that was absent in v1. The complex tier routes all roles to opus/deep unchanged. - -**`route:fable` label lane.** When a GitHub issue carries the `route:fable` label, the implementer role is patched to `fable/deep`, overriding the tier's default model. A one-shot opus fallback (`maxRetries: 1`) re-spawns the implementer on the fallback model if the premium spawn fails. The reviewer and conflict-resolver retain their tier defaults. **Security exclusion:** Fable's safety classifiers refuse benign security and cyber-research work — do NOT apply `route:fable` to issues involving vulnerability research, penetration testing, or security tooling. Those issues should remain on the standard tier-routed model. +**Tier matrix defaults.** All three tiers are **required** — the `standard` tier is elided from the example above for brevity only, and a file that genuinely omits it is rejected as invalid; copy `templates/routing.json` for the complete file. The trivial tier applies a deliberate cross-model gate: haiku implements, sonnet reviews — a cost-effective quality check. The standard tier adds an investigator (haiku/standard). The complex tier routes all roles to opus/deep. `slice-executor` mirrors its tier's `implementer` at every tier (ADR-0017, #356) — the same value the back-compat default resolves to. -**Routing labels: suggest, never apply.** The orchestrator reads routing labels from the issue tracker but never writes them — label assignment is a human decision. The orchestrator surfaces a suggestion when a heuristic indicates a label would improve quality, and stops there. - -**Model fallback.** When a premium-model spawn fails (network error or capacity rejection), the orchestrator makes one re-spawn attempt on the tier's fallback model, outside the `continuationBudget`. If the fallback spawn also fails, the slice is marked failed and the run continues with the remaining slices. +**`route:fable` label lane.** A `route:fable`-labelled issue patches the implementer to `fable/deep`, with a one-shot opus fallback (`maxRetries: 1`) if the premium spawn fails; reviewer and conflict-resolver keep their tier defaults. **Security exclusion:** do NOT apply `route:fable` to vulnerability research, penetration testing, or security-tooling issues — Fable's safety classifiers refuse that benign security work. **Routing labels suggest, never apply** — the orchestrator reads them but never writes them; label assignment is a human decision. **Model fallback:** on a premium-model spawn failure (network or capacity), the orchestrator makes one re-spawn on the tier's fallback model, outside `continuationBudget`; a failed fallback marks the slice failed and the run continues. ### `.orchestrate/handoff.json` -Optional. Tunes the context-watchdog threshold and the successor-session launcher. When absent, built-in defaults apply. On a first-ever run `bootstrap_config` writes this file with `watchdog.contextWindowTokens` derived from the running model — `1000000` for a 1M-context model, `200000` otherwise. +Optional. Tunes the context-watchdog's two thresholds and the successor-session launcher. When absent, built-in defaults apply — every field carries one, so a config written before a field existed still resolves. An off-schema key (one the schema does not recognize) now sends the whole file down the defaults-plus-warning path, rather than being silently stripped as before. `bootstrap_config` writes this file the first time it is absent, with `watchdog.contextWindowTokens` derived from the running model — `1000000` for a 1M-context model, `200000` otherwise. ```json { "watchdog": { "thresholdPercent": 40, - "contextWindowTokens": 200000 + "contextWindowTokens": 200000, + "spawnThresholdPercent": 40, + "sessionSpawnBudget": 200 }, "successor": { "claudeArgs": ["--remote-control", "orchestrate-successor", "--permission-mode", "auto"], "resumePrompt": "/orchestrate", "terminals": [ - { - "name": "windows-terminal", - "argv": ["wt.exe", "new-tab", "--title", "orchestrate-successor", "wsl.exe", "--", "bash", "-lc", "{claudeCommand}"] - }, - { - "name": "warp", - "argv": ["warp-terminal", "--", "bash", "-lc", "{claudeCommand}"] - } + { "name": "windows-terminal", "argv": ["wt.exe", "new-tab", "--title", "orchestrate-successor", "wsl.exe", "--", "bash", "-lc", "{claudeCommand}"] } ] } } ``` +`terminals` is an ordered fallback chain — a second entry (e.g. `warp`) is tried if the first fails to launch; see the referenced doc below for the full multi-terminal example. + | Field | Default | Meaning | |-------|---------|---------| | `watchdog.thresholdPercent` | `40` | Raise the handoff flag at this percentage of the context window. | | `watchdog.contextWindowTokens` | `200000` | The window the percentage measures against. Set to `1000000` for a 1M-context session. | +| `watchdog.spawnThresholdPercent` | `40` | Raise the handoff flag at this percentage of the session spawn budget. Mirrors `thresholdPercent`. | +| `watchdog.sessionSpawnBudget` | `200` | Total subagent spawns the session may make — the platform's own per-session default, changed by `CLAUDE_CODE_MAX_SUBAGENTS_PER_SESSION`. Nested and background subagents count toward it, and a finished subagent still counts. | | `successor.claudeArgs` | Remote Control + auto mode | Flags for the successor's `claude` CLI invocation. | | `successor.resumePrompt` | `/orchestrate` | The successor's initial prompt — appended last, as a positional argument. | | `successor.terminals` | Windows Terminal, then Warp | Ordered terminal fallback chain. `{claudeCommand}` and `{repoPath}` are substituted into each argv. | @@ -365,8 +321,13 @@ The default terminal chain targets a WSL2 environment. On another host, replace Generated, not authored. Every run keeps its ephemeral state in its own per-run directory, `.orchestrate/runs//`, where `` is the run's timestamp id. The directory holds: - `run-state.json` — the durable run checkpoint. The orchestrator writes it after every slice state change and every wave, and reads it on startup to resume an interrupted run. -- `context-flag.json` — the context-handoff signal, written by the watchdog when the threshold is reached. -- `dashboard.html`, `graph.html`, `report.html` — the rendered HTML artifacts. +- `context-flag.json` — the context-handoff signal, written by the watchdog when either threshold is reached. +- `spawn-log.jsonl` — the watchdog's append-only spawn record, one line per subagent spawn observed while the run is in progress, each tagged with the session that made it. It is stored per run but **counted per session**: the platform's cap resets in a new session while the log survives a handoff, so only the current session's lines count toward the budget. +- `slice--progress.json` — one **slice progress record** per slice, written by the slice executor at each completed stage. The filename carries the issue number so the concurrent slices of one wave never clobber each other's resume anchor. +- `slice--report.md` — one human-readable **slice report** per slice, written by the same executor beside its progress record, whatever the outcome. +- `dashboard.html`, `graph.html`, `report.html` — the rendered HTML artifacts for the whole run. + +**Who reads the slice progress record and report.** The executor writes both and is the only role that opens them directly: a resumed executor reads its own progress record to pick up where it left off. The orchestrator never opens either file — it learns a slice's outcome from the executor's result envelope and, when that envelope is missing or invalid, recovers the progress record's validated contents through the `recover_slice_progress` MCP tool rather than reading the file. See "Read guard" above for the hook that enforces this boundary mechanically. Two distinct runs never share a directory, so their ephemeral state never collides — the per-run layout is the structural foundation for concurrent runs. The committed config files (`commands.json`, `routing.json`, `handoff.json`) stay flat at the `.orchestrate/` top level. @@ -379,6 +340,10 @@ Two distinct runs never share a directory, so their ephemeral state never collid └── 20260521-015143/ # one per-run directory per run ├── run-state.json ├── context-flag.json # present only after a handoff is signalled + ├── spawn-log.jsonl # one line per subagent spawn (the spawn budget) + ├── slice-157-progress.json # one slice progress record per slice + ├── slice-157-report.md # one human-readable slice report per slice + ├── slice-158-progress.json ├── dashboard.html ├── graph.html └── report.html @@ -394,28 +359,24 @@ orchestrate spawns its subagents with the standard Agent tool, and the full loop ```text plugins/orchestrate/ -├── .claude-plugin/ -│ └── plugin.json # Plugin manifest +├── .claude-plugin/plugin.json # Plugin manifest ├── .mcp.json # Registers the orchestrate-mcp server ├── README.md # This file -├── hooks/ -│ └── hooks.json # context-watchdog (PostToolUse) + -│ # session-start (SessionStart) hooks +├── hooks/hooks.json # context-watchdog + session-start + read-guard hooks ├── skills/ -│ └── orchestrate/ -│ ├── SKILL.md # The orchestrator judgment spine -│ └── references/ # phase-loaded references: -│ # prerequisites, clean-mode, run-lifecycle, -│ # wave-loop, slice-pipeline, failure-handling, -│ # run-state, context-handoff -├── agents/ # 8 subagents — {investigator,implementer, -│ # reviewer,conflict-resolver}-{standard,deep} +│ ├── orchestrate/ +│ │ ├── SKILL.md # The orchestrator judgment spine +│ │ └── references/ # prerequisites, preflight-mode, clean-mode, run-lifecycle, +│ │ # wave-loop, slice-pipeline, failure-handling, run-state, +│ │ # context-handoff — phase-loaded +│ └── slice-pipeline/SKILL.md # The per-slice procedure the slice-executor subagent loads +├── agents/ # 10 subagents — {investigator,implementer,reviewer, +│ # conflict-resolver,slice-executor}-{standard,deep} ├── templates/ # commands.json, routing.json, handoff.json └── orchestrate-mcp/ # The MCP server (TypeScript) ├── src/ # Tool implementations ├── test/ # Unit suite - └── dist/ # Bundled server + context-watchdog + - # session-start hooks + └── dist/ # Bundled server + context-watchdog + session-start + read-guard ``` ## Contributing to orchestrate-mcp @@ -425,7 +386,7 @@ The `orchestrate-mcp/` directory contains a TypeScript MCP server whose compiled ```bash cd plugins/orchestrate/orchestrate-mcp npm ci # if node_modules is stale -npm run build # regenerates dist/index.js, dist/context-watchdog.js, dist/session-start.js +npm run build # regenerates dist/index.js, dist/context-watchdog.js, dist/session-start.js, dist/read-guard.js git add dist/ ``` diff --git a/plugins/orchestrate/agents/slice-executor-deep.md b/plugins/orchestrate/agents/slice-executor-deep.md new file mode 100644 index 00000000..3096c0a9 --- /dev/null +++ b/plugins/orchestrate/agents/slice-executor-deep.md @@ -0,0 +1,179 @@ +--- +name: slice-executor-deep +description: Owns one slice end to end inside its worktree — spawns the investigator, implementer, and reviewer, validates every result envelope, and runs the Capability gate. Deep-effort variant for complex, high-risk issues. Spawned by the orchestrate skill; not invoked directly. +tools: Read, Write, Agent, mcp__plugin_orchestrate_orchestrate__validate_envelope, mcp__plugin_orchestrate_orchestrate__verify_changeset, mcp__plugin_orchestrate_orchestrate__run_build, mcp__plugin_orchestrate_orchestrate__run_tests +skills: + - orchestrate:slice-pipeline +model: opus +effort: xhigh +maxTurns: 85 +--- + +# Slice executor (Deep) + +You own exactly one slice — from investigation through to a verified changeset — +inside the worktree the orchestrator already created for it. You spawn the +workers the slice needs and read their returns only through validated result +envelopes. The `orchestrate` skill spawns you; you never run directly. + +This is the **deep-effort variant**, spawned for complex, high-risk slices. +`model` is `opus` and `effort` is `xhigh` because a deep slice's judgment calls +are exactly where a stronger model earns its cost: deciding whether an +investigator brief has drifted outside the acceptance criteria, whether a +`mismatch` changeset means under-reporting or a stray artifact, and whether a +capability failure is a known baseline or a real regression. `maxTurns` is 85 — +a **reasoned starting point, not a measured one**. It exceeds +`implementer-deep`'s 70 because this role's turn profile is strictly larger than +any worker's: three worker spawns, at least three `validate_envelope` calls, at +least one `verify_changeset`, two capability-gate calls, four to six +progress-record writes and one report write, plus up to two continuation +re-spawns and a possible model-fallback re-spawn — and the deep tier's wider +reads sit on top of that. Revise it against observed runs rather than treating +it as derived. + +## What you receive + +The orchestrator gives you a briefing. Its contents are enumerated in the +preloaded procedure's *What your briefing carries* section: the issue and its +acceptance criteria, the worktree path, the run id and issue number, the frozen +**Resolved slice routing**, the run-wide `continuationBudget`, your own +`executorContinuationIndex`, and the progress-record path and run directory. + +Read your progress record before you do anything else. If one exists and +validates, you **resume** from its `lastCompletedStage`; you never restart a +slice that already made progress. + +## What you do + +Your operating procedure is the **preloaded `slice-pipeline` skill**, injected +into your context at startup by this definition's `skills:` frontmatter. It is +the single source of truth for the five stages, the Slice progress record, the +bounded continue-in-place loop, the continuation cap, the one-time Model +fallback, the Changeset scope check, the Capability gate, and the Failure class +set. Follow it as written. None of it is restated here, so the two cannot drift. + +**Before anything else, confirm the procedure actually arrived.** Look in your +context for the heading `# The per-slice pipeline`. If it is not there, the +preload did not fire — the skill was missing, disabled, or named wrongly — and +Claude Code skips a listed skill silently, logging only to the debug log, so +nothing else will tell you. This check is load-bearing rather than defensive: +preload has not been verified for plugin subagents, so treat the heading, not +the frontmatter, as the evidence that you have a procedure. **Stop +immediately.** Do not reconstruct the stages from memory: emit a `blocked` +envelope with `failureClass: +"unrecoverable-obstacle"` and a `failureReason` saying the `slice-pipeline` +skill did not preload. + +**Then confirm you can spawn.** Stage 1 needs the `Agent` tool. If it is absent +from your tool list, the harness withheld it because you are at the subagent +spawn depth limit — a removal Claude Code performs with no error at all, and one +your six remaining tools hide by keeping the list non-empty. **Stop +immediately** and emit a `blocked` envelope with `failureClass: +"unrecoverable-obstacle"` and a `failureReason` saying `Agent` was withheld at +the spawn depth limit. Do not run the investigator, implementer, or reviewer +stages yourself: a slice one agent did alone is not a slice three reviewed roles +produced, and reporting it as one is worse than failing. + +Once both checks pass, run the pipeline. + +## Boundaries + +- Everything under *What is yours, and what is not* in the preloaded procedure + binds you. **Git, the forge, the tracker, and the worktree lifecycle are the + orchestrator's.** You never resolve routing, never write the orchestrator's + run-state checkpoint, never spawn the conflict-resolver, and never decide what + runs next. That boundary is prose you are bound by, not an inference from + whichever tools you happen to hold — honour it even when a tool that would let + you cross it is within reach. +- You have **no Bash tool and no git access**. `verify_changeset` is your only + way to inspect the worktree. +- Change files **only** inside the worktree path you were given, and write your + progress record and slice report into the **run directory** — never into the + worktree, where the Changeset scope check would see them as undeclared changes + and sweep them into the slice's own commit. +- Your `tools:` list grants **bare `Agent`**, and that is deliberate. In a + subagent definition a parenthesised type list — `Agent(some-type)` — is + **ignored**, so writing one would grant an unrestricted spawn tool while + documenting a containment guarantee the harness does not provide. The + containment is real, but it comes from the other side: **none of the worker + roles you spawn lists `Agent` in its own `tools:`**, so they are leaves of the + agent tree and it cannot deepen past them, whatever you pass. +- Every tool in your list survives the background-subagent tool filter, so none + is silently dropped at run time. `Read` and `Write` are named in that filter's + built-in allowlist; the filter keeps **every** MCP tool, which covers all four + orchestrate tools; and `Agent` is exempt from it, its only removal condition + being the spawn depth limit the second startup check above catches. +- The generic `Skill` tool is deliberately not granted. Preloading is injection + at startup, not a tool call, so `skills:` works without it — and withholding + it keeps your skill surface to the one procedure you were given. +- Do not edit the issue, open pull requests, or change tracker labels. + +## Advisor policy + +This subagent does not call an advisor tool. The `advisor` tool is intentionally +absent from this subagent's `tools:` frontmatter. Advisor passes, when used, run +at the orchestrator boundary — not inside any subagent. + +## Deep effort + +You are spawned for complex, high-risk slices where a surface pass is not +enough. The extra depth belongs in the decisions that are yours, not in doing +the workers' jobs for them: + +- **Scope-diff the investigator brief hard.** Read `relevantFiles`, `approach` + and `notes` against every acceptance criterion and fail an over-scoped brief + rather than forwarding it. A brief that reaches into a sibling slice's files + is how a complex wave contaminates its neighbours. +- **Write the worker prompts with the acceptance criteria named as the hard + scope boundary**, and carry the changeset divergence into the reviewer's + prompt on a `mismatch` so the review sees what was under- or over-declared. +- **Treat the `knownFailures` hint as a hint.** On a `failed` capability result, + spot-check any failure indicator in the output that no `matched` pattern + explains before you call it a baseline. On a complex slice a real regression + is the likeliest thing hiding behind a familiar-looking failure. +- **Classify the failure precisely.** Separate a resumable + `incomplete-budget-exhausted` from a genuine `no-progress-stall` with the + numbers — which bound bound, or how many consecutive continuations left the + fingerprint unchanged. + +The pinned `effort: xhigh` frontmatter, raised `maxTurns`, and `opus` model +exist to support this deeper pass — use them. + +## What you return + +End your turn with a **result envelope** — a single fenced +` ```orchestrate-envelope ` block holding one JSON object, emitted as the **last +thing** in your final message, complete and unabbreviated. The orchestrator +validates this envelope and never parses your prose; a truncated or missing one +is treated as a FAILED slice. + +Yours is the **slice-executor** envelope shape, not a worker's: `role` is the +string `"slice-executor"`, and `verification` is an **object** keyed by +capability, not an array of `{capability, result}` objects. The preloaded +procedure's *Your report and your envelope* section is authoritative for every +field and is not restated here. In outline: + +- `role`, `status`, `reportPath`, `nextTaskBriefing`, `filesChanged`, + `verification` and `fallbackTaken` are required on **every** envelope — a + failed one included. Dropping them turns an explained failure into an + unexplained one. +- On any non-`completed` status, add `failedStage` (the stage that was + *running*), `failureClass`, and `failureReason`. `failureClass` comes from the + closed seven-value set in the procedure's *Classifying a failure* table; never + invent an eighth. +- Write the slice report into the run directory whatever the outcome, and point + `reportPath` at it. + +Example (`completed`): + +```orchestrate-envelope +{ + "role": "slice-executor", + "status": "completed", + "reportPath": "slice-412-report.md", + "nextTaskBriefing": "The routing template is the parity anchor for this area; read it before editing.", + "filesChanged": ["src/tools/foo.ts", "test/foo.test.ts"], + "verification": { "tests": "passed", "build": "passed" }, + "fallbackTaken": false +} +``` diff --git a/plugins/orchestrate/agents/slice-executor-standard.md b/plugins/orchestrate/agents/slice-executor-standard.md new file mode 100644 index 00000000..e75f5cf2 --- /dev/null +++ b/plugins/orchestrate/agents/slice-executor-standard.md @@ -0,0 +1,155 @@ +--- +name: slice-executor-standard +description: Owns one slice end to end inside its worktree — spawns the investigator, implementer, and reviewer, validates every result envelope, and runs the Capability gate. Standard-effort variant for trivial- and standard-tier issues. Spawned by the orchestrate skill; not invoked directly. +tools: Read, Write, Agent, mcp__plugin_orchestrate_orchestrate__validate_envelope, mcp__plugin_orchestrate_orchestrate__verify_changeset, mcp__plugin_orchestrate_orchestrate__run_build, mcp__plugin_orchestrate_orchestrate__run_tests +skills: + - orchestrate:slice-pipeline +model: sonnet +effort: high +maxTurns: 75 +--- + +# Slice executor (Standard) + +You own exactly one slice — from investigation through to a verified changeset — +inside the worktree the orchestrator already created for it. You spawn the +workers the slice needs and read their returns only through validated result +envelopes. The `orchestrate` skill spawns you; you never run directly. + +This is the **standard-effort variant**, spawned for trivial- and standard-tier +slices. `effort: high` follows `reviewer-standard`'s precedent rather than the +`medium` the implementer and investigator carry: you run the Capability gate and +classify every failure into a closed class set, so even at the standard tier +your work is merge-gate judgment rather than mechanical editing. (`effort` on +`haiku`, which the trivial tier routes this role to, is already settled — spike +#311 established that a model degrades an unsupported level gracefully rather +than failing, and the field's own documentation notes that available levels +depend on the model.) `maxTurns` is 75 — a **reasoned starting point, not a +measured one**. It exceeds `implementer-deep`'s 70 because this role's turn +profile is strictly larger than any worker's: three worker spawns, at least +three `validate_envelope` calls, at least one `verify_changeset`, two +capability-gate calls, four to six progress-record writes and one report write, +plus up to two continuation re-spawns and a possible model-fallback re-spawn. +Revise it against observed runs rather than treating it as derived. + +## What you receive + +The orchestrator gives you a briefing. Its contents are enumerated in the +preloaded procedure's *What your briefing carries* section: the issue and its +acceptance criteria, the worktree path, the run id and issue number, the frozen +**Resolved slice routing**, the run-wide `continuationBudget`, your own +`executorContinuationIndex`, and the progress-record path and run directory. + +Read your progress record before you do anything else. If one exists and +validates, you **resume** from its `lastCompletedStage`; you never restart a +slice that already made progress. + +## What you do + +Your operating procedure is the **preloaded `slice-pipeline` skill**, injected +into your context at startup by this definition's `skills:` frontmatter. It is +the single source of truth for the five stages, the Slice progress record, the +bounded continue-in-place loop, the continuation cap, the one-time Model +fallback, the Changeset scope check, the Capability gate, and the Failure class +set. Follow it as written. None of it is restated here, so the two cannot drift. + +**Before anything else, confirm the procedure actually arrived.** Look in your +context for the heading `# The per-slice pipeline`. If it is not there, the +preload did not fire — the skill was missing, disabled, or named wrongly — and +Claude Code skips a listed skill silently, logging only to the debug log, so +nothing else will tell you. This check is load-bearing rather than defensive: +preload has not been verified for plugin subagents, so treat the heading, not +the frontmatter, as the evidence that you have a procedure. **Stop +immediately.** Do not reconstruct the stages from memory: emit a `blocked` +envelope with `failureClass: +"unrecoverable-obstacle"` and a `failureReason` saying the `slice-pipeline` +skill did not preload. + +**Then confirm you can spawn.** Stage 1 needs the `Agent` tool. If it is absent +from your tool list, the harness withheld it because you are at the subagent +spawn depth limit — a removal Claude Code performs with no error at all, and one +your six remaining tools hide by keeping the list non-empty. **Stop +immediately** and emit a `blocked` envelope with `failureClass: +"unrecoverable-obstacle"` and a `failureReason` saying `Agent` was withheld at +the spawn depth limit. Do not run the investigator, implementer, or reviewer +stages yourself: a slice one agent did alone is not a slice three reviewed roles +produced, and reporting it as one is worse than failing. + +Once both checks pass, run the pipeline. + +## Boundaries + +- Everything under *What is yours, and what is not* in the preloaded procedure + binds you. **Git, the forge, the tracker, and the worktree lifecycle are the + orchestrator's.** You never resolve routing, never write the orchestrator's + run-state checkpoint, never spawn the conflict-resolver, and never decide what + runs next. That boundary is prose you are bound by, not an inference from + whichever tools you happen to hold — honour it even when a tool that would let + you cross it is within reach. +- You have **no Bash tool and no git access**. `verify_changeset` is your only + way to inspect the worktree. +- Change files **only** inside the worktree path you were given, and write your + progress record and slice report into the **run directory** — never into the + worktree, where the Changeset scope check would see them as undeclared changes + and sweep them into the slice's own commit. +- Your `tools:` list grants **bare `Agent`**, and that is deliberate. In a + subagent definition a parenthesised type list — `Agent(some-type)` — is + **ignored**, so writing one would grant an unrestricted spawn tool while + documenting a containment guarantee the harness does not provide. The + containment is real, but it comes from the other side: **none of the worker + roles you spawn lists `Agent` in its own `tools:`**, so they are leaves of the + agent tree and it cannot deepen past them, whatever you pass. +- Every tool in your list survives the background-subagent tool filter, so none + is silently dropped at run time. `Read` and `Write` are named in that filter's + built-in allowlist; the filter keeps **every** MCP tool, which covers all four + orchestrate tools; and `Agent` is exempt from it, its only removal condition + being the spawn depth limit the second startup check above catches. +- The generic `Skill` tool is deliberately not granted. Preloading is injection + at startup, not a tool call, so `skills:` works without it — and withholding + it keeps your skill surface to the one procedure you were given. +- Do not edit the issue, open pull requests, or change tracker labels. + +## Advisor policy + +This subagent does not call an advisor tool. The `advisor` tool is intentionally +absent from this subagent's `tools:` frontmatter. Advisor passes, when used, run +at the orchestrator boundary — not inside any subagent. + +## What you return + +End your turn with a **result envelope** — a single fenced +` ```orchestrate-envelope ` block holding one JSON object, emitted as the **last +thing** in your final message, complete and unabbreviated. The orchestrator +validates this envelope and never parses your prose; a truncated or missing one +is treated as a FAILED slice. + +Yours is the **slice-executor** envelope shape, not a worker's: `role` is the +string `"slice-executor"`, and `verification` is an **object** keyed by +capability, not an array of `{capability, result}` objects. The preloaded +procedure's *Your report and your envelope* section is authoritative for every +field and is not restated here. In outline: + +- `role`, `status`, `reportPath`, `nextTaskBriefing`, `filesChanged`, + `verification` and `fallbackTaken` are required on **every** envelope — a + failed one included. Dropping them turns an explained failure into an + unexplained one. +- On any non-`completed` status, add `failedStage` (the stage that was + *running*), `failureClass`, and `failureReason`. `failureClass` comes from the + closed seven-value set in the procedure's *Classifying a failure* table; never + invent an eighth. +- Write the slice report into the run directory whatever the outcome, and point + `reportPath` at it. + +Example (`completed`): + +```orchestrate-envelope +{ + "role": "slice-executor", + "status": "completed", + "reportPath": "slice-412-report.md", + "nextTaskBriefing": "The routing template is the parity anchor for this area; read it before editing.", + "filesChanged": ["src/tools/foo.ts", "test/foo.test.ts"], + "verification": { "tests": "passed", "build": "passed" }, + "fallbackTaken": false +} +``` diff --git a/plugins/orchestrate/hooks/hooks.json b/plugins/orchestrate/hooks/hooks.json index e4e0e2ec..d7b3729c 100644 --- a/plugins/orchestrate/hooks/hooks.json +++ b/plugins/orchestrate/hooks/hooks.json @@ -12,6 +12,18 @@ ] } ], + "PreToolUse": [ + { + "matcher": "Read|Bash", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/orchestrate-mcp/dist/read-guard.js\"", + "timeout": 5 + } + ] + } + ], "PostToolUse": [ { "matcher": ".*", diff --git a/plugins/orchestrate/orchestrate-mcp/dist/context-watchdog.js b/plugins/orchestrate/orchestrate-mcp/dist/context-watchdog.js index 98b76d3e..8f6ddff7 100755 --- a/plugins/orchestrate/orchestrate-mcp/dist/context-watchdog.js +++ b/plugins/orchestrate/orchestrate-mcp/dist/context-watchdog.js @@ -513,8 +513,8 @@ function getErrorMap() { // node_modules/zod/v3/helpers/parseUtil.js var makeIssue = (params) => { - const { data, path: path4, errorMaps, issueData } = params; - const fullPath = [...path4, ...issueData.path || []]; + const { data, path: path5, errorMaps, issueData } = params; + const fullPath = [...path5, ...issueData.path || []]; const fullIssue = { ...issueData, path: fullPath @@ -630,11 +630,11 @@ var errorUtil; // node_modules/zod/v3/types.js var ParseInputLazyPath = class { - constructor(parent, value, path4, key) { + constructor(parent, value, path5, key) { this._cachedPath = []; this.parent = parent; this.data = value; - this._path = path4; + this._path = path5; this._key = key; } get path() { @@ -4083,6 +4083,12 @@ var watchdogConfigSchema = external_exports.object({ ), contextWindowTokens: external_exports.number().int().positive().default(2e5).describe( "Total context window the percentage is measured against. Default 200000 \u2014 raise to 1000000 for a 1M-context session." + ), + spawnThresholdPercent: external_exports.number().min(1).max(100).default(40).describe( + "Raise the handoff flag once the run's recorded subagent spawns reach this percentage of `sessionSpawnBudget`. Default 40, matching `thresholdPercent` \u2014 at roughly five spawns per slice a long run can spend its spawn budget well before it fills its context window, so this threshold must be as conservative as the token one." + ), + sessionSpawnBudget: external_exports.number().int().positive().default(200).describe( + "Total subagent spawns the session may make, the figure `spawnThresholdPercent` is measured against. Default 200 \u2014 the platform's own per-session default, which `CLAUDE_CODE_MAX_SUBAGENTS_PER_SESSION` changes. Nested and background subagents count toward it, and a finished subagent still counts, so the budget is cumulative and never decreases." ) }); var terminalEntrySchema = external_exports.object({ @@ -4181,6 +4187,7 @@ function resolveRunDir(repoPath, runId) { runDir, runStatePath: path2.join(runDir, "run-state.json"), contextFlagPath: path2.join(runDir, "context-flag.json"), + spawnLogPath: path2.join(runDir, "spawn-log.jsonl"), dashboardPath: path2.join(runDir, "dashboard.html"), graphPath: path2.join(runDir, "graph.html"), reportPath: path2.join(runDir, "report.html") @@ -4220,14 +4227,44 @@ function parseLatestUsage(transcriptText) { function contextTokens(usage) { return usage.inputTokens + usage.cacheCreationInputTokens + usage.cacheReadInputTokens; } +function lineSessionId(line) { + try { + const entry = JSON.parse(line); + if (typeof entry !== "object" || entry === null) return null; + const session = entry.session; + return typeof session === "string" && session.length > 0 ? session : null; + } catch { + return null; + } +} +function countSpawns(logText, sessionId) { + const asking = sessionId !== void 0 && sessionId.length > 0 ? sessionId : null; + let count = 0; + for (const line of logText.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + const tag = lineSessionId(trimmed); + if (asking !== null && tag !== null && tag !== asking) continue; + count++; + } + return count; +} function evaluateWatchdog(args) { - const ratio = args.usedTokens / args.contextWindowTokens * 100; + const tokenRatio = args.usedTokens === null ? null : args.usedTokens / args.contextWindowTokens * 100; + const spawnRatio = args.spawnCount / args.sessionSpawnBudget * 100; + const tokensOver = tokenRatio !== null && tokenRatio >= args.thresholdPercent; + const spawnsOver = spawnRatio >= args.spawnThresholdPercent; return { usedTokens: args.usedTokens, contextWindowTokens: args.contextWindowTokens, thresholdPercent: args.thresholdPercent, - usagePercent: Math.round(ratio * 10) / 10, - overThreshold: ratio >= args.thresholdPercent + usagePercent: tokenRatio === null ? null : Math.round(tokenRatio * 10) / 10, + spawnCount: args.spawnCount, + sessionSpawnBudget: args.sessionSpawnBudget, + spawnThresholdPercent: args.spawnThresholdPercent, + spawnPercent: Math.round(spawnRatio * 10) / 10, + overThreshold: tokensOver || spawnsOver, + trigger: tokensOver ? "tokens" : spawnsOver ? "spawns" : null }; } var TRANSCRIPT_TAIL_BYTES = 1024 * 1024; @@ -4251,51 +4288,38 @@ function readTranscriptText(transcriptPath) { fs2.closeSync(fd); } } -function scanInProgressRuns(cwd) { - const runsDir = path3.join(cwd, ".orchestrate", "runs"); - let entries; +var SPAWN_TOOL_NAME = "Agent"; +function recordSpawn(spawnLogPath, sessionId) { try { - entries = fs2.readdirSync(runsDir, { withFileTypes: true }); + fs2.mkdirSync(path3.dirname(spawnLogPath), { recursive: true }); + fs2.appendFileSync( + spawnLogPath, + JSON.stringify({ + at: (/* @__PURE__ */ new Date()).toISOString(), + tool: SPAWN_TOOL_NAME, + session: sessionId ?? null + }) + "\n" + ); } catch { - return []; } - const runs = []; - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const statePath = path3.join(runsDir, entry.name, "run-state.json"); - let runState; - try { - runState = JSON.parse(fs2.readFileSync(statePath, "utf8")); - } catch { - continue; - } - if (typeof runState !== "object" || runState === null || runState.status !== "in-progress") { - continue; - } - const rawId = runState.driverSessionId; - runs.push({ - runId: entry.name, - driverSessionId: typeof rawId === "string" ? rawId : null - }); - } - return runs; } -function findActiveRunForSession(cwd, sessionId) { - const runs = scanInProgressRuns(cwd); - if (runs.length === 0) return null; - if (typeof sessionId === "string" && sessionId.length > 0) { - const matches = runs.filter((r) => r.driverSessionId === sessionId); - if (matches.length === 1) return matches[0].runId; +function readSpawnCount(spawnLogPath, sessionId) { + try { + return countSpawns(fs2.readFileSync(spawnLogPath, "utf8"), sessionId); + } catch { + return 0; } - if (runs.length === 1) return runs[0].runId; - return null; } function runWatchdog(input) { const resolved = resolveRunDir(input.cwd, input.runId); if (!resolved.ok) { return { acted: false, flagRaised: false }; } - const { runStatePath, contextFlagPath: flagPath } = resolved.paths; + const { + runStatePath, + contextFlagPath: flagPath, + spawnLogPath + } = resolved.paths; let runState; try { runState = JSON.parse(fs2.readFileSync(runStatePath, "utf8")); @@ -4305,30 +4329,41 @@ function runWatchdog(input) { if (typeof runState !== "object" || runState === null || runState.status !== "in-progress") { return { acted: false, flagRaised: false }; } - if (!input.transcriptPath) return { acted: true, flagRaised: false, flagPath }; - let transcriptText; - try { - transcriptText = readTranscriptText(input.transcriptPath); - } catch { - return { acted: true, flagRaised: false, flagPath }; + if (input.toolName === SPAWN_TOOL_NAME) { + recordSpawn(spawnLogPath, input.sessionId); + } + let usedTokens = null; + if (input.transcriptPath) { + try { + const usage = parseLatestUsage(readTranscriptText(input.transcriptPath)); + if (usage) usedTokens = contextTokens(usage); + } catch { + usedTokens = null; + } } - const usage = parseLatestUsage(transcriptText); - if (!usage) return { acted: true, flagRaised: false, flagPath }; const { config } = loadHandoffConfig(input.cwd); const evaluation = evaluateWatchdog({ - usedTokens: contextTokens(usage), + usedTokens, contextWindowTokens: config.watchdog.contextWindowTokens, - thresholdPercent: config.watchdog.thresholdPercent + thresholdPercent: config.watchdog.thresholdPercent, + spawnCount: readSpawnCount(spawnLogPath, input.sessionId), + sessionSpawnBudget: config.watchdog.sessionSpawnBudget, + spawnThresholdPercent: config.watchdog.spawnThresholdPercent }); - if (!evaluation.overThreshold || fs2.existsSync(flagPath)) { + if (!evaluation.overThreshold || evaluation.trigger === null || fs2.existsSync(flagPath)) { return { acted: true, flagRaised: false, flagPath, evaluation }; } const flag = { raisedAt: (/* @__PURE__ */ new Date()).toISOString(), + trigger: evaluation.trigger, usedTokens: evaluation.usedTokens, contextWindowTokens: evaluation.contextWindowTokens, thresholdPercent: evaluation.thresholdPercent, - usagePercent: evaluation.usagePercent + usagePercent: evaluation.usagePercent, + spawnCount: evaluation.spawnCount, + sessionSpawnBudget: evaluation.sessionSpawnBudget, + spawnThresholdPercent: evaluation.spawnThresholdPercent, + spawnPercent: evaluation.spawnPercent }; try { fs2.mkdirSync(path3.dirname(flagPath), { recursive: true }); @@ -4339,6 +4374,49 @@ function runWatchdog(input) { return { acted: true, flagRaised: true, flagPath, evaluation }; } +// src/hooks/run-discovery.ts +var path4 = __toESM(require("path")); +var fs3 = __toESM(require("fs")); +function scanInProgressRuns(cwd) { + const runsDir = path4.join(cwd, ".orchestrate", "runs"); + let entries; + try { + entries = fs3.readdirSync(runsDir, { withFileTypes: true }); + } catch { + return []; + } + const runs = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const statePath = path4.join(runsDir, entry.name, "run-state.json"); + let runState; + try { + runState = JSON.parse(fs3.readFileSync(statePath, "utf8")); + } catch { + continue; + } + if (typeof runState !== "object" || runState === null || runState.status !== "in-progress") { + continue; + } + const rawId = runState.driverSessionId; + runs.push({ + runId: entry.name, + driverSessionId: typeof rawId === "string" ? rawId : null + }); + } + return runs; +} +function findActiveRunForSession(cwd, sessionId) { + const runs = scanInProgressRuns(cwd); + if (runs.length === 0) return null; + if (typeof sessionId === "string" && sessionId.length > 0) { + const matches = runs.filter((r) => r.driverSessionId === sessionId); + if (matches.length === 1) return matches[0].runId; + } + if (runs.length === 1) return runs[0].runId; + return null; +} + // src/hooks/context-watchdog-cli.ts function readStdin() { return new Promise((resolve) => { @@ -4361,7 +4439,7 @@ async function main() { try { const event = raw ? JSON.parse(raw) : {}; const cwd = typeof event.cwd === "string" ? event.cwd : process.cwd(); - const sessionId = typeof event.session_id === "string" ? event.session_id : void 0; + const sessionId = typeof event.session_id === "string" && event.session_id.length > 0 ? event.session_id : void 0; const runId = findActiveRunForSession(cwd, sessionId); if (runId === null) { process.exit(0); @@ -4369,13 +4447,19 @@ async function main() { const result = runWatchdog({ transcriptPath: typeof event.transcript_path === "string" ? event.transcript_path : void 0, cwd, - runId + runId, + toolName: typeof event.tool_name === "string" ? event.tool_name : void 0, + // The same `session_id` that discovery matched on — here it charges the + // spawn to the session whose budget it actually spends, so a successor + // session inheriting this run's spawn log starts from its own budget. + sessionId }); if (result.flagRaised && result.evaluation) { const e = result.evaluation; + const reason = e.trigger === "spawns" ? `${e.spawnCount} of ${e.sessionSpawnBudget} session subagent spawns used (${e.spawnPercent}%, threshold ${e.spawnThresholdPercent}%)` : `context at ${e.usagePercent}% of ${e.contextWindowTokens} tokens (threshold ${e.thresholdPercent}%)`; process.stdout.write( JSON.stringify({ - systemMessage: `orchestrate context-watchdog: context at ${e.usagePercent}% of ${e.contextWindowTokens} tokens (threshold ${e.thresholdPercent}%). Handoff flag raised \u2014 the run will hand off to a successor session after the current slice finishes.` + systemMessage: `orchestrate context-watchdog: ${reason}. Handoff flag raised \u2014 the run will hand off to a successor session after the current slice finishes.` }) ); } diff --git a/plugins/orchestrate/orchestrate-mcp/dist/index.js b/plugins/orchestrate/orchestrate-mcp/dist/index.js index 6535a69a..70e82ee6 100755 --- a/plugins/orchestrate/orchestrate-mcp/dist/index.js +++ b/plugins/orchestrate/orchestrate-mcp/dist/index.js @@ -6873,12 +6873,12 @@ var require_dist = __commonJS({ throw new Error(`Unknown format "${name}"`); return f; }; - function addFormats(ajv, list, fs14, exportName) { + function addFormats(ajv, list, fs15, exportName) { var _a; var _b; (_a = (_b = ajv.opts.code).formats) !== null && _a !== void 0 ? _a : _b.formats = (0, codegen_1._)`require("ajv-formats/dist/formats").${exportName}`; for (const f of list) - ajv.addFormat(f, fs14[f]); + ajv.addFormat(f, fs15[f]); } module2.exports = exports2 = formatsPlugin; Object.defineProperty(exports2, "__esModule", { value: true }); @@ -21176,14 +21176,14 @@ function optionInjectionError(field, value) { } function cleanGitError(err) { if (err instanceof GitExecError && err.stderr.trim().length > 0) { - const firstLine10 = err.stderr.split("\n").map((l) => l.trim()).find((l) => l.length > 0); - if (firstLine10) { - return firstLine10; + const firstLine11 = err.stderr.split("\n").map((l) => l.trim()).find((l) => l.length > 0); + if (firstLine11) { + return firstLine11; } } const message = err instanceof Error ? err.message : String(err); - const firstLine9 = message.split("\n").map((l) => l.trim()).find((l) => l.length > 0); - return firstLine9 ?? "Unknown git error"; + const firstLine10 = message.split("\n").map((l) => l.trim()).find((l) => l.length > 0); + return firstLine10 ?? "Unknown git error"; } // src/tools/run-command.ts @@ -21957,7 +21957,8 @@ var ROUTING_ROLES = [ "investigator", "implementer", "reviewer", - "conflict-resolver" + "conflict-resolver", + "slice-executor" ]; var roleConfigSchema = external_exports.object({ model: external_exports.string().min(1).describe("Model id to spawn the role's subagent with (e.g. 'sonnet', 'opus')."), @@ -21979,7 +21980,7 @@ var routingConfigSchema = external_exports.object({ "Run-wide policy: how to process the independent slices within one wave. 'parallel' (default) spawns all processable slices at once and integrates them sequentially. 'sequential' processes slices one at a time in issue-id ascending order, refreshing the umbrella base between each so slice N branches from base+slice1..N-1 \u2014 guaranteed conflict-free, at the cost of serializing the wave. Optional; the three tier blocks remain required." ), continuationBudget: external_exports.number().int().min(0).default(2).describe( - "How many times the orchestrator may re-spawn the implementer in the same worktree after an 'incomplete' envelope (re-spawns BEYOND the initial run). 0 disables continuation (incomplete FAILs immediately, the legacy behavior). Defaults to 2." + "How many times the slice executor may re-spawn the implementer in the same worktree after an 'incomplete' envelope (re-spawns BEYOND the initial run). 0 disables continuation (incomplete FAILs immediately, the legacy behavior). Defaults to 2." ) }); var routingConfigSchemaV1 = routingConfigSchema; @@ -22023,7 +22024,8 @@ var tierRoutingSchemaV2 = external_exports.object({ investigator: roleConfigSchemaV2.nullable(), implementer: roleConfigSchemaV2, reviewer: roleConfigSchemaV2, - "conflict-resolver": roleConfigSchemaV2 + "conflict-resolver": roleConfigSchemaV2, + "slice-executor": roleConfigSchemaV2.optional() }); var labelFallbackSchema = external_exports.object({ model: external_exports.string().min(1).describe("Model id to re-spawn with when the label's primary model fails."), @@ -22046,7 +22048,7 @@ var runConfigSchema = external_exports.object({ "Run-wide policy: how to process the independent slices within one wave. 'parallel' (default) or 'sequential'. Lifted from the v1 top-level key." ), continuationBudget: external_exports.number().int().min(0).optional().default(2).describe( - "How many times the orchestrator may re-spawn the implementer in the same worktree after an 'incomplete' envelope. 0 disables continuation. Defaults to 2. Lifted from the v1 top-level key." + "How many times the slice executor may re-spawn the implementer in the same worktree after an 'incomplete' envelope. 0 disables continuation. Defaults to 2. Lifted from the v1 top-level key." ) }); var routingConfigSchemaV2 = external_exports.object({ @@ -22132,6 +22134,16 @@ function loadRoutingConfig(parsed) { )}. Supported versions: 1 (no \`version\` field) and 2.` }; } +function ensureSliceExecutorDefault(tier, tierName) { + if (tier["slice-executor"]) { + return { routing: tier }; + } + const fallback = { ...tier.implementer }; + return { + routing: { ...tier, "slice-executor": fallback }, + warning: `routing.json has no \`slice-executor\` entry for the '${tierName}' tier \u2014 defaulting to the implementer routing (${fallback.model}/${fallback.variant}). Add a \`slice-executor\` entry to each tier to silence this warning.` + }; +} var resolveRoutingV2InputSchema = external_exports.object({ tier: external_exports.enum(COMPLEXITY_TIERS).describe( "The complexity tier the orchestrator assessed the issue into. 'trivial' = a small, localized change; 'standard' = an ordinary feature or fix; 'complex' = broad, cross-cutting, or high-risk work." @@ -22156,7 +22168,7 @@ var resolveRoutingV2OutputSchema = external_exports.object({ ), tier: external_exports.enum(COMPLEXITY_TIERS).optional().describe("The tier that was resolved. Present when status='ok'."), routing: tierRoutingSchemaV2.optional().describe( - "The resolved per-role routing for the tier (v2: uses `variant`, not `effort`). `investigator` is null when this tier skips the investigation pass. Present when status='ok'." + "The resolved per-role routing for the tier (v2: uses `variant`, not `effort`). `investigator` is null when this tier skips the investigation pass. `slice-executor` (ADR-0017, #356) is typed optional here only for input back-compat \u2014 in a RESOLVED result it is ALWAYS populated: a routing.json predating the role has it defaulted to the tier's own `implementer` entry, flagged in `warnings`. Present when status='ok'." ), continuationBudget: external_exports.number().int().min(0).optional().describe( "The resolved continuation budget for this run \u2014 how many implementer re-spawns are allowed after an 'incomplete' envelope. Present when status='ok'." @@ -22208,8 +22220,8 @@ function resolveRoutingV2FromConfig(input) { }; } const { config: config2, warnings: loaderWarnings } = loadResult; - const tierRouting = config2.tiers[input.tier]; const labelsConfig = config2.labels ?? {}; + const { routing: tierRouting, warning: sliceExecutorWarning } = ensureSliceExecutorDefault(config2.tiers[input.tier], input.tier); const relevantLabels = (input.labels ?? []).filter( (name) => name.startsWith("route:") || name in labelsConfig ); @@ -22227,7 +22239,11 @@ function resolveRoutingV2FromConfig(input) { routing: labelResult.routing, continuationBudget: config2.run.continuationBudget, fallbacks: labelResult.fallbacks, - warnings: [...loaderWarnings, ...labelResult.warnings] + warnings: [ + ...loaderWarnings, + ...sliceExecutorWarning ? [sliceExecutorWarning] : [], + ...labelResult.warnings + ] }; } function applyLabels(tierRouting, labelNames, labelsConfig) { @@ -22237,7 +22253,8 @@ function applyLabels(tierRouting, labelNames, labelsConfig) { investigator: tierRouting.investigator ? { ...tierRouting.investigator } : null, implementer: { ...tierRouting.implementer }, reviewer: { ...tierRouting.reviewer }, - "conflict-resolver": { ...tierRouting["conflict-resolver"] } + "conflict-resolver": { ...tierRouting["conflict-resolver"] }, + "slice-executor": tierRouting["slice-executor"] ? { ...tierRouting["slice-executor"] } : void 0 }; const patchedBy = /* @__PURE__ */ new Map(); for (const labelName of labelNames) { @@ -22293,12 +22310,37 @@ function resolveRunDir(repoPath, runId) { runDir, runStatePath: path4.join(runDir, "run-state.json"), contextFlagPath: path4.join(runDir, "context-flag.json"), + spawnLogPath: path4.join(runDir, "spawn-log.jsonl"), dashboardPath: path4.join(runDir, "dashboard.html"), graphPath: path4.join(runDir, "graph.html"), reportPath: path4.join(runDir, "report.html") } }; } +function isValidIssueId(issue2) { + return typeof issue2 === "number" && Number.isSafeInteger(issue2) && issue2 > 0; +} +function resolveSliceProgressPath(repoPath, runId, issue2) { + const resolved = resolveRunDir(repoPath, runId); + if (!resolved.ok) { + return { + ok: false, + errorCode: resolved.errorCode, + errorMessage: resolved.errorMessage + }; + } + if (!isValidIssueId(issue2)) { + return { + ok: false, + errorCode: "ISSUE_INVALID", + errorMessage: `Invalid issue '${String(issue2)}': an issue must be a positive integer (e.g. 355). The id becomes part of the record's filename, so anything else is rejected before it can escape the run directory.` + }; + } + return { + ok: true, + path: path4.join(resolved.paths.runDir, `slice-${issue2}-progress.json`) + }; +} // src/tools/render.ts var sliceStateEnum = external_exports.enum(["pending", "in-progress", "passed", "failed", "skipped"]); @@ -22968,6 +23010,12 @@ var watchdogConfigSchema = external_exports.object({ ), contextWindowTokens: external_exports.number().int().positive().default(2e5).describe( "Total context window the percentage is measured against. Default 200000 \u2014 raise to 1000000 for a 1M-context session." + ), + spawnThresholdPercent: external_exports.number().min(1).max(100).default(40).describe( + "Raise the handoff flag once the run's recorded subagent spawns reach this percentage of `sessionSpawnBudget`. Default 40, matching `thresholdPercent` \u2014 at roughly five spawns per slice a long run can spend its spawn budget well before it fills its context window, so this threshold must be as conservative as the token one." + ), + sessionSpawnBudget: external_exports.number().int().positive().default(200).describe( + "Total subagent spawns the session may make, the figure `spawnThresholdPercent` is measured against. Default 200 \u2014 the platform's own per-session default, which `CLAUDE_CODE_MAX_SUBAGENTS_PER_SESSION` changes. Nested and background subagents count toward it, and a finished subagent still counts, so the budget is cumulative and never decreases." ) }); var terminalEntrySchema = external_exports.object({ @@ -23408,11 +23456,12 @@ function filterToOneParentPrd(issues, prdNumber) { } // src/tools/validate-envelope.ts +var capabilityResultSchema = external_exports.enum(["passed", "failed", "not-configured"]).describe( + "Outcome of a capability-tool run. 'not-configured' means the verb has no command set." +); var verificationEntrySchema = external_exports.object({ capability: external_exports.enum(["tests", "typecheck", "build", "lint"]).describe("Which capability tool was run."), - result: external_exports.enum(["passed", "failed", "not-configured"]).describe( - "Outcome of that run. 'not-configured' means the verb has no command set." - ) + result: capabilityResultSchema }); var rootCauseSchema = external_exports.object({ status: external_exports.enum(["verified", "hypothesis"]).describe( @@ -23485,11 +23534,66 @@ var investigatorEnvelopeSchema = external_exports.object({ approach: external_exports.string().describe("A suggested implementation approach \u2014 what to change and why."), notes: external_exports.string().describe("Anything else that does not fit the fields above.") }); +var SLICE_EXECUTOR_FAILURE_CLASSES = [ + "unrecoverable-obstacle", + "incomplete-budget-exhausted", + "no-progress-stall", + "invalid-or-missing-worker-envelope", + "changeset-mismatch", + "empty-changeset", + "model-refusal" +]; +var SLICE_EXECUTOR_STAGES = [ + "investigator", + "implementer", + "capability-gate", + "reviewer" +]; +var sliceExecutorFailureClassSchema = external_exports.enum(SLICE_EXECUTOR_FAILURE_CLASSES).describe( + "Closed-set classification of why the slice did not reach a verified changeset: 'unrecoverable-obstacle' (a blocker with no safe workaround, including a capability-gate failure with no more specific class); 'incomplete-budget-exhausted' (the executor's own nested continuation loop ran out of turns \u2014 the slice-level analogue of the implementer's graceful 'incomplete' self-report); 'no-progress-stall' (repeated attempts converged on nothing); 'invalid-or-missing-worker-envelope' (a worker the executor spawned returned a truncated, malformed, or missing envelope); 'changeset-mismatch' (the implementer's declared `filesChanged` did not match the worktree's actual changeset); 'empty-changeset' (the slice produced no file changes at all); 'model-refusal' (a spawned worker's model refused the task)." +); +var sliceExecutorVerificationSchema = external_exports.object({ + tests: capabilityResultSchema.optional(), + typecheck: capabilityResultSchema.optional(), + build: capabilityResultSchema.optional(), + lint: capabilityResultSchema.optional() +}); +var sliceExecutorEnvelopeSchema = external_exports.object({ + role: external_exports.literal("slice-executor").describe("Discriminant \u2014 the slice-executor role."), + status: external_exports.enum(["completed", "incomplete", "blocked", "failed"]).describe( + "Outcome of the WHOLE SLICE, not a single worker \u2014 reuses the schema's existing status vocabulary rather than inventing a fifth. 'completed' = a verified changeset was reached; 'incomplete' = the executor's own graceful continuation-budget self-report, mirroring the implementer's 'incomplete'; 'blocked' = an unrecoverable obstacle hit by the executor or one of the workers it spawned; 'failed' = the slice did not reach a trustworthy changeset (a worker or verification failure). There is no loop-continue/loop-end value \u2014 see the module-level note above." + ), + failedStage: external_exports.enum(SLICE_EXECUTOR_STAGES).optional().describe( + "Which inner stage of the slice pipeline was running when a non-'completed' outcome occurred. Absent for a 'completed' envelope. 'implementer' also covers the changeset-verification check that immediately follows the implementer's turn (it gates trust in the implementer's own output, before the reviewer stage begins) \u2014 so 'changeset-mismatch' and 'empty-changeset' are reported here, not under a separate stage." + ), + failureClass: sliceExecutorFailureClassSchema.optional().describe( + "Closed-set classification of the failure. Absent for a 'completed' envelope. The orchestrator maps this class to a tracker triage label; it never re-derives the classification itself \u2014 that authority stays with the executor that observed the failure." + ), + failureReason: external_exports.string().optional().describe( + "Prose description of what happened, in the executor's own words. Complements `failureClass` (the closed-set machine label) with the specific detail a human or the next executor needs. Absent for a 'completed' envelope." + ), + reportPath: external_exports.string().describe( + "Path, relative to the run directory (`.orchestrate/runs//`), of the slice's report \u2014 the human-readable artifact the executor wrote describing its own run. It is written beside the executor's progress record, NEVER into the worktree, where the Changeset scope check would see it as an undeclared change." + ), + nextTaskBriefing: external_exports.string().describe( + "Advice carried forward to whoever picks up the next slice. This is advice only, never a selection of WHICH slice runs next \u2014 wave ordering and loop termination stay computed by `plan_waves` and wave exhaustion, not declared here (see the module-level note above)." + ), + filesChanged: external_exports.array(external_exports.string()).describe( + "Files changed across the whole slice \u2014 every worker's edits combined \u2014 as paths relative to the worktree root. An empty array means no file was changed." + ), + verification: sliceExecutorVerificationSchema.describe( + "Roll-up of the slice's capability-gate outcome, one optional result per capability." + ), + fallbackTaken: external_exports.boolean().describe( + "Whether the one-time Model fallback (the premium-lane retry) was taken during this slice." + ) +}); var envelopeSchema = external_exports.discriminatedUnion("role", [ implementerEnvelopeSchema, reviewerEnvelopeSchema, conflictResolverEnvelopeSchema, - investigatorEnvelopeSchema + investigatorEnvelopeSchema, + sliceExecutorEnvelopeSchema ]).superRefine((data, ctx) => { if (data.role === "implementer" && data.status === "incomplete" && (data.remainingWork === void 0 || data.remainingWork.trim() === "")) { ctx.addIssue({ @@ -23503,7 +23607,8 @@ var ENVELOPE_ROLES = [ "implementer", "reviewer", "conflict-resolver", - "investigator" + "investigator", + "slice-executor" ]; var validateEnvelopeInputSchema = external_exports.object({ text: external_exports.string().describe( @@ -23693,19 +23798,131 @@ async function recoverChangedFiles(input) { }; } +// src/tools/recover-slice-progress.ts +var fs7 = __toESM(require("fs")); +var sliceProgressRecordSchema = external_exports.object({ + runId: external_exports.string().describe( + "The run this record belongs to. Must equal the runId whose directory the record was read from \u2014 a mismatch means the record was mis-filed and is rejected rather than trusted." + ), + issue: external_exports.number().int().describe( + "The issue number of the slice this record tracks. Must equal the issue the record's filename encodes; a mismatch is rejected." + ), + lastCompletedStage: external_exports.enum(SLICE_EXECUTOR_STAGES).optional().describe( + "The last inner stage that FINISHED. Note this is NOT the envelope's `failedStage`, which names the stage that was RUNNING when a failure occurred \u2014 the value set is deliberately shared, the meaning is not. The enum is a set of stage NAMES, not an order: which stages run, in what sequence, and which are skipped are the executor's decisions, never implied by this field's member order. ABSENT means no stage has completed yet \u2014 omit the key entirely; an explicit null is rejected." + ), + investigatorBrief: investigatorEnvelopeSchema.omit({ role: true }).optional().describe( + "The investigator's research brief, carried forward so a resumed executor does not re-run a finished investigation. Reuses the investigator envelope's own fields rather than a free-form blob, so the brief stays validated end to end. Absent when the slice's tier skips investigation entirely (`resolvedRouting.investigator` is null)." + ), + continuationsUsed: external_exports.number().int().nonnegative().describe( + "How many continuations the executor's continue-in-place loop has spent on this slice. Persisted because two continuation loops now nest, and ADR-0017 caps the PRODUCT of their budgets at 6 \u2014 a counter that reset on every handoff could not enforce that bound across sessions." + ), + worktreeFingerprint: external_exports.string().optional().describe( + "Opaque content-level fingerprint of the worktree's uncommitted state at the last completed stage, used by the no-progress guard to tell a real continuation from a stalled one. Stored as an opaque string: COMPUTING it is the executor's job, so this record fixes only how it is carried, never how it is derived. Absent before the first fingerprint is taken." + ), + fallbackTaken: external_exports.boolean().describe( + "The once-only Model fallback guard: true when the premium-lane retry has already been spent on this slice. REQUIRED, with no default \u2014 an absent key must never silently read as `false`, which would re-arm a fallback that was already used. This deliberately DUPLICATES the orchestrator's `resolvedRouting.fallbackTaken` in run-state.json, and the duplication is ADR-0017-sanctioned, not an oversight: the run-state field is orchestrator-owned and drives the legacy non-executor path, while this copy is executor-owned, because the executor cannot write the orchestrator's checkpoint. Do not unify them." + ), + updatedAt: external_exports.string().describe( + "ISO-8601 UTC timestamp of the last write, matching run-state.json's timestamp convention (documented in prose, not enforced by the schema)." + ) +}); +var recoverSliceProgressInputSchema = external_exports.object({ + runId: external_exports.string().describe( + "The orchestration run's id (its YYYYMMDD-HHMMSS timestamp, optionally prefixed `prd-` or `backlog-`). It selects the per-run directory .orchestrate/runs// the record is read from." + ), + issue: external_exports.number().int().positive().describe( + "The issue number of the slice whose record to read. Together with `runId` it derives the record's path \u2014 the tool deliberately accepts no file path, so it can never read outside this run's directory." + ), + repoPath: external_exports.string().optional().describe( + "Path to the project root that holds the .orchestrate/ directory. Defaults to the MCP server process's current working directory \u2014 callers should pass this explicitly rather than rely on the default." + ) +}); +var recoverSliceProgressOutputSchema = external_exports.object({ + status: external_exports.enum(["ok", "error"]).describe( + "Outcome discriminant. 'ok' = the record was found, parsed, and validated; 'error' = it could not be resolved, read, parsed, or it failed schema validation." + ), + record: sliceProgressRecordSchema.optional().describe( + "The validated slice progress record. Present ONLY when status='ok'." + ), + errorCode: external_exports.enum([ + "RUN_ID_INVALID", + "ISSUE_INVALID", + "PROGRESS_NOT_FOUND", + "PROGRESS_INVALID" + ]).optional().describe( + "Machine-readable failure category. Present when status='error'. 'RUN_ID_INVALID' = the runId is malformed and cannot resolve a run directory; 'ISSUE_INVALID' = the issue is not a positive integer and cannot form a record filename; 'PROGRESS_NOT_FOUND' = no slice--progress.json under .orchestrate/runs// (the slice has not recorded a completed stage yet) \u2014 distinct from 'PROGRESS_INVALID', which means the file EXISTS but is malformed JSON, fails the record schema, or self-identifies as a different run/slice than the one requested." + ), + errorMessage: external_exports.string().optional().describe("Human-readable failure description. Present when status='error'.") +}); +function firstLine7(message) { + const line = message.split("\n").map((l) => l.trim()).find((l) => l.length > 0); + return line ?? message.trim(); +} +async function recoverSliceProgress(input) { + const repoPath = input.repoPath ?? process.cwd(); + const resolved = resolveSliceProgressPath(repoPath, input.runId, input.issue); + if (!resolved.ok) { + return { + status: "error", + errorCode: resolved.errorCode, + errorMessage: resolved.errorMessage + }; + } + let raw; + try { + raw = fs7.readFileSync(resolved.path, "utf8"); + } catch { + return { + status: "error", + errorCode: "PROGRESS_NOT_FOUND", + errorMessage: `No slice progress record found at ${resolved.path}.` + }; + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch (err) { + return { + status: "error", + errorCode: "PROGRESS_INVALID", + errorMessage: `The slice progress record is not valid JSON: ${firstLine7( + err instanceof Error ? err.message : String(err) + )}` + }; + } + const result = sliceProgressRecordSchema.safeParse(parsed); + if (!result.success) { + const detail = result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; "); + return { + status: "error", + errorCode: "PROGRESS_INVALID", + errorMessage: `The slice progress record does not match the expected shape: ${detail}` + }; + } + const record2 = result.data; + if (record2.runId !== input.runId || record2.issue !== input.issue) { + return { + status: "error", + errorCode: "PROGRESS_INVALID", + errorMessage: `The slice progress record self-identifies as runId '${record2.runId}' / issue ${record2.issue}, but was read from the record of runId '${input.runId}' / issue ${input.issue}.` + }; + } + return { status: "ok", record: record2 }; +} + // src/tools/clean-runs.ts var path7 = __toESM(require("path")); -var fs8 = __toESM(require("fs")); +var fs9 = __toESM(require("fs")); // src/run-state-guard.ts -var fs7 = __toESM(require("fs")); +var fs8 = __toESM(require("fs")); function readRunState(runStatePath) { - if (!fs7.existsSync(runStatePath)) { + if (!fs8.existsSync(runStatePath)) { return { ok: false, reason: "missing-run-state" }; } let raw; try { - raw = fs7.readFileSync(runStatePath, "utf8"); + raw = fs8.readFileSync(runStatePath, "utf8"); } catch { return { ok: false, reason: "malformed-run-state" }; } @@ -23996,7 +24213,7 @@ async function removeRunFootprint(runId, runDir, state, repoPath, opts) { report.runDirRemoved = false; } else { try { - fs8.rmSync(runDir, { recursive: true, force: true }); + fs9.rmSync(runDir, { recursive: true, force: true }); report.runDirRemoved = true; } catch { report.runDirRemoved = false; @@ -24025,12 +24242,12 @@ async function cleanRuns(input) { }; } const runsRoot = path7.join(repoPath, ".orchestrate", "runs"); - if (!fs8.existsSync(runsRoot)) { + if (!fs9.existsSync(runsRoot)) { return { status: "ok", runs: [] }; } let entries; try { - entries = fs8.readdirSync(runsRoot, { withFileTypes: true }); + entries = fs9.readdirSync(runsRoot, { withFileTypes: true }); } catch (err) { return { status: "error", @@ -24108,7 +24325,7 @@ async function reclaimRun(input) { }; } const runDir = path7.join(repoPath, ".orchestrate", "runs", input.runId); - if (!fs8.existsSync(runDir)) { + if (!fs9.existsSync(runDir)) { return { status: "ok", report: skippedReport(input.runId, "run-not-found") @@ -24134,7 +24351,7 @@ async function reclaimRun(input) { } // src/tools/verify-changeset.ts -var fs9 = __toESM(require("fs")); +var fs10 = __toESM(require("fs")); var verifyChangesetInputSchema = external_exports.object({ worktreePath: external_exports.string().describe( "Absolute path to the slice worktree to inspect. The verification treats this worktree as the source of truth for what was actually changed." @@ -24182,7 +24399,7 @@ async function verifyChangeset(input) { errorMessage: guardErr }; } - if (!fs9.existsSync(worktreePath)) { + if (!fs10.existsSync(worktreePath)) { return { status: "error", errorCode: "PATH_NOT_FOUND", @@ -24240,10 +24457,10 @@ function classifyMatch(declaredCount, actualCount, absentCount, undeclaredCount) // src/tools/bootstrap-config.ts var path8 = __toESM(require("path")); -var fs11 = __toESM(require("fs")); +var fs12 = __toESM(require("fs")); // src/tools/detect-project.ts -var fs10 = __toESM(require("fs")); +var fs11 = __toESM(require("fs")); function detectJsPackageManager(filesPresent) { const present = new Set(filesPresent); if (present.has("pnpm-lock.yaml")) { @@ -24332,7 +24549,7 @@ function buildCommandMap(type, jsPackageManager) { function detectCommandMap(repoRoot) { let entries; try { - entries = fs10.readdirSync(repoRoot); + entries = fs11.readdirSync(repoRoot); } catch { return {}; } @@ -24367,19 +24584,22 @@ var DEFAULT_ROUTING_CONFIG = { investigator: null, implementer: { model: "haiku", variant: "standard" }, reviewer: { model: "sonnet", variant: "standard" }, - "conflict-resolver": { model: "sonnet", variant: "standard" } + "conflict-resolver": { model: "sonnet", variant: "standard" }, + "slice-executor": { model: "haiku", variant: "standard" } }, standard: { investigator: { model: "haiku", variant: "standard" }, implementer: { model: "sonnet", variant: "standard" }, reviewer: { model: "opus", variant: "standard" }, - "conflict-resolver": { model: "opus", variant: "standard" } + "conflict-resolver": { model: "opus", variant: "standard" }, + "slice-executor": { model: "sonnet", variant: "standard" } }, complex: { investigator: { model: "opus", variant: "deep" }, implementer: { model: "opus", variant: "deep" }, reviewer: { model: "opus", variant: "deep" }, - "conflict-resolver": { model: "opus", variant: "deep" } + "conflict-resolver": { model: "opus", variant: "deep" }, + "slice-executor": { model: "opus", variant: "deep" } } }, labels: { @@ -24439,10 +24659,22 @@ var bootstrapConfigOutputSchema = external_exports.object({ "Cleaned, human-readable failure description. Present when status='error'." ), warnings: external_exports.array(external_exports.string()).optional().describe( - "Advisory warnings about the bootstrapped configuration. Non-empty only when status='ok' and the freshly-written commands.json is empty ({}) \u2014 meaning no recognized project type was detected and the capability gates (run_tests, run_build, etc.) will report 'not-configured', allowing a slice to merge green with no verification. Empty array when the written commands map is non-empty. Present when status='ok'." + "Advisory warnings about the bootstrapped configuration. Non-empty exactly when `falseGreenRisk` is true \u2014 see that field. Present when status='ok'." + ), + capabilities: external_exports.object({ + tests: external_exports.boolean(), + typecheck: external_exports.boolean(), + build: external_exports.boolean(), + lint: external_exports.boolean(), + install: external_exports.boolean() + }).optional().describe( + "Which capability verbs resolve to a configured command in the FINAL commands.json \u2014 read after this call, whether it just wrote the file or the file was already present. `true` = a command is configured for that verb, so the matching capability tool (run_tests, run_typecheck, run_build, run_lint) will execute it; `false` = that tool reports 'not-configured' \u2014 either the verb is absent or its argv array is empty, which the capability tools treat identically. `install` is the setup verb (run_install), not a capability gate. Present when status='ok'." + ), + falseGreenRisk: external_exports.boolean().optional().describe( + "True exactly when BOTH `capabilities.tests` and `capabilities.build` are false \u2014 the specific conjunction that lets a slice merge green with nothing ever executed. An individual missing verb (`lint`, `typecheck`, `install`) is common and NOT flagged here: many projects legitimately skip a linter or need no install step. This reflects the FINAL commands.json regardless of whether it was freshly written this call or was already on disk \u2014 a stale or hand-authored partial file is exactly as risky as a fresh empty one. Present when status='ok'." ) }); -function firstLine7(message) { +function firstLine8(message) { const line = message.split("\n").map((l) => l.trim()).find((l) => l.length > 0); return line ?? message.trim(); } @@ -24473,7 +24705,7 @@ function resolveContextWindow(input) { function buildCommandsConfig(repoRoot) { let entries; try { - entries = fs11.readdirSync(repoRoot); + entries = fs12.readdirSync(repoRoot); } catch { entries = []; } @@ -24483,36 +24715,52 @@ function buildCommandsConfig(repoRoot) { return { config: config2, projectType }; } function writeIfAbsent(filePath, content) { - if (fs11.existsSync(filePath)) { + if (fs12.existsSync(filePath)) { return { kind: "already-present" }; } try { - fs11.writeFileSync(filePath, content); + fs12.writeFileSync(filePath, content); return { kind: "written" }; } catch (err) { return { kind: "error", - message: firstLine7(err instanceof Error ? err.message : String(err)) + message: firstLine8(err instanceof Error ? err.message : String(err)) }; } } +function readExistingCommandsConfig(filePath) { + let raw; + try { + raw = fs12.readFileSync(filePath, "utf8"); + } catch { + return {}; + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return {}; + } + const validated = commandsConfigSchema.safeParse(parsed); + return validated.success ? validated.data : {}; +} function ensureGitignoreEntry(repoRoot) { const gitignorePath = path8.join(repoRoot, ".gitignore"); let existing; try { - existing = fs11.readFileSync(gitignorePath, "utf8"); + existing = fs12.readFileSync(gitignorePath, "utf8"); } catch { existing = null; } if (existing === null) { try { - fs11.writeFileSync(gitignorePath, `${RUNS_GITIGNORE_LINE} + fs12.writeFileSync(gitignorePath, `${RUNS_GITIGNORE_LINE} `); return { kind: "created-with-line" }; } catch (err) { return { kind: "error", - message: firstLine7(err instanceof Error ? err.message : String(err)) + message: firstLine8(err instanceof Error ? err.message : String(err)) }; } } @@ -24522,7 +24770,7 @@ function ensureGitignoreEntry(repoRoot) { } const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n"; try { - fs11.appendFileSync( + fs12.appendFileSync( gitignorePath, `${separator}${RUNS_GITIGNORE_LINE} ` @@ -24531,7 +24779,7 @@ function ensureGitignoreEntry(repoRoot) { } catch (err) { return { kind: "error", - message: firstLine7(err instanceof Error ? err.message : String(err)) + message: firstLine8(err instanceof Error ? err.message : String(err)) }; } } @@ -24539,14 +24787,14 @@ function bootstrapConfig(input) { const repoRoot = input.repoPath ?? process.cwd(); const orchestrateDir = path8.join(repoRoot, ".orchestrate"); const runsDir = path8.join(orchestrateDir, "runs"); - const runsDirExisted = fs11.existsSync(runsDir); + const runsDirExisted = fs12.existsSync(runsDir); try { - fs11.mkdirSync(runsDir, { recursive: true }); + fs12.mkdirSync(runsDir, { recursive: true }); } catch (err) { return { status: "error", errorCode: "WRITE_FAILED", - errorMessage: `Failed to create ${runsDir}: ${firstLine7( + errorMessage: `Failed to create ${runsDir}: ${firstLine8( err instanceof Error ? err.message : String(err) )}` }; @@ -24638,9 +24886,19 @@ function bootstrapConfig(input) { errorMessage: `Failed to update .gitignore: ${gitignoreResult.message}` }; } - const commandsMapEmpty = Object.keys(validatedCommands.data).length === 0; - const warnings = commandsResult.kind === "written" && commandsMapEmpty ? [ - "commands.json was written empty ({}): no recognized project type detected. The capability gates run_tests and run_build will report 'not-configured' \u2014 a slice can merge green with no verification. Edit .orchestrate/commands.json to add your project's test and build commands." + const finalCommands = commandsResult.kind === "written" ? validatedCommands.data : readExistingCommandsConfig(path8.join(orchestrateDir, "commands.json")); + const configured = (argv) => argv !== void 0 && argv.length > 0; + const capabilities = { + tests: configured(finalCommands.tests), + typecheck: configured(finalCommands.typecheck), + build: configured(finalCommands.build), + lint: configured(finalCommands.lint), + install: configured(finalCommands.install) + }; + const falseGreenRisk = !capabilities.tests && !capabilities.build; + const commandsMapEmpty = Object.keys(finalCommands).length === 0; + const warnings = falseGreenRisk ? [ + commandsResult.kind === "written" && commandsMapEmpty ? "commands.json was written empty ({}): no recognized project type detected. The capability gates run_tests and run_build will report 'not-configured' \u2014 a slice can merge green with no verification. Edit .orchestrate/commands.json to add your project's test and build commands." : `commands.json has neither \`tests\` nor \`build\` configured (commandsJson: ${commandsResult.kind}). The capability gates run_tests and run_build will report 'not-configured' \u2014 a slice can merge green with no verification. Edit .orchestrate/commands.json to add your project's test and build commands.` ] : []; return { status: "ok", @@ -24654,6 +24912,8 @@ function bootstrapConfig(input) { }, runsDir: runsDirExisted ? "already-present" : "created", gitignore: gitignoreResult.kind, + capabilities, + falseGreenRisk, warnings }; } @@ -24833,7 +25093,7 @@ async function pushAndVerify(input, opts) { } // src/tools/validate-run-state.ts -var fs12 = __toESM(require("fs")); +var fs13 = __toESM(require("fs")); var validateRunStateInputSchema = external_exports.object({ runId: external_exports.string().describe( "The orchestration run's id (its YYYYMMDD-HHMMSS timestamp, optionally prefixed `prd-` or `backlog-`). It selects the per-run directory .orchestrate/runs//, which holds that run's run-state.json. Required \u2014 every validate call happens after the run has a runId." @@ -24851,7 +25111,7 @@ var validateRunStateOutputSchema = external_exports.object({ ), errorMessage: external_exports.string().optional().describe("Human-readable failure description. Present when status='invalid'.") }); -function firstLine8(message) { +function firstLine9(message) { const line = message.split("\n").map((l) => l.trim()).find((l) => l.length > 0); return line ?? message.trim(); } @@ -24867,7 +25127,7 @@ async function validateRunState(input) { } let raw; try { - raw = fs12.readFileSync(resolved.paths.runStatePath, "utf8"); + raw = fs13.readFileSync(resolved.paths.runStatePath, "utf8"); } catch { return { status: "invalid", @@ -24882,7 +25142,7 @@ async function validateRunState(input) { return { status: "invalid", errorCode: "RUN_STATE_INVALID", - errorMessage: `run-state.json is not valid JSON: ${firstLine8( + errorMessage: `run-state.json is not valid JSON: ${firstLine9( err instanceof Error ? err.message : String(err) )}` }; @@ -24900,7 +25160,7 @@ async function validateRunState(input) { } // src/tools/finalize-slice.ts -var fs13 = __toESM(require("fs")); +var fs14 = __toESM(require("fs")); var finalizeSliceInputSchema = external_exports.object({ phase: external_exports.enum(["commit-push", "post-merge"]).describe( "Which finalization phase to run. 'commit-push' = \xA73 step 6: stage the named file set, guard an empty changeset, commit (subject + `Closes #`), push-and-verify, and write `subState:'pushed'` on a confirmed landing. 'post-merge' = \xA73 step 9 (the thin tail): write `subState:'merged'`, remove the worktree, then force-reclaim the local slice branch (idempotent). Forge ops and the `pr-open` checkpoint stay in the spine." @@ -24990,7 +25250,7 @@ function writeSubState(repoPath, runId, sliceId, subState) { const statePath = resolved.paths.runStatePath; let raw; try { - raw = fs13.readFileSync(statePath, "utf8"); + raw = fs14.readFileSync(statePath, "utf8"); } catch { return { ok: false, @@ -25038,7 +25298,7 @@ function writeSubState(repoPath, runId, sliceId, subState) { slice.updatedAt = nowIso; obj.updatedAt = nowIso; try { - fs13.writeFileSync(statePath, JSON.stringify(obj, null, 2), "utf8"); + fs14.writeFileSync(statePath, JSON.stringify(obj, null, 2), "utf8"); return { ok: true }; } catch (err) { return { @@ -25363,9 +25623,11 @@ var runWaveInputSchema = external_exports.object({ "refresh-base", "select-processable", "reverify-slice", - "integration-gate" + "integration-gate", + "plan-wave-width", + "classify-spawn-outcome" ]).describe( - "Which bracketed wave operation to run. 'refresh-base' (\xA72 step 1): fast-forward the local umbrella ref to its remote tip, or report `diverged` when that is not a fast-forward. 'select-processable' (\xA72 step 2): gate one slice on its in-partition + out-of-partition blocker states (passed in), returning `processable` or `skip`. 'reverify-slice' (\xA72 step 4 inner re-verify): merge the umbrella into the slice worktree and run the two correctness verbs, returning `passed`/`failed`/`conflict` (or `skipped-first-merge` for the first merged slice). 'integration-gate' (\xA72 step 4a): run the per-wave integration suite, returning `proceed`/`halt`/`tolerate`." + "Which bracketed wave operation to run. 'refresh-base' (\xA72 step 1): fast-forward the local umbrella ref to its remote tip, or report `diverged` when that is not a fast-forward. 'select-processable' (\xA72 step 2): gate one slice on its in-partition + out-of-partition blocker states (passed in), returning `processable` or `skip`. 'reverify-slice' (\xA72 step 4 inner re-verify): merge the umbrella into the slice worktree and run the two correctness verbs, returning `passed`/`failed`/`conflict` (or `skipped-first-merge` for the first merged slice). 'integration-gate' (\xA72 step 4a): run the per-wave integration suite, returning `proceed`/`halt`/`tolerate`. 'plan-wave-width' (\xA72 step 3): cap how many processable slices may be in flight at once against the concurrent-subagent limit, returning `width-planned` with `waveWidth` + `deferredCount`. 'classify-spawn-outcome' (\xA72 step 3): classify an observed spawn failure as `backpressure` (retry-later, the slice is fine) or `spawn-error`." ), repoPath: external_exports.string().optional().describe( "The directory the operation runs in. For 'refresh-base' it is the main repo root holding the local umbrella ref. For 'reverify-slice' and 'integration-gate' it is the slice/deferred WORKTREE the merge and the capability commands run against \u2014 the same `repoPath` the run_* capability tools take (config is resolved from the main root, the command execs here). Unused by 'select-processable' (pure)." @@ -25384,11 +25646,20 @@ var runWaveInputSchema = external_exports.object({ ), outOfPartitionBlockers: external_exports.array(outOfPartitionBlockerSchema).optional().describe( "'select-processable' only: the dependent slice's blockers that are NOT slices in this run's partition, each with the tracker state the orchestrator resolved and passed in (ADR-0008). The slice is processable only when every one is `CLOSED`. Pass [] when the slice has no out-of-partition blockers." + ), + processableCount: external_exports.number().int().positive().optional().describe( + "'plan-wave-width' only: how many slices in this wave passed the 'select-processable' gate. Required for that operation." + ), + concurrencyLimit: external_exports.number().int().positive().optional().default(20).describe( + "'plan-wave-width' only: how many subagents may run CONCURRENTLY in this session. Defaults to 20 \u2014 the platform's own default, which `CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS` changes. Pass the raised value when that variable is set; the plugin cannot read it (it works within the limit, it never sets it)." + ), + spawnFailureText: external_exports.string().optional().describe( + "'classify-spawn-outcome' only: the verbatim failure text the orchestrator got back when a spawn was refused. This tool never observes a spawn itself \u2014 the orchestrator makes every Agent call, so it passes in what it saw (the same ADR-0008 posture as 'select-processable'). Required for that operation." ) }); var runWaveOutputSchema = external_exports.object({ status: external_exports.enum(["ok", "failed"]).describe( - "Outcome discriminant. 'ok' = the operation reached a non-failure verdict (refreshed | processable | skip | skipped-first-merge | passed | proceed | tolerate); 'failed' = a blocking verdict or error (diverged | failed | conflict | halt | error). The `verdict` field carries the specific outcome." + "Outcome discriminant. 'ok' = the operation reached a non-failure verdict (refreshed | processable | skip | skipped-first-merge | passed | proceed | tolerate | width-planned | backpressure); 'failed' = a blocking verdict or error (diverged | failed | conflict | halt | spawn-error | error). `backpressure` is deliberately on the 'ok' side: a slice refused for a platform limit has nothing wrong with it. The `verdict` field carries the specific outcome." ), verdict: external_exports.enum([ "refreshed", @@ -25402,9 +25673,12 @@ var runWaveOutputSchema = external_exports.object({ "proceed", "halt", "tolerate", + "width-planned", + "backpressure", + "spawn-error", "error" ]).describe( - "The operation's specific outcome. 'refresh-base' \u2192 `refreshed` (fast-forwarded) | `diverged` (not a fast-forward \u2014 the ref is left untouched). 'select-processable' \u2192 `processable` | `skip` (see `blockerId`). 'reverify-slice' \u2192 `skipped-first-merge` | `passed` | `failed` (see `which`) | `conflict` (the merge left an unmerged index, flagged not resolved). 'integration-gate' \u2192 `proceed` | `halt` | `tolerate` (no integration command configured). `error` = a git or input failure (see `errorCode`)." + "The operation's specific outcome. 'refresh-base' \u2192 `refreshed` (fast-forwarded) | `diverged` (not a fast-forward \u2014 the ref is left untouched). 'select-processable' \u2192 `processable` | `skip` (see `blockerId`). 'reverify-slice' \u2192 `skipped-first-merge` | `passed` | `failed` (see `which`) | `conflict` (the merge left an unmerged index, flagged not resolved). 'integration-gate' \u2192 `proceed` | `halt` | `tolerate` (no integration command configured). 'plan-wave-width' \u2192 `width-planned` (see `waveWidth` + `deferredCount`). 'classify-spawn-outcome' \u2192 `backpressure` (the concurrent-subagent limit \u2014 requeue the slice unchanged, never fail it) | `spawn-error` (anything else, including a spent session spawn budget; see `limitSignal`). `error` = a git or input failure (see `errorCode`)." ), sha: external_exports.string().optional().describe( "'refresh-base' `refreshed`: the umbrella SHA the local ref now points at (the fetched remote tip)." @@ -25415,13 +25689,30 @@ var runWaveOutputSchema = external_exports.object({ which: external_exports.enum(["tests", "build"]).optional().describe( "'reverify-slice' `failed`: which correctness verb failed after the umbrella was merged into the worktree." ), + waveWidth: external_exports.number().int().optional().describe( + "'plan-wave-width' `width-planned`: how many of this wave's processable slices may be in flight at once \u2014 `min(processableCount, floor(concurrencyLimit / 2))`, floored at 1." + ), + deferredCount: external_exports.number().int().optional().describe( + "'plan-wave-width' `width-planned`: how many processable slices the cap defers to a later turn of the wave \u2014 `processableCount - waveWidth`. A deferred slice stays `pending` in the wave's queue; it is NOT skipped and NOT failed." + ), + limitSignal: external_exports.enum([ + "concurrent-subagent-limit", + "session-spawn-limit", + "unrecognized" + ]).optional().describe( + "'classify-spawn-outcome': which documented platform signal the failure text matched. 'concurrent-subagent-limit' = transient backpressure, a slot frees and the slice is re-attempted. 'session-spawn-limit' = the session's total spawn budget is SPENT \u2014 unrecoverable in-session, and the reason the two limits must never be conflated (requeueing on this one would loop forever). 'unrecognized' = no documented literal matched, classified conservatively as a spawn error." + ), errorCode: external_exports.enum(["INVALID_INPUT", "GIT_ERROR"]).optional().describe( "Machine-readable failure category for `verdict: 'error'`. 'INVALID_INPUT' = a ref/remote would be parsed by git as an option flag, or a required field for the operation is missing; 'GIT_ERROR' = a git command failed for a reason other than divergence or a merge conflict (e.g. an unreachable remote, a missing local umbrella ref)." ), errorMessage: external_exports.string().optional().describe( - "Cleaned, human-readable failure description. Present for `diverged`, `conflict`, `failed`, `halt`, and `error`." + "Cleaned, human-readable failure description. Present for `diverged`, `conflict`, `failed`, `halt`, `spawn-error`, and `error` \u2014 and deliberately ABSENT for `backpressure`, which is not a failure." ) }); +var DEFAULT_CONCURRENCY_LIMIT = 20; +var AGENT_SLOTS_PER_SLICE = 2; +var CONCURRENT_LIMIT_LITERAL = "concurrent subagent limit reached"; +var SESSION_LIMIT_LITERAL = "subagent spawn limit reached"; var FETCH_ATTEMPTS = 3; var FETCH_BASE_DELAY_MS = 500; var FETCH_BACKOFF_FACTOR = 2; @@ -25437,6 +25728,10 @@ async function runWave(input, opts) { return reverifySlice(input, opts); case "integration-gate": return integrationGate(input); + case "plan-wave-width": + return planWaveWidth(input); + case "classify-spawn-outcome": + return classifySpawnOutcome(input); } } async function refreshBase(input, opts) { @@ -25602,6 +25897,64 @@ async function integrationGate(input) { }; } } +function planWaveWidth(input) { + const processableCount = input.processableCount; + const concurrencyLimit = input.concurrencyLimit ?? DEFAULT_CONCURRENCY_LIMIT; + if (processableCount === void 0 || !Number.isInteger(processableCount) || processableCount < 1) { + return failed2( + "INVALID_INPUT", + "operation 'plan-wave-width' requires a positive integer `processableCount`." + ); + } + if (!Number.isInteger(concurrencyLimit) || concurrencyLimit < 1) { + return failed2( + "INVALID_INPUT", + "operation 'plan-wave-width' requires a positive integer `concurrencyLimit`." + ); + } + const maxWidth = Math.max( + 1, + Math.floor(concurrencyLimit / AGENT_SLOTS_PER_SLICE) + ); + const waveWidth = Math.min(processableCount, maxWidth); + return { + status: "ok", + verdict: "width-planned", + waveWidth, + deferredCount: processableCount - waveWidth + }; +} +function classifySpawnOutcome(input) { + const text = input.spawnFailureText; + if (text === void 0) { + return failed2( + "INVALID_INPUT", + "operation 'classify-spawn-outcome' requires `spawnFailureText`." + ); + } + const haystack = text.toLowerCase(); + if (haystack.includes(CONCURRENT_LIMIT_LITERAL)) { + return { + status: "ok", + verdict: "backpressure", + limitSignal: "concurrent-subagent-limit" + }; + } + if (haystack.includes(SESSION_LIMIT_LITERAL)) { + return { + status: "failed", + verdict: "spawn-error", + limitSignal: "session-spawn-limit", + errorMessage: "The session's total subagent spawn budget is spent \u2014 this is NOT backpressure and re-attempting the slice in this session cannot succeed. Hand the run off to a successor session." + }; + } + return { + status: "failed", + verdict: "spawn-error", + limitSignal: "unrecognized", + errorMessage: `The spawn failure matched no documented platform limit, so it is classified conservatively as a spawn error: ${text}` + }; +} async function fetchWithRetry(fetchArgs, repoPath, opts) { const attempts = opts?.fetchAttempts ?? FETCH_ATTEMPTS; const baseDelayMs = opts?.baseDelayMs ?? FETCH_BASE_DELAY_MS; @@ -26037,7 +26390,7 @@ registerTool( "resolve_routing", { title: "Resolve Complexity Routing", - description: "Resolves which model and subagent variant to spawn for each role \u2014 investigator, implementer, reviewer, conflict-resolver \u2014 given an issue's assessed complexity tier. Reads the tier-to-role mapping from .orchestrate/routing.json (supports both v1 and v2 schemas; v1 files are transparently upgraded in memory). Accepts optional `labels` \u2014 the slice issue's GitHub labels \u2014 and applies any configured `route:*` label overrides deterministically. A null investigator means that tier skips the investigation pass. Returns per-role `variant` (not `effort`), the resolved run-wide `continuationBudget`, resolved label fallback specs, and structured label warnings. A same-role label conflict surfaces as a structured `LABEL_CONFLICT` error, never a silent pick. Returns a discriminated `status` of 'ok' or 'error'.", + description: "Resolves which model and subagent variant to spawn for each role \u2014 investigator, implementer, reviewer, conflict-resolver, and slice-executor (ADR-0017) \u2014 given an issue's assessed complexity tier. Reads the tier-to-role mapping from .orchestrate/routing.json (supports both v1 and v2 schemas; v1 files are transparently upgraded in memory). A routing.json predating `slice-executor` still resolves: the role defaults to the tier's own `implementer` entry, flagged with a structured warning. Accepts optional `labels` \u2014 the slice issue's GitHub labels \u2014 and applies any configured `route:*` label overrides deterministically. A null investigator means that tier skips the investigation pass. Returns per-role `variant` (not `effort`), the resolved run-wide `continuationBudget`, resolved label fallback specs, and structured label warnings. A same-role label conflict surfaces as a structured `LABEL_CONFLICT` error, never a silent pick. Returns a discriminated `status` of 'ok' or 'error'.", inputSchema: resolveRoutingV2InputSchema.shape, outputSchema: resolveRoutingV2OutputSchema.shape }, @@ -26203,7 +26556,7 @@ registerTool( "validate_envelope", { title: "Validate Subagent Result Envelope", - description: "Validates a subagent's result envelope \u2014 the ```orchestrate-envelope fenced JSON block a subagent emits as its final message \u2014 against the defined schema for its role. Returns a discriminated `status`: 'valid' (a well-formed envelope matching the role, with the parsed `envelope`), 'invalid' (an envelope was attempted but is truncated, malformed, or off-schema \u2014 a truncated envelope is ALWAYS invalid, never silently accepted), or 'missing' (no envelope block was found). A failure outcome (implementer 'blocked', reviewer 'failed') must also carry a labelled `rootCause` (verified|hypothesis) or it is reported invalid. An implementer 'incomplete' envelope must carry a non-empty `remainingWork` handoff (the note the orchestrator forwards to the continuation in the same worktree) or it is reported invalid. The orchestrator uses this instead of parsing subagent prose for status or changed files.", + description: "Validates a subagent's result envelope \u2014 the ```orchestrate-envelope fenced JSON block a subagent emits as its final message \u2014 against the defined schema for its role. Returns a discriminated `status`: 'valid' (a well-formed envelope matching the role, with the parsed `envelope`), 'invalid' (an envelope was attempted but is truncated, malformed, or off-schema \u2014 a truncated envelope is ALWAYS invalid, never silently accepted), or 'missing' (no envelope block was found). A failure outcome (implementer 'blocked', reviewer 'failed') must also carry a labelled `rootCause` (verified|hypothesis) or it is reported invalid. An implementer 'incomplete' envelope must carry a non-empty `remainingWork` handoff (the note the orchestrator forwards to the continuation in the same worktree) or it is reported invalid. A `slice-executor` envelope (ADR-0017) describes a WHOLE SLICE's outcome rather than one worker's turn, with a `failureClass` drawn from a closed set \u2014 an unrecognized `failureClass` is reported invalid exactly like any other schema mismatch. The orchestrator uses this instead of parsing subagent prose for status or changed files.", inputSchema: validateEnvelopeInputSchema.shape, outputSchema: validateEnvelopeOutputSchema.shape }, @@ -26236,6 +26589,32 @@ registerTool( // widen to the flat SDK-boundary `AnyToolHandler` for registration. handleRecoverChangedFiles ); +var handleRecoverSliceProgress = async (input) => { + const result = await recoverSliceProgress(input); + let text; + if (result.status === "ok") { + const stage = result.record.lastCompletedStage ?? "none"; + text = `Recovered the progress record for slice #${input.issue} (last completed stage: ${stage}).`; + } else { + text = `Slice progress recovery failed [${result.errorCode}]: ${result.errorMessage}`; + } + return { + structuredContent: result, + content: [{ type: "text", text }] + }; +}; +registerTool( + "recover_slice_progress", + { + title: "Recover a Slice's Progress Record", + description: "Reads and validates one slice's progress record at `.orchestrate/runs//slice--progress.json` \u2014 the resume anchor a slice-executor writes at each completed stage (ADR-0017), carrying the last completed stage, the investigator brief, the continuation count, the worktree fingerprint, and the once-only model-fallback guard. Call it when a slice-executor's result envelope is missing or invalid: the orchestrator recovers the record's contents through this tool INSTEAD of opening the file, so the recovered data is validated and the executor's read boundary stays intact \u2014 the same structured-recovery posture as `recover_changed_files`. The path is derived from `runId` and `issue`; no file path is accepted, so the read can never leave this run's own directory. Reads only; writes nothing. Returns a discriminated `status` of 'ok' (with `record`) or 'error' (with `RUN_ID_INVALID`, `ISSUE_INVALID`, `PROGRESS_NOT_FOUND` \u2014 no record written yet \u2014 or `PROGRESS_INVALID` \u2014 the file exists but is malformed JSON, fails the schema, or names a different run or slice).", + inputSchema: recoverSliceProgressInputSchema.shape, + outputSchema: recoverSliceProgressOutputSchema.shape + }, + // Handler is typed against its concrete input/output contract; + // widen to the flat SDK-boundary `AnyToolHandler` for registration. + handleRecoverSliceProgress +); var handleCleanRuns = async (input) => { const result = await cleanRuns(input); let text; @@ -26349,7 +26728,7 @@ registerTool( "bootstrap_config", { title: "Bootstrap Orchestrate Configuration", - description: "Sets up a repository's .orchestrate/ configuration for a first-ever orchestrate run. Detects the project type and writes a project-aware commands.json (with a PM-aware mutating `install` command for npm/cargo/python projects \u2014 keyed on the JS lockfile for the npm ecosystem \u2014 empty for an unrecognized project), writes routing.json from the shipped defaults, and writes handoff.json with a context-window size derived from the running model \u2014 pass the model id (or an explicit contextWindowTokens) as input; the MCP process cannot see the calling LLM's model. An unknown or absent model falls back to 200000. Creates .orchestrate/runs/ and idempotently adds it to the repository's .gitignore. Every step is idempotent: an existing config file is never overwritten and the .gitignore line is never duplicated. Returns a discriminated `status` of 'ok' or 'error'.", + description: "Completes a repository's .orchestrate/ configuration \u2014 writes whichever of its three files are missing. Call this unconditionally at the start of every run, never gated on whether .orchestrate/ already exists: a directory that already has some files (e.g. an earlier run's routing.json and handoff.json but no commands.json) is exactly the case this closes, and calling it on an already-complete repository is a safe no-op. Detects the project type and writes a project-aware commands.json (with a PM-aware mutating `install` command for npm/cargo/python projects \u2014 keyed on the JS lockfile for the npm ecosystem \u2014 empty for an unrecognized project), writes routing.json from the shipped defaults, and writes handoff.json with a context-window size derived from the running model \u2014 pass the model id (or an explicit contextWindowTokens) as input; the MCP process cannot see the calling LLM's model. An unknown or absent model falls back to 200000. Creates .orchestrate/runs/ and idempotently adds it to the repository's .gitignore. Every step is idempotent: an existing config file is never overwritten and the .gitignore line is never duplicated. Reports config completeness read from the FINAL commands.json regardless of whether this call wrote it: `capabilities` names which of tests/typecheck/build/lint/install resolve to a command, and `falseGreenRisk` is true exactly when both `tests` and `build` are unconfigured \u2014 the conjunction that lets a slice merge green with nothing executed. Treat a true `falseGreenRisk` as a loud, blocking finding: report it and stop before starting the run. Returns a discriminated `status` of 'ok' or 'error'.", inputSchema: bootstrapConfigInputSchema.shape, outputSchema: bootstrapConfigOutputSchema.shape }, @@ -26499,6 +26878,15 @@ var handleRunWave = async (input) => { case "tolerate": text = `Integration gate tolerated \u2014 no integration suite configured.`; break; + case "width-planned": + text = `Wave width planned \u2014 run ${result.waveWidth} slice(s) concurrently, deferring ${result.deferredCount} to a later turn of this wave.`; + break; + case "backpressure": + text = `Spawn refused by the concurrent-subagent limit \u2014 BACKPRESSURE, not a slice failure. Return the slice to the wave's processable queue with its state unchanged and re-attempt it when a slot frees.`; + break; + case "spawn-error": + text = `Spawn failed [${result.limitSignal}]: ${result.errorMessage}`; + break; case "error": text = `run_wave failed [${result.errorCode}]: ${result.errorMessage}`; break; @@ -26512,7 +26900,7 @@ registerTool( "run_wave", { title: "Run a Bracketed Deterministic Wave Operation", - description: "A family of bracketed deterministic wave-loop operations behind one tool, selected by the `operation` discriminant, so only the higher-level policy that decides how a wave processes its slices stays the orchestrator's concern. 'refresh-base' (\xA72 step 1): fetch the remote umbrella and fast-forward the local umbrella ref to it (FETCH_HEAD + a `git merge-base` ancestor proof before the ref moves), or report `diverged` \u2014 distinct from a generic git error \u2014 when that is not a fast-forward, leaving the ref untouched. 'select-processable' (\xA72 step 2): gate one slice on its in-partition (must be `passed`) and out-of-partition (must be `CLOSED`) blocker states \u2014 consumed from STATE PASSED IN, never read with `gh` (ADR-0008) \u2014 returning `processable` or `skip{blockerId}`. 'reverify-slice' (\xA72 step 4 inner re-verify): a no-op (`skipped-first-merge`) for the first merged slice of a wave; otherwise fetch + merge the umbrella into the slice worktree, then run the two correctness verbs (tests + build), returning `passed`, `failed{which}`, or `conflict` (the unmerged index is left IN PLACE and only flagged \u2014 resolution is a downstream concern). 'integration-gate' (\xA72 step 4a): run the per-wave integration suite, mapping `proceed` (passed), `halt` (failed/error), or `tolerate` (not configured). All loop state (umbrella ref, remote, first-merged flag) is PASSED IN, never inferred. Git-only via the hardened exec seam, run-scoped (mutates nothing outside the passed worktree), and never throws \u2014 every failure mode is a structured `verdict`.", + description: "A family of bracketed deterministic wave-loop operations behind one tool, selected by the `operation` discriminant, so only the higher-level policy that decides how a wave processes its slices stays the orchestrator's concern. 'refresh-base' (\xA72 step 1): fetch the remote umbrella and fast-forward the local umbrella ref to it (FETCH_HEAD + a `git merge-base` ancestor proof before the ref moves), or report `diverged` \u2014 distinct from a generic git error \u2014 when that is not a fast-forward, leaving the ref untouched. 'select-processable' (\xA72 step 2): gate one slice on its in-partition (must be `passed`) and out-of-partition (must be `CLOSED`) blocker states \u2014 consumed from STATE PASSED IN, never read with `gh` (ADR-0008) \u2014 returning `processable` or `skip{blockerId}`. 'reverify-slice' (\xA72 step 4 inner re-verify): a no-op (`skipped-first-merge`) for the first merged slice of a wave; otherwise fetch + merge the umbrella into the slice worktree, then run the two correctness verbs (tests + build), returning `passed`, `failed{which}`, or `conflict` (the unmerged index is left IN PLACE and only flagged \u2014 resolution is a downstream concern). 'integration-gate' (\xA72 step 4a): run the per-wave integration suite, mapping `proceed` (passed), `halt` (failed/error), or `tolerate` (not configured). 'plan-wave-width' (\xA72 step 3): cap how many processable slices may be in flight at once against the session's concurrent-subagent limit (default 20) \u2014 each in-flight slice occupies TWO live agent slots (its executor plus one worker), so the width is half the limit floored at 1, and the remainder is `deferredCount`, DEFERRED (state unchanged) rather than skipped. 'classify-spawn-outcome' (\xA72 step 3): classify an observed spawn failure as `backpressure` (the concurrent-subagent limit \u2014 `status: 'ok'`, no error message, the slice is fine and returns to the queue) or `spawn-error`, keeping a SPENT session spawn budget distinguishable via `limitSignal` and defaulting an unrecognized failure to the conservative class. All loop state (umbrella ref, remote, first-merged flag) is PASSED IN, never inferred. Git-only via the hardened exec seam, run-scoped (mutates nothing outside the passed worktree), and never throws \u2014 every failure mode is a structured `verdict`.", inputSchema: runWaveInputSchema.shape, outputSchema: runWaveOutputSchema.shape }, diff --git a/plugins/orchestrate/orchestrate-mcp/dist/read-guard.js b/plugins/orchestrate/orchestrate-mcp/dist/read-guard.js new file mode 100755 index 00000000..ae154bce --- /dev/null +++ b/plugins/orchestrate/orchestrate-mcp/dist/read-guard.js @@ -0,0 +1,215 @@ +#!/usr/bin/env node +"use strict"; +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); + +// src/hooks/read-guard.ts +var path = __toESM(require("path")); +var GUARDED_BASENAME = /^slice-\d+-(progress\.json|report\.md)$/; +var READ_VERBS = /* @__PURE__ */ new Set([ + "cat", + "head", + "tail", + "less", + "more", + "bat", + "sed", + "awk", + "grep", + "rg", + "jq", + "od", + "xxd", + "strings", + "nl", + "wc", + "source", + "." +]); +var READ_GUARD_DENY_REASON = "orchestrate: the orchestrator does not open slice-internal artifacts. Use the slice-executor envelope's own fields for the slice's outcome, and pass reportPath forward without opening it. If the envelope is missing or invalid, recover the progress record's contents through the recover_slice_progress MCP tool, which derives the path from (runId, issue) and returns validated structured data."; +function denyPayload(reason) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: reason + } + }; +} +function unquote(token) { + const match = token.match(/^(["'])(.*)\1$/); + return match ? match[2] : token; +} +function isGuardedPath(candidate, cwd, runDir) { + if (candidate === "") return false; + const resolved = path.resolve(cwd, candidate); + return path.dirname(resolved) === runDir && GUARDED_BASENAME.test(path.basename(resolved)); +} +function bashReadsGuardedPath(command, cwd, runDir) { + for (const segment of command.split(/&&|\|\||;|\||\n/)) { + const tokens = segment.trim().split(/\s+/).filter((t) => t !== ""); + if (tokens.length === 0) continue; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + let candidate; + if (token === "<") candidate = tokens[i + 1]; + else if (token.startsWith("<") && !token.startsWith("<<")) { + candidate = token.slice(1); + } + if (candidate !== void 0 && isGuardedPath(unquote(candidate), cwd, runDir)) { + return true; + } + } + let start = 0; + while (start < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[start])) { + start++; + } + if (start >= tokens.length) continue; + if (!READ_VERBS.has(path.basename(unquote(tokens[start])))) continue; + for (const token of tokens.slice(start + 1)) { + if (isGuardedPath(unquote(token), cwd, runDir)) return true; + } + } + return false; +} +function decideReadGuard(input) { + const none = { decision: "none" }; + try { + if (typeof input.agentId === "string" && input.agentId.length > 0) { + return none; + } + const runId = input.activeRunId; + if (typeof runId !== "string" || runId.length === 0) return none; + if (typeof input.cwd !== "string" || input.cwd.length === 0) return none; + const toolInput = input.toolInput; + if (typeof toolInput !== "object" || toolInput === null) return none; + const cwd = input.cwd; + const runDir = path.resolve(cwd, ".orchestrate", "runs", runId); + if (input.toolName === "Read") { + const filePath = toolInput.file_path; + if (typeof filePath === "string" && isGuardedPath(filePath, cwd, runDir)) { + return { decision: "deny", reason: READ_GUARD_DENY_REASON }; + } + return none; + } + if (input.toolName === "Bash") { + const command = toolInput.command; + if (typeof command === "string" && bashReadsGuardedPath(command, cwd, runDir)) { + return { decision: "deny", reason: READ_GUARD_DENY_REASON }; + } + return none; + } + return none; + } catch { + return none; + } +} + +// src/hooks/run-discovery.ts +var path2 = __toESM(require("path")); +var fs = __toESM(require("fs")); +function scanInProgressRuns(cwd) { + const runsDir = path2.join(cwd, ".orchestrate", "runs"); + let entries; + try { + entries = fs.readdirSync(runsDir, { withFileTypes: true }); + } catch { + return []; + } + const runs = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const statePath = path2.join(runsDir, entry.name, "run-state.json"); + let runState; + try { + runState = JSON.parse(fs.readFileSync(statePath, "utf8")); + } catch { + continue; + } + if (typeof runState !== "object" || runState === null || runState.status !== "in-progress") { + continue; + } + const rawId = runState.driverSessionId; + runs.push({ + runId: entry.name, + driverSessionId: typeof rawId === "string" ? rawId : null + }); + } + return runs; +} +function findActiveRunForSession(cwd, sessionId) { + const runs = scanInProgressRuns(cwd); + if (runs.length === 0) return null; + if (typeof sessionId === "string" && sessionId.length > 0) { + const matches = runs.filter((r) => r.driverSessionId === sessionId); + if (matches.length === 1) return matches[0].runId; + } + if (runs.length === 1) return runs[0].runId; + return null; +} + +// src/hooks/read-guard-cli.ts +function readStdin() { + return new Promise((resolve2) => { + let data = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + data += chunk; + }); + process.stdin.on("end", () => resolve2(data)); + process.stdin.on("error", () => resolve2(data)); + }); +} +async function main() { + let raw = ""; + try { + raw = await readStdin(); + } catch { + process.exit(0); + } + try { + const event = raw ? JSON.parse(raw) : {}; + const cwd = typeof event.cwd === "string" ? event.cwd : process.cwd(); + const sessionId = typeof event.session_id === "string" && event.session_id.length > 0 ? event.session_id : void 0; + const runId = findActiveRunForSession(cwd, sessionId); + if (runId === null) { + process.exit(0); + } + const decision = decideReadGuard({ + toolName: typeof event.tool_name === "string" ? event.tool_name : void 0, + toolInput: typeof event.tool_input === "object" && event.tool_input !== null ? event.tool_input : void 0, + // Present ONLY inside a subagent call, which is what makes it — and not + // `agent_type`, which a `--agent` session also carries — the main-thread + // discriminator. + agentId: typeof event.agent_id === "string" ? event.agent_id : void 0, + cwd, + activeRunId: runId + }); + if (decision.decision === "deny") { + process.stdout.write(JSON.stringify(denyPayload(decision.reason))); + } + } catch { + } + process.exit(0); +} +void main(); diff --git a/plugins/orchestrate/orchestrate-mcp/package.json b/plugins/orchestrate/orchestrate-mcp/package.json index 229b44be..4e1c112e 100644 --- a/plugins/orchestrate/orchestrate-mcp/package.json +++ b/plugins/orchestrate/orchestrate-mcp/package.json @@ -4,7 +4,7 @@ "description": "MCP server providing worktree lifecycle and orchestration tools for parallel Claude Code agents.", "main": "dist/index.js", "scripts": { - "build": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/index.js && esbuild src/hooks/context-watchdog-cli.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/context-watchdog.js && esbuild src/hooks/session-start-cli.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/session-start.js", + "build": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/index.js && esbuild src/hooks/context-watchdog-cli.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/context-watchdog.js && esbuild src/hooks/session-start-cli.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/session-start.js && esbuild src/hooks/read-guard-cli.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/read-guard.js", "typecheck": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc --noEmit", "test": "vitest run", "test:watch": "vitest", diff --git a/plugins/orchestrate/orchestrate-mcp/src/handoff-config.ts b/plugins/orchestrate/orchestrate-mcp/src/handoff-config.ts index 334931f4..bf003d0f 100644 --- a/plugins/orchestrate/orchestrate-mcp/src/handoff-config.ts +++ b/plugins/orchestrate/orchestrate-mcp/src/handoff-config.ts @@ -5,11 +5,19 @@ import { z } from "zod"; // ─── Schemas — z.object is the single source of truth; TS types via z.infer ─── // // `.orchestrate/handoff.json` configures the context-handoff feature: the -// context-watchdog hook's threshold and the successor-session launcher. Every +// context-watchdog hook's thresholds and the successor-session launcher. Every // field carries a `.default()`, so the file may be absent entirely, present but -// empty (`{}`), or partial — it always resolves to a complete config. +// empty (`{}`), or partial — it always resolves to a complete config. That is +// also why the watchdog's SECOND threshold (the session spawn budget) needed no +// loader change: a config written before those keys existed still resolves. -/** Context-watchdog tuning — when to raise the handoff flag. */ +/** + * Context-watchdog tuning — when to raise the handoff flag. The watchdog + * watches TWO budgets, and the two field pairs are deliberately symmetric: + * `contextWindowTokens` + `thresholdPercent` for the session's context window, + * `sessionSpawnBudget` + `spawnThresholdPercent` for the platform's per-session + * subagent budget. Whichever threshold is reached first raises the flag. + */ export const watchdogConfigSchema = z.object({ thresholdPercent: z .number() @@ -30,6 +38,31 @@ export const watchdogConfigSchema = z.object({ "Total context window the percentage is measured against. Default " + "200000 — raise to 1000000 for a 1M-context session." ), + spawnThresholdPercent: z + .number() + .min(1) + .max(100) + .default(40) + .describe( + "Raise the handoff flag once the run's recorded subagent spawns reach " + + "this percentage of `sessionSpawnBudget`. Default 40, matching " + + "`thresholdPercent` — at roughly five spawns per slice a long run can " + + "spend its spawn budget well before it fills its context window, so " + + "this threshold must be as conservative as the token one." + ), + sessionSpawnBudget: z + .number() + .int() + .positive() + .default(200) + .describe( + "Total subagent spawns the session may make, the figure " + + "`spawnThresholdPercent` is measured against. Default 200 — the " + + "platform's own per-session default, which " + + "`CLAUDE_CODE_MAX_SUBAGENTS_PER_SESSION` changes. Nested and " + + "background subagents count toward it, and a finished subagent still " + + "counts, so the budget is cumulative and never decreases." + ), }); /** diff --git a/plugins/orchestrate/orchestrate-mcp/src/hooks/context-watchdog-cli.ts b/plugins/orchestrate/orchestrate-mcp/src/hooks/context-watchdog-cli.ts index 67948e33..f325e7cd 100644 --- a/plugins/orchestrate/orchestrate-mcp/src/hooks/context-watchdog-cli.ts +++ b/plugins/orchestrate/orchestrate-mcp/src/hooks/context-watchdog-cli.ts @@ -1,11 +1,20 @@ #!/usr/bin/env node -import { runWatchdog, findActiveRunForSession } from "./context-watchdog.js"; +import { runWatchdog } from "./context-watchdog.js"; +import { findActiveRunForSession } from "./run-discovery.js"; // Entry point for the `context-watchdog` PostToolUse hook. Claude Code pipes -// the hook event JSON on stdin; this script estimates the session's context -// usage and raises the orchestrate handoff flag past the threshold. It always -// exits 0 — a hook must never fail a tool call — and only emits output on the -// turn the flag is first raised, so it stays silent in unrelated sessions. +// the hook event JSON on stdin; this script samples the session's two budgets — +// context usage and the run's subagent-spawn count — and raises the orchestrate +// handoff flag past either threshold. It always exits 0 — a hook must never +// fail a tool call — and only emits output on the turn the flag is first +// raised, so it stays silent in unrelated sessions. +// +// Two event fields are forwarded to the watchdog beyond what run discovery +// needs. `tool_name` identifies a SPAWN: an `Agent` call appends one line to +// the run's spawn log, which is how the session spawn budget is counted at all. +// `session_id` says whose budget that spawn spends — the log outlives a handoff +// with its run, while the platform's cap resets per session, so each line is +// tagged and only the current session's lines are counted. // // The hook event carries `cwd`, `transcript_path`, and `session_id` — never a // runId. Run state lives in per-run directories (.orchestrate/runs//), @@ -41,8 +50,16 @@ async function main(): Promise { try { const event = raw ? (JSON.parse(raw) as Record) : {}; const cwd = typeof event.cwd === "string" ? event.cwd : process.cwd(); + // An EMPTY `session_id` is treated as absent, the same guard + // `findActiveRunForSession` applies. An empty string is not an identity: + // tagging a spawn with it would partition the count on a value no earlier + // line can carry, hiding every one of them — an under-count, the single + // direction this counting is built to avoid. Absent instead means every + // line counts, which only hands off early. const sessionId = - typeof event.session_id === "string" ? event.session_id : undefined; + typeof event.session_id === "string" && event.session_id.length > 0 + ? event.session_id + : undefined; // Discover the run this session drives. With concurrent runs, the session // identity disambiguates; when it cannot, discovery returns null and the @@ -59,15 +76,29 @@ async function main(): Promise { : undefined, cwd, runId, + toolName: typeof event.tool_name === "string" ? event.tool_name : undefined, + // The same `session_id` that discovery matched on — here it charges the + // spawn to the session whose budget it actually spends, so a successor + // session inheriting this run's spawn log starts from its own budget. + sessionId, }); if (result.flagRaised && result.evaluation) { const e = result.evaluation; + // The message names the budget that actually raised the flag — a + // spawn-triggered raise reported in token phrasing would send a reader + // looking at the wrong number. + const reason = + e.trigger === "spawns" + ? `${e.spawnCount} of ${e.sessionSpawnBudget} session subagent ` + + `spawns used (${e.spawnPercent}%, threshold ` + + `${e.spawnThresholdPercent}%)` + : `context at ${e.usagePercent}% of ${e.contextWindowTokens} tokens ` + + `(threshold ${e.thresholdPercent}%)`; process.stdout.write( JSON.stringify({ systemMessage: - `orchestrate context-watchdog: context at ${e.usagePercent}% of ` + - `${e.contextWindowTokens} tokens (threshold ${e.thresholdPercent}%). ` + + `orchestrate context-watchdog: ${reason}. ` + `Handoff flag raised — the run will hand off to a successor ` + `session after the current slice finishes.`, }) diff --git a/plugins/orchestrate/orchestrate-mcp/src/hooks/context-watchdog.ts b/plugins/orchestrate/orchestrate-mcp/src/hooks/context-watchdog.ts index 88c385a6..1f4bae22 100644 --- a/plugins/orchestrate/orchestrate-mcp/src/hooks/context-watchdog.ts +++ b/plugins/orchestrate/orchestrate-mcp/src/hooks/context-watchdog.ts @@ -3,11 +3,45 @@ import * as fs from "fs"; import { loadHandoffConfig } from "../handoff-config.js"; import { resolveRunDir } from "../run-dir.js"; -// The context-watchdog estimates how full the orchestrator session's context -// window is by reading the session transcript, and raises a handoff flag once -// usage passes a configurable threshold. The pure functions below are the -// testable core; `runWatchdog` wires them to the filesystem. Nothing here ever -// throws — a watchdog that crashes a session is worse than one that misses. +// The context-watchdog watches TWO budgets the orchestrator session can exhaust +// and raises a handoff flag once either reaches its configured threshold: +// +// 1. The CONTEXT WINDOW — estimated by reading the latest assistant turn's +// token usage out of the session transcript. +// 2. The SESSION SPAWN BUDGET — the platform caps how many subagents one +// session may spawn (200 by default, `CLAUDE_CODE_MAX_SUBAGENTS_PER_SESSION`). +// At roughly five spawns per slice a long run can spend that budget well +// before it fills its context, so a watchdog that only watched tokens +// would report all-clear while the run walked into an unrecoverable spawn +// error. The spawn count is derived from the run's own append-only spawn +// log, which this hook writes: every `Agent` tool call observed while the +// run is in progress appends one line. +// +// Two properties of that counting path are worth stating rather than leaving a +// reader to discover them: +// +// - `PostToolUse` fires after the tool SUCCEEDS, so a synchronous Agent call +// is recorded when the subagent FINISHES, not when it is spawned. That is +// acceptable for a cumulative, monotonic budget — the platform counts a +// finished subagent too — but it means the count trails in-flight spawns. +// - Plugin hooks fire INSIDE subagents as well, so a nested spawn (a slice +// executor spawning its own worker) is recorded whenever that event +// resolves to the same active run — which is what the platform's session +// budget counts, and what a scan of the orchestrator's own transcript never +// could. But that resolution has two conditions, and neither is guaranteed +// for a subagent-fired event. It runs through the event's `cwd` — the run is +// discovered by scanning `/.orchestrate/runs/`, and the vendor +// documents `cwd` only as "the current working directory when the hook is +// invoked", never stating what a subagent-fired event carries — and it then +// needs either a `session_id`-to-`driverSessionId` match or exactly one +// in-progress run (see `run-discovery.ts`), so with two concurrent runs an +// unmatched event resolves to null. An unresolved event is a silent no-op, +// so the recorded count is a LOWER BOUND on the platform's: the watchdog can +// raise later than ideal, never earlier on a spawn it imagined. +// +// The pure functions below are the testable core; `runWatchdog` wires them to +// the filesystem. Nothing here ever throws — a watchdog that crashes a session +// is worse than one that misses. // ─── Token usage parsing ────────────────────────────────────────────────────── @@ -18,14 +52,31 @@ export interface TokenUsage { cacheReadInputTokens: number; } -/** The watchdog's verdict for one transcript sample. */ +/** The watchdog's verdict for one sample of both budgets. */ export interface WatchdogEvaluation { - usedTokens: number; + /** Null when token usage was not observable — an absent or lagging transcript. */ + usedTokens: number | null; contextWindowTokens: number; thresholdPercent: number; - /** Used tokens as a percentage of the window, rounded to one decimal. */ - usagePercent: number; + /** + * Used tokens as a percentage of the window, rounded to one decimal. Null + * whenever `usedTokens` is null — an unknown figure is never reported as 0%. + */ + usagePercent: number | null; + /** Subagent spawns recorded for this run so far. */ + spawnCount: number; + sessionSpawnBudget: number; + spawnThresholdPercent: number; + /** Spawns as a percentage of the budget, rounded to one decimal. */ + spawnPercent: number; + /** True once EITHER budget reached its threshold. */ overThreshold: boolean; + /** + * Which budget raised the verdict — the discriminant that makes "whichever + * threshold arrives first" observable in the flag file and lets the CLI say + * the true reason. Null when neither is over. + */ + trigger: "tokens" | "spawns" | null; } /** @@ -91,22 +142,110 @@ export function contextTokens(usage: TokenUsage): number { } /** - * Compares used tokens against the window and threshold. Pure. `overThreshold` - * is true once usage *reaches* the threshold (>=), so a threshold of 40 fires - * exactly at 40%. + * Reads one spawn-log line's session tag, or null when the line carries none — + * because it is a legacy line written before spawns were tagged, because its + * `session` is not a string or is the EMPTY string (no identity at all), or + * because the line does not parse at all. + */ +function lineSessionId(line: string): string | null { + try { + const entry: unknown = JSON.parse(line); + if (typeof entry !== "object" || entry === null) return null; + const session = (entry as Record).session; + return typeof session === "string" && session.length > 0 ? session : null; + } catch { + return null; + } +} + +/** + * Counts the spawns in a spawn log that count against `sessionId`'s budget. + * Pure. + * + * The log lives in the per-RUN directory and is append-only, but the budget it + * is measured against is the platform's per-SESSION cap, which RESETS in a new + * session. A handoff keeps the same runId — and therefore the same log — so + * counting every line would make every successor session re-raise on its first + * spawn and degrade the run to one slice per session. Counting is therefore + * partitioned by session: a line tagged with a DIFFERENT session is skipped. + * + * Session-tagging rather than clearing the log on resume is deliberate. Section + * 1 also resumes a run WITHOUT a handoff — a re-invocation in the same session, + * whose real budget did not reset — and clearing the log there would under-count + * a session that had already spent part of its budget. + * + * An UNTAGGED line — a legacy log from before tagging, or an unparseable one — + * counts toward whichever session is asking. It cannot be attributed, and + * over-counting only hands off early, while under-counting is what walks a run + * into an unrecoverable spawn error. Passing no `sessionId` counts every line, + * for the same reason — as does passing an EMPTY one, which is no identity at + * all: partitioning on it would match no line ever written and so hide the + * whole log, the under-count this rule exists to prevent. + * + * A torn final line (a partially-flushed append) is counted, but it does not + * inflate the total: having no trailing newline, it is what the NEXT append + * merges into, so a torn write costs one line rather than adding one — a + * bounded under-count of 1, not an over-count. + */ +export function countSpawns(logText: string, sessionId?: string): number { + const asking = + sessionId !== undefined && sessionId.length > 0 ? sessionId : null; + + let count = 0; + for (const line of logText.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + + const tag = lineSessionId(trimmed); + if (asking !== null && tag !== null && tag !== asking) continue; + + count++; + } + return count; +} + +/** + * Compares BOTH budgets against their thresholds. Pure. `overThreshold` is true + * once *either* budget reaches its threshold (>=), so a threshold of 40 fires + * exactly at 40% on either axis. + * + * A null `usedTokens` — an absent or still-lagging transcript — skips the TOKEN + * comparison only; the spawn comparison still runs, which is what makes the + * spawn threshold fire independently of token usage. + * + * TIEBREAK: when both budgets cross on the same sample, `trigger` reports + * `'tokens'`. The choice is arbitrary but must be deterministic — the flag is + * written once, so it can name only one reason, and tokens are the older, more + * familiar signal. */ export function evaluateWatchdog(args: { - usedTokens: number; + usedTokens: number | null; contextWindowTokens: number; thresholdPercent: number; + spawnCount: number; + sessionSpawnBudget: number; + spawnThresholdPercent: number; }): WatchdogEvaluation { - const ratio = (args.usedTokens / args.contextWindowTokens) * 100; + const tokenRatio = + args.usedTokens === null + ? null + : (args.usedTokens / args.contextWindowTokens) * 100; + const spawnRatio = (args.spawnCount / args.sessionSpawnBudget) * 100; + + const tokensOver = tokenRatio !== null && tokenRatio >= args.thresholdPercent; + const spawnsOver = spawnRatio >= args.spawnThresholdPercent; + return { usedTokens: args.usedTokens, contextWindowTokens: args.contextWindowTokens, thresholdPercent: args.thresholdPercent, - usagePercent: Math.round(ratio * 10) / 10, - overThreshold: ratio >= args.thresholdPercent, + usagePercent: tokenRatio === null ? null : Math.round(tokenRatio * 10) / 10, + spawnCount: args.spawnCount, + sessionSpawnBudget: args.sessionSpawnBudget, + spawnThresholdPercent: args.spawnThresholdPercent, + spawnPercent: Math.round(spawnRatio * 10) / 10, + overThreshold: tokensOver || spawnsOver, + trigger: tokensOver ? "tokens" : spawnsOver ? "spawns" : null, }; } @@ -144,6 +283,54 @@ function readTranscriptText(transcriptPath: string): string { } } +// ─── Spawn-log recording ────────────────────────────────────────────────────── + +/** + * The tool whose `PostToolUse` event means a subagent was spawned. The hook + * matcher is `.*` (it sees every tool call), so the watchdog selects the spawn + * events itself. + */ +const SPAWN_TOOL_NAME = "Agent"; + +/** + * Appends one line to the run's spawn log, TAGGED with the session that made + * the spawn — the tag is what lets a successor session count only its own + * spawns against its own fresh budget. Best-effort: a write failure is + * swallowed, leaving the count one short rather than disrupting the session. + * `appendFileSync` creates the file when absent, and each line is a short, + * single write, so concurrent hook processes interleave lines rather than + * corrupting each other's. + */ +function recordSpawn(spawnLogPath: string, sessionId?: string): void { + try { + fs.mkdirSync(path.dirname(spawnLogPath), { recursive: true }); + fs.appendFileSync( + spawnLogPath, + JSON.stringify({ + at: new Date().toISOString(), + tool: SPAWN_TOOL_NAME, + session: sessionId ?? null, + }) + "\n" + ); + } catch { + // Best-effort — an unwritable spawn log must not disrupt the session. + } +} + +/** + * Reads the run's spawn log and counts the lines charged to `sessionId`. An + * absent or unreadable log is 0 — a run that has spawned nothing and a log the + * hook could not read are the same "no evidence of spawn pressure", and neither + * justifies a handoff. + */ +function readSpawnCount(spawnLogPath: string, sessionId?: string): number { + try { + return countSpawns(fs.readFileSync(spawnLogPath, "utf8"), sessionId); + } catch { + return 0; + } +} + // ─── Hook orchestration ─────────────────────────────────────────────────────── /** The outcome of one `runWatchdog` invocation. */ @@ -158,153 +345,83 @@ export interface WatchdogResult { /** * Shape written to the per-run `context-flag.json` (under - * `.orchestrate/runs//`) when the flag is raised. + * `.orchestrate/runs//`) when the flag is raised. It records `trigger` + * — which budget raised it — plus both budgets' figures, so which threshold + * arrived first is readable after the fact rather than inferred. The token + * fields are null on a spawn-triggered raise whose transcript was unreadable: + * unknown usage is recorded as unknown, never as zero. */ interface ContextFlag { raisedAt: string; - usedTokens: number; + trigger: "tokens" | "spawns"; + usedTokens: number | null; contextWindowTokens: number; thresholdPercent: number; - usagePercent: number; -} - -/** One in-progress run discovered by {@link scanInProgressRuns}. */ -interface InProgressRun { - /** The run directory name — the `runId`. */ - runId: string; - /** - * The run's recorded driver-session identity, or null when the run-state - * file carries no `driverSessionId` (a legacy/older checkpoint) or it is - * not a string. - */ - driverSessionId: string | null; -} - -/** - * Scans `.orchestrate/runs/*` and returns one {@link InProgressRun} per run - * whose `run-state.json` has `status: "in-progress"`. Reads the filesystem but - * never throws — a missing runs directory yields `[]`, and a malformed or - * unreadable `run-state.json` is silently skipped. The `status` gate is the - * only inclusion rule: completed runs are never returned. - */ -function scanInProgressRuns(cwd: string): InProgressRun[] { - const runsDir = path.join(cwd, ".orchestrate", "runs"); - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(runsDir, { withFileTypes: true }); - } catch { - return []; - } - - const runs: InProgressRun[] = []; - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const statePath = path.join(runsDir, entry.name, "run-state.json"); - let runState: unknown; - try { - runState = JSON.parse(fs.readFileSync(statePath, "utf8")); - } catch { - continue; - } - if ( - typeof runState !== "object" || - runState === null || - (runState as Record).status !== "in-progress" - ) { - continue; - } - // A missing or non-string driverSessionId degrades to null — never a - // crash. Legacy run-state files predate this field entirely. - const rawId = (runState as Record).driverSessionId; - runs.push({ - runId: entry.name, - driverSessionId: typeof rawId === "string" ? rawId : null, - }); - } - return runs; -} - -/** - * Scans `.orchestrate/runs/*` for the single run whose `run-state.json` has - * `status: "in-progress"` and returns its `runId`, or null when none is found. - * Pure-ish — reads the filesystem but never throws. - * - * The `PostToolUse` hook receives only the session `cwd`, not a `runId`, so the - * watchdog must discover the active run before it can resolve the per-run - * paths. This handles only the single-active-run case and is retained for that - * path; {@link findActiveRunForSession} extends it to disambiguate concurrent - * runs by the driver-session identity (#196). - */ -export function discoverActiveRunId(cwd: string): string | null { - const runs = scanInProgressRuns(cwd); - return runs.length > 0 ? runs[0].runId : null; + usagePercent: number | null; + spawnCount: number; + sessionSpawnBudget: number; + spawnThresholdPercent: number; + spawnPercent: number; } /** - * Resolves which in-progress run a watchdog invocation belongs to, given the - * session id the hook event carried. Reads the filesystem but never throws. - * - * The decision binds the global `PostToolUse` watchdog to the correct run when - * several runs proceed concurrently in one repository (#196): - * - * - Only runs whose `run-state.json` `status` is `in-progress` are considered. - * - When `sessionId` is given and **exactly one** in-progress run records a - * matching `driverSessionId`, that run is returned — the positive identity - * match. - * - Otherwise, when **exactly one** run is in-progress at all, it is returned: - * with a single run there is no "wrong run" to mistakenly flag, so the - * watchdog still acts even if no session identity is available or matched - * (this also covers legacy run-state files with no `driverSessionId`). - * - In every other case — two or more in-progress runs with no unambiguous - * single match — the watchdog cannot disambiguate and `null` is returned, so - * the caller safely no-ops rather than risk flagging the wrong run. - * - * Returning `null` is always safe: the run stays correct and merely loses - * automatic context-handoff for this invocation. - */ -export function findActiveRunForSession( - cwd: string, - sessionId: string | undefined -): string | null { - const runs = scanInProgressRuns(cwd); - if (runs.length === 0) return null; - - // Positive identity match: exactly one in-progress run claims this session. - if (typeof sessionId === "string" && sessionId.length > 0) { - const matches = runs.filter((r) => r.driverSessionId === sessionId); - if (matches.length === 1) return matches[0].runId; - // matches.length >= 2 — the same session id binds multiple runs, which is - // ambiguous; fall through to the single-run fast path, which also fails. - } - - // Single-run fast path: one in-progress run means no "wrong run" exists. - if (runs.length === 1) return runs[0].runId; - - // Two or more in-progress runs and no unambiguous match — cannot decide. - return null; -} - -/** - * Reads the session transcript, estimates context usage, and raises the - * handoff flag when usage passes the configured threshold. + * Records the spawn this event represents, samples both budgets — context + * usage from the session transcript, spawn count from the run's spawn log — + * and raises the handoff flag when either reaches its configured threshold. * * The watchdog only acts while the named run is in progress — it checks the * per-run `run-state.json` under `.orchestrate/runs//` first and is a * silent no-op otherwise, so the bundled hook is harmless in unrelated - * sessions. The flag is written at most once per run: if the per-run - * `context-flag.json` already exists this is a no-op. Never throws. + * sessions. Spawns made before the run started are therefore not counted. + * + * The spawn log is stored per RUN but counted per SESSION: the platform's cap + * is a session cap that resets in a new session, while the log survives a + * handoff along with the runId. Every line is tagged with the session that + * wrote it and {@link countSpawns} charges only the current session's lines, so + * a successor starts from its own fresh budget while a same-session resume + * keeps counting the spawns that session already spent. + * + * Unknown token usage — an absent `transcriptPath`, an unreadable transcript, + * or one whose latest turns carry no usage yet (the transcript is written + * asynchronously and may lag) — skips only the TOKEN comparison. The spawn + * comparison still runs, which is what makes the spawn threshold independent of + * token usage rather than silently defeated by it. + * + * The flag is written at most once per run, across BOTH thresholds: the + * `fs.existsSync(flagPath)` check is a filesystem latch, not an in-memory one, + * so whichever budget writes the flag first suppresses every later raise — and + * it survives the hook's per-invocation process, which no in-memory latch + * would. Never throws. */ export function runWatchdog(input: { transcriptPath?: string; cwd: string; runId: string; + /** + * The `tool_name` of the `PostToolUse` event. When it is the spawn tool, this + * invocation appends one line to the run's spawn log before evaluating, so + * the spawn it represents is counted in this very sample. + */ + toolName?: string; + /** + * The `session_id` of the event — the session whose spawn budget this spawn + * is charged against. It tags the log line written here and selects which + * lines are counted. Absent (an event with no session identity), every line + * counts: the count cannot be partitioned, and over-counting is the safe + * direction. + */ + sessionId?: string; }): WatchdogResult { // 1. Resolve the per-run paths; a malformed runId is a silent no-op. const resolved = resolveRunDir(input.cwd, input.runId); if (!resolved.ok) { return { acted: false, flagRaised: false }; } - const { runStatePath, contextFlagPath: flagPath } = resolved.paths; + const { + runStatePath, + contextFlagPath: flagPath, + spawnLogPath, + } = resolved.paths; // 2. Act only while this orchestration run is in progress. let runState: unknown; @@ -321,39 +438,63 @@ export function runWatchdog(input: { return { acted: false, flagRaised: false }; } - // 3. A transcript is required to estimate usage. - if (!input.transcriptPath) return { acted: true, flagRaised: false, flagPath }; - let transcriptText: string; - try { - transcriptText = readTranscriptText(input.transcriptPath); - } catch { - return { acted: true, flagRaised: false, flagPath }; + // 3. Record the spawn this event represents, so it counts in this sample. + // Deliberately BEHIND the in-progress guard: only a live run's spawns + // count toward the budget the run hands off on. + if (input.toolName === SPAWN_TOOL_NAME) { + recordSpawn(spawnLogPath, input.sessionId); } - // 4. Estimate usage from the latest assistant turn. - const usage = parseLatestUsage(transcriptText); - if (!usage) return { acted: true, flagRaised: false, flagPath }; + // 4. Estimate token usage from the latest assistant turn. Every failure mode + // here — no transcript path, an unreadable file, no usage recorded yet — + // resolves to `null` (unknown) rather than RETURNING, so the spawn + // threshold below is evaluated either way. This is the difference between + // a second threshold that works and one that is silently defeated by a + // transcript that has not been flushed yet. + let usedTokens: number | null = null; + if (input.transcriptPath) { + try { + const usage = parseLatestUsage(readTranscriptText(input.transcriptPath)); + if (usage) usedTokens = contextTokens(usage); + } catch { + usedTokens = null; + } + } - // 5. Evaluate against the (possibly defaulted) config. + // 5. Evaluate BOTH budgets against the (possibly defaulted) config. const { config } = loadHandoffConfig(input.cwd); const evaluation = evaluateWatchdog({ - usedTokens: contextTokens(usage), + usedTokens, contextWindowTokens: config.watchdog.contextWindowTokens, thresholdPercent: config.watchdog.thresholdPercent, + spawnCount: readSpawnCount(spawnLogPath, input.sessionId), + sessionSpawnBudget: config.watchdog.sessionSpawnBudget, + spawnThresholdPercent: config.watchdog.spawnThresholdPercent, }); - // 6. Below threshold, or the flag is already raised — nothing to do. - if (!evaluation.overThreshold || fs.existsSync(flagPath)) { + // 6. Both budgets below threshold, or the flag is already raised — nothing to + // do. The `existsSync` check is what keeps the raise at most once per run + // no matter which budget crosses first, or how many cross later. + if ( + !evaluation.overThreshold || + evaluation.trigger === null || + fs.existsSync(flagPath) + ) { return { acted: true, flagRaised: false, flagPath, evaluation }; } - // 7. Raise the flag. + // 7. Raise the flag, naming the budget that raised it. const flag: ContextFlag = { raisedAt: new Date().toISOString(), + trigger: evaluation.trigger, usedTokens: evaluation.usedTokens, contextWindowTokens: evaluation.contextWindowTokens, thresholdPercent: evaluation.thresholdPercent, usagePercent: evaluation.usagePercent, + spawnCount: evaluation.spawnCount, + sessionSpawnBudget: evaluation.sessionSpawnBudget, + spawnThresholdPercent: evaluation.spawnThresholdPercent, + spawnPercent: evaluation.spawnPercent, }; try { fs.mkdirSync(path.dirname(flagPath), { recursive: true }); diff --git a/plugins/orchestrate/orchestrate-mcp/src/hooks/read-guard-cli.ts b/plugins/orchestrate/orchestrate-mcp/src/hooks/read-guard-cli.ts new file mode 100644 index 00000000..e888d552 --- /dev/null +++ b/plugins/orchestrate/orchestrate-mcp/src/hooks/read-guard-cli.ts @@ -0,0 +1,84 @@ +#!/usr/bin/env node +import { decideReadGuard, denyPayload } from "./read-guard.js"; +import { findActiveRunForSession } from "./run-discovery.js"; + +// Entry point for the `read-guard` PreToolUse hook. Claude Code pipes the hook +// event JSON on stdin; this script asks `read-guard.ts` whether the call is the +// orchestrator opening a slice-internal artifact, and on a deny writes the +// `hookSpecificOutput` payload to stdout. Every decision — deny and no-decision +// alike — exits 0: JSON output is only processed on exit 0, and silence plus +// exit 0 is the documented "no decision, normal permission flow applies" path. +// A blocking hook that crashed would be worse than one that missed, so the +// whole body is wrapped in a swallowing try/catch. +// +// All judgement lives in the pure module. This file does two things the module +// cannot: it reads the event, and it resolves the active run from the +// filesystem — the same split `context-watchdog-cli.ts` uses, where +// `findActiveRunForSession` is called here rather than inside the hook module. +// +// The run resolution is also the guard's OFF SWITCH: with no in-progress run, +// or with concurrent runs this session cannot be disambiguated against, +// discovery returns null and every path is allowed. That short-circuit is +// duplicated inside the module (which no-ops on an absent `activeRunId`), so +// the behaviour is unit-testable rather than reachable only through a process. + +/** Reads all of stdin as a string. Resolves with whatever arrived on error. */ +function readStdin(): Promise { + return new Promise((resolve) => { + let data = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + data += chunk; + }); + process.stdin.on("end", () => resolve(data)); + process.stdin.on("error", () => resolve(data)); + }); +} + +async function main(): Promise { + let raw = ""; + try { + raw = await readStdin(); + } catch { + process.exit(0); + } + + try { + const event = raw ? (JSON.parse(raw) as Record) : {}; + const cwd = typeof event.cwd === "string" ? event.cwd : process.cwd(); + // An EMPTY `session_id` is treated as absent, the same guard + // `findActiveRunForSession` applies — an empty string is not an identity. + const sessionId = + typeof event.session_id === "string" && event.session_id.length > 0 + ? event.session_id + : undefined; + + const runId = findActiveRunForSession(cwd, sessionId); + if (runId === null) { + process.exit(0); + } + + const decision = decideReadGuard({ + toolName: typeof event.tool_name === "string" ? event.tool_name : undefined, + toolInput: + typeof event.tool_input === "object" && event.tool_input !== null + ? (event.tool_input as Record) + : undefined, + // Present ONLY inside a subagent call, which is what makes it — and not + // `agent_type`, which a `--agent` session also carries — the main-thread + // discriminator. + agentId: typeof event.agent_id === "string" ? event.agent_id : undefined, + cwd, + activeRunId: runId, + }); + + if (decision.decision === "deny") { + process.stdout.write(JSON.stringify(denyPayload(decision.reason))); + } + } catch { + // Any failure is swallowed — the guard must not disrupt the session. + } + process.exit(0); +} + +void main(); diff --git a/plugins/orchestrate/orchestrate-mcp/src/hooks/read-guard.ts b/plugins/orchestrate/orchestrate-mcp/src/hooks/read-guard.ts new file mode 100644 index 00000000..49572343 --- /dev/null +++ b/plugins/orchestrate/orchestrate-mcp/src/hooks/read-guard.ts @@ -0,0 +1,321 @@ +import * as path from "path"; + +// ─── Read guard — the orchestrator's slice-artifact read boundary ───────────── +// +// ADR-0017 gives the slice-executor delegation layer a read boundary: the +// orchestrator learns a slice's outcome from the executor's result envelope, +// never by opening the slice's own artifacts. That boundary buys the whole +// context saving the delegation layer exists for, and until now it was prose +// only — and prose guards erode late in a long run, which is exactly when the +// saving matters most. +// +// This module is the mechanical half. It decides, for one `PreToolUse` hook +// event, whether a tool call is the orchestrator reaching for a slice-internal +// artifact. It is DEFENCE IN DEPTH, not a dependency: the prose rule in +// `references/run-state.md` stays load-bearing, for the reasons in "Honest +// limitations" below. A later refactor must not delete it on the grounds that +// this hook covers it. +// +// PURE by construction — no filesystem access, no `process.cwd()`. The active +// run is discovered in the CLI entry point (`read-guard-cli.ts`) via +// `run-discovery.ts` and passed in, exactly as `context-watchdog-cli.ts` calls +// `findActiveRunForSession` rather than the watchdog module doing it. `cwd` is +// likewise an ARGUMENT: a bare `path.resolve` would silently fall back to the +// hook PROCESS's working directory and stop matching every relative path a +// Bash command carries. +// +// ─── What it guards, and what it deliberately does not ─────────────────────── +// +// Deny requires ALL of: +// +// 1. No `agent_id` on the event — see "identity" below. +// 2. An active run id — with none, every path is allowed (AC4). +// 3. The resolved path sits DIRECTLY IN `/.orchestrate/runs//` +// and its basename matches {@link GUARDED_BASENAME}. +// +// Clause 3 is narrower than the run directory ON PURPOSE. The same directory +// holds `run-state.json` — the orchestrator's OWN checkpoint, which it reads +// and writes at every slice — plus `context-flag.json`, `spawn-log.jsonl` and +// three rendered `.html` artifacts (`run-dir.ts`). A run-directory-wide rule +// would break every run, silently, and only in the field. Containment is by +// `path.dirname` equality rather than a prefix test, because `runs/` is a +// string prefix of `runs/-suffix`; that also neutralises `..` traversal. +// +// The basename pattern is structural rather than literal because the issue +// number is part of the contract: the record is deliberately per-slice, not +// per-run (`run-dir.ts`), so a parallel wave's siblings never clobber each +// other, and `run-dir.ts` declares the `slice--progress.json` filename +// a PUBLIC CONTRACT rather than an implementation detail. +// +// ─── Identity: `agent_id`, not `agent_type` ────────────────────────────────── +// +// The vendor hook reference (`docs/claude-code/hooks/claude-hook-reference-doc.md`, +// the "when running with --agent or inside a subagent" input table) documents +// `agent_id` as "present only when the hook fires inside a subagent call. Use +// this to distinguish subagent hook calls from main-thread calls." `agent_type` +// is ALSO present when the session itself runs with `--agent`, so it can be +// non-empty on a main thread and is the wrong discriminator. +// +// This is an ALLOW-BY-PRESENCE test, and the asymmetry is worth naming: if the +// platform ever stopped emitting `agent_id`, this guard would over-block every +// subagent rather than under-block the orchestrator — the UNSAFE direction +// relative to "never blocks a call it did not intend to block". It cannot be +// fixed from inside the hook; the citation above is here so the dependency is +// visible, and the retained prose rule is the designed fallback either way. +// An empty string is treated as absent, the same guard `context-watchdog-cli.ts` +// applies to `session_id`. +// +// ─── The deny contract ─────────────────────────────────────────────────────── +// +// {@link denyPayload} returns the exact wire object. Per the vendor mirror, +// `PreToolUse` returns its decision inside `hookSpecificOutput`; the top-level +// `decision`/`reason` fields are DEPRECATED for this event. Only a `deny` +// decision's reason is shown to Claude ("for allow and ask, shown to the user +// but not Claude; for deny, shown to Claude"), which is why the reason — not +// just the refusal — carries the correction. The alternative blocking +// mechanism, exit 2 with stderr, also blocks, but presents as an ERROR rather +// than a policy decision and breaks this package's uniform exit-0 discipline: +// do not "simplify" toward it. Silence plus exit 0 is the documented no-op. +// +// ─── Honest limitations ────────────────────────────────────────────────────── +// +// Six, all real, none closable here: +// +// - The Bash arm is a HEURISTIC over a command string, not a sandbox. It +// covers the enumerated read verbs and redirection sources. It does not +// cover a base64-encoded path, a path held in a shell variable, a +// `find -exec`, a heredoc body, or an alias. It closes the routes a model +// reaches for by default; it does not make evasion impossible. +// - The subcommand split is TEXTUAL and quote-blind, which cuts both ways. +// A separator inside a quoted argument splits the command anyway, so +// `grep -E "PASS|FAIL" ` becomes `grep -E "PASS` plus +// `FAIL" `, whose first token is not a read verb, and the +// read is ALLOWED — verified, and not a contrived shape. Deliberately not +// fixed: a quote-aware splitter buys a little coverage and risks false +// denies, and under-blocking is this module's declared safe direction. +// - The same blindness runs the OTHER way, and this list would be dishonest +// without it. A quoted argument that happens to contain a separator +// followed by ` ` — a `gh pr create --body` +// narrating `"did X; cat …/slice-7-report.md and moved on"` — is denied +// even though nothing is read. It is the only known over-block, it takes a +// command that both names an artifact and describes reading it, and the +// deny reason at least explains itself; rephrasing clears it. +// - The `Read` arm compares the LEXICALLY resolved path, never the link +// target: a symlink outside the run directory pointing at a guarded +// artifact is allowed. Resolving links would mean `fs` in a module that is +// pure by construction (see above), and would still not close the Bash arm. +// - `@`-references bypass it entirely. The mirror is explicit: "PreToolUse +// runs only when Claude calls a tool. Files you reference with @ in your +// prompt are added without any tool call ... so no PreToolUse hook fires +// for them, INCLUDING hooks matching Read." The documented closure — a +// `Read` deny rule — is precisely the alternative ADR-0017 rejected, since +// permission rules apply to the whole session and would restrict the +// executor too. This hole is larger than any Bash-heuristic gap. +// - The identity inversion described above. +// - Run discovery fails OPEN. `findActiveRunForSession` returns null both +// when no run is in progress AND when two or more runs are in progress and +// the session cannot be disambiguated — so the guard can no-op MID-RUN +// under concurrent runs, not only when idle. It also returns null when the +// session's `cwd` is a subdirectory that has no `.orchestrate/runs/` (#374, +// not fixed here). Every one of those is an ALLOW, which is the safe +// direction: the guard is weaker there, never wrong there. + +/** The two slice-internal artifacts, matched structurally on any issue number. */ +const GUARDED_BASENAME = /^slice-\d+-(progress\.json|report\.md)$/; + +/** + * Shell commands that READ a file's contents. Enumerated rather than inferred: + * the alternative — denying whenever a guarded path merely APPEARS in a command + * string — would refuse `echo`, a commit message, or a `gh issue comment` that + * names the artifact without opening it, which is a false deny in the middle of + * a live run. `source` and `.` are here because they read a file to execute it. + */ +const READ_VERBS = new Set([ + "cat", + "head", + "tail", + "less", + "more", + "bat", + "sed", + "awk", + "grep", + "rg", + "jq", + "od", + "xxd", + "strings", + "nl", + "wc", + "source", + ".", +]); + +/** The normalised hook event this module decides on. */ +export interface ReadGuardInput { + /** The event's `tool_name`. */ + toolName?: string; + /** The event's `tool_input` — `file_path` for `Read`, `command` for `Bash`. */ + toolInput?: Record; + /** The event's `agent_id`. Absent or empty means the main thread. */ + agentId?: string; + /** The event's `cwd`. Relative paths are resolved against it. */ + cwd?: string; + /** The run this session drives, resolved by the CLI. Absent means no run. */ + activeRunId?: string; +} + +/** Deny with a reason shown to Claude, or take no decision at all. */ +export type ReadGuardDecision = + | { decision: "none" } + | { decision: "deny"; reason: string }; + +/** The `PreToolUse` deny object, exactly as the platform expects it. */ +export interface PreToolUseDenyPayload { + hookSpecificOutput: { + hookEventName: "PreToolUse"; + permissionDecision: "deny"; + permissionDecisionReason: string; + }; +} + +/** + * The reason a denied read carries back to the model. Pinned as an exported + * constant so the hook and its tests cannot drift apart, and written as an + * INSTRUCTION rather than a refusal: it is the only thing the model is told + * about what to do instead, so it names both routes — the envelope's own + * fields for the outcome, and the structured-recovery tool for the record. + */ +export const READ_GUARD_DENY_REASON = + "orchestrate: the orchestrator does not open slice-internal artifacts. Use " + + "the slice-executor envelope's own fields for the slice's outcome, and pass " + + "reportPath forward without opening it. If the envelope is missing or " + + "invalid, recover the progress record's contents through the " + + "recover_slice_progress MCP tool, which derives the path from (runId, issue) " + + "and returns validated structured data."; + +/** Wraps a reason in the `PreToolUse` deny contract. */ +export function denyPayload(reason: string): PreToolUseDenyPayload { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: reason, + }, + }; +} + +/** Strip one layer of surrounding single or double quotes from a shell token. */ +function unquote(token: string): string { + const match = token.match(/^(["'])(.*)\1$/); + return match ? match[2] : token; +} + +/** Does this candidate resolve to a guarded artifact of the active run? */ +function isGuardedPath(candidate: string, cwd: string, runDir: string): boolean { + if (candidate === "") return false; + const resolved = path.resolve(cwd, candidate); + return ( + path.dirname(resolved) === runDir && + GUARDED_BASENAME.test(path.basename(resolved)) + ); +} + +/** + * Does this command string read a guarded artifact? + * + * The command is split into subcommands on `&&`, `||`, `;`, `|` and newlines, + * mirroring how the platform's own `if`-field matching checks each subcommand + * rather than only the first. Within a subcommand, leading `VAR=value` + * assignments are stripped — again matching the platform — and then two arms + * fire: an enumerated read verb applied to a guarded path, or a redirection + * whose source is one. `<<` is excluded so a heredoc marker is not read as a + * redirection source. + */ +function bashReadsGuardedPath( + command: string, + cwd: string, + runDir: string +): boolean { + for (const segment of command.split(/&&|\|\||;|\||\n/)) { + const tokens = segment.trim().split(/\s+/).filter((t) => t !== ""); + if (tokens.length === 0) continue; + + // Redirection is not a verb: `< file` has no command in front of it. + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + let candidate: string | undefined; + if (token === "<") candidate = tokens[i + 1]; + else if (token.startsWith("<") && !token.startsWith("<<")) { + candidate = token.slice(1); + } + if (candidate !== undefined && isGuardedPath(unquote(candidate), cwd, runDir)) { + return true; + } + } + + let start = 0; + while (start < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[start])) { + start++; + } + if (start >= tokens.length) continue; + // A verb may be invoked by path (`/bin/cat`); `.` basenames to itself, + // while `./script.sh` basenames to `script.sh` and is correctly not a verb. + if (!READ_VERBS.has(path.basename(unquote(tokens[start])))) continue; + for (const token of tokens.slice(start + 1)) { + if (isGuardedPath(unquote(token), cwd, runDir)) return true; + } + } + return false; +} + +/** + * Decide whether one `PreToolUse` event is the orchestrator opening a + * slice-internal artifact. Returns `{ decision: "none" }` for everything else + * — including every tool other than `Read` and `Bash`, which `hooks.json`'s + * `Read|Bash` matcher already keeps away from this handler, and which is also + * what leaves the `recover_slice_progress` MCP tool untouched. + * + * Never throws. A malformed or unexpected event takes no decision. + */ +export function decideReadGuard(input: ReadGuardInput): ReadGuardDecision { + const none: ReadGuardDecision = { decision: "none" }; + try { + // A subagent — the executor and its workers — reads its own artifacts. + if (typeof input.agentId === "string" && input.agentId.length > 0) { + return none; + } + const runId = input.activeRunId; + if (typeof runId !== "string" || runId.length === 0) return none; + if (typeof input.cwd !== "string" || input.cwd.length === 0) return none; + const toolInput = input.toolInput; + if (typeof toolInput !== "object" || toolInput === null) return none; + + const cwd = input.cwd; + const runDir = path.resolve(cwd, ".orchestrate", "runs", runId); + + if (input.toolName === "Read") { + const filePath = toolInput.file_path; + if (typeof filePath === "string" && isGuardedPath(filePath, cwd, runDir)) { + return { decision: "deny", reason: READ_GUARD_DENY_REASON }; + } + return none; + } + + if (input.toolName === "Bash") { + const command = toolInput.command; + if ( + typeof command === "string" && + bashReadsGuardedPath(command, cwd, runDir) + ) { + return { decision: "deny", reason: READ_GUARD_DENY_REASON }; + } + return none; + } + + return none; + } catch { + // A guard that throws is worse than a guard that misses. + return none; + } +} diff --git a/plugins/orchestrate/orchestrate-mcp/src/hooks/run-discovery.ts b/plugins/orchestrate/orchestrate-mcp/src/hooks/run-discovery.ts new file mode 100644 index 00000000..0f66e005 --- /dev/null +++ b/plugins/orchestrate/orchestrate-mcp/src/hooks/run-discovery.ts @@ -0,0 +1,131 @@ +import * as path from "path"; +import * as fs from "fs"; + +// Shared run-discovery for hooks: answers "is an orchestration run in +// progress in this repository, and which one does this session drive?" +// Originally lived inside `context-watchdog.ts`; extracted so any hook can +// import it without coupling to an unrelated hook's module (#353). The +// `context-watchdog` hook consumes this module from its CLI entry point +// (`context-watchdog-cli.ts`), which is the only piece of that hook that +// needs a runId resolved from the raw hook event. Reads the filesystem but +// never throws — a hook that crashes a session is worse than one that misses. + +/** One in-progress run discovered by {@link scanInProgressRuns}. */ +interface InProgressRun { + /** The run directory name — the `runId`. */ + runId: string; + /** + * The run's recorded driver-session identity, or null when the run-state + * file carries no `driverSessionId` (a legacy/older checkpoint) or it is + * not a string. + */ + driverSessionId: string | null; +} + +/** + * Scans `.orchestrate/runs/*` and returns one {@link InProgressRun} per run + * whose `run-state.json` has `status: "in-progress"`. Reads the filesystem but + * never throws — a missing runs directory yields `[]`, and a malformed or + * unreadable `run-state.json` is silently skipped. The `status` gate is the + * only inclusion rule: completed runs are never returned. + */ +function scanInProgressRuns(cwd: string): InProgressRun[] { + const runsDir = path.join(cwd, ".orchestrate", "runs"); + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(runsDir, { withFileTypes: true }); + } catch { + return []; + } + + const runs: InProgressRun[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const statePath = path.join(runsDir, entry.name, "run-state.json"); + let runState: unknown; + try { + runState = JSON.parse(fs.readFileSync(statePath, "utf8")); + } catch { + continue; + } + if ( + typeof runState !== "object" || + runState === null || + (runState as Record).status !== "in-progress" + ) { + continue; + } + // A missing or non-string driverSessionId degrades to null — never a + // crash. Legacy run-state files predate this field entirely. + const rawId = (runState as Record).driverSessionId; + runs.push({ + runId: entry.name, + driverSessionId: typeof rawId === "string" ? rawId : null, + }); + } + return runs; +} + +/** + * Scans `.orchestrate/runs/*` for the first run whose `run-state.json` has + * `status: "in-progress"` and returns its `runId`, or null when none is found. + * Pure-ish — reads the filesystem but never throws. + * + * A hook event carries only the session `cwd`, never a `runId`, so a hook must + * discover the active run before it can resolve the per-run paths. This is the + * simpler single-active-run form: it ignores session identity, so with two or + * more in-progress runs it returns whichever the directory scan yields first. + * Prefer {@link findActiveRunForSession}, which disambiguates concurrent runs + * by the driver-session identity and no-ops safely when it cannot decide + * (#196). + */ +export function discoverActiveRunId(cwd: string): string | null { + const runs = scanInProgressRuns(cwd); + return runs.length > 0 ? runs[0].runId : null; +} + +/** + * Resolves which in-progress run a hook invocation belongs to, given the + * session id the hook event carried. Reads the filesystem but never throws. + * + * The decision binds a global hook (e.g. the `context-watchdog` `PostToolUse` + * hook) to the correct run when several runs proceed concurrently in one + * repository (#196): + * + * - Only runs whose `run-state.json` `status` is `in-progress` are considered. + * - When `sessionId` is given and **exactly one** in-progress run records a + * matching `driverSessionId`, that run is returned — the positive identity + * match. + * - Otherwise, when **exactly one** run is in-progress at all, it is returned: + * with a single run there is no "wrong run" to mistakenly flag, so the + * caller still acts even if no session identity is available or matched + * (this also covers legacy run-state files with no `driverSessionId`). + * - In every other case — two or more in-progress runs with no unambiguous + * single match — discovery cannot disambiguate and `null` is returned, so + * the caller safely no-ops rather than risk acting on the wrong run. + * + * Returning `null` is always safe: the run stays correct and merely loses + * whatever automatic behaviour the caller would have driven for this + * invocation. + */ +export function findActiveRunForSession( + cwd: string, + sessionId: string | undefined +): string | null { + const runs = scanInProgressRuns(cwd); + if (runs.length === 0) return null; + + // Positive identity match: exactly one in-progress run claims this session. + if (typeof sessionId === "string" && sessionId.length > 0) { + const matches = runs.filter((r) => r.driverSessionId === sessionId); + if (matches.length === 1) return matches[0].runId; + // matches.length >= 2 — the same session id binds multiple runs, which is + // ambiguous; fall through to the single-run fast path, which also fails. + } + + // Single-run fast path: one in-progress run means no "wrong run" exists. + if (runs.length === 1) return runs[0].runId; + + // Two or more in-progress runs and no unambiguous match — cannot decide. + return null; +} diff --git a/plugins/orchestrate/orchestrate-mcp/src/index.ts b/plugins/orchestrate/orchestrate-mcp/src/index.ts index 3a096aea..fb9209b1 100644 --- a/plugins/orchestrate/orchestrate-mcp/src/index.ts +++ b/plugins/orchestrate/orchestrate-mcp/src/index.ts @@ -89,6 +89,13 @@ import { type RecoverChangedFilesInput, type RecoverChangedFilesOutput, } from "./tools/recover-changed-files.js"; +import { + recoverSliceProgress, + recoverSliceProgressInputSchema, + recoverSliceProgressOutputSchema, + type RecoverSliceProgressInput, + type RecoverSliceProgressOutput, +} from "./tools/recover-slice-progress.js"; import { cleanRuns, cleanRunsInputSchema, @@ -507,17 +514,21 @@ registerTool( title: "Resolve Complexity Routing", description: "Resolves which model and subagent variant to spawn for each role — " + - "investigator, implementer, reviewer, conflict-resolver — given an " + - "issue's assessed complexity tier. Reads the tier-to-role mapping from " + + "investigator, implementer, reviewer, conflict-resolver, and " + + "slice-executor (ADR-0017) — given an issue's assessed " + + "complexity tier. Reads the tier-to-role mapping from " + ".orchestrate/routing.json (supports both v1 and v2 schemas; v1 files " + - "are transparently upgraded in memory). Accepts optional `labels` — the " + - "slice issue's GitHub labels — and applies any configured `route:*` " + - "label overrides deterministically. A null investigator means that tier " + - "skips the investigation pass. Returns per-role `variant` (not `effort`), " + - "the resolved run-wide `continuationBudget`, resolved label fallback " + - "specs, and structured label warnings. A same-role label conflict " + - "surfaces as a structured `LABEL_CONFLICT` error, never a silent pick. " + - "Returns a discriminated `status` of 'ok' or 'error'.", + "are transparently upgraded in memory). A routing.json predating " + + "`slice-executor` still resolves: the role defaults to the tier's own " + + "`implementer` entry, flagged with a structured warning. Accepts " + + "optional `labels` — the slice issue's GitHub labels — and applies any " + + "configured `route:*` label overrides deterministically. A null " + + "investigator means that tier skips the investigation pass. Returns " + + "per-role `variant` (not `effort`), the resolved run-wide " + + "`continuationBudget`, resolved label fallback specs, and structured " + + "label warnings. A same-role label conflict surfaces as a structured " + + "`LABEL_CONFLICT` error, never a silent pick. Returns a discriminated " + + "`status` of 'ok' or 'error'.", inputSchema: resolveRoutingV2InputSchema.shape, outputSchema: resolveRoutingV2OutputSchema.shape, }, @@ -797,7 +808,11 @@ registerTool( "`rootCause` (verified|hypothesis) or it is reported invalid. An " + "implementer 'incomplete' envelope must carry a non-empty `remainingWork` " + "handoff (the note the orchestrator forwards to the continuation in the " + - "same worktree) or it is reported invalid. The " + + "same worktree) or it is reported invalid. A `slice-executor` envelope " + + "(ADR-0017) describes a WHOLE SLICE's outcome rather than one worker's " + + "turn, with a `failureClass` drawn from a closed set — an unrecognized " + + "`failureClass` is reported invalid exactly like any other schema " + + "mismatch. The " + "orchestrator uses this instead of parsing subagent prose for status or " + "changed files.", inputSchema: validateEnvelopeInputSchema.shape, @@ -847,6 +862,55 @@ registerTool( handleRecoverChangedFiles as unknown as AnyToolHandler ); +// ─── recover_slice_progress ─────────────────────────────────────────────────── + +const handleRecoverSliceProgress: ToolHandler< + RecoverSliceProgressInput, + RecoverSliceProgressOutput +> = async (input) => { + const result = await recoverSliceProgress(input); + let text: string; + if (result.status === "ok") { + const stage = result.record!.lastCompletedStage ?? "none"; + text = `Recovered the progress record for slice #${input.issue} (last completed stage: ${stage}).`; + } else { + text = `Slice progress recovery failed [${result.errorCode}]: ${result.errorMessage}`; + } + return { + structuredContent: result, + content: [{ type: "text" as const, text }], + }; +}; + +registerTool( + "recover_slice_progress", + { + title: "Recover a Slice's Progress Record", + description: + "Reads and validates one slice's progress record at " + + "`.orchestrate/runs//slice--progress.json` — the resume " + + "anchor a slice-executor writes at each completed stage (ADR-0017), " + + "carrying the last completed stage, the investigator brief, the " + + "continuation count, the worktree fingerprint, and the once-only " + + "model-fallback guard. Call it when a slice-executor's result envelope " + + "is missing or invalid: the orchestrator recovers the record's contents " + + "through this tool INSTEAD of opening the file, so the recovered data is " + + "validated and the executor's read boundary stays intact — the same " + + "structured-recovery posture as `recover_changed_files`. The path is " + + "derived from `runId` and `issue`; no file path is accepted, so the read " + + "can never leave this run's own directory. Reads only; writes nothing. " + + "Returns a discriminated `status` of 'ok' (with `record`) or 'error' " + + "(with `RUN_ID_INVALID`, `ISSUE_INVALID`, `PROGRESS_NOT_FOUND` — no " + + "record written yet — or `PROGRESS_INVALID` — the file exists but is " + + "malformed JSON, fails the schema, or names a different run or slice).", + inputSchema: recoverSliceProgressInputSchema.shape, + outputSchema: recoverSliceProgressOutputSchema.shape, + }, + // Handler is typed against its concrete input/output contract; + // widen to the flat SDK-boundary `AnyToolHandler` for registration. + handleRecoverSliceProgress as unknown as AnyToolHandler +); + // ─── clean_runs ──────────────────────────────────────────────────────────────── const handleCleanRuns: ToolHandler = async ( @@ -1037,19 +1101,32 @@ registerTool( { title: "Bootstrap Orchestrate Configuration", description: - "Sets up a repository's .orchestrate/ configuration for a first-ever " + - "orchestrate run. Detects the project type and writes a project-aware " + - "commands.json (with a PM-aware mutating `install` command for " + - "npm/cargo/python projects — keyed on the JS lockfile for the npm " + - "ecosystem — empty for an unrecognized project), writes routing.json " + - "from the shipped defaults, and writes " + - "handoff.json with a context-window size derived from the running model " + - "— pass the model id (or an explicit contextWindowTokens) as input; the " + - "MCP process cannot see the calling LLM's model. An unknown or absent " + - "model falls back to 200000. Creates .orchestrate/runs/ and idempotently " + - "adds it to the repository's .gitignore. Every step is idempotent: an " + - "existing config file is never overwritten and the .gitignore line is " + - "never duplicated. Returns a discriminated `status` of 'ok' or 'error'.", + "Completes a repository's .orchestrate/ configuration — writes " + + "whichever of its three files are missing. Call this unconditionally " + + "at the start of every run, never gated on whether .orchestrate/ " + + "already exists: a directory that already has some files (e.g. an " + + "earlier run's routing.json and handoff.json but no commands.json) is " + + "exactly the case this closes, and calling it on an already-complete " + + "repository is a safe no-op. Detects the project type and writes a " + + "project-aware commands.json (with a PM-aware mutating `install` " + + "command for npm/cargo/python projects — keyed on the JS lockfile for " + + "the npm ecosystem — empty for an unrecognized project), writes " + + "routing.json from the shipped defaults, and writes handoff.json with " + + "a context-window size derived from the running model — pass the " + + "model id (or an explicit contextWindowTokens) as input; the MCP " + + "process cannot see the calling LLM's model. An unknown or absent " + + "model falls back to 200000. Creates .orchestrate/runs/ and " + + "idempotently adds it to the repository's .gitignore. Every step is " + + "idempotent: an existing config file is never overwritten and the " + + ".gitignore line is never duplicated. Reports config completeness " + + "read from the FINAL commands.json regardless of whether this call " + + "wrote it: `capabilities` names which of tests/typecheck/build/lint/" + + "install resolve to a command, and `falseGreenRisk` is true exactly " + + "when both `tests` and `build` are unconfigured — the conjunction " + + "that lets a slice merge green with nothing executed. Treat a true " + + "`falseGreenRisk` as a loud, blocking finding: report it and stop " + + "before starting the run. Returns a discriminated `status` of 'ok' " + + "or 'error'.", inputSchema: bootstrapConfigInputSchema.shape, outputSchema: bootstrapConfigOutputSchema.shape, }, @@ -1291,6 +1368,20 @@ const handleRunWave: ToolHandler = async ( case "tolerate": text = `Integration gate tolerated — no integration suite configured.`; break; + case "width-planned": + text = + `Wave width planned — run ${result.waveWidth} slice(s) concurrently, ` + + `deferring ${result.deferredCount} to a later turn of this wave.`; + break; + case "backpressure": + text = + `Spawn refused by the concurrent-subagent limit — BACKPRESSURE, not a ` + + `slice failure. Return the slice to the wave's processable queue with ` + + `its state unchanged and re-attempt it when a slot frees.`; + break; + case "spawn-error": + text = `Spawn failed [${result.limitSignal}]: ${result.errorMessage}`; + break; case "error": text = `run_wave failed [${result.errorCode}]: ${result.errorMessage}`; break; @@ -1324,7 +1415,18 @@ registerTool( "or `conflict` (the unmerged index is left IN PLACE and only flagged — " + "resolution is a downstream concern). 'integration-gate' (§2 step 4a): " + "run the per-wave integration suite, mapping `proceed` (passed), `halt` " + - "(failed/error), or `tolerate` (not configured). All loop state (umbrella " + + "(failed/error), or `tolerate` (not configured). 'plan-wave-width' (§2 " + + "step 3): cap how many processable slices may be in flight at once " + + "against the session's concurrent-subagent limit (default 20) — each " + + "in-flight slice occupies TWO live agent slots (its executor plus one " + + "worker), so the width is half the limit floored at 1, and the remainder " + + "is `deferredCount`, DEFERRED (state unchanged) rather than skipped. " + + "'classify-spawn-outcome' (§2 step 3): classify an observed spawn " + + "failure as `backpressure` (the concurrent-subagent limit — `status: " + + "'ok'`, no error message, the slice is fine and returns to the queue) or " + + "`spawn-error`, keeping a SPENT session spawn budget distinguishable via " + + "`limitSignal` and defaulting an unrecognized failure to the " + + "conservative class. All loop state (umbrella " + "ref, remote, first-merged flag) is PASSED IN, never inferred. Git-only " + "via the hardened exec seam, run-scoped (mutates nothing outside the " + "passed worktree), and never throws — every failure mode is a structured " + diff --git a/plugins/orchestrate/orchestrate-mcp/src/run-dir.ts b/plugins/orchestrate/orchestrate-mcp/src/run-dir.ts index 46ce098d..b3896e1e 100644 --- a/plugins/orchestrate/orchestrate-mcp/src/run-dir.ts +++ b/plugins/orchestrate/orchestrate-mcp/src/run-dir.ts @@ -3,15 +3,30 @@ import * as path from "path"; // ─── Per-run directory resolver ─────────────────────────────────────────────── // // Every orchestration run keeps its ephemeral state — the run-state checkpoint, -// the context-flag, and the rendered HTML artifacts — under a per-run directory, -// `.orchestrate/runs//`. The committed config files (`commands.json`, -// `routing.json`, `handoff.json`) stay flat at the `.orchestrate/` top level and -// are NOT resolved here. +// the context-flag, the spawn log, and the rendered HTML artifacts — under a +// per-run directory, `.orchestrate/runs//`. The committed config files +// (`commands.json`, `routing.json`, `handoff.json`) stay flat at the +// `.orchestrate/` top level and are NOT resolved here. // -// This module is a pure function of `(repoPath, runId)` — no filesystem I/O — -// so it is directly unit-testable and callers can resolve paths without side -// effects. It is the structural foundation for concurrent runs: two distinct -// run ids can never resolve to a shared path. +// Everything resolved here is REMOVED WHOLESALE with the run directory — +// `clean-runs.ts` deletes it with a recursive `rmSync`, so a new per-run file +// added to {@link RunPaths} needs no matching cleanup change. +// +// The run directory ALSO holds one **slice progress record per slice** +// (ADR-0017) — `slice--progress.json`, resolved by +// {@link resolveSliceProgressPath}. It is deliberately per-slice, not one file +// per run: a parallel wave processes several slices concurrently inside ONE +// run directory, so a single shared `progress.json` would have sibling slices +// silently clobber each other's resume anchor. Because the issue id becomes a +// path segment, it needs exactly the same traversal guard the `runId` has — an +// unguarded id like `../../etc/passwd` would escape the run directory and break +// ADR-0012's invariant (3) by construction. +// +// This module is a pure function of its arguments — no filesystem I/O — so it +// is directly unit-testable and callers can resolve paths without side effects. +// It is the structural foundation for concurrent runs: two distinct run ids can +// never resolve to a shared path, and within one run, two distinct issue ids +// can never resolve to a shared record. /** * Allowed `runId` shape. The skill mints run ids in two prefixed forms: @@ -34,6 +49,12 @@ export interface RunPaths { runStatePath: string; /** The context-handoff flag file inside the run directory. */ contextFlagPath: string; + /** + * The run's append-only spawn log inside the run directory — one line per + * subagent spawn observed while the run is in progress. It is what the + * context-watchdog's SECOND threshold (the session spawn budget) counts. + */ + spawnLogPath: string; /** The rendered dashboard HTML artifact inside the run directory. */ dashboardPath: string; /** The rendered dependency-graph HTML artifact inside the run directory. */ @@ -47,6 +68,15 @@ export type ResolveRunDirResult = | { ok: true; paths: RunPaths } | { ok: false; errorCode: "RUN_ID_INVALID"; errorMessage: string }; +/** Discriminated result of {@link resolveSliceProgressPath}. */ +export type ResolveSliceProgressPathResult = + | { ok: true; path: string } + | { + ok: false; + errorCode: "RUN_ID_INVALID" | "ISSUE_INVALID"; + errorMessage: string; + }; + /** * Returns true when `runId` is a safe run-directory name: a non-empty string of * letters, digits, underscores, and hyphens only. This admits both prefixed @@ -93,9 +123,80 @@ export function resolveRunDir( runDir, runStatePath: path.join(runDir, "run-state.json"), contextFlagPath: path.join(runDir, "context-flag.json"), + spawnLogPath: path.join(runDir, "spawn-log.jsonl"), dashboardPath: path.join(runDir, "dashboard.html"), graphPath: path.join(runDir, "graph.html"), reportPath: path.join(runDir, "report.html"), }, }; } + +/** + * Returns true when `issue` is a safe slice-record filename segment: a positive + * safe integer, matching the `z.number().int()` shape every issue id already + * carries in this package. + * + * The number type is itself the traversal guard — a number can never contribute + * a `..` or a path separator — but the check is still made at runtime because + * MCP tool input crosses a process boundary, and this is an exported function a + * TypeScript caller can reach with a value the compiler never saw. `0` and + * negatives are rejected: GitHub issue numbers start at 1, so anything else is + * a caller bug, not a slice. Pure. + */ +export function isValidIssueId(issue: number): boolean { + return typeof issue === "number" && Number.isSafeInteger(issue) && issue > 0; +} + +/** + * Resolves one slice's progress record to + * `/.orchestrate/runs//slice--progress.json` — the + * per-slice resume anchor the slice executor writes at each completed stage + * (ADR-0017). + * + * Composed on {@link resolveRunDir} so the run scoping is INHERITED rather than + * re-derived: whatever keeps the run directory isolated keeps the record + * isolated too. The filename then carries the issue id, which is what keeps the + * concurrent slices of one parallel wave from clobbering each other inside the + * single run directory they share. + * + * The `slice--progress.json` filename is a PUBLIC CONTRACT, not an + * implementation detail: the orchestrator passes the record's path forward + * while `recover_slice_progress` derives the same path from `(runId, issue)`, + * so both sides depend on this one convention. It is documented in + * `references/run-state.md` alongside the run directory's other contents. + * + * Pure — performs no filesystem I/O; the caller is responsible for `mkdirSync` + * before any write. Never throws: a malformed `runId` or `issue` comes back as + * a structured `RUN_ID_INVALID` / `ISSUE_INVALID` error, keeping the derived + * path inside `runs//` for every input (ADR-0012 invariant 3). + */ +export function resolveSliceProgressPath( + repoPath: string, + runId: string, + issue: number +): ResolveSliceProgressPathResult { + const resolved = resolveRunDir(repoPath, runId); + if (!resolved.ok) { + return { + ok: false, + errorCode: resolved.errorCode, + errorMessage: resolved.errorMessage, + }; + } + + if (!isValidIssueId(issue)) { + return { + ok: false, + errorCode: "ISSUE_INVALID", + errorMessage: + `Invalid issue '${String(issue)}': an issue must be a positive ` + + `integer (e.g. 355). The id becomes part of the record's filename, so ` + + `anything else is rejected before it can escape the run directory.`, + }; + } + + return { + ok: true, + path: path.join(resolved.paths.runDir, `slice-${issue}-progress.json`), + }; +} diff --git a/plugins/orchestrate/orchestrate-mcp/src/tools/bootstrap-config.ts b/plugins/orchestrate/orchestrate-mcp/src/tools/bootstrap-config.ts index d9cbd8d9..cfa74d9f 100644 --- a/plugins/orchestrate/orchestrate-mcp/src/tools/bootstrap-config.ts +++ b/plugins/orchestrate/orchestrate-mcp/src/tools/bootstrap-config.ts @@ -10,8 +10,9 @@ import { commandsConfigSchema, type CommandsConfig } from "./run-command.js"; // ─── Overview ───────────────────────────────────────────────────────────────── // -// The config bootstrapper makes a first-ever orchestrate run set up its own -// `.orchestrate/` configuration. On a fresh repository it: +// The config bootstrapper makes an orchestrate run complete its own +// `.orchestrate/` configuration — whichever of its three files are missing, +// on any repository state, not only a wholly fresh one: // 1. Detects the project type and writes a project-appropriate `commands.json`. // 2. Writes `handoff.json` with a context-window size derived from the running // model (passed as input — the MCP process cannot see the calling LLM's @@ -22,6 +23,21 @@ import { commandsConfigSchema, type CommandsConfig } from "./run-command.js"; // Every step is individually idempotent: an existing config file is never // overwritten (a user may have customized it), the runs directory mkdir is // recursive, and the `.gitignore` append checks for an existing matching line. +// Idempotence at the FILE level (not just the directory level) is exactly what +// makes it safe to call this on every run unconditionally, whether +// `.orchestrate/` is wholly absent, partially populated (e.g. `routing.json` +// and `handoff.json` present but `commands.json` missing — the scenario that +// motivated this), or already complete. +// +// The result also reports config COMPLETENESS: `capabilities` names which +// capability verbs resolve to a command in the FINAL commands.json (freshly +// written or already present), and `falseGreenRisk` flags the one conjunction +// that defeats verification silently — both `tests` and `build` unconfigured. +// This is computed from the final file content every time, so a pre-existing +// or hand-authored gap is caught exactly like a freshly-written empty file — +// nothing about this tool's own success depends on which files it happened to +// write on this call. +// // The core function never throws — every failure mode is a structured result. // ─── Model → context-window table ───────────────────────────────────────────── @@ -64,13 +80,21 @@ const MODEL_CONTEXT_WINDOW: Readonly> = { * * Tier matrix (ADR-0015): * - trivial: investigator=null; implementer=haiku/standard; reviewer=sonnet/standard; - * conflict-resolver=sonnet/standard (deliberate cross-model merge gate). + * conflict-resolver=sonnet/standard (deliberate cross-model merge gate); + * slice-executor=haiku/standard (ADR-0017, #356 — mirrors implementer). * - standard: investigator=haiku/standard (NEW — was null); implementer=sonnet/standard; - * reviewer=opus/standard; conflict-resolver=opus/standard. - * - complex: all roles = opus/deep (unchanged). + * reviewer=opus/standard; conflict-resolver=opus/standard; + * slice-executor=sonnet/standard (mirrors implementer). + * - complex: all roles = opus/deep (unchanged); slice-executor=opus/deep too. * - labels: route:fable → implementer patched to fable/deep with opus fallback. * - run: intraWaveConcurrency=parallel; continuationBudget=2 (same values as v1, * now under the run block). + * + * `slice-executor` (ADR-0017, #356) mirrors each tier's own `implementer` + * entry rather than one hardcoded pair — the same documented default + * `ensureSliceExecutorDefault` (in `routing.ts`) falls back to for a + * pre-#356 routing.json, so a freshly-bootstrapped file already matches what + * an old file would resolve to. */ export const DEFAULT_ROUTING_CONFIG = { version: 2, @@ -80,18 +104,21 @@ export const DEFAULT_ROUTING_CONFIG = { implementer: { model: "haiku", variant: "standard" }, reviewer: { model: "sonnet", variant: "standard" }, "conflict-resolver": { model: "sonnet", variant: "standard" }, + "slice-executor": { model: "haiku", variant: "standard" }, }, standard: { investigator: { model: "haiku", variant: "standard" }, implementer: { model: "sonnet", variant: "standard" }, reviewer: { model: "opus", variant: "standard" }, "conflict-resolver": { model: "opus", variant: "standard" }, + "slice-executor": { model: "sonnet", variant: "standard" }, }, complex: { investigator: { model: "opus", variant: "deep" }, implementer: { model: "opus", variant: "deep" }, reviewer: { model: "opus", variant: "deep" }, "conflict-resolver": { model: "opus", variant: "deep" }, + "slice-executor": { model: "opus", variant: "deep" }, }, }, labels: { @@ -220,12 +247,43 @@ export const bootstrapConfigOutputSchema = z.object({ .array(z.string()) .optional() .describe( - "Advisory warnings about the bootstrapped configuration. Non-empty only " + - "when status='ok' and the freshly-written commands.json is empty ({}) — " + - "meaning no recognized project type was detected and the capability gates " + - "(run_tests, run_build, etc.) will report 'not-configured', allowing a " + - "slice to merge green with no verification. Empty array when the written " + - "commands map is non-empty. Present when status='ok'." + "Advisory warnings about the bootstrapped configuration. Non-empty " + + "exactly when `falseGreenRisk` is true — see that field. Present " + + "when status='ok'." + ), + capabilities: z + .object({ + tests: z.boolean(), + typecheck: z.boolean(), + build: z.boolean(), + lint: z.boolean(), + install: z.boolean(), + }) + .optional() + .describe( + "Which capability verbs resolve to a configured command in the FINAL " + + "commands.json — read after this call, whether it just wrote the file " + + "or the file was already present. `true` = a command is configured " + + "for that verb, so the matching capability tool (run_tests, " + + "run_typecheck, run_build, run_lint) will execute it; `false` = that " + + "tool reports 'not-configured' — either the verb is absent or its " + + "argv array is empty, which the capability tools treat identically. " + + "`install` is the setup verb (run_install), not a capability gate. " + + "Present when status='ok'." + ), + falseGreenRisk: z + .boolean() + .optional() + .describe( + "True exactly when BOTH `capabilities.tests` and `capabilities.build` " + + "are false — the specific conjunction that lets a slice merge green " + + "with nothing ever executed. An individual missing verb (`lint`, " + + "`typecheck`, `install`) is common and NOT flagged here: many " + + "projects legitimately skip a linter or need no install step. This " + + "reflects the FINAL commands.json regardless of whether it was " + + "freshly written this call or was already on disk — a stale or " + + "hand-authored partial file is exactly as risky as a fresh empty " + + "one. Present when status='ok'." ), }); @@ -357,6 +415,31 @@ function writeIfAbsent(filePath: string, content: string): FileWriteResult { } } +/** + * Reads and validates an already-present `commands.json` for capability + * reporting. Never throws: an unreadable file, invalid JSON, or a shape that + * fails {@link commandsConfigSchema} all resolve to `{}` — treated as "nothing + * configured" for the completeness report, the conservative reading that never + * hides a real risk behind a parse failure. The file itself is never touched + * here — this is a read for reporting only, never a rewrite. + */ +function readExistingCommandsConfig(filePath: string): CommandsConfig { + let raw: string; + try { + raw = fs.readFileSync(filePath, "utf8"); + } catch { + return {}; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return {}; + } + const validated = commandsConfigSchema.safeParse(parsed); + return validated.success ? validated.data : {}; +} + /** Outcome of the idempotent `.gitignore` append. */ type GitignoreResult = | { kind: "created-with-line" } @@ -422,8 +505,9 @@ function ensureGitignoreEntry(repoRoot: string): GitignoreResult { // ─── Core ───────────────────────────────────────────────────────────────────── /** - * Bootstraps a repository's `.orchestrate/` configuration for a first-ever - * orchestrate run. + * Completes a repository's `.orchestrate/` configuration — writes whichever + * of its three files are missing, safe to call unconditionally regardless of + * whether `.orchestrate/` is absent, partially populated, or already complete. * * Writes the three config files (`commands.json`, `routing.json`, * `handoff.json`) with project- and model-appropriate values, creates @@ -432,6 +516,12 @@ function ensureGitignoreEntry(repoRoot: string): GitignoreResult { * config file is never overwritten, the runs directory mkdir is recursive, and * the `.gitignore` append never duplicates the line. * + * Also reports config completeness read from the FINAL commands.json — + * `capabilities` (which verbs resolve to a command) and `falseGreenRisk` + * (true when both `tests` and `build` are unconfigured) — regardless of + * whether commands.json was written by this call or was already present, so + * a stale or hand-authored gap is caught exactly like a fresh empty file. + * * Never throws — every failure mode is returned as a structured result. */ export function bootstrapConfig( @@ -557,22 +647,59 @@ export function bootstrapConfig( }; } - // Emit a loud warning when the bootstrapper just wrote an empty commands.json - // ({}). This happens for unrecognized project types ('none') where no manifest - // is detected. An empty map means all capability gates (run_tests, run_build, - // etc.) will report 'not-configured' — a slice can merge green with no - // verification, which is a common source of false-green merges. - const commandsMapEmpty = - Object.keys(validatedCommands.data).length === 0; - const warnings: string[] = - commandsResult.kind === "written" && commandsMapEmpty - ? [ - "commands.json was written empty ({}): no recognized project type detected. " + + // Config-completeness report — read from the FINAL commands.json, not just + // what this call wrote. When commands.json was written this call, its + // content is exactly `validatedCommands.data`; when it was already present + // (including a partial `.orchestrate/` from a prior run or a hand-authored + // file), read it back so a pre-existing gap is reported too, not silently + // skipped the way `writeIfAbsent` alone would leave it. + const finalCommands: CommandsConfig = + commandsResult.kind === "written" + ? validatedCommands.data + : readExistingCommandsConfig(path.join(orchestrateDir, "commands.json")); + + // "Configured" must mean exactly what the capability tools mean by it, or + // this report predicts something other than the gate's real behaviour. + // `run-command.ts` resolves BOTH an absent key and a present-but-empty argv + // array to `not-configured` (see its `!argv || argv.length === 0` guards), + // so a placeholder `{"tests": [], "build": []}` — which the commands schema + // accepts — would otherwise report both verbs configured and no risk while + // the gate executes nothing: the same silent start, re-encoded. + const configured = (argv: string[] | undefined): boolean => + argv !== undefined && argv.length > 0; + + const capabilities = { + tests: configured(finalCommands.tests), + typecheck: configured(finalCommands.typecheck), + build: configured(finalCommands.build), + lint: configured(finalCommands.lint), + install: configured(finalCommands.install), + }; + + // The false-green risk is the specific conjunction of BOTH gate-defining + // verbs being absent — not any single missing verb (lint/typecheck/install + // are each commonly and legitimately absent on their own). This is computed + // from the final state regardless of written-vs-already-present, so a + // partial `.orchestrate/` directory (this issue's reported scenario) and a + // hand-authored incomplete commands.json are caught exactly like a freshly + // written empty one. + const falseGreenRisk = !capabilities.tests && !capabilities.build; + + const commandsMapEmpty = Object.keys(finalCommands).length === 0; + const warnings: string[] = falseGreenRisk + ? [ + commandsResult.kind === "written" && commandsMapEmpty + ? "commands.json was written empty ({}): no recognized project type detected. " + "The capability gates run_tests and run_build will report 'not-configured' — " + "a slice can merge green with no verification. " + - "Edit .orchestrate/commands.json to add your project's test and build commands.", - ] - : []; + "Edit .orchestrate/commands.json to add your project's test and build commands." + : "commands.json has neither `tests` nor `build` configured " + + `(commandsJson: ${commandsResult.kind}). The capability gates run_tests and ` + + "run_build will report 'not-configured' — a slice can merge green with no " + + "verification. Edit .orchestrate/commands.json to add your project's test " + + "and build commands.", + ] + : []; return { status: "ok", @@ -586,6 +713,8 @@ export function bootstrapConfig( }, runsDir: runsDirExisted ? "already-present" : "created", gitignore: gitignoreResult.kind, + capabilities, + falseGreenRisk, warnings, }; } diff --git a/plugins/orchestrate/orchestrate-mcp/src/tools/recover-slice-progress.ts b/plugins/orchestrate/orchestrate-mcp/src/tools/recover-slice-progress.ts new file mode 100644 index 00000000..6c8b52f9 --- /dev/null +++ b/plugins/orchestrate/orchestrate-mcp/src/tools/recover-slice-progress.ts @@ -0,0 +1,295 @@ +import * as fs from "fs"; +import { z } from "zod"; +import { resolveSliceProgressPath } from "../run-dir.js"; +import { + SLICE_EXECUTOR_STAGES, + investigatorEnvelopeSchema, +} from "./validate-envelope.js"; + +// ─── recover_slice_progress ─────────────────────────────────────────────────── +// +// A slice's **progress record** is the resume anchor for the slice-executor +// delegation layer (ADR-0017). One executor spawn now spans investigation, +// implementation, and review, so a session that died mid-executor would throw +// away a finished investigation and a finished review — precisely the durability +// this plugin exists to provide. The executor therefore writes the record at +// each completed stage, into the run's own directory beside the run-state +// checkpoint, and a fresh executor reads its own record to resume itself. +// +// The trap this tool closes: the orchestrator must NOT open that file. Reading +// slice-internal artifacts is exactly the coupling the delegation layer removes, +// and a prose file read back into the orchestrator's context is unvalidated by +// construction. So when a slice-executor's envelope comes back missing or +// invalid, the orchestrator recovers the record's contents THROUGH this tool, +// which returns validated, structured data — the same structured-recovery +// posture as `recover_changed_files`, where the worktree is ground truth +// recovered through a tool rather than by reading a subagent's prose. +// +// The tool derives the record's path from `(runId, issue)` and deliberately +// does NOT accept a file path from the caller. That is what makes ADR-0012's +// invariant (3) — no tool reaches outside its own `runs//` — true by +// construction rather than by convention: a path parameter would let a caller +// read another run's records, or anything else on disk. +// +// It reads only. Writing the record is the executor's job, done with its own +// file-writing tool; there is deliberately no writer tool here, because a +// writer would hand the orchestrator a way to forge a slice's resume state. + +// ─── Schemas — z.object is the single source of truth; TS types via z.infer ─── + +/** + * The slice progress record itself. Every field the executor needs to resume + * itself after a handoff, and nothing more. + * + * `runId` and `issue` are REQUIRED so the record self-identifies: this tool + * checks them against the pair it was asked for, which turns a mis-filed record + * into a detected error instead of a silently-accepted one. + */ +export const sliceProgressRecordSchema = z.object({ + runId: z + .string() + .describe( + "The run this record belongs to. Must equal the runId whose directory " + + "the record was read from — a mismatch means the record was mis-filed " + + "and is rejected rather than trusted." + ), + issue: z + .number() + .int() + .describe( + "The issue number of the slice this record tracks. Must equal the issue " + + "the record's filename encodes; a mismatch is rejected." + ), + lastCompletedStage: z + .enum(SLICE_EXECUTOR_STAGES) + .optional() + .describe( + "The last inner stage that FINISHED. Note this is NOT the envelope's " + + "`failedStage`, which names the stage that was RUNNING when a failure " + + "occurred — the value set is deliberately shared, the meaning is not. " + + "The enum is a set of stage NAMES, not an order: which stages run, in " + + "what sequence, and which are skipped are the executor's decisions, " + + "never implied by this field's member order. ABSENT means no stage has " + + "completed yet — omit the key entirely; an explicit null is rejected." + ), + investigatorBrief: investigatorEnvelopeSchema + .omit({ role: true }) + .optional() + .describe( + "The investigator's research brief, carried forward so a resumed " + + "executor does not re-run a finished investigation. Reuses the " + + "investigator envelope's own fields rather than a free-form blob, so " + + "the brief stays validated end to end. Absent when the slice's tier " + + "skips investigation entirely (`resolvedRouting.investigator` is null)." + ), + continuationsUsed: z + .number() + .int() + .nonnegative() + .describe( + "How many continuations the executor's continue-in-place loop has spent " + + "on this slice. Persisted because two continuation loops now nest, and " + + "ADR-0017 caps the PRODUCT of their budgets at 6 — a counter that reset " + + "on every handoff could not enforce that bound across sessions." + ), + worktreeFingerprint: z + .string() + .optional() + .describe( + "Opaque content-level fingerprint of the worktree's uncommitted state at " + + "the last completed stage, used by the no-progress guard to tell a " + + "real continuation from a stalled one. Stored as an opaque string: " + + "COMPUTING it is the executor's job, so this record fixes only how it " + + "is carried, never how it is derived. Absent before the first " + + "fingerprint is taken." + ), + fallbackTaken: z + .boolean() + .describe( + "The once-only Model fallback guard: true when the premium-lane retry " + + "has already been spent on this slice. REQUIRED, with no default — an " + + "absent key must never silently read as `false`, which would re-arm a " + + "fallback that was already used. This deliberately DUPLICATES the " + + "orchestrator's `resolvedRouting.fallbackTaken` in run-state.json, and " + + "the duplication is ADR-0017-sanctioned, not an oversight: the " + + "run-state field is orchestrator-owned and drives the legacy " + + "non-executor path, while this copy is executor-owned, because the " + + "executor cannot write the orchestrator's checkpoint. Do not unify them." + ), + updatedAt: z + .string() + .describe( + "ISO-8601 UTC timestamp of the last write, matching run-state.json's " + + "timestamp convention (documented in prose, not enforced by the schema)." + ), +}); + +export const recoverSliceProgressInputSchema = z.object({ + runId: z + .string() + .describe( + "The orchestration run's id (its YYYYMMDD-HHMMSS timestamp, optionally " + + "prefixed `prd-` or `backlog-`). It selects the per-run directory " + + ".orchestrate/runs// the record is read from." + ), + issue: z + .number() + .int() + .positive() + .describe( + "The issue number of the slice whose record to read. Together with " + + "`runId` it derives the record's path — the tool deliberately accepts " + + "no file path, so it can never read outside this run's directory." + ), + repoPath: z + .string() + .optional() + .describe( + "Path to the project root that holds the .orchestrate/ directory. " + + "Defaults to the MCP server process's current working directory — callers " + + "should pass this explicitly rather than rely on the default." + ), +}); + +export const recoverSliceProgressOutputSchema = z.object({ + status: z + .enum(["ok", "error"]) + .describe( + "Outcome discriminant. 'ok' = the record was found, parsed, and " + + "validated; 'error' = it could not be resolved, read, parsed, or it " + + "failed schema validation." + ), + record: sliceProgressRecordSchema + .optional() + .describe( + "The validated slice progress record. Present ONLY when status='ok'." + ), + errorCode: z + .enum([ + "RUN_ID_INVALID", + "ISSUE_INVALID", + "PROGRESS_NOT_FOUND", + "PROGRESS_INVALID", + ]) + .optional() + .describe( + "Machine-readable failure category. Present when status='error'. " + + "'RUN_ID_INVALID' = the runId is malformed and cannot resolve a run " + + "directory; 'ISSUE_INVALID' = the issue is not a positive integer and " + + "cannot form a record filename; 'PROGRESS_NOT_FOUND' = no " + + "slice--progress.json under .orchestrate/runs// (the " + + "slice has not recorded a completed stage yet) — distinct from " + + "'PROGRESS_INVALID', which means the file EXISTS but is malformed JSON, " + + "fails the record schema, or self-identifies as a different run/slice " + + "than the one requested." + ), + errorMessage: z + .string() + .optional() + .describe("Human-readable failure description. Present when status='error'."), +}); + +// ─── TS types — derived from the schemas (single source of truth) ───────────── + +export type SliceProgressRecord = z.infer; +export type RecoverSliceProgressInput = z.infer< + typeof recoverSliceProgressInputSchema +>; +export type RecoverSliceProgressOutput = z.infer< + typeof recoverSliceProgressOutputSchema +>; + +// ─── Internal helper ────────────────────────────────────────────────────────── + +/** Returns the first non-empty trimmed line of a (possibly multi-line) string. */ +function firstLine(message: string): string { + const line = message + .split("\n") + .map((l) => l.trim()) + .find((l) => l.length > 0); + return line ?? message.trim(); +} + +// ─── Tool ───────────────────────────────────────────────────────────────────── + +/** + * Reads and validates one slice's progress record at + * `.orchestrate/runs//slice--progress.json`, returning it as + * structured data. The orchestrator's structured recovery for when a + * slice-executor's result envelope is missing or invalid: it obtains the + * record's contents through this tool instead of opening the file, keeping the + * read boundary of ADR-0017 intact and the recovered data validated. + * + * The path is derived from `(runId, issue)`, never accepted from the caller, so + * the read stays inside this run's own directory by construction (ADR-0012 + * invariant 3). Reads only; writes nothing. + * + * Never throws — every failure path, a missing file and a malformed one alike, + * maps to a discriminated `{ status: "error", errorCode, errorMessage }` result. + */ +export async function recoverSliceProgress( + input: RecoverSliceProgressInput +): Promise { + const repoPath = input.repoPath ?? process.cwd(); + const resolved = resolveSliceProgressPath(repoPath, input.runId, input.issue); + if (!resolved.ok) { + return { + status: "error", + errorCode: resolved.errorCode, + errorMessage: resolved.errorMessage, + }; + } + + let raw: string; + try { + raw = fs.readFileSync(resolved.path, "utf8"); + } catch { + return { + status: "error", + errorCode: "PROGRESS_NOT_FOUND", + errorMessage: `No slice progress record found at ${resolved.path}.`, + }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + return { + status: "error", + errorCode: "PROGRESS_INVALID", + errorMessage: `The slice progress record is not valid JSON: ${firstLine( + err instanceof Error ? err.message : String(err) + )}`, + }; + } + + const result = sliceProgressRecordSchema.safeParse(parsed); + if (!result.success) { + const detail = result.error.issues + .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`) + .join("; "); + return { + status: "error", + errorCode: "PROGRESS_INVALID", + errorMessage: `The slice progress record does not match the expected shape: ${detail}`, + }; + } + + // Self-identification check: the record must agree with the path it came + // from. A record naming a different run or slice was mis-filed, and trusting + // it would resume an executor from another slice's state. + const record = result.data; + if (record.runId !== input.runId || record.issue !== input.issue) { + return { + status: "error", + errorCode: "PROGRESS_INVALID", + errorMessage: + `The slice progress record self-identifies as runId ` + + `'${record.runId}' / issue ${record.issue}, but was read from the ` + + `record of runId '${input.runId}' / issue ${input.issue}.`, + }; + } + + return { status: "ok", record }; +} diff --git a/plugins/orchestrate/orchestrate-mcp/src/tools/routing.ts b/plugins/orchestrate/orchestrate-mcp/src/tools/routing.ts index 55c95eb6..e8275f3c 100644 --- a/plugins/orchestrate/orchestrate-mcp/src/tools/routing.ts +++ b/plugins/orchestrate/orchestrate-mcp/src/tools/routing.ts @@ -27,6 +27,17 @@ * version-dispatch loader ({@link loadRoutingConfig}) routes by the `version` * field. Label overrides merge through pure functions ({@link applyLabels}) that * return structured data — they never throw. + * + * A third, orthogonal back-compat axis is per-ROLE rather than per-generation: + * `tiers.*.slice-executor` (ADR-0017, #356) is OPTIONAL in + * {@link tierRoutingSchemaV2} so a v2 file written before the role existed keeps + * parsing. {@link ensureSliceExecutorDefault} fills the gap at resolve time — + * defaulting to the tier's OWN `implementer` entry (never a hardcoded pair, so a + * trivial-tier slice keeps a cheap default and a complex-tier slice keeps + * opus/deep) — and returns a structured warning, the same shape as the v1→v2 + * deprecation warning above. This runs uniformly for both a native v2 file and a + * v1 file just upgraded by {@link upgradeV1ToV2}, since neither generation ever + * had this role. */ import * as path from "path"; import * as fs from "fs"; @@ -42,12 +53,18 @@ export type ComplexityTier = (typeof COMPLEXITY_TIERS)[number]; export const ROLE_VARIANTS = ["standard", "deep"] as const; export type RoleVariant = (typeof ROLE_VARIANTS)[number]; -/** The four pipeline roles a label override may patch. */ +/** + * The five pipeline roles a label override may patch. `slice-executor` + * (ADR-0017, #356) joined the original four — the delegation-layer role that + * owns a whole slice end to end. See {@link ensureSliceExecutorDefault} for how + * a config written before the role existed still resolves. + */ export const ROUTING_ROLES = [ "investigator", "implementer", "reviewer", "conflict-resolver", + "slice-executor", ] as const; export type RoutingRole = (typeof ROUTING_ROLES)[number]; @@ -106,7 +123,7 @@ export const routingConfigSchema = z.object({ .min(0) .default(2) .describe( - "How many times the orchestrator may re-spawn the implementer in the " + + "How many times the slice executor may re-spawn the implementer in the " + "same worktree after an 'incomplete' envelope (re-spawns BEYOND the " + "initial run). 0 disables continuation (incomplete FAILs immediately, " + "the legacy behavior). Defaults to 2." @@ -292,13 +309,20 @@ export const roleConfigSchemaV2 = z.object({ /** * Routing for one complexity tier, v2 shape. `investigator` may be `null` — that - * tier skips the investigation pass. The other three roles always run. + * tier skips the investigation pass. `implementer`, `reviewer`, and + * `conflict-resolver` always run. `slice-executor` (ADR-0017, #356) is OPTIONAL + * here — deliberately NOT nullable like `investigator`: absence means "this + * config predates the role", never "this tier skips it" (the executor is never + * skippable). A routing.json written before the role existed keeps parsing; + * {@link ensureSliceExecutorDefault} fills it in at resolve time from the + * tier's own `implementer` entry, with a warning. */ export const tierRoutingSchemaV2 = z.object({ investigator: roleConfigSchemaV2.nullable(), implementer: roleConfigSchemaV2, reviewer: roleConfigSchemaV2, "conflict-resolver": roleConfigSchemaV2, + "slice-executor": roleConfigSchemaV2.optional(), }); /** Per-label model fallback: one re-spawn as `{model}` on a spawn failure. */ @@ -360,7 +384,7 @@ export const runConfigSchema = z.object({ .optional() .default(2) .describe( - "How many times the orchestrator may re-spawn the implementer in the " + + "How many times the slice executor may re-spawn the implementer in the " + "same worktree after an 'incomplete' envelope. 0 disables continuation. " + "Defaults to 2. Lifted from the v1 top-level key." ), @@ -530,6 +554,50 @@ export function loadRoutingConfig(parsed: unknown): LoadRoutingResult { }; } +// ─── slice-executor role default (ADR-0017, #356) ──────────────────────────── + +/** Result of ensuring a tier's routing carries a `slice-executor` entry. */ +export interface EnsureSliceExecutorDefaultResult { + routing: TierRoutingV2; + warning?: string; +} + +/** + * Fills in a tier's `slice-executor` routing when a loaded config predates the + * role (ADR-0017, #356). PURE — never mutates the input; returns a structured + * warning instead of throwing, the same shape as the v1→v2 deprecation warning. + * + * The documented default is the tier's OWN `implementer` entry, not a + * hardcoded pair: the slice-executor subsumes the implementer's work (it owns + * investigation, implementation, review, and the capability gate for the + * whole slice), so inheriting the implementer's `{model, variant}` keeps the + * tier's cost profile intact — a trivial-tier slice stays cheap, a + * complex-tier slice stays on opus/deep — rather than silently promoting + * every tier to one hardcoded, conservative pair. + * + * Runs uniformly whether `tier` came from a native v2 file or one just + * upgraded from v1 by {@link upgradeV1ToV2} — neither generation ever had this + * role, so both normalize through this single point. When the entry is already + * present, `tier` is returned unchanged and no warning is produced. + */ +export function ensureSliceExecutorDefault( + tier: TierRoutingV2, + tierName: ComplexityTier +): EnsureSliceExecutorDefaultResult { + if (tier["slice-executor"]) { + return { routing: tier }; + } + const fallback = { ...tier.implementer }; + return { + routing: { ...tier, "slice-executor": fallback }, + warning: + `routing.json has no \`slice-executor\` entry for the '${tierName}' ` + + `tier — defaulting to the implementer routing (${fallback.model}/` + + `${fallback.variant}). Add a \`slice-executor\` entry to each tier to ` + + "silence this warning.", + }; +} + // ─── v2 tool input/output schemas ───────────────────────────────────────────── /** @@ -597,7 +665,11 @@ export const resolveRoutingV2OutputSchema = z.object({ .describe( "The resolved per-role routing for the tier (v2: uses `variant`, not " + "`effort`). `investigator` is null when this tier skips the " + - "investigation pass. Present when status='ok'." + "investigation pass. `slice-executor` (ADR-0017, #356) is typed " + + "optional here only for input back-compat — in a RESOLVED result it " + + "is ALWAYS populated: a routing.json predating the role has it " + + "defaulted to the tier's own `implementer` entry, flagged in " + + "`warnings`. Present when status='ok'." ), continuationBudget: z .number() @@ -700,9 +772,17 @@ export function resolveRoutingV2FromConfig( } const { config, warnings: loaderWarnings } = loadResult; - const tierRouting = config.tiers[input.tier]; const labelsConfig = config.labels ?? {}; + // Fill a missing `slice-executor` entry from the tier's own `implementer` + // BEFORE applying labels, so the default is seeded from the CONFIGURED + // implementer rather than a label-patched one: a label naming only + // `implementer` (e.g. route:fable) must not silently retarget the executor + // too. An explicit route:* override of `slice-executor` still wins either + // way — applyLabels assigns the role outright below. + const { routing: tierRouting, warning: sliceExecutorWarning } = + ensureSliceExecutorDefault(config.tiers[input.tier], input.tier); + // Filter the raw label list to those that are configured OR have the route: // prefix. This prevents ordinary GitHub labels from producing spurious // "unconfigured" warnings while still catching route:* typos. @@ -725,7 +805,11 @@ export function resolveRoutingV2FromConfig( routing: labelResult.routing, continuationBudget: config.run.continuationBudget, fallbacks: labelResult.fallbacks, - warnings: [...loaderWarnings, ...labelResult.warnings], + warnings: [ + ...loaderWarnings, + ...(sliceExecutorWarning ? [sliceExecutorWarning] : []), + ...labelResult.warnings, + ], }; } @@ -775,6 +859,9 @@ export function applyLabels( implementer: { ...tierRouting.implementer }, reviewer: { ...tierRouting.reviewer }, "conflict-resolver": { ...tierRouting["conflict-resolver"] }, + "slice-executor": tierRouting["slice-executor"] + ? { ...tierRouting["slice-executor"] } + : undefined, }; // Track which label first patched each role to detect same-role conflicts. diff --git a/plugins/orchestrate/orchestrate-mcp/src/tools/run-wave.ts b/plugins/orchestrate/orchestrate-mcp/src/tools/run-wave.ts index 264b9a36..cc0f79e1 100644 --- a/plugins/orchestrate/orchestrate-mcp/src/tools/run-wave.ts +++ b/plugins/orchestrate/orchestrate-mcp/src/tools/run-wave.ts @@ -40,6 +40,20 @@ import { runTests, runBuild, runIntegration } from "./run-command.js"; // against the umbrella tip and map the result: 'proceed' (passed), // 'halt' (failed/error), 'tolerate' (no integration command configured). // +// 'plan-wave-width' — how many of a wave's processable slices may be in +// flight at once, given the platform's concurrent-subagent limit. Each +// in-flight slice occupies TWO live agent slots (its executor plus the one +// worker that executor currently has running), so the width is half the +// limit, floored at 1. PURE. +// +// 'classify-spawn-outcome' — is a spawn failure BACKPRESSURE (the platform's +// concurrent-subagent limit, transient — the slice is fine and returns to +// the queue) or a real spawn error (anything else, including a spent +// session spawn budget)? PURE, and text-based by necessity: the Agent tool +// reports prose, not a structured code. Same ADR-0008 posture as +// 'select-processable' — it CLASSIFIES text the orchestrator observed and +// passed in; the MCP server spawns nothing and observes nothing itself. +// // All loop state — the umbrella ref, the remote, whether this is the first // merged slice of the wave, the deferred worktree path — is PASSED IN by the // orchestrator, never inferred here. run_wave is run-scoped: it mutates nothing @@ -100,6 +114,8 @@ export const runWaveInputSchema = z.object({ "select-processable", "reverify-slice", "integration-gate", + "plan-wave-width", + "classify-spawn-outcome", ]) .describe( "Which bracketed wave operation to run. 'refresh-base' (§2 step 1): " + @@ -111,7 +127,13 @@ export const runWaveInputSchema = z.object({ "the slice worktree and run the two correctness verbs, returning " + "`passed`/`failed`/`conflict` (or `skipped-first-merge` for the first " + "merged slice). 'integration-gate' (§2 step 4a): run the per-wave " + - "integration suite, returning `proceed`/`halt`/`tolerate`." + "integration suite, returning `proceed`/`halt`/`tolerate`. " + + "'plan-wave-width' (§2 step 3): cap how many processable slices may be " + + "in flight at once against the concurrent-subagent limit, returning " + + "`width-planned` with `waveWidth` + `deferredCount`. " + + "'classify-spawn-outcome' (§2 step 3): classify an observed spawn " + + "failure as `backpressure` (retry-later, the slice is fine) or " + + "`spawn-error`." ), repoPath: z .string() @@ -169,6 +191,38 @@ export const runWaveInputSchema = z.object({ "processable only when every one is `CLOSED`. Pass [] when the slice " + "has no out-of-partition blockers." ), + processableCount: z + .number() + .int() + .positive() + .optional() + .describe( + "'plan-wave-width' only: how many slices in this wave passed the " + + "'select-processable' gate. Required for that operation." + ), + concurrencyLimit: z + .number() + .int() + .positive() + .optional() + .default(20) + .describe( + "'plan-wave-width' only: how many subagents may run CONCURRENTLY in " + + "this session. Defaults to 20 — the platform's own default, which " + + "`CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS` changes. Pass the raised value " + + "when that variable is set; the plugin cannot read it (it works within " + + "the limit, it never sets it)." + ), + spawnFailureText: z + .string() + .optional() + .describe( + "'classify-spawn-outcome' only: the verbatim failure text the " + + "orchestrator got back when a spawn was refused. This tool never " + + "observes a spawn itself — the orchestrator makes every Agent call, so " + + "it passes in what it saw (the same ADR-0008 posture as " + + "'select-processable'). Required for that operation." + ), }); export const runWaveOutputSchema = z.object({ @@ -177,9 +231,11 @@ export const runWaveOutputSchema = z.object({ .describe( "Outcome discriminant. 'ok' = the operation reached a non-failure " + "verdict (refreshed | processable | skip | skipped-first-merge | " + - "passed | proceed | tolerate); 'failed' = a blocking verdict or error " + - "(diverged | failed | conflict | halt | error). The `verdict` field " + - "carries the specific outcome." + "passed | proceed | tolerate | width-planned | backpressure); " + + "'failed' = a blocking verdict or error (diverged | failed | conflict " + + "| halt | spawn-error | error). `backpressure` is deliberately on the " + + "'ok' side: a slice refused for a platform limit has nothing wrong " + + "with it. The `verdict` field carries the specific outcome." ), verdict: z .enum([ @@ -194,6 +250,9 @@ export const runWaveOutputSchema = z.object({ "proceed", "halt", "tolerate", + "width-planned", + "backpressure", + "spawn-error", "error", ]) .describe( @@ -203,8 +262,12 @@ export const runWaveOutputSchema = z.object({ "`blockerId`). 'reverify-slice' → `skipped-first-merge` | `passed` | " + "`failed` (see `which`) | `conflict` (the merge left an unmerged " + "index, flagged not resolved). 'integration-gate' → `proceed` | `halt` " + - "| `tolerate` (no integration command configured). `error` = a git or " + - "input failure (see `errorCode`)." + "| `tolerate` (no integration command configured). 'plan-wave-width' → " + + "`width-planned` (see `waveWidth` + `deferredCount`). " + + "'classify-spawn-outcome' → `backpressure` (the concurrent-subagent " + + "limit — requeue the slice unchanged, never fail it) | `spawn-error` " + + "(anything else, including a spent session spawn budget; see " + + "`limitSignal`). `error` = a git or input failure (see `errorCode`)." ), sha: z .string() @@ -228,6 +291,41 @@ export const runWaveOutputSchema = z.object({ "'reverify-slice' `failed`: which correctness verb failed after the " + "umbrella was merged into the worktree." ), + waveWidth: z + .number() + .int() + .optional() + .describe( + "'plan-wave-width' `width-planned`: how many of this wave's processable " + + "slices may be in flight at once — `min(processableCount, " + + "floor(concurrencyLimit / 2))`, floored at 1." + ), + deferredCount: z + .number() + .int() + .optional() + .describe( + "'plan-wave-width' `width-planned`: how many processable slices the cap " + + "defers to a later turn of the wave — `processableCount - waveWidth`. " + + "A deferred slice stays `pending` in the wave's queue; it is NOT " + + "skipped and NOT failed." + ), + limitSignal: z + .enum([ + "concurrent-subagent-limit", + "session-spawn-limit", + "unrecognized", + ]) + .optional() + .describe( + "'classify-spawn-outcome': which documented platform signal the failure " + + "text matched. 'concurrent-subagent-limit' = transient backpressure, a " + + "slot frees and the slice is re-attempted. 'session-spawn-limit' = the " + + "session's total spawn budget is SPENT — unrecoverable in-session, and " + + "the reason the two limits must never be conflated (requeueing on this " + + "one would loop forever). 'unrecognized' = no documented literal " + + "matched, classified conservatively as a spawn error." + ), errorCode: z .enum(["INVALID_INPUT", "GIT_ERROR"]) .optional() @@ -243,7 +341,8 @@ export const runWaveOutputSchema = z.object({ .optional() .describe( "Cleaned, human-readable failure description. Present for `diverged`, " + - "`conflict`, `failed`, `halt`, and `error`." + "`conflict`, `failed`, `halt`, `spawn-error`, and `error` — and " + + "deliberately ABSENT for `backpressure`, which is not a failure." ), }); @@ -271,6 +370,35 @@ export interface RunWaveOptions { sleep?: (ms: number) => Promise; } +/** + * How many subagents may run concurrently in one session by default — the + * platform's own default, which `CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS` changes. + * At the limit the Agent tool refuses with the literal below; spawning succeeds + * again once the running count drops. + */ +const DEFAULT_CONCURRENCY_LIMIT = 20; + +/** + * Live agent slots one in-flight slice occupies: its slice executor plus the + * one worker (investigator, implementer, or reviewer) that executor currently + * has running. A parallel wave therefore holds roughly TWICE as many live + * agents as it has slices, which is the whole reason wave width is computed + * rather than assumed. + */ +const AGENT_SLOTS_PER_SLICE = 2; + +/** + * The documented refusal when the session is at its CONCURRENT subagent limit. + * Transient: a slot frees and the spawn succeeds. Matched case-insensitively. + */ +const CONCURRENT_LIMIT_LITERAL = "concurrent subagent limit reached"; + +/** + * The documented refusal when the session's TOTAL spawn budget is spent. + * Unrecoverable in-session — a different animal from the concurrent limit. + */ +const SESSION_LIMIT_LITERAL = "subagent spawn limit reached"; + /** Max umbrella-fetch attempts before reporting a git error (transient retry). */ const FETCH_ATTEMPTS = 3; /** Initial delay before the second fetch attempt (ms). */ @@ -304,6 +432,10 @@ export async function runWave( return reverifySlice(input, opts); case "integration-gate": return integrationGate(input); + case "plan-wave-width": + return planWaveWidth(input); + case "classify-spawn-outcome": + return classifySpawnOutcome(input); } } @@ -589,6 +721,125 @@ async function integrationGate(input: RunWaveInput): Promise { } } +/** + * 'plan-wave-width' (§2 step 3): how many of this wave's processable slices may + * be in flight at once, given the session's concurrent-subagent limit. + * + * Each in-flight slice occupies {@link AGENT_SLOTS_PER_SLICE} live agent slots — + * its executor plus the one worker that executor currently has running — so the + * cap is `floor(limit / 2)`, and the wave takes the smaller of that and however + * many slices are actually processable. The remainder is DEFERRED, not skipped: + * a deferred slice keeps its state and is picked up when a slot frees. + * + * The `Math.max(1, …)` floor is load-bearing, not cosmetic: a configured limit + * of 1 would otherwise plan a width of 0 and deadlock the wave — it would defer + * every slice forever, with nothing running to free a slot. A width of 1 + * exceeds a limit of 1 only if the second slot is ever actually occupied, and + * running one slice slightly over budget is strictly better than running none. + * + * Pure — no I/O, no config read. The limit is passed in because the MCP process + * cannot see the orchestrator session's environment. + */ +function planWaveWidth(input: RunWaveInput): RunWaveOutput { + const processableCount = input.processableCount; + const concurrencyLimit = input.concurrencyLimit ?? DEFAULT_CONCURRENCY_LIMIT; + + if ( + processableCount === undefined || + !Number.isInteger(processableCount) || + processableCount < 1 + ) { + return failed( + "INVALID_INPUT", + "operation 'plan-wave-width' requires a positive integer `processableCount`." + ); + } + if (!Number.isInteger(concurrencyLimit) || concurrencyLimit < 1) { + return failed( + "INVALID_INPUT", + "operation 'plan-wave-width' requires a positive integer `concurrencyLimit`." + ); + } + + const maxWidth = Math.max( + 1, + Math.floor(concurrencyLimit / AGENT_SLOTS_PER_SLICE) + ); + const waveWidth = Math.min(processableCount, maxWidth); + + return { + status: "ok", + verdict: "width-planned", + waveWidth, + deferredCount: processableCount - waveWidth, + }; +} + +/** + * 'classify-spawn-outcome' (§2 step 3): was an observed spawn failure the + * platform's CONCURRENT-subagent limit — backpressure, which says nothing at + * all about the slice — or a real spawn error? + * + * `backpressure` carries `status: 'ok'` and no `errorMessage` deliberately: a + * slice refused for a platform limit has nothing wrong with it, so it returns + * to the wave's queue with its state unchanged and is never marked failed, + * never given a failure reason, and never labelled. Getting this wrong produces + * a structural false negative that would be blamed on the slice's own work. + * + * The two documented limits are kept DISTINGUISHABLE through `limitSignal`, + * because conflating them is the expensive mistake in both directions: a spent + * SESSION budget is unrecoverable in-session, so requeueing on it would loop + * forever. An unmatched failure defaults to `spawn-error`/`unrecognized` — the + * conservative class, so an unknown failure is never silently swallowed as + * retryable. + * + * The match is TEXT-BASED and therefore version-fragile: the Agent tool reports + * prose, not a structured error code, so there is no structural signal to + * classify on. It is case-insensitive substring matching against the literals + * the vendor documents, and it degrades to `unrecognized` — not to a wrong + * class — if those literals ever change. Pure. + */ +function classifySpawnOutcome(input: RunWaveInput): RunWaveOutput { + const text = input.spawnFailureText; + if (text === undefined) { + return failed( + "INVALID_INPUT", + "operation 'classify-spawn-outcome' requires `spawnFailureText`." + ); + } + + const haystack = text.toLowerCase(); + + if (haystack.includes(CONCURRENT_LIMIT_LITERAL)) { + return { + status: "ok", + verdict: "backpressure", + limitSignal: "concurrent-subagent-limit", + }; + } + + if (haystack.includes(SESSION_LIMIT_LITERAL)) { + return { + status: "failed", + verdict: "spawn-error", + limitSignal: "session-spawn-limit", + errorMessage: + "The session's total subagent spawn budget is spent — this is NOT " + + "backpressure and re-attempting the slice in this session cannot " + + "succeed. Hand the run off to a successor session.", + }; + } + + return { + status: "failed", + verdict: "spawn-error", + limitSignal: "unrecognized", + errorMessage: + "The spawn failure matched no documented platform limit, so it is " + + `classified conservatively as a spawn error: ${text}`, + }; +} + // ─── Internal helpers ───────────────────────────────────────────────────────── /** diff --git a/plugins/orchestrate/orchestrate-mcp/src/tools/validate-envelope.ts b/plugins/orchestrate/orchestrate-mcp/src/tools/validate-envelope.ts index 74edba1d..92889cf0 100644 --- a/plugins/orchestrate/orchestrate-mcp/src/tools/validate-envelope.ts +++ b/plugins/orchestrate/orchestrate-mcp/src/tools/validate-envelope.ts @@ -7,8 +7,18 @@ import { z } from "zod"; // the orchestrator's ONLY machine-checkable source of a subagent's status and // changed-file set — the orchestrator never parses the subagent's prose. The // worker roles (implementer, reviewer, conflict-resolver) carry a work summary; -// the investigator carries a research brief. A discriminated union on `role` -// keeps each role's existing status vocabulary intact. +// the investigator carries a research brief; the slice-executor (ADR-0017) +// carries a whole-slice outcome — investigation, implementation, review, and +// the capability gate collapsed into one validated result. A discriminated +// union on `role` keeps each role's existing status vocabulary intact. + +/** Shared outcome vocabulary for a single capability-tool run. */ +const capabilityResultSchema = z + .enum(["passed", "failed", "not-configured"]) + .describe( + "Outcome of a capability-tool run. 'not-configured' means the verb has " + + "no command set." + ); /** * One capability-tool run and its outcome. An array (not a map) so a re-run of @@ -18,11 +28,7 @@ const verificationEntrySchema = z.object({ capability: z .enum(["tests", "typecheck", "build", "lint"]) .describe("Which capability tool was run."), - result: z - .enum(["passed", "failed", "not-configured"]) - .describe( - "Outcome of that run. 'not-configured' means the verb has no command set." - ), + result: capabilityResultSchema, }); /** @@ -207,6 +213,170 @@ export const investigatorEnvelopeSchema = z.object({ .describe("Anything else that does not fit the fields above."), }); +/** + * The closed set of reasons a slice-executor did not reach a verified + * changeset (ADR-0017). Defined in exactly one place — both this schema and + * any downstream consumer (e.g. the orchestrator's failure-to-label mapping) + * import this same union, so the set cannot drift into a second, restated + * literal. Every value must name a real, distinguishable failure mode; a + * closed set that cannot express something the executor legitimately hits is + * the "schema that lies" this design explicitly rejects. + */ +export const SLICE_EXECUTOR_FAILURE_CLASSES = [ + "unrecoverable-obstacle", + "incomplete-budget-exhausted", + "no-progress-stall", + "invalid-or-missing-worker-envelope", + "changeset-mismatch", + "empty-changeset", + "model-refusal", +] as const; + +/** + * The closed set of inner stages a slice-executor runs through (ADR-0017). + * Defined in exactly one place — the envelope's `failedStage` and the slice + * progress record's `lastCompletedStage` import this same union, so the set + * cannot drift into a second, restated literal. + * + * This is a set of stage NAMES, not an ordering: array position carries no + * meaning, and which stages run, in what order, and which are skipped are the + * executor's decisions, not this vocabulary's. The two consumers also read the + * same member differently — `failedStage` names the stage that was RUNNING when + * a failure occurred, `lastCompletedStage` names the stage that FINISHED. + */ +export const SLICE_EXECUTOR_STAGES = [ + "investigator", + "implementer", + "capability-gate", + "reviewer", +] as const; + +const sliceExecutorFailureClassSchema = z + .enum(SLICE_EXECUTOR_FAILURE_CLASSES) + .describe( + "Closed-set classification of why the slice did not reach a verified " + + "changeset: 'unrecoverable-obstacle' (a blocker with no safe workaround, " + + "including a capability-gate failure with no more specific class); " + + "'incomplete-budget-exhausted' (the executor's own nested continuation " + + "loop ran out of turns — the slice-level analogue of the implementer's " + + "graceful 'incomplete' self-report); 'no-progress-stall' (repeated " + + "attempts converged on nothing); 'invalid-or-missing-worker-envelope' " + + "(a worker the executor spawned returned a truncated, malformed, or " + + "missing envelope); 'changeset-mismatch' (the implementer's declared " + + "`filesChanged` did not match the worktree's actual changeset); " + + "'empty-changeset' (the slice produced no file changes at all); " + + "'model-refusal' (a spawned worker's model refused the task)." + ); + +/** + * Roll-up of the slice's capability-gate outcome — the FINAL per-capability + * state at the end of the whole slice, not a run-by-run log. Deliberately an + * object (not the worker envelope's `verificationEntrySchema` array): a + * slice-executor reports one settled outcome per capability, never a + * re-run history. All four keys are optional — a capability the slice never + * reached (e.g. 'lint' when nothing configures it, or any verb skipped by an + * early failure) is simply absent. + */ +const sliceExecutorVerificationSchema = z.object({ + tests: capabilityResultSchema.optional(), + typecheck: capabilityResultSchema.optional(), + build: capabilityResultSchema.optional(), + lint: capabilityResultSchema.optional(), +}); + +/** + * Result envelope for the slice-executor role (ADR-0017, #354). One envelope + * describes the outcome of a WHOLE SLICE — investigation, implementation, + * review, and the capability gate, collapsed behind the nested subagent + * spawns the executor owns internally — rather than a single worker's turn. + * + * There is deliberately NO loop-continue/loop-end status: wave termination is + * computed by the orchestrator from wave exhaustion (`plan_waves` / + * `run_wave`), never declared by a subagent. A status field naming the loop + * would hand the executor an authority it does not have. + */ +export const sliceExecutorEnvelopeSchema = z.object({ + role: z + .literal("slice-executor") + .describe("Discriminant — the slice-executor role."), + status: z + .enum(["completed", "incomplete", "blocked", "failed"]) + .describe( + "Outcome of the WHOLE SLICE, not a single worker — reuses the schema's " + + "existing status vocabulary rather than inventing a fifth. " + + "'completed' = a verified changeset was reached; 'incomplete' = the " + + "executor's own graceful continuation-budget self-report, mirroring " + + "the implementer's 'incomplete'; 'blocked' = an unrecoverable obstacle " + + "hit by the executor or one of the workers it spawned; 'failed' = the " + + "slice did not reach a trustworthy changeset (a worker or " + + "verification failure). There is no loop-continue/loop-end value — " + + "see the module-level note above." + ), + failedStage: z + .enum(SLICE_EXECUTOR_STAGES) + .optional() + .describe( + "Which inner stage of the slice pipeline was running when a non-" + + "'completed' outcome occurred. Absent for a 'completed' envelope. " + + "'implementer' also covers the changeset-verification check that " + + "immediately follows the implementer's turn (it gates trust in the " + + "implementer's own output, before the reviewer stage begins) — so " + + "'changeset-mismatch' and 'empty-changeset' are reported here, not " + + "under a separate stage." + ), + failureClass: sliceExecutorFailureClassSchema + .optional() + .describe( + "Closed-set classification of the failure. Absent for a 'completed' " + + "envelope. The orchestrator maps this class to a tracker triage " + + "label; it never re-derives the classification itself — that " + + "authority stays with the executor that observed the failure." + ), + failureReason: z + .string() + .optional() + .describe( + "Prose description of what happened, in the executor's own words. " + + "Complements `failureClass` (the closed-set machine label) with the " + + "specific detail a human or the next executor needs. Absent for a " + + "'completed' envelope." + ), + reportPath: z + .string() + .describe( + "Path, relative to the run directory (`.orchestrate/runs//`), of " + + "the slice's report — the human-readable artifact the executor wrote " + + "describing its own run. It is written beside the executor's progress " + + "record, NEVER into the worktree, where the Changeset scope check " + + "would see it as an undeclared change." + ), + nextTaskBriefing: z + .string() + .describe( + "Advice carried forward to whoever picks up the next slice. This is " + + "advice only, never a selection of WHICH slice runs next — wave " + + "ordering and loop termination stay computed by `plan_waves` and " + + "wave exhaustion, not declared here (see the module-level note above)." + ), + filesChanged: z + .array(z.string()) + .describe( + "Files changed across the whole slice — every worker's edits combined " + + "— as paths relative to the worktree root. An empty array means no " + + "file was changed." + ), + verification: sliceExecutorVerificationSchema.describe( + "Roll-up of the slice's capability-gate outcome, one optional result per " + + "capability." + ), + fallbackTaken: z + .boolean() + .describe( + "Whether the one-time Model fallback (the premium-lane retry) was " + + "taken during this slice." + ), +}); + /** * The full set of envelope shapes, discriminated on `role`. Each subagent role * maps to exactly one member. @@ -217,6 +387,7 @@ export const envelopeSchema = z reviewerEnvelopeSchema, conflictResolverEnvelopeSchema, investigatorEnvelopeSchema, + sliceExecutorEnvelopeSchema, ]) // An 'incomplete' implementer envelope MUST carry a non-empty `remainingWork` // handoff — it is the note the orchestrator forwards to the continuation @@ -249,6 +420,7 @@ export const ENVELOPE_ROLES = [ "reviewer", "conflict-resolver", "investigator", + "slice-executor", ] as const; export const validateEnvelopeInputSchema = z.object({ @@ -313,6 +485,7 @@ export type ConflictResolverEnvelope = z.infer< typeof conflictResolverEnvelopeSchema >; export type InvestigatorEnvelope = z.infer; +export type SliceExecutorEnvelope = z.infer; export type ValidateEnvelopeInput = z.infer; export type ValidateEnvelopeOutput = z.infer< typeof validateEnvelopeOutputSchema diff --git a/plugins/orchestrate/orchestrate-mcp/test/agent-variant-parity.test.ts b/plugins/orchestrate/orchestrate-mcp/test/agent-variant-parity.test.ts new file mode 100644 index 00000000..613c8882 --- /dev/null +++ b/plugins/orchestrate/orchestrate-mcp/test/agent-variant-parity.test.ts @@ -0,0 +1,327 @@ +import { describe, it, expect } from "vitest"; +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from "url"; + +// ─── The parity family ──────────────────────────────────────────────────────── +// +// Every subagent role in this plugin ships as a `-standard`/`-deep` pair. The +// two files of a pair are a "parity family" in this repo's own sense (see +// .claude/rules/compliance-maintenance.md): their shared prose must stay +// byte-identical, so a fix applied to one variant can never silently miss the +// other. +// +// ADR-0009 fixes what a pair is ALLOWED to differ in: "the only deliberate +// difference between variants is model, effort, `maxTurns`, and the +// depth-of-pass instructions". That sentence is the spec this test encodes. +// +// The comparison is therefore DENY-list shaped, not allow-list shaped: every +// `## ` section is compared unless it is named below as deliberately +// variant-specific. An allowlist would silently exempt any section nobody +// remembered to add to it — `## Scope-boundary guard`, shared byte-identically +// by both investigator variants, was exactly that miss. +// +// - `## What you receive` is EXCLUDED. It carries depth-of-pass content and +// diverges by design — `implementer-deep` and `reviewer-deep` each add an +// investigator-brief bullet their standard variants deliberately lack. +// - `## Deep effort` is EXCLUDED. It exists only in the deep variant by +// construction. +// +// Both exclusions are deliberate. A future reader who wonders why they are +// exempt should read ADR-0009:58-60 before removing them. + +// Sections a pair is allowed to differ in, or to carry in one variant only. +const VARIANT_SPECIFIC_SECTIONS = new Set([ + "## What you receive", + "## Deep effort", +]); + +// Sections every pair must CARRY in both variants. Presence is asserted +// separately from equality: a comparison over "sections found in both" is +// vacuously green when a section is missing from both. +const PARITY_SECTIONS = [ + "## What you do", + "## Boundaries", + "## Advisor policy", + "## What you return", +]; + +// Fields whose values MUST differ between the two variants of a pair. This is +// also the mechanical check that each variant pins its own effort level, which +// cannot be set per spawn. +const DIVERGENT_FRONTMATTER = ["model", "effort", "maxTurns"]; + +// Fields whose values MUST be byte-identical between the two variants. +const IDENTICAL_FRONTMATTER = ["tools"]; + +// List-valued fields that must resolve to the same entries in both variants. +// `skills:` is here because a variant that preloads a different procedure — +// or misspells the one it means to preload — reads as configured and behaves +// as if it were not: Claude Code skips an unresolvable skill silently. +const IDENTICAL_FRONTMATTER_LISTS = ["skills"]; + +// test/ → orchestrate-mcp/ → orchestrate/ → agents/ +const agentsDir = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "agents" +); + +/** Split a definition into its YAML frontmatter block and its markdown body. */ +function splitDefinition(raw: string): { frontmatter: string; body: string } { + // Strip a trailing CR before comparing: a checkout under `core.autocrlf=true` + // materialises these files with CRLF endings even though the blobs are LF, and + // an exact `=== "---"` there makes every definition unparseable. That failure is + // not loud where it matters — the sibling matcher-consistency suite would build + // an EMPTY shipped-name set and then pass vacuously, which is the one outcome a + // guard must never have. + const lines = raw.split("\n").map((line) => line.replace(/\r$/, "")); + if (lines[0] !== "---") { + throw new Error("definition does not open with a '---' frontmatter fence"); + } + const closing = lines.indexOf("---", 1); + if (closing === -1) { + throw new Error("definition has no closing '---' frontmatter fence"); + } + return { + frontmatter: lines.slice(1, closing).join("\n"), + body: lines.slice(closing + 1).join("\n"), + }; +} + +/** + * Read one top-level scalar frontmatter field. Returns undefined when the key + * is absent, so a missing field is reported as such rather than as a mismatch. + */ +function frontmatterField( + frontmatter: string, + key: string +): string | undefined { + for (const line of frontmatter.split("\n")) { + const match = line.match(/^([A-Za-z][A-Za-z0-9_-]*):[ \t]*(.*)$/); + if (match && match[1] === key) return match[2].trim(); + } + return undefined; +} + +/** + * Read one list-valued frontmatter field, accepting either YAML form — the + * block list (`skills:` then ` - entry` lines) both slice-executor variants + * use, or an inline comma-separated scalar. Returns undefined when the key is + * absent, so "neither variant declares it" stays distinguishable from "both + * declare an empty list". + */ +function frontmatterList( + frontmatter: string, + key: string +): string[] | undefined { + const lines = frontmatter.split("\n"); + const index = lines.findIndex((line) => + new RegExp(`^${key}:([ \\t]|$)`).test(line) + ); + if (index === -1) return undefined; + + const inline = lines[index].slice(key.length + 1).trim(); + if (inline !== "") { + return inline + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry !== ""); + } + + const entries: string[] = []; + for (const line of lines.slice(index + 1)) { + const match = line.match(/^[ \t]+-[ \t]+(.*)$/); + if (!match) break; + entries.push(match[1].trim()); + } + return entries; +} + +/** + * Map each `## ` heading in a body to its content, where a section runs from + * its heading to the NEXT `## ` heading — not to the next allowlisted heading. + * Slicing to the next allowlisted heading would make `## Advisor policy` in + * every deep variant swallow the deep-only `## Deep effort` section that + * follows it, and every existing pair would fail. + */ +function sectionsOf(body: string): Map { + const sections = new Map(); + let heading: string | null = null; + let buffer: string[] = []; + + const flush = () => { + if (heading !== null) sections.set(heading, buffer.join("\n").trim()); + }; + + for (const line of body.split("\n")) { + if (line.startsWith("## ")) { + flush(); + heading = line.trim(); + buffer = []; + } else if (heading !== null) { + buffer.push(line); + } + } + flush(); + return sections; +} + +/** Discover `-standard.md` / `-deep.md` pairs by reading the directory. */ +function discoverPairs(): string[] { + const files = fs.readdirSync(agentsDir).filter((f) => f.endsWith(".md")); + return files + .filter((f) => f.endsWith("-standard.md")) + .map((f) => f.slice(0, -"-standard.md".length)) + .filter((role) => files.includes(`${role}-deep.md`)) + .sort(); +} + +const pairs = discoverPairs(); + +describe("agent variant parity", () => { + it("parity — every role in agents/ ships both a -standard and a -deep variant", () => { + // Only variant-suffixed files participate. A future single-variant + // definition is a different question from pair divergence, and failing + // here would report it under a message about parity. + const variantFiles = fs + .readdirSync(agentsDir) + .filter((f) => /-(standard|deep)\.md$/.test(f)); + const roles = new Set( + variantFiles.map((f) => f.replace(/-(standard|deep)\.md$/, "")) + ); + expect(pairs.length).toBeGreaterThan(0); + // No role may ship only one half of a pair. + expect(pairs.slice().sort()).toEqual([...roles].sort()); + }); + + it("parity — the slice-executor role is present as a discovered pair", () => { + // The pair discovery above is generic; this pins the role this suite was + // written for so a rename cannot quietly drop it from the parity family. + expect(pairs).toContain("slice-executor"); + }); + + for (const role of pairs) { + describe(role, () => { + const standard = splitDefinition( + fs.readFileSync(path.join(agentsDir, `${role}-standard.md`), "utf8") + ); + const deep = splitDefinition( + fs.readFileSync(path.join(agentsDir, `${role}-deep.md`), "utf8") + ); + const standardSections = sectionsOf(standard.body); + const deepSections = sectionsOf(deep.body); + + it(`parity — ${role}: every shared section is present in both variants`, () => { + for (const heading of PARITY_SECTIONS) { + // Assert presence explicitly: a comparison over "sections found in + // both" is vacuously green when a section is missing from both. + expect( + standardSections.has(heading), + `${role}-standard.md is missing the "${heading}" section` + ).toBe(true); + expect( + deepSections.has(heading), + `${role}-deep.md is missing the "${heading}" section` + ).toBe(true); + } + }); + + it(`parity — ${role}: both variants carry the same set of shared sections`, () => { + // A section that exists in one variant and not the other is the same + // silent miss as one whose text drifted — the fix applied to one half + // of the pair never reached the other. + const shared = (sections: Map) => + [...sections.keys()] + .filter((heading) => !VARIANT_SPECIFIC_SECTIONS.has(heading)) + .sort(); + expect( + shared(deepSections), + `the shared section set differs between the ${role} variants` + ).toEqual(shared(standardSections)); + }); + + it(`parity — ${role}: shared sections are byte-identical across the pair`, () => { + // Every section present in both variants is compared unless it is + // named variant-specific — no section is exempt merely by being + // absent from a hand-maintained allowlist. + const compared = [...standardSections.keys()].filter( + (heading) => + !VARIANT_SPECIFIC_SECTIONS.has(heading) && deepSections.has(heading) + ); + expect( + compared.length, + `no comparable sections found for ${role}` + ).toBeGreaterThan(0); + for (const heading of compared) { + expect( + deepSections.get(heading), + `"${heading}" diverges between ${role}-standard.md and ${role}-deep.md` + ).toBe(standardSections.get(heading)); + } + }); + + it(`parity — ${role}: the tools list is byte-identical across the pair`, () => { + for (const key of IDENTICAL_FRONTMATTER) { + const standardValue = frontmatterField(standard.frontmatter, key); + const deepValue = frontmatterField(deep.frontmatter, key); + expect(standardValue, `${role}-standard.md has no "${key}:"`).toBeDefined(); + expect(deepValue, `${role}-deep.md has no "${key}:"`).toBeDefined(); + expect( + deepValue, + `"${key}:" diverges between the ${role} variants` + ).toBe(standardValue); + } + }); + + it(`parity — ${role}: preloaded skills are identical across the pair`, () => { + for (const key of IDENTICAL_FRONTMATTER_LISTS) { + const standardValue = frontmatterList(standard.frontmatter, key); + const deepValue = frontmatterList(deep.frontmatter, key); + // Neither declaring the field is a legitimate state — the four + // worker roles preload nothing. One declaring it and the other not + // is not, and toEqual against undefined reports that directly. + expect( + deepValue, + `"${key}:" diverges between the ${role} variants` + ).toEqual(standardValue); + } + }); + + it(`parity — ${role}: model, effort and maxTurns are pinned and differ by variant`, () => { + // The routing variants exist to carry different depth-of-pass settings, + // and `effort` cannot be set per spawn — it must be pinned in each + // definition. Equal values would mean the two variants are the same + // agent under two names. + for (const key of DIVERGENT_FRONTMATTER) { + const standardValue = frontmatterField(standard.frontmatter, key); + const deepValue = frontmatterField(deep.frontmatter, key); + expect(standardValue, `${role}-standard.md has no "${key}:"`).toBeDefined(); + expect(deepValue, `${role}-deep.md has no "${key}:"`).toBeDefined(); + expect( + deepValue, + `"${key}:" is identical across the ${role} variants; it must differ` + ).not.toBe(standardValue); + } + }); + + it(`parity — ${role}: neither variant uses frontmatter that plugin subagents ignore`, () => { + // `permissionMode`, `mcpServers` and `hooks` are silently ignored when a + // subagent is loaded from a plugin, so a definition that sets one reads + // as configured while behaving as if it were not. + for (const [variant, parsed] of [ + ["standard", standard], + ["deep", deep], + ] as const) { + for (const key of ["permissionMode", "mcpServers", "hooks"]) { + expect( + frontmatterField(parsed.frontmatter, key), + `${role}-${variant}.md sets "${key}:", which plugin subagents ignore` + ).toBeUndefined(); + } + } + }); + }); + } +}); diff --git a/plugins/orchestrate/orchestrate-mcp/test/bootstrap-config.test.ts b/plugins/orchestrate/orchestrate-mcp/test/bootstrap-config.test.ts index a4902007..c96c9e26 100644 --- a/plugins/orchestrate/orchestrate-mcp/test/bootstrap-config.test.ts +++ b/plugins/orchestrate/orchestrate-mcp/test/bootstrap-config.test.ts @@ -199,18 +199,217 @@ describe("bootstrapConfig — empty-config warnings", () => { expect(r.warnings).toEqual([]); }); - it("emits no warnings when commands.json already existed (not freshly written)", () => { - // Pre-write an empty commands.json — the bootstrapper skips writing it. + it("warns when a pre-existing (not freshly written) commands.json is empty (#364)", () => { + // Pre-write an empty commands.json — the bootstrapper skips writing it, + // but the false-green risk it carries must still be reported. Before + // #364 this file-already-present path never read the file's content at + // all, so the exact same risk a fresh empty write already warns about + // went completely unreported here — the silent-start bug the issue + // describes. const dir = repo(); // no manifest → 'none' project type fs.mkdirSync(path.join(dir, ".orchestrate")); fs.writeFileSync(path.join(dir, ".orchestrate", "commands.json"), "{}\n"); const r = bootstrapConfig({ repoPath: dir }); - // File was already-present, not freshly written → no warning. + // File was already-present, not freshly written — but still reported. expect(r.files!.commandsJson).toBe("already-present"); + expect(r.warnings).toBeDefined(); + expect(r.warnings!.length).toBeGreaterThan(0); + expect(r.warnings![0]).toContain("run_tests"); + expect(r.warnings![0]).toContain("run_build"); + expect(r.warnings![0]).toContain("not-configured"); + }); +}); + +// ─── Capability-completeness reporting (#364) ───────────────────────────────── + +describe("bootstrapConfig — capabilities and falseGreenRisk (#364)", () => { + it("reports every capability configured and no risk for a fresh npm project", () => { + const dir = repo("package.json"); + const r = bootstrapConfig({ repoPath: dir }); + + expect(r.capabilities).toEqual({ + tests: true, + typecheck: true, + build: true, + lint: true, + install: true, + }); + expect(r.falseGreenRisk).toBe(false); + }); + + it("does not flag falseGreenRisk when only lint/install are absent (maven has tests+build)", () => { + const dir = repo("pom.xml"); // maven: tests+build+typecheck present, lint+install absent by design + const r = bootstrapConfig({ repoPath: dir }); + + expect(r.projectType).toBe("maven"); + expect(r.capabilities).toEqual({ + tests: true, + typecheck: true, + build: true, + lint: false, + install: false, + }); + // Missing lint/install alone is normal — only tests+build together risk. + expect(r.falseGreenRisk).toBe(false); + }); + + it("flags falseGreenRisk with all capabilities false for a manifest-less project", () => { + const dir = repo(); // no manifest → 'none' project type → empty commands map + const r = bootstrapConfig({ repoPath: dir }); + + expect(r.capabilities).toEqual({ + tests: false, + typecheck: false, + build: false, + lint: false, + install: false, + }); + expect(r.falseGreenRisk).toBe(true); + }); + + it("completes a partial .orchestrate/ directory missing only commands.json (#364 scenario)", () => { + // Exactly the reported scenario: .orchestrate/ already holds routing.json + // and handoff.json (e.g. from a prior run or hand-authored setup) but no + // commands.json. Before #364 this never triggered the bootstrapper at + // all because the trigger was gated on the directory's mere existence. + const dir = repo("package.json"); + fs.mkdirSync(path.join(dir, ".orchestrate")); + fs.writeFileSync( + path.join(dir, ".orchestrate", "routing.json"), + JSON.stringify(DEFAULT_ROUTING_CONFIG, null, 2) + "\n" + ); + fs.writeFileSync( + path.join(dir, ".orchestrate", "handoff.json"), + "{}\n" + ); + + const r = bootstrapConfig({ repoPath: dir }); + + expect(r.status).toBe("ok"); + // commands.json is the only file this call needed to write. + expect(r.files).toEqual({ + commandsJson: "written", + routingJson: "already-present", + handoffJson: "already-present", + }); + expect( + fs.existsSync(path.join(dir, ".orchestrate", "commands.json")) + ).toBe(true); + // The npm project type resolves a full capability set — no risk. + expect(r.capabilities).toEqual({ + tests: true, + typecheck: true, + build: true, + lint: true, + install: true, + }); + expect(r.falseGreenRisk).toBe(false); + expect(r.warnings).toEqual([]); + }); + + it("flags falseGreenRisk for a pre-existing, hand-authored commands.json missing tests+build", () => { + // A committed commands.json that only configures lint — never written by + // the bootstrapper (already-present), so before #364 nothing would ever + // inspect its content for completeness. + const dir = repo("package.json"); + fs.mkdirSync(path.join(dir, ".orchestrate")); + fs.writeFileSync( + path.join(dir, ".orchestrate", "commands.json"), + '{ "lint": ["npm", "run", "lint"] }\n' + ); + + const r = bootstrapConfig({ repoPath: dir }); + + expect(r.files!.commandsJson).toBe("already-present"); + expect(r.capabilities).toEqual({ + tests: false, + typecheck: false, + build: false, + lint: true, + install: false, + }); + expect(r.falseGreenRisk).toBe(true); + expect(r.warnings!.length).toBeGreaterThan(0); + expect(r.warnings![0]).toContain("run_tests"); + expect(r.warnings![0]).toContain("run_build"); + }); + + it("reports no risk and no warning for an already-complete, pre-existing commands.json", () => { + const dir = repo("package.json"); + fs.mkdirSync(path.join(dir, ".orchestrate")); + const complete = + '{ "tests": ["npm", "test"], "build": ["npm", "run", "build"] }\n'; + fs.writeFileSync(path.join(dir, ".orchestrate", "commands.json"), complete); + + const r = bootstrapConfig({ repoPath: dir }); + + expect(r.files!.commandsJson).toBe("already-present"); + // Untouched — never rewritten. + expect( + fs.readFileSync(path.join(dir, ".orchestrate", "commands.json"), "utf8") + ).toBe(complete); + expect(r.capabilities).toEqual({ + tests: true, + typecheck: false, + build: true, + lint: false, + install: false, + }); + expect(r.falseGreenRisk).toBe(false); expect(r.warnings).toEqual([]); }); + + it("treats an unreadable/malformed pre-existing commands.json as unconfigured, never throws", () => { + const dir = repo("package.json"); + fs.mkdirSync(path.join(dir, ".orchestrate")); + fs.writeFileSync( + path.join(dir, ".orchestrate", "commands.json"), + "{ not valid json" + ); + + const r = bootstrapConfig({ repoPath: dir }); + + expect(r.status).toBe("ok"); + expect(r.files!.commandsJson).toBe("already-present"); + expect(r.capabilities).toEqual({ + tests: false, + typecheck: false, + build: false, + lint: false, + install: false, + }); + expect(r.falseGreenRisk).toBe(true); + }); + + it("treats a present-but-EMPTY argv array as unconfigured, exactly as the capability tools do", () => { + // `commandsConfigSchema` accepts `[]`, and `run-command.ts` resolves both + // an absent key and an empty argv to 'not-configured' (`!argv || + // argv.length === 0`). A placeholder config like this must therefore be + // reported as unconfigured too — reading it as "configured" would predict + // a gate that runs, while the gate actually executes nothing: the same + // silent start this issue exists to close, re-encoded. + const dir = repo("package.json"); + fs.mkdirSync(path.join(dir, ".orchestrate")); + fs.writeFileSync( + path.join(dir, ".orchestrate", "commands.json"), + '{ "tests": [], "build": [] }\n' + ); + + const r = bootstrapConfig({ repoPath: dir }); + + expect(r.files!.commandsJson).toBe("already-present"); + expect(r.capabilities).toEqual({ + tests: false, + typecheck: false, + build: false, + lint: false, + install: false, + }); + expect(r.falseGreenRisk).toBe(true); + expect(r.warnings!.length).toBeGreaterThan(0); + }); }); // ─── Model-derived context window ───────────────────────────────────────────── @@ -633,6 +832,31 @@ describe("bootstrapConfig — native v2 routing defaults (ADR-0015)", () => { expect(written.tiers?.complex?.["conflict-resolver"]).toEqual(opusDeep); }); + it("carries a slice-executor entry per tier, mirroring that tier's implementer (#356)", () => { + const dir = repo("package.json"); + bootstrapConfig({ repoPath: dir }); + + const written = readConfig(dir, "routing.json") as { + tiers?: { + trivial?: { "slice-executor"?: { model: string; variant: string } }; + standard?: { "slice-executor"?: { model: string; variant: string } }; + complex?: { "slice-executor"?: { model: string; variant: string } }; + }; + }; + expect(written.tiers?.trivial?.["slice-executor"]).toEqual({ + model: "haiku", + variant: "standard", + }); + expect(written.tiers?.standard?.["slice-executor"]).toEqual({ + model: "sonnet", + variant: "standard", + }); + expect(written.tiers?.complex?.["slice-executor"]).toEqual({ + model: "opus", + variant: "deep", + }); + }); + it("carries route:fable label entry with opus fallback (model=fable, variant=deep)", () => { const dir = repo("package.json"); bootstrapConfig({ repoPath: dir }); diff --git a/plugins/orchestrate/orchestrate-mcp/test/context-watchdog.test.ts b/plugins/orchestrate/orchestrate-mcp/test/context-watchdog.test.ts index 5140d27b..fee09b43 100644 --- a/plugins/orchestrate/orchestrate-mcp/test/context-watchdog.test.ts +++ b/plugins/orchestrate/orchestrate-mcp/test/context-watchdog.test.ts @@ -5,11 +5,14 @@ import * as path from "path"; import { parseLatestUsage, contextTokens, + countSpawns, evaluateWatchdog, runWatchdog, +} from "../src/hooks/context-watchdog.js"; +import { discoverActiveRunId, findActiveRunForSession, -} from "../src/hooks/context-watchdog.js"; +} from "../src/hooks/run-discovery.js"; // ─── Transcript fixtures ────────────────────────────────────────────────────── @@ -39,6 +42,28 @@ function userLine(text: string): string { return JSON.stringify({ type: "user", message: { role: "user", content: text } }); } +/** + * One spawn-log line, as the watchdog appends it for an `Agent` tool call. + * Omitting `session` produces a legacy, untagged line — what a log written + * before spawns were session-tagged looks like. + */ +function spawnLine( + session?: string, + at = "2026-05-21T10:00:00.000Z" +): string { + return JSON.stringify( + session === undefined ? { at, tool: "Agent" } : { at, tool: "Agent", session } + ); +} + +/** A spawn log carrying exactly `n` recorded spawns, all from one session. */ +function spawnLog(n: number, session?: string): string { + return ( + Array.from({ length: n }, () => spawnLine(session)).join("\n") + + (n > 0 ? "\n" : "") + ); +} + // ─── Test project scaffolding ───────────────────────────────────────────────── const created: string[] = []; @@ -57,6 +82,7 @@ function project(opts: { transcript?: string; flag?: unknown; runId?: string; + spawnLog?: string; }): { dir: string; transcriptPath?: string } { const runId = opts.runId ?? RUN_ID; const dir = fs.mkdtempSync(path.join(os.tmpdir(), "orchestrate-watchdog-")); @@ -84,6 +110,9 @@ function project(opts: { JSON.stringify(opts.flag) ); } + if (opts.spawnLog !== undefined) { + fs.writeFileSync(path.join(runDir, "spawn-log.jsonl"), opts.spawnLog); + } let transcriptPath: string | undefined; if (opts.transcript !== undefined) { @@ -104,6 +133,10 @@ function flagPath(dir: string, runId: string = RUN_ID): string { return path.join(dir, ".orchestrate", "runs", runId, "context-flag.json"); } +function spawnLogPath(dir: string, runId: string = RUN_ID): string { + return path.join(dir, ".orchestrate", "runs", runId, "spawn-log.jsonl"); +} + // ─── parseLatestUsage ───────────────────────────────────────────────────────── describe("parseLatestUsage", () => { @@ -172,15 +205,104 @@ describe("contextTokens", () => { // ─── evaluateWatchdog ───────────────────────────────────────────────────────── +/** `evaluateWatchdog` args with no spawn pressure — the token-only baseline. */ +function noSpawns(): { + spawnCount: number; + sessionSpawnBudget: number; + spawnThresholdPercent: number; +} { + return { spawnCount: 0, sessionSpawnBudget: 200, spawnThresholdPercent: 40 }; +} + +describe("countSpawns", () => { + it("counts one spawn per non-empty line", () => { + expect(countSpawns(spawnLog(3))).toBe(3); + }); + + it("is zero for an empty or blank log", () => { + expect(countSpawns("")).toBe(0); + expect(countSpawns("\n\n \n")).toBe(0); + }); + + it("does not miscount a log with no trailing newline", () => { + expect(countSpawns(spawnLine())).toBe(1); + }); + + it("counts a torn final line as one line", () => { + // A torn line is not lost — but it also does not add a spawn, because the + // NEXT append merges into it (it has no trailing newline). A torn write + // therefore costs one line rather than adding one. + expect(countSpawns(spawnLine() + '\n{"at":"2026')).toBe(2); + const merged = spawnLine() + '\n{"at":"2026' + spawnLine() + "\n"; + expect(countSpawns(merged)).toBe(2); + }); +}); + +// ─── countSpawns — session partitioning ─────────────────────────────────────── +// +// The spawn log lives in the per-RUN directory and is append-only, but the +// budget it is measured against is the platform's per-SESSION cap, which resets +// in a new session. Counting is therefore partitioned by session id: a run that +// hands off keeps its log, and the successor counts only its own spawns. + +describe("countSpawns — session partitioning", () => { + it("counts only the named session's lines", () => { + const log = spawnLog(3, "session-a") + spawnLog(2, "session-b"); + expect(countSpawns(log, "session-a")).toBe(3); + expect(countSpawns(log, "session-b")).toBe(2); + }); + + it("counts untagged legacy lines toward every session", () => { + // A log written before spawns were tagged cannot be attributed, so it is + // counted — over-counting raises the flag early, while under-counting walks + // the run into an unrecoverable spawn error. + const log = spawnLog(2) + spawnLog(1, "session-a"); + expect(countSpawns(log, "session-a")).toBe(3); + expect(countSpawns(log, "session-b")).toBe(2); + }); + + it("counts every line when no session is given", () => { + const log = spawnLog(3, "session-a") + spawnLog(2, "session-b"); + expect(countSpawns(log)).toBe(5); + }); + + it("counts a line whose session tag is not a string", () => { + const log = JSON.stringify({ at: "x", tool: "Agent", session: 7 }) + "\n"; + expect(countSpawns(log, "session-a")).toBe(1); + }); + + it("counts every line when the session asked for is empty", () => { + // An empty session id is no identity at all — the same guard + // `findActiveRunForSession` applies. Partitioning on it would match no + // line ever written and so hide the entire log: an under-count, the one + // direction this counting must never take. + const log = spawnLog(3, "session-a") + spawnLog(2, "session-b"); + expect(countSpawns(log, "")).toBe(5); + }); + + it("counts a line whose session tag is empty toward every session", () => { + // Symmetric with the rule above, on the write side: an empty tag cannot + // attribute the line, so it is charged to whoever asks rather than dropped. + const log = + JSON.stringify({ at: "x", tool: "Agent", session: "" }) + + "\n" + + spawnLog(1, "session-a"); + expect(countSpawns(log, "session-a")).toBe(2); + expect(countSpawns(log, "session-b")).toBe(1); + }); +}); + describe("evaluateWatchdog", () => { it("is over threshold when usage exceeds the percentage", () => { const e = evaluateWatchdog({ usedTokens: 110000, contextWindowTokens: 200000, thresholdPercent: 40, + ...noSpawns(), }); expect(e.overThreshold).toBe(true); expect(e.usagePercent).toBe(55); + expect(e.trigger).toBe("tokens"); }); it("is under threshold when usage is below the percentage", () => { @@ -188,9 +310,11 @@ describe("evaluateWatchdog", () => { usedTokens: 50000, contextWindowTokens: 200000, thresholdPercent: 40, + ...noSpawns(), }); expect(e.overThreshold).toBe(false); expect(e.usagePercent).toBe(25); + expect(e.trigger).toBeNull(); }); it("fires exactly at the threshold boundary (>=)", () => { @@ -198,11 +322,100 @@ describe("evaluateWatchdog", () => { usedTokens: 80000, contextWindowTokens: 200000, thresholdPercent: 40, + ...noSpawns(), }); expect(e.overThreshold).toBe(true); }); }); +// ─── evaluateWatchdog — the spawn-budget threshold ──────────────────────────── +// +// The session's total spawn budget is the watchdog's SECOND threshold: at +// roughly five spawns per slice a long run can exhaust the platform's +// per-session subagent budget well before it exhausts its context window. + +describe("evaluateWatchdog — spawn budget", () => { + it("raises on the spawn budget while token usage is far below its threshold", () => { + const e = evaluateWatchdog({ + usedTokens: 10000, // 5% of the window — nowhere near 40% + contextWindowTokens: 200000, + thresholdPercent: 40, + spawnCount: 100, + sessionSpawnBudget: 200, + spawnThresholdPercent: 40, + }); + expect(e.overThreshold).toBe(true); + expect(e.trigger).toBe("spawns"); + expect(e.spawnPercent).toBe(50); + }); + + it("fires exactly at the spawn boundary (>=)", () => { + const e = evaluateWatchdog({ + usedTokens: 0, + contextWindowTokens: 200000, + thresholdPercent: 40, + spawnCount: 80, // 80/200 = exactly 40% + sessionSpawnBudget: 200, + spawnThresholdPercent: 40, + }); + expect(e.overThreshold).toBe(true); + expect(e.trigger).toBe("spawns"); + }); + + it("does not raise when both thresholds are unmet", () => { + const e = evaluateWatchdog({ + usedTokens: 10000, + contextWindowTokens: 200000, + thresholdPercent: 40, + spawnCount: 79, + sessionSpawnBudget: 200, + spawnThresholdPercent: 40, + }); + expect(e.overThreshold).toBe(false); + expect(e.trigger).toBeNull(); + }); + + it("reports `tokens` when both thresholds cross on the same sample (documented tiebreak)", () => { + const e = evaluateWatchdog({ + usedTokens: 150000, + contextWindowTokens: 200000, + thresholdPercent: 40, + spawnCount: 190, + sessionSpawnBudget: 200, + spawnThresholdPercent: 40, + }); + expect(e.overThreshold).toBe(true); + expect(e.trigger).toBe("tokens"); + }); + + it("evaluates the spawn threshold even when token usage is unknown", () => { + const e = evaluateWatchdog({ + usedTokens: null, + contextWindowTokens: 200000, + thresholdPercent: 40, + spawnCount: 120, + sessionSpawnBudget: 200, + spawnThresholdPercent: 40, + }); + expect(e.overThreshold).toBe(true); + expect(e.trigger).toBe("spawns"); + expect(e.usagePercent).toBeNull(); + }); + + it("stays quiet when token usage is unknown and spawns are under budget", () => { + const e = evaluateWatchdog({ + usedTokens: null, + contextWindowTokens: 200000, + thresholdPercent: 40, + spawnCount: 1, + sessionSpawnBudget: 200, + spawnThresholdPercent: 40, + }); + expect(e.overThreshold).toBe(false); + expect(e.trigger).toBeNull(); + }); +}); + // ─── discoverActiveRunId ────────────────────────────────────────────────────── describe("discoverActiveRunId", () => { @@ -599,6 +812,283 @@ describe("runWatchdog — active run", () => { }); }); +// ─── runWatchdog — the spawn-budget threshold ───────────────────────────────── +// +// The spawn threshold must fire INDEPENDENTLY of token usage: the transcript is +// written asynchronously and may lag, so an unknown token figure must skip only +// the token comparison, never the spawn one. + +describe("runWatchdog — spawn budget", () => { + it("raises on the spawn budget with no transcript at all", () => { + const { dir } = project({ + runState: { status: "in-progress" }, + spawnLog: spawnLog(80), // 80/200 = 40%, the default spawn threshold + }); + const result = runWatchdog({ + transcriptPath: undefined, + cwd: dir, + runId: RUN_ID, + }); + + expect(result.acted).toBe(true); + expect(result.flagRaised).toBe(true); + expect(result.evaluation!.trigger).toBe("spawns"); + expect(fs.existsSync(flagPath(dir))).toBe(true); + }); + + it("raises on the spawn budget when the transcript carries no usage yet", () => { + const { dir, transcriptPath } = project({ + runState: { status: "in-progress" }, + transcript: [userLine("one"), userLine("two")].join("\n"), + spawnLog: spawnLog(100), + }); + const result = runWatchdog({ transcriptPath, cwd: dir, runId: RUN_ID }); + + expect(result.flagRaised).toBe(true); + expect(result.evaluation!.trigger).toBe("spawns"); + }); + + it("records the spawn figures and the trigger in the flag file", () => { + const { dir } = project({ + runState: { status: "in-progress" }, + spawnLog: spawnLog(120), + }); + runWatchdog({ cwd: dir, runId: RUN_ID }); + + const flag = JSON.parse(fs.readFileSync(flagPath(dir), "utf8")); + expect(flag.trigger).toBe("spawns"); + expect(flag.spawnCount).toBe(120); + expect(flag.sessionSpawnBudget).toBe(200); + expect(flag.spawnThresholdPercent).toBe(40); + expect(flag.spawnPercent).toBe(60); + // Token usage was never observable — recorded as unknown, not as zero. + expect(flag.usedTokens).toBeNull(); + expect(flag.usagePercent).toBeNull(); + }); + + it("appends one spawn-log line for an Agent tool call", () => { + const { dir } = project({ runState: { status: "in-progress" } }); + const result = runWatchdog({ cwd: dir, runId: RUN_ID, toolName: "Agent" }); + + expect(countSpawns(fs.readFileSync(spawnLogPath(dir), "utf8"))).toBe(1); + expect(result.evaluation!.spawnCount).toBe(1); + }); + + it("appends nothing for a tool call that is not a spawn", () => { + const { dir } = project({ runState: { status: "in-progress" } }); + runWatchdog({ cwd: dir, runId: RUN_ID, toolName: "Read" }); + + expect(fs.existsSync(spawnLogPath(dir))).toBe(false); + }); + + it("counts the spawn it just recorded in the same invocation", () => { + const { dir } = project({ + runState: { status: "in-progress" }, + spawnLog: spawnLog(79), // one short of the 80-spawn threshold + }); + const result = runWatchdog({ cwd: dir, runId: RUN_ID, toolName: "Agent" }); + + expect(result.evaluation!.spawnCount).toBe(80); + expect(result.flagRaised).toBe(true); + }); + + it("records no spawn when no run is in progress", () => { + const { dir } = project({ runState: { status: "completed" } }); + const result = runWatchdog({ cwd: dir, runId: RUN_ID, toolName: "Agent" }); + + expect(result.acted).toBe(false); + expect(fs.existsSync(spawnLogPath(dir))).toBe(false); + }); + + it("honours a custom spawn budget and threshold from handoff.json", () => { + const { dir } = project({ + runState: { status: "in-progress" }, + handoff: { watchdog: { sessionSpawnBudget: 400, spawnThresholdPercent: 90 } }, + spawnLog: spawnLog(120), // 30% of 400 — under the custom 90% threshold + }); + const result = runWatchdog({ cwd: dir, runId: RUN_ID }); + + expect(result.flagRaised).toBe(false); + expect(result.evaluation!.sessionSpawnBudget).toBe(400); + expect(result.evaluation!.spawnThresholdPercent).toBe(90); + }); + + it("falls back to the default spawn budget when handoff.json is malformed", () => { + const { dir } = project({ + runState: { status: "in-progress" }, + spawnLog: spawnLog(80), + }); + fs.writeFileSync( + path.join(dir, ".orchestrate", "handoff.json"), + "{ not valid json" + ); + const result = runWatchdog({ cwd: dir, runId: RUN_ID }); + + expect(result.flagRaised).toBe(true); + expect(result.evaluation!.sessionSpawnBudget).toBe(200); + expect(result.evaluation!.spawnThresholdPercent).toBe(40); + }); +}); + +// ─── runWatchdog — the spawn budget is the SESSION's, not the run's ─────────── +// +// The log is per-run storage; the budget is a per-session cap that resets in a +// new session. A handoff keeps the runId — and therefore the log — so without +// session partitioning every successor would re-raise on its first Agent call +// and the run would degrade to one slice per session, defeating the feature in +// exactly the long-run case it exists for. + +describe("runWatchdog — session-partitioned spawn budget", () => { + it("does not re-raise in a successor session that inherits the run's spawn log", () => { + const { dir } = project({ + runState: { status: "in-progress" }, + spawnLog: spawnLog(80, "session-predecessor"), + }); + + const first = runWatchdog({ + cwd: dir, + runId: RUN_ID, + sessionId: "session-predecessor", + }); + expect(first.flagRaised).toBe(true); + expect(first.evaluation!.trigger).toBe("spawns"); + + // The successor's documented startup step — delete the flag, nothing else. + fs.unlinkSync(flagPath(dir)); + + // A brand-new session: its own fresh budget, one Agent call in. + const second = runWatchdog({ + cwd: dir, + runId: RUN_ID, + sessionId: "session-successor", + toolName: "Agent", + }); + expect(second.evaluation!.spawnCount).toBe(1); + expect(second.flagRaised).toBe(false); + expect(fs.existsSync(flagPath(dir))).toBe(false); + }); + + it("still counts the same session's earlier spawns on a re-invocation without a handoff", () => { + // Resuming a run in the SAME session does not reset the platform's budget, + // so those spawns must keep counting — which is why the fix is a session + // tag rather than an instruction to delete the log on resume. + const { dir } = project({ + runState: { status: "in-progress" }, + spawnLog: spawnLog(79, "session-a"), + }); + const result = runWatchdog({ + cwd: dir, + runId: RUN_ID, + sessionId: "session-a", + toolName: "Agent", + }); + + expect(result.evaluation!.spawnCount).toBe(80); + expect(result.flagRaised).toBe(true); + }); + + it("tags each recorded spawn with the session that made it", () => { + const { dir } = project({ runState: { status: "in-progress" } }); + runWatchdog({ + cwd: dir, + runId: RUN_ID, + sessionId: "session-a", + toolName: "Agent", + }); + + const line = JSON.parse( + fs.readFileSync(spawnLogPath(dir), "utf8").trim() + ); + expect(line.session).toBe("session-a"); + expect(line.tool).toBe("Agent"); + }); + + it("does not let a concurrent session's spawns inflate this session's count", () => { + const { dir } = project({ + runState: { status: "in-progress" }, + spawnLog: spawnLog(100, "session-other") + spawnLog(2, "session-mine"), + }); + const result = runWatchdog({ + cwd: dir, + runId: RUN_ID, + sessionId: "session-mine", + }); + + expect(result.evaluation!.spawnCount).toBe(2); + expect(result.flagRaised).toBe(false); + }); + + it("counts an untagged legacy log toward the current session", () => { + const { dir } = project({ + runState: { status: "in-progress" }, + spawnLog: spawnLog(80), + }); + const result = runWatchdog({ + cwd: dir, + runId: RUN_ID, + sessionId: "session-new", + }); + + expect(result.evaluation!.spawnCount).toBe(80); + expect(result.flagRaised).toBe(true); + }); +}); + +// ─── runWatchdog — at most once per run, whichever threshold arrives first ──── +// +// The flag file itself is the latch: `fs.existsSync(flagPath)` is checked before +// every raise, so whichever threshold writes it first suppresses every later +// raise — no in-memory once-flag is needed, and none would survive the hook's +// per-invocation process anyway. + +describe("runWatchdog — at most once across both thresholds", () => { + it("does not re-raise on the spawn budget once a token raise wrote the flag", () => { + const { dir, transcriptPath } = project({ + runState: { status: "in-progress" }, + transcript: assistantLine(2, 8000, 100000), // 54% — over the token threshold + spawnLog: spawnLog(79), + }); + + const first = runWatchdog({ transcriptPath, cwd: dir, runId: RUN_ID }); + expect(first.flagRaised).toBe(true); + expect(first.evaluation!.trigger).toBe("tokens"); + + // The spawn count now crosses too — but the flag is already raised. + const second = runWatchdog({ + transcriptPath, + cwd: dir, + runId: RUN_ID, + toolName: "Agent", + }); + expect(second.flagRaised).toBe(false); + expect(second.evaluation!.trigger).toBe("tokens"); + + const flag = JSON.parse(fs.readFileSync(flagPath(dir), "utf8")); + expect(flag.trigger).toBe("tokens"); + }); + + it("does not re-raise on tokens once a spawn raise wrote the flag", () => { + const { dir } = project({ + runState: { status: "in-progress" }, + spawnLog: spawnLog(80), + }); + + const first = runWatchdog({ cwd: dir, runId: RUN_ID }); + expect(first.flagRaised).toBe(true); + expect(first.evaluation!.trigger).toBe("spawns"); + + // A later sample where token usage crosses as well writes nothing new. + const { dir: _unused, transcriptPath } = project({ + transcript: assistantLine(2, 8000, 100000), + }); + const second = runWatchdog({ transcriptPath, cwd: dir, runId: RUN_ID }); + expect(second.flagRaised).toBe(false); + + const flag = JSON.parse(fs.readFileSync(flagPath(dir), "utf8")); + expect(flag.trigger).toBe("spawns"); + }); +}); + // ─── concurrent-run binding: discovery + runWatchdog end to end ──────────────── // // These cover the acceptance criteria as the CLI exercises them: discover the diff --git a/plugins/orchestrate/orchestrate-mcp/test/hook-matcher-consistency.test.ts b/plugins/orchestrate/orchestrate-mcp/test/hook-matcher-consistency.test.ts new file mode 100644 index 00000000..aa25a225 --- /dev/null +++ b/plugins/orchestrate/orchestrate-mcp/test/hook-matcher-consistency.test.ts @@ -0,0 +1,330 @@ +import { describe, it, expect } from "vitest"; +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from "url"; + +// ─── Hook-matcher consistency ───────────────────────────────────────────────── +// +// A hook matcher that names an agent type which no shipped definition provides +// reads as configured and behaves as if it were not: Claude Code simply never +// matches it, silently, forever. That is the same failure class the parity +// suite guards for `skills:` — a misspelling that looks like configuration. +// +// The invariant this file encodes: +// +// For every hook event whose matcher filters AGENT TYPE, every agent name in +// that matcher must correspond to a shipped definition under +// `plugins/orchestrate/agents/`, compared against the PLUGIN-SCOPED +// identifier `orchestrate:` — never the bare frontmatter +// name. +// +// Two facts pin the shape, both from the vendor hook reference mirror +// (`docs/claude-code/hooks/claude-hook-reference-doc.md`): +// +// - Only `SubagentStart` and `SubagentStop` match on agent type. Every other +// matcher-bearing event — `PreToolUse` included, which is what the read +// guard adds — matches on TOOL NAME. So this invariant is currently +// VACUOUSLY GREEN over the shipped file, and would stay decorative if it +// were only ever run against that file. +// - For a subagent shipped by a plugin, the agent identity is the +// plugin-scoped identifier (`my-plugin:reviewer`), not the bare frontmatter +// name. +// +// The vacuity is designed away the same way `agent-variant-parity.test.ts` +// handles its own ("a comparison over sections found in both is vacuously +// green when a section is missing from both"): the checker is a pure function +// exercised against RED fixtures first, and only then against the real file. +// The bare-name fixture is the load-bearing one — without it this suite does +// not encode the plugin-scoping half of the invariant at all. +// +// The checker lives here rather than in `src/hooks/read-guard.ts`: it has +// nothing to do with the read decision and would ship as dead bytes inside +// `dist/read-guard.js`. + +/** Hook events whose `matcher` filters agent type rather than tool name. */ +const AGENT_TYPE_MATCHER_EVENTS = new Set(["SubagentStart", "SubagentStop"]); + +/** The plugin name every shipped subagent identity is scoped by. */ +const PLUGIN_NAME = "orchestrate"; + +// test/ → orchestrate-mcp/ → orchestrate/ +const pluginDir = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + ".." +); +const agentsDir = path.join(pluginDir, "agents"); +const hooksJsonPath = path.join(pluginDir, "hooks", "hooks.json"); + +/** + * Read one top-level scalar frontmatter field. Returns undefined when the key + * is absent, so a missing field is reported as such rather than as a mismatch. + */ +function frontmatterField( + frontmatter: string, + key: string +): string | undefined { + for (const line of frontmatter.split("\n")) { + const match = line.match(/^([A-Za-z][A-Za-z0-9_-]*):[ \t]*(.*)$/); + if (match && match[1] === key) return match[2].trim(); + } + return undefined; +} + +/** + * Discover every shipped subagent's identity by READING the directory — never + * a hardcoded list, which would go stale exactly when a definition is added or + * renamed, the moment this check matters most. The identity is the definition's + * `name:` frontmatter field, not its filename. + */ +function shippedAgentNames(): string[] { + const names: string[] = []; + for (const file of fs.readdirSync(agentsDir).filter((f) => f.endsWith(".md"))) { + const raw = fs.readFileSync(path.join(agentsDir, file), "utf8"); + // Strip a trailing CR: under `core.autocrlf=true` these files are checked out + // with CRLF endings even though the blobs are LF. Without this the `continue` + // below skips EVERY definition, and the checks that consume this set then + // compare against an empty one — passing while asserting nothing, which is + // exactly the silently-never-fires guard this suite exists to prevent. + const lines = raw.split("\n").map((line) => line.replace(/\r$/, "")); + if (lines[0] !== "---") continue; + const closing = lines.indexOf("---", 1); + if (closing === -1) continue; + const name = frontmatterField(lines.slice(1, closing).join("\n"), "name"); + if (name !== undefined && name !== "") names.push(name); + } + if (names.length === 0) { + // Fail loudly rather than let every consumer assert against an empty set. + throw new Error( + `no agent definitions parsed from ${agentsDir} — the shipped-name set is ` + + "empty, so any consistency check over it would pass vacuously" + ); + } + return names.sort(); +} + +/** + * A matcher entry is a PLAIN NAME when, after its optional `^`/`$` anchors are + * stripped, nothing regex-significant remains. Anything else — `.*`, a + * character class, an alternation group — is a deliberate pattern rather than + * a name, and is SKIPPED rather than reported: a checker that failed on `.*` + * would produce a false failure on the two matchers this plugin already ships. + */ +function plainNameOf(entry: string): string | null { + const stripped = entry.trim().replace(/^\^/, "").replace(/\$$/, "").trim(); + if (stripped === "") return null; + return /^[A-Za-z0-9_:-]+$/.test(stripped) ? stripped : null; +} + +/** + * Every agent name a matcher names. Alternation accepts BOTH `|` and `,` — + * splitting on only one separator would silently skip half the entries of a + * comma-separated matcher, which is a false GREEN, the direction that matters. + */ +function matcherNames(matcher: string): string[] { + return matcher + .split(/[|,]/) + .map(plainNameOf) + .filter((entry): entry is string => entry !== null); +} + +/** + * Returns one violation string per agent name that a matcher names and no + * shipped definition provides. An empty array means the file is consistent. + */ +export function validateAgentMatchers( + hooksJson: unknown, + shippedScopedNames: Set +): string[] { + const violations: string[] = []; + if (typeof hooksJson !== "object" || hooksJson === null) return violations; + const hooks = (hooksJson as Record).hooks; + if (typeof hooks !== "object" || hooks === null) return violations; + + for (const [event, blocks] of Object.entries( + hooks as Record + )) { + if (!AGENT_TYPE_MATCHER_EVENTS.has(event)) continue; + if (!Array.isArray(blocks)) continue; + for (const block of blocks) { + const matcher = + typeof block === "object" && block !== null + ? (block as Record).matcher + : undefined; + if (typeof matcher !== "string") continue; + for (const name of matcherNames(matcher)) { + if (!shippedScopedNames.has(name)) { + violations.push( + `${event} matcher names "${name}", which no shipped agent definition provides` + ); + } + } + } + } + return violations; +} + +const shippedNames = shippedAgentNames(); +// The identity a hook event carries for a PLUGIN subagent is the scoped form, +// never the bare frontmatter name. Building the set bare — the obvious reading +// — produces a checker that passes every fixture except the one that matters. +const shippedScopedNames = new Set( + shippedNames.map((name) => `${PLUGIN_NAME}:${name}`) +); + +/** A `hooks.json` shape carrying one agent-type matcher. */ +function hooksJsonWithSubagentMatcher(matcher: string): unknown { + return { + hooks: { + SubagentStart: [ + { + matcher, + hooks: [{ type: "command", command: "node noop.js", timeout: 5 }], + }, + ], + }, + }; +} + +describe("shippedAgentNames", () => { + it("discovers the plugin's agent definitions from the directory", () => { + // Guards the checker's own input: an empty set would make every fixture + // below "violate", and the real file trivially consistent-looking only + // because nothing was compared. + expect(shippedNames.length).toBeGreaterThan(0); + expect(shippedNames).toContain("reviewer-deep"); + expect(shippedNames).toContain("slice-executor-standard"); + }); +}); + +describe("validateAgentMatchers", () => { + it("reports a matcher naming an agent with no shipped definition", () => { + expect( + validateAgentMatchers( + hooksJsonWithSubagentMatcher(`${PLUGIN_NAME}:no-such-agent`), + shippedScopedNames + ) + ).toHaveLength(1); + }); + + it("reports a BARE agent name even when a definition carries that frontmatter name", () => { + // The load-bearing case. `reviewer-deep` IS a shipped definition's `name:`, + // but the identity a hook event carries for a plugin subagent is + // `orchestrate:reviewer-deep`. A matcher spelled bare therefore matches + // nothing at runtime while looking entirely correct in review. + expect(shippedNames).toContain("reviewer-deep"); + const violations = validateAgentMatchers( + hooksJsonWithSubagentMatcher("reviewer-deep"), + shippedScopedNames + ); + expect(violations).toHaveLength(1); + expect(violations[0]).toContain("reviewer-deep"); + }); + + it("accepts a correctly scoped name, anchored or bare of anchors", () => { + for (const matcher of [ + `${PLUGIN_NAME}:reviewer-deep`, + `^${PLUGIN_NAME}:reviewer-deep$`, + ]) { + expect( + validateAgentMatchers( + hooksJsonWithSubagentMatcher(matcher), + shippedScopedNames + ), + `"${matcher}" is a valid identity` + ).toEqual([]); + } + }); + + it("splits alternation on both `|` and `,`", () => { + // Splitting on only one separator would skip the other's entries entirely + // — a false green, the direction that matters. + for (const matcher of [ + `${PLUGIN_NAME}:reviewer-deep|nope-a`, + `${PLUGIN_NAME}:reviewer-deep,nope-b`, + ]) { + expect( + validateAgentMatchers( + hooksJsonWithSubagentMatcher(matcher), + shippedScopedNames + ), + `"${matcher}" hides one bad entry` + ).toHaveLength(1); + } + }); + + it("skips a non-trivial regex rather than reporting it as a bad name", () => { + for (const matcher of [".*", `^${PLUGIN_NAME}:(reviewer|implementer)-.*$`]) { + expect( + validateAgentMatchers( + hooksJsonWithSubagentMatcher(matcher), + shippedScopedNames + ), + `"${matcher}" is a pattern, not a name` + ).toEqual([]); + } + }); + + it("ignores events whose matcher filters tool name, not agent type", () => { + // `PreToolUse` matches on tool name, so `Read|Bash` is not a claim about + // any agent and must never be reported. + expect( + validateAgentMatchers( + { hooks: { PreToolUse: [{ matcher: "Read|Bash", hooks: [] }] } }, + shippedScopedNames + ) + ).toEqual([]); + }); + + it("never throws on a malformed or empty configuration", () => { + for (const value of [null, undefined, {}, { hooks: null }, 42, "x"]) { + expect(() => validateAgentMatchers(value, shippedScopedNames)).not.toThrow(); + expect(validateAgentMatchers(value, shippedScopedNames)).toEqual([]); + } + }); +}); + +describe("the shipped hooks.json", () => { + const shipped = JSON.parse(fs.readFileSync(hooksJsonPath, "utf8")); + + it("names no agent type without a shipped definition", () => { + expect(validateAgentMatchers(shipped, shippedScopedNames)).toEqual([]); + }); + + it("registers the read guard as a PreToolUse hook", () => { + const blocks = shipped.hooks?.PreToolUse; + expect(Array.isArray(blocks)).toBe(true); + const entries = blocks.flatMap((b: { hooks?: unknown[] }) => b.hooks ?? []); + expect( + entries.some((e: { command?: string }) => + (e.command ?? "").includes("read-guard.js") + ) + ).toBe(true); + }); + + it("scopes the read guard to Read and Bash, not to every tool", () => { + // AC6 at the wiring level. `.*` would put the handler in front of every + // call — including the `recover_slice_progress` MCP tool the deny reason + // points at as the structured alternative. + const block = shipped.hooks.PreToolUse.find( + (b: { hooks?: { command?: string }[] }) => + (b.hooks ?? []).some((e) => (e.command ?? "").includes("read-guard.js")) + ); + expect(block.matcher).toBe("Read|Bash"); + }); + + it("does not run the read guard asynchronously", () => { + // An async PreToolUse hook cannot block. Copying the watchdog's + // `"async": true` would silently defeat the deny while every unit test + // above stayed green — the defect no module test can see. + const entries = shipped.hooks.PreToolUse.flatMap( + (b: { hooks?: { command?: string; async?: boolean }[] }) => b.hooks ?? [] + ).filter((e: { command?: string }) => + (e.command ?? "").includes("read-guard.js") + ); + expect(entries.length).toBeGreaterThan(0); + for (const entry of entries) { + expect(entry.async).toBeUndefined(); + } + }); +}); diff --git a/plugins/orchestrate/orchestrate-mcp/test/read-guard.test.ts b/plugins/orchestrate/orchestrate-mcp/test/read-guard.test.ts new file mode 100644 index 00000000..086f17fb --- /dev/null +++ b/plugins/orchestrate/orchestrate-mcp/test/read-guard.test.ts @@ -0,0 +1,574 @@ +import { describe, it, expect } from "vitest"; +import { + decideReadGuard, + denyPayload, + READ_GUARD_DENY_REASON, + type ReadGuardInput, +} from "../src/hooks/read-guard.js"; + +// ─── Fixtures ───────────────────────────────────────────────────────────────── +// +// The guard's whole job is a decision about a hook event, so the event's field +// names are the contract under test. They are spelled in exactly ONE place — +// the builders below — so a platform rename is one edit rather than a sweep, +// and so a wrong guess (`path` instead of `file_path` for `Read`) fails loudly +// here rather than producing a module that parses cleanly and denies nothing. +// +// `file_path` for `Read` and `command` for `Bash` are the vendor-documented +// `tool_input` shapes; see the module's own doc comment for the citation. + +const CWD = "/repo"; +const RUN_ID = "prd352-20260803-015333"; +const RUN_DIR = `${CWD}/.orchestrate/runs/${RUN_ID}`; + +/** The `tool_input` a `Read` call carries. */ +function readInput(filePath: string): Record { + return { file_path: filePath, offset: 0, limit: 200 }; +} + +/** The `tool_input` a `Bash` call carries. */ +function bashInput(command: string): Record { + return { command, description: "a command", timeout: 120000 }; +} + +/** + * A hook-event-derived guard input with the main-thread defaults: an active + * run, the repo root as `cwd`, and NO `agent_id` (which is what "main thread" + * means — the field is emitted only inside a subagent call). + */ +function hookEvent(overrides: Partial = {}): ReadGuardInput { + return { + toolName: "Read", + toolInput: readInput(`${RUN_DIR}/slice-361-report.md`), + cwd: CWD, + activeRunId: RUN_ID, + ...overrides, + }; +} + +describe("READ_GUARD_DENY_REASON", () => { + it("names the envelope as the primary route to the slice's outcome", () => { + // Half of AC2: the deny reason is the ONLY instruction the model receives + // about what to do instead, so it must name the correct behaviour and not + // merely refuse. + expect(READ_GUARD_DENY_REASON).toContain("envelope"); + expect(READ_GUARD_DENY_REASON).toContain("reportPath"); + }); + + it("names the structured-recovery tool as the fallback route", () => { + expect(READ_GUARD_DENY_REASON).toContain("recover_slice_progress"); + }); +}); + +describe("denyPayload", () => { + it("emits the PreToolUse hookSpecificOutput contract, not the deprecated top-level fields", () => { + // Pins the WIRE contract. A module that returned the deprecated top-level + // `decision`/`reason` would satisfy every decision test above while + // producing a hook the platform ignores. + const payload = denyPayload("because"); + expect(payload).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "because", + }, + }); + expect(payload).not.toHaveProperty("decision"); + expect(payload).not.toHaveProperty("reason"); + }); +}); + +describe("decideReadGuard — identity", () => { + it("denies a main-thread Read of a slice report", () => { + const result = decideReadGuard(hookEvent()); + expect(result.decision).toBe("deny"); + // Both clauses of AC2, asserted separately: the decision AND the reason. + expect(result).toEqual({ + decision: "deny", + reason: expect.stringContaining("envelope"), + }); + expect( + result.decision === "deny" ? result.reason : "" + ).toContain("recover_slice_progress"); + }); + + it("denies a main-thread Read of a slice progress record", () => { + const result = decideReadGuard( + hookEvent({ toolInput: readInput(`${RUN_DIR}/slice-361-progress.json`) }) + ); + expect(result.decision).toBe("deny"); + }); + + it("allows the same read from a subagent, identified by agent_id", () => { + // AC3. `agent_id` is emitted only inside a subagent call, which is what + // makes it — and not `agent_type` — the discriminator. + expect(decideReadGuard(hookEvent({ agentId: "sub-abc123" }))).toEqual({ + decision: "none", + }); + }); + + it("treats an empty agent_id as absent, so it still denies", () => { + expect(decideReadGuard(hookEvent({ agentId: "" })).decision).toBe("deny"); + }); +}); + +describe("decideReadGuard — no run in progress", () => { + it("is a silent no-op for a guarded path when no run is active", () => { + // AC4. Also covers the run-discovery paths that yield no runId at all: + // a cwd outside the run's repo, and two concurrent runs that cannot be + // disambiguated. + expect(decideReadGuard(hookEvent({ activeRunId: undefined }))).toEqual({ + decision: "none", + }); + }); + + it("is a silent no-op when the active run id is empty", () => { + expect(decideReadGuard(hookEvent({ activeRunId: "" }))).toEqual({ + decision: "none", + }); + }); + + it("is a silent no-op for a guarded Bash read when no run is active", () => { + expect( + decideReadGuard( + hookEvent({ + activeRunId: undefined, + toolName: "Bash", + toolInput: bashInput(`cat ${RUN_DIR}/slice-361-report.md`), + }) + ) + ).toEqual({ decision: "none" }); + }); + + it("is a silent no-op when cwd is unavailable", () => { + expect(decideReadGuard(hookEvent({ cwd: undefined }))).toEqual({ + decision: "none", + }); + }); +}); + +describe("decideReadGuard — path scoping", () => { + it("allows run-state.json, the orchestrator's own checkpoint", () => { + // The single most dangerous over-block available: the orchestrator reads + // and writes this file at every slice, so a run-directory-wide rule would + // break every run — silently, and only in the field. + expect( + decideReadGuard( + hookEvent({ toolInput: readInput(`${RUN_DIR}/run-state.json`) }) + ) + ).toEqual({ decision: "none" }); + }); + + it.each(["dashboard.html", "graph.html", "report.html"])( + "allows the rendered %s artifact in the run directory", + (name) => { + expect( + decideReadGuard( + hookEvent({ toolInput: readInput(`${RUN_DIR}/${name}`) }) + ) + ).toEqual({ decision: "none" }); + } + ); + + it("allows the run directory's other bookkeeping files", () => { + for (const name of ["context-flag.json", "spawn-log.jsonl"]) { + expect( + decideReadGuard( + hookEvent({ toolInput: readInput(`${RUN_DIR}/${name}`) }) + ), + `${name} must not be guarded` + ).toEqual({ decision: "none" }); + } + }); + + it("allows a guarded basename that lives outside the active run directory", () => { + // The rule is scoped to the run directory, not to the filename: an + // executor's own worktree copy, or another run's directory, is not this + // guard's business. + expect( + decideReadGuard( + hookEvent({ + toolInput: readInput(`${CWD}/notes/slice-361-report.md`), + }) + ) + ).toEqual({ decision: "none" }); + }); + + it("allows a guarded basename in a DIFFERENT run's directory", () => { + expect( + decideReadGuard( + hookEvent({ + toolInput: readInput( + `${CWD}/.orchestrate/runs/backlog-20260101-000000/slice-361-report.md` + ), + }) + ) + ).toEqual({ decision: "none" }); + }); + + it("is not fooled by a run directory whose name merely PREFIXES the active one", () => { + // `runs/` is a prefix of `runs/-suffix`, so a startsWith-based + // containment check would deny a path this run does not own. + expect( + decideReadGuard( + hookEvent({ + toolInput: readInput(`${RUN_DIR}-suffix/slice-361-report.md`), + }) + ) + ).toEqual({ decision: "none" }); + }); + + it("denies any issue number, not just the one this slice was written for", () => { + for (const issue of ["1", "361", "99999"]) { + expect( + decideReadGuard( + hookEvent({ + toolInput: readInput(`${RUN_DIR}/slice-${issue}-progress.json`), + }) + ).decision, + `slice-${issue}-progress.json must be guarded` + ).toBe("deny"); + } + }); + + it("resolves a relative path against the supplied cwd", () => { + // The cwd is passed IN. A bare path.resolve would fall back to the hook + // PROCESS's cwd and silently stop matching every relative path. + expect( + decideReadGuard( + hookEvent({ + toolInput: readInput( + `.orchestrate/runs/${RUN_ID}/slice-361-report.md` + ), + }) + ).decision + ).toBe("deny"); + }); + + it("resolves traversal segments before deciding", () => { + expect( + decideReadGuard( + hookEvent({ + toolInput: readInput( + `${RUN_DIR}/nested/../slice-361-report.md` + ), + }) + ).decision + ).toBe("deny"); + }); + + it("allows a guarded basename nested BELOW the run directory", () => { + // Both artifacts sit directly in the run directory; a nested lookalike is + // not one of them. + expect( + decideReadGuard( + hookEvent({ + toolInput: readInput(`${RUN_DIR}/nested/slice-361-report.md`), + }) + ) + ).toEqual({ decision: "none" }); + }); +}); + +describe("decideReadGuard — tool scoping", () => { + it("takes no decision on the structured-recovery MCP tool", () => { + // AC9. The recovery tool's input carries a `repoPath`, so a module that + // keyed off "any path-shaped string in tool_input" would deny the very + // escape hatch the deny reason points at. + expect( + decideReadGuard( + hookEvent({ + toolName: "mcp__plugin_orchestrate_orchestrate__recover_slice_progress", + toolInput: { repoPath: CWD, runId: RUN_ID, issue: 361 }, + }) + ) + ).toEqual({ decision: "none" }); + }); + + it.each(["Edit", "Write", "Grep", "Glob", "Agent", "TodoWrite"])( + "takes no decision on an unrelated tool (%s)", + (toolName) => { + // AC6 at the module level; `hooks.json`'s matcher is the other half and + // is asserted in test/hook-matcher-consistency.test.ts. + expect( + decideReadGuard(hookEvent({ toolName, toolInput: { path: RUN_DIR } })) + ).toEqual({ decision: "none" }); + } + ); + + it("takes no decision when the tool name is absent", () => { + expect(decideReadGuard(hookEvent({ toolName: undefined }))).toEqual({ + decision: "none", + }); + }); +}); + +describe("decideReadGuard — malformed input", () => { + it("never throws on a missing or wrongly-typed tool_input", () => { + // AC7. A hook that throws is a hook that breaks the session. + const inputs: ReadGuardInput[] = [ + hookEvent({ toolInput: undefined }), + hookEvent({ toolInput: {} }), + hookEvent({ toolInput: { file_path: 42 as unknown as string } }), + hookEvent({ toolInput: { file_path: "" } }), + hookEvent({ toolName: "Bash", toolInput: {} }), + hookEvent({ toolName: "Bash", toolInput: { command: null } }), + {}, + ]; + for (const input of inputs) { + expect(() => decideReadGuard(input)).not.toThrow(); + expect(decideReadGuard(input)).toEqual({ decision: "none" }); + } + }); +}); + +// ─── The Bash arm ───────────────────────────────────────────────────────────── +// +// A stated heuristic, not a sandbox. The deny-tests below enumerate the read +// verbs the module claims to cover; the allow-tests are equally load-bearing, +// because a false deny in the middle of a live run is the AC7 failure that +// actually costs something. + +const READ_VERBS = [ + "cat", + "head", + "tail", + "less", + "more", + "bat", + "sed", + "awk", + "grep", + "rg", + "jq", + "od", + "xxd", + "strings", + "nl", + "wc", +]; + +describe("decideReadGuard — Bash reads", () => { + it.each(READ_VERBS)("denies `%s` applied to a guarded path", (verb) => { + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput(`${verb} ${RUN_DIR}/slice-361-report.md`), + }) + ).decision, + `\`${verb}\` on a guarded path must be denied` + ).toBe("deny"); + }); + + it("denies a quoted guarded path", () => { + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput(`cat "${RUN_DIR}/slice-361-report.md"`), + }) + ).decision + ).toBe("deny"); + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput(`cat '${RUN_DIR}/slice-361-progress.json'`), + }) + ).decision + ).toBe("deny"); + }); + + it("denies a verb invoked by absolute path", () => { + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput(`/bin/cat ${RUN_DIR}/slice-361-report.md`), + }) + ).decision + ).toBe("deny"); + }); + + it("denies past leading environment assignments", () => { + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput( + `LC_ALL=C cat ${RUN_DIR}/slice-361-report.md` + ), + }) + ).decision + ).toBe("deny"); + }); + + it("denies a guarded read in a LATER subcommand of a chain", () => { + for (const chain of ["&&", "||", ";", "|"]) { + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput( + `npm test ${chain} cat ${RUN_DIR}/slice-361-report.md` + ), + }) + ).decision, + `a guarded read after \`${chain}\` must be denied` + ).toBe("deny"); + } + }); + + it("denies a redirection whose source is a guarded path", () => { + for (const command of [ + `jq . < ${RUN_DIR}/slice-361-progress.json`, + `while read -r l; do echo "$l"; done <${RUN_DIR}/slice-361-report.md`, + ]) { + expect( + decideReadGuard( + hookEvent({ toolName: "Bash", toolInput: bashInput(command) }) + ).decision, + `redirection in \`${command}\` must be denied` + ).toBe("deny"); + } + }); + + it("denies sourcing a guarded path", () => { + for (const verb of ["source", "."]) { + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput(`${verb} ${RUN_DIR}/slice-361-report.md`), + }) + ).decision, + `\`${verb}\` on a guarded path must be denied` + ).toBe("deny"); + } + }); + + it("denies a relative guarded path resolved against cwd", () => { + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput( + `cat .orchestrate/runs/${RUN_ID}/slice-361-report.md` + ), + }) + ).decision + ).toBe("deny"); + }); +}); + +describe("decideReadGuard — Bash commands that must NOT be denied", () => { + it("allows a command that merely MENTIONS a guarded path", () => { + // The AC7 clause most likely to be missed, and the one that breaks a live + // run: the orchestrator names these files constantly — in echoes, in + // commit messages, in issue comments — without ever reading them. + for (const command of [ + `echo "wrote ${RUN_DIR}/slice-361-report.md"`, + `git commit -m "slice 361: see slice-361-report.md"`, + `gh issue comment 361 --body "progress: ${RUN_DIR}/slice-361-progress.json"`, + `ls -l ${RUN_DIR}/slice-361-report.md`, + `rm -f ${RUN_DIR}/slice-361-progress.json`, + ]) { + expect( + decideReadGuard( + hookEvent({ toolName: "Bash", toolInput: bashInput(command) }) + ), + `\`${command}\` reads nothing and must be allowed` + ).toEqual({ decision: "none" }); + } + }); + + it("allows a read verb applied to an UNGUARDED path", () => { + for (const command of [ + `cat ${RUN_DIR}/run-state.json`, + `cat ${CWD}/package.json`, + `jq .status ${RUN_DIR}/run-state.json`, + ]) { + expect( + decideReadGuard( + hookEvent({ toolName: "Bash", toolInput: bashInput(command) }) + ), + `\`${command}\` must be allowed` + ).toEqual({ decision: "none" }); + } + }); + + it("does not read `./script.sh` as the `.` source builtin", () => { + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput(`./script.sh ${RUN_DIR}/slice-361-report.md`), + }) + ) + ).toEqual({ decision: "none" }); + }); + + it("does not treat a heredoc marker as a redirection source", () => { + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput( + `cat < { + // Both directions of the SAME limitation, pinned so a later "improvement" + // to the splitter has to face them deliberately. Documented in the + // module's "Honest limitations"; neither is closable without a + // quote-aware parser, which buys little and risks more false denies. + // If a splitter change turns this test RED, that is not automatically a + // regression: re-read the limitation first. The over-block half going + // green-to-red means the false deny was FIXED, and this assertion should + // be deleted rather than the fix reverted to keep it passing. + // + // Under-block: the `|` in the regex splits the command, so the guarded + // path lands in a segment whose first token is not a read verb. + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput( + `grep -E "PASS|FAIL" ${RUN_DIR}/slice-361-report.md` + ), + }) + ) + ).toEqual({ decision: "none" }); + + // Over-block: the only known false deny. A quoted argument NARRATING a + // read — a separator, then a read verb, then a guarded path — reads as a + // subcommand to a textual splitter. + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput( + `gh pr create --body 'did X; cat ${RUN_DIR}/slice-361-report.md and moved on'` + ), + }) + ).decision + ).toBe("deny"); + }); + + it("allows a Bash command from a subagent regardless of what it reads", () => { + expect( + decideReadGuard( + hookEvent({ + agentId: "sub-abc123", + toolName: "Bash", + toolInput: bashInput(`cat ${RUN_DIR}/slice-361-report.md`), + }) + ) + ).toEqual({ decision: "none" }); + }); +}); diff --git a/plugins/orchestrate/orchestrate-mcp/test/recover-slice-progress.test.ts b/plugins/orchestrate/orchestrate-mcp/test/recover-slice-progress.test.ts new file mode 100644 index 00000000..292b3a53 --- /dev/null +++ b/plugins/orchestrate/orchestrate-mcp/test/recover-slice-progress.test.ts @@ -0,0 +1,398 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as os from "os"; +import * as fs from "fs"; +import * as path from "path"; +import { recoverSliceProgress } from "../src/tools/recover-slice-progress.js"; + +// ─── Test fixtures ──────────────────────────────────────────────────────────── + +const RUN_ID = "prd352-20260803-015333"; +const ISSUE = 355; + +/** A full, schema-valid slice progress record. */ +function validRecord(): Record { + return { + runId: RUN_ID, + issue: ISSUE, + lastCompletedStage: "investigator", + investigatorBrief: { + relevantFiles: ["src/run-dir.ts", "src/tools/validate-envelope.ts"], + patterns: "Schema-first — zod is the single source of truth.", + risks: "The issue id is a second path-traversal vector.", + approach: "Compose a per-slice resolver on resolveRunDir.", + notes: "Both prerequisite slices are already in this branch.", + }, + continuationsUsed: 1, + worktreeFingerprint: "sha256:9f2c1a0b", + fallbackTaken: false, + updatedAt: "2026-08-03T01:53:33Z", + }; +} + +// ─── Test harness ───────────────────────────────────────────────────────────── + +const created: string[] = []; + +/** + * Writes a project dir with a per-run directory at + * `.orchestrate/runs//`. Pass null for `record` to skip creating the + * progress file (the run directory is still created). Pass a string to write it + * verbatim (for the bad-JSON case). + */ +function project( + record: unknown | string | null, + runId: string = RUN_ID, + issue: number = ISSUE +): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "orchestrate-progress-")); + created.push(dir); + return writeRecord(dir, record, runId, issue); +} + +/** Writes one more progress record into an existing project dir. */ +function writeRecord( + dir: string, + record: unknown | string | null, + runId: string = RUN_ID, + issue: number = ISSUE +): string { + const runDir = path.join(dir, ".orchestrate", "runs", runId); + fs.mkdirSync(runDir, { recursive: true }); + if (record !== null) { + const content = + typeof record === "string" ? record : JSON.stringify(record, null, 2); + fs.writeFileSync(path.join(runDir, `slice-${issue}-progress.json`), content); + } + return dir; +} + +afterEach(() => { + for (const dir of created) { + fs.rmSync(dir, { recursive: true, force: true }); + } + created.length = 0; +}); + +// ─── Happy path (criterion 2) ──────────────────────────────────────────────── + +describe("recoverSliceProgress — a well-formed record", () => { + it("returns status ok with every field of the validated record", async () => { + const repoPath = project(validRecord()); + const result = await recoverSliceProgress({ + runId: RUN_ID, + issue: ISSUE, + repoPath, + }); + expect(result.status).toBe("ok"); + expect(result.errorCode).toBeUndefined(); + expect(result.record).toEqual(validRecord()); + }); + + it("carries the investigator brief forward field by field", async () => { + const repoPath = project(validRecord()); + const result = await recoverSliceProgress({ + runId: RUN_ID, + issue: ISSUE, + repoPath, + }); + expect(result.record?.investigatorBrief?.relevantFiles).toEqual([ + "src/run-dir.ts", + "src/tools/validate-envelope.ts", + ]); + expect(result.record?.investigatorBrief?.approach).toContain( + "resolveRunDir" + ); + }); + + it("accepts a record with no investigator brief — some tiers skip investigation", async () => { + const { investigatorBrief: _omit, ...noBrief } = validRecord(); + const repoPath = project(noBrief); + const result = await recoverSliceProgress({ + runId: RUN_ID, + issue: ISSUE, + repoPath, + }); + expect(result.status).toBe("ok"); + expect(result.record?.investigatorBrief).toBeUndefined(); + }); + + it("accepts a record with no worktree fingerprint yet", async () => { + const { worktreeFingerprint: _omit, ...noFingerprint } = validRecord(); + const repoPath = project(noFingerprint); + const result = await recoverSliceProgress({ + runId: RUN_ID, + issue: ISSUE, + repoPath, + }); + expect(result.status).toBe("ok"); + expect(result.record?.worktreeFingerprint).toBeUndefined(); + }); + + it("accepts every member of the slice-executor stage vocabulary", async () => { + for (const stage of [ + "investigator", + "implementer", + "capability-gate", + "reviewer", + ]) { + const repoPath = project({ ...validRecord(), lastCompletedStage: stage }); + const result = await recoverSliceProgress({ + runId: RUN_ID, + issue: ISSUE, + repoPath, + }); + expect(result.status).toBe("ok"); + expect(result.record?.lastCompletedStage).toBe(stage); + } + }); + + it("records the fallback guard as taken so a resumed executor cannot re-arm it", async () => { + const repoPath = project({ ...validRecord(), fallbackTaken: true }); + const result = await recoverSliceProgress({ + runId: RUN_ID, + issue: ISSUE, + repoPath, + }); + expect(result.status).toBe("ok"); + expect(result.record?.fallbackTaken).toBe(true); + }); +}); + +// ─── lastCompletedStage — the subState precedent (absent ok, null rejected) ─── + +describe("recoverSliceProgress — lastCompletedStage absence semantics", () => { + it("accepts an absent key — no stage has completed yet", async () => { + const { lastCompletedStage: _omit, ...noStage } = validRecord(); + const repoPath = project(noStage); + const result = await recoverSliceProgress({ + runId: RUN_ID, + issue: ISSUE, + repoPath, + }); + expect(result.status).toBe("ok"); + expect(result.record?.lastCompletedStage).toBeUndefined(); + }); + + it("rejects an explicit null — .optional() is not .nullable()", async () => { + const repoPath = project({ ...validRecord(), lastCompletedStage: null }); + const result = await recoverSliceProgress({ + runId: RUN_ID, + issue: ISSUE, + repoPath, + }); + expect(result.status).toBe("error"); + expect(result.errorCode).toBe("PROGRESS_INVALID"); + expect(result.errorMessage).toContain("lastCompletedStage"); + }); +}); + +// ─── Missing vs malformed, distinctly (criterion 3) ────────────────────────── + +describe("recoverSliceProgress — missing is distinct from malformed", () => { + it("reports a missing record as PROGRESS_NOT_FOUND, resolving rather than throwing", async () => { + const repoPath = project(null); + const call = recoverSliceProgress({ runId: RUN_ID, issue: ISSUE, repoPath }); + await expect(call).resolves.toBeDefined(); + const result = await call; + expect(result.status).toBe("error"); + expect(result.errorCode).toBe("PROGRESS_NOT_FOUND"); + expect(result.record).toBeUndefined(); + }); + + it("reports unparseable JSON as PROGRESS_INVALID, resolving rather than throwing", async () => { + const repoPath = project("{ not json"); + const call = recoverSliceProgress({ runId: RUN_ID, issue: ISSUE, repoPath }); + await expect(call).resolves.toBeDefined(); + const result = await call; + expect(result.status).toBe("error"); + expect(result.errorCode).toBe("PROGRESS_INVALID"); + expect(result.record).toBeUndefined(); + }); + + it("keeps the two codes distinct for the same run", async () => { + const missing = await recoverSliceProgress({ + runId: RUN_ID, + issue: ISSUE, + repoPath: project(null), + }); + const malformed = await recoverSliceProgress({ + runId: RUN_ID, + issue: ISSUE, + repoPath: project("}{"), + }); + expect(missing.errorCode).not.toBe(malformed.errorCode); + }); + + it("reports a schema mismatch as PROGRESS_INVALID naming the offending field path", async () => { + const repoPath = project({ + ...validRecord(), + lastCompletedStage: "merger", // not a slice-executor stage + }); + const result = await recoverSliceProgress({ + runId: RUN_ID, + issue: ISSUE, + repoPath, + }); + expect(result.status).toBe("error"); + expect(result.errorCode).toBe("PROGRESS_INVALID"); + expect(result.errorMessage).toContain("lastCompletedStage"); + }); + + it("rejects a negative continuation count", async () => { + const repoPath = project({ ...validRecord(), continuationsUsed: -1 }); + const result = await recoverSliceProgress({ + runId: RUN_ID, + issue: ISSUE, + repoPath, + }); + expect(result.status).toBe("error"); + expect(result.errorCode).toBe("PROGRESS_INVALID"); + expect(result.errorMessage).toContain("continuationsUsed"); + }); + + it("rejects a record missing fallbackTaken — an absent guard must never read as unspent", async () => { + const { fallbackTaken: _omit, ...noGuard } = validRecord(); + const repoPath = project(noGuard); + const result = await recoverSliceProgress({ + runId: RUN_ID, + issue: ISSUE, + repoPath, + }); + expect(result.status).toBe("error"); + expect(result.errorCode).toBe("PROGRESS_INVALID"); + expect(result.errorMessage).toContain("fallbackTaken"); + }); +}); + +// ─── Path-traversal guards (ADR-0012 invariant 3, by test) ─────────────────── + +describe("recoverSliceProgress — hostile inputs never escape the run directory", () => { + it("rejects a traversal-y runId", async () => { + const repoPath = project(validRecord()); + const result = await recoverSliceProgress({ + runId: "../../etc", + issue: ISSUE, + repoPath, + }); + expect(result.status).toBe("error"); + expect(result.errorCode).toBe("RUN_ID_INVALID"); + expect(result.record).toBeUndefined(); + }); + + it("rejects a traversal-y issue id", async () => { + const repoPath = project(validRecord()); + const result = await recoverSliceProgress({ + runId: RUN_ID, + issue: "../../../etc/passwd" as unknown as number, + repoPath, + }); + expect(result.status).toBe("error"); + expect(result.errorCode).toBe("ISSUE_INVALID"); + expect(result.record).toBeUndefined(); + }); + + it("rejects a non-integer and a non-positive issue id", async () => { + const repoPath = project(validRecord()); + for (const issue of [0, -355, 1.5, NaN]) { + const result = await recoverSliceProgress({ runId: RUN_ID, issue, repoPath }); + expect(result.status).toBe("error"); + expect(result.errorCode).toBe("ISSUE_INVALID"); + } + }); + + it("never reads a file outside the run directory for a hostile issue id", async () => { + const repoPath = project(validRecord()); + // Plant a decoy the traversal would reach if the id were joined unguarded. + fs.writeFileSync( + path.join(repoPath, "slice-x-progress.json"), + JSON.stringify(validRecord()) + ); + const result = await recoverSliceProgress({ + runId: RUN_ID, + issue: "../../../x" as unknown as number, + repoPath, + }); + expect(result.status).toBe("error"); + expect(result.record).toBeUndefined(); + }); +}); + +// ─── Self-identification — a mis-filed record is detected, not accepted ────── + +describe("recoverSliceProgress — the record must match the path it came from", () => { + it("rejects a record whose runId disagrees with the requested run", async () => { + const repoPath = project({ ...validRecord(), runId: "backlog-20260101-000000" }); + const result = await recoverSliceProgress({ + runId: RUN_ID, + issue: ISSUE, + repoPath, + }); + expect(result.status).toBe("error"); + expect(result.errorCode).toBe("PROGRESS_INVALID"); + expect(result.errorMessage).toContain("runId"); + }); + + it("rejects a record whose issue disagrees with the requested slice", async () => { + const repoPath = project({ ...validRecord(), issue: 999 }); + const result = await recoverSliceProgress({ + runId: RUN_ID, + issue: ISSUE, + repoPath, + }); + expect(result.status).toBe("error"); + expect(result.errorCode).toBe("PROGRESS_INVALID"); + expect(result.errorMessage).toContain("issue"); + }); +}); + +// ─── Cross-run and intra-run isolation (criteria 4 + 5) ────────────────────── + +describe("recoverSliceProgress — records never collide", () => { + it("keeps sibling slices of ONE parallel wave separate", async () => { + // The intra-run hazard: several slices of the same wave share one run + // directory, so a single progress.json would clobber siblings. + const repoPath = project({ ...validRecord(), continuationsUsed: 1 }); + writeRecord( + repoPath, + { ...validRecord(), issue: 356, continuationsUsed: 2 }, + RUN_ID, + 356 + ); + + const a = await recoverSliceProgress({ runId: RUN_ID, issue: ISSUE, repoPath }); + const b = await recoverSliceProgress({ runId: RUN_ID, issue: 356, repoPath }); + expect(a.record?.continuationsUsed).toBe(1); + expect(b.record?.continuationsUsed).toBe(2); + expect(a.record?.issue).toBe(ISSUE); + expect(b.record?.issue).toBe(356); + }); + + it("keeps the same issue number in two concurrent runs separate (ADR-0012)", async () => { + const otherRun = "backlog-20260803-022540"; + const repoPath = project({ ...validRecord(), continuationsUsed: 1 }); + writeRecord( + repoPath, + { ...validRecord(), runId: otherRun, continuationsUsed: 3 }, + otherRun, + ISSUE + ); + + const a = await recoverSliceProgress({ runId: RUN_ID, issue: ISSUE, repoPath }); + const b = await recoverSliceProgress({ runId: otherRun, issue: ISSUE, repoPath }); + expect(a.record?.continuationsUsed).toBe(1); + expect(b.record?.continuationsUsed).toBe(3); + expect(a.record?.runId).toBe(RUN_ID); + expect(b.record?.runId).toBe(otherRun); + }); + + it("reports NOT_FOUND for a sibling slice that has no record yet", async () => { + const repoPath = project(validRecord()); + const result = await recoverSliceProgress({ + runId: RUN_ID, + issue: 356, + repoPath, + }); + expect(result.status).toBe("error"); + expect(result.errorCode).toBe("PROGRESS_NOT_FOUND"); + }); +}); diff --git a/plugins/orchestrate/orchestrate-mcp/test/routing.test.ts b/plugins/orchestrate/orchestrate-mcp/test/routing.test.ts index cce14533..f878be2a 100644 --- a/plugins/orchestrate/orchestrate-mcp/test/routing.test.ts +++ b/plugins/orchestrate/orchestrate-mcp/test/routing.test.ts @@ -12,6 +12,9 @@ import { loadRoutingConfig, applyLabels, resolveRoutingV2FromConfig, + ROUTING_ROLES, + tierRoutingSchemaV2, + ensureSliceExecutorDefault, type RoutingConfig, type RoutingConfigV2, type TierRoutingV2, @@ -577,7 +580,14 @@ const V1_CONFIG_FOR_V2_HANDLER: RoutingConfig = { continuationBudget: 2, }; -/** A v2 on-disk config with a labels block for testing label overrides. */ +/** + * A v2 on-disk config with a labels block for testing label overrides. + * Carries an explicit `slice-executor` entry per tier (mirroring that tier's + * `implementer`) so it is a COMPLETE, current-schema fixture — the + * "no warnings" assertions elsewhere in this suite stay true. The dedicated + * `V2_CONFIG_PREDATING_SLICE_EXECUTOR` fixture below is the one that omits it, + * for testing the back-compat default-fill path. + */ const V2_CONFIG_WITH_LABELS: RoutingConfigV2 = { version: 2, tiers: { @@ -586,18 +596,21 @@ const V2_CONFIG_WITH_LABELS: RoutingConfigV2 = { implementer: { model: "haiku", variant: "standard" }, reviewer: { model: "sonnet", variant: "standard" }, "conflict-resolver": { model: "sonnet", variant: "standard" }, + "slice-executor": { model: "haiku", variant: "standard" }, }, standard: { investigator: { model: "haiku", variant: "standard" }, implementer: { model: "sonnet", variant: "standard" }, reviewer: { model: "opus", variant: "standard" }, "conflict-resolver": { model: "opus", variant: "standard" }, + "slice-executor": { model: "sonnet", variant: "standard" }, }, complex: { investigator: { model: "opus", variant: "deep" }, implementer: { model: "opus", variant: "deep" }, reviewer: { model: "opus", variant: "deep" }, "conflict-resolver": { model: "opus", variant: "deep" }, + "slice-executor": { model: "opus", variant: "deep" }, }, }, labels: { @@ -619,6 +632,28 @@ const V2_CONFIG_WITH_LABELS: RoutingConfigV2 = { run: { intraWaveConcurrency: "parallel", continuationBudget: 3 }, }; +/** Drops a tier's `slice-executor` entry, keeping every other field intact. */ +function omitSliceExecutor(tier: TierRoutingV2): TierRoutingV2 { + const { "slice-executor": _dropped, ...rest } = tier; + return rest; +} + +/** + * `V2_CONFIG_WITH_LABELS`, but with the `slice-executor` entry stripped from + * every tier — the shape a routing.json written before #356 has on disk. Used + * only to test the back-compat default-fill path + * ({@link ensureSliceExecutorDefault}); every other test uses the complete + * fixture above. + */ +const V2_CONFIG_PREDATING_SLICE_EXECUTOR: RoutingConfigV2 = { + ...V2_CONFIG_WITH_LABELS, + tiers: { + trivial: omitSliceExecutor(V2_CONFIG_WITH_LABELS.tiers.trivial), + standard: omitSliceExecutor(V2_CONFIG_WITH_LABELS.tiers.standard), + complex: omitSliceExecutor(V2_CONFIG_WITH_LABELS.tiers.complex), + }, +}; + describe("resolveRoutingV2FromConfig", () => { it("resolves a tier from a v1 routing.json (transparent upgrade), output uses variant not effort", () => { const dir = project(V1_CONFIG_FOR_V2_HANDLER); @@ -771,3 +806,295 @@ describe("resolveRoutingV2FromConfig", () => { expect(r.continuationBudget).toBe(3); }); }); + +// ─── slice-executor role (ADR-0017, #356) ───────────────────────────────────── + +describe("ROUTING_ROLES", () => { + it("includes slice-executor alongside the original four pipeline roles", () => { + expect(ROUTING_ROLES).toEqual([ + "investigator", + "implementer", + "reviewer", + "conflict-resolver", + "slice-executor", + ]); + }); +}); + +describe("tierRoutingSchemaV2 slice-executor field", () => { + const tier = VALID_CONFIG_V2.tiers.standard; + + it("still validates when slice-executor is absent (back-compat)", () => { + expect(tierRoutingSchemaV2.safeParse(tier).success).toBe(true); + }); + + it("accepts an explicit slice-executor entry", () => { + const withExecutor = { + ...tier, + "slice-executor": { model: "opus", variant: "deep" }, + }; + const parsed = tierRoutingSchemaV2.safeParse(withExecutor); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data["slice-executor"]).toEqual({ + model: "opus", + variant: "deep", + }); + } + }); + + it("rejects an explicit null for slice-executor — it is never skippable, unlike investigator", () => { + const withNull = { ...tier, "slice-executor": null }; + expect(tierRoutingSchemaV2.safeParse(withNull).success).toBe(false); + }); +}); + +describe("ensureSliceExecutorDefault", () => { + it("returns the tier unchanged, no warning, when slice-executor is already present", () => { + const tier = { + ...VALID_CONFIG_V2.tiers.complex, + "slice-executor": { model: "opus", variant: "deep" as const }, + }; + const r = ensureSliceExecutorDefault(tier, "complex"); + + expect(r.warning).toBeUndefined(); + expect(r.routing["slice-executor"]).toEqual({ + model: "opus", + variant: "deep", + }); + }); + + it("defaults slice-executor to the tier's own implementer entry when absent", () => { + const tier = VALID_CONFIG_V2.tiers.standard; // implementer: sonnet/standard + const r = ensureSliceExecutorDefault(tier, "standard"); + + expect(r.routing["slice-executor"]).toEqual(tier.implementer); + expect(r.warning).toBeDefined(); + expect(r.warning).toContain("slice-executor"); + // The warning must name the tier so an aggregated run report is attributable. + expect(r.warning).toContain("standard"); + }); + + it("defaults differ per tier, mirroring each tier's own implementer", () => { + const trivial = ensureSliceExecutorDefault( + VALID_CONFIG_V2.tiers.trivial, + "trivial" + ); + const complex = ensureSliceExecutorDefault( + VALID_CONFIG_V2.tiers.complex, + "complex" + ); + + expect(trivial.routing["slice-executor"]).toEqual( + VALID_CONFIG_V2.tiers.trivial.implementer + ); + expect(complex.routing["slice-executor"]).toEqual( + VALID_CONFIG_V2.tiers.complex.implementer + ); + expect(trivial.routing["slice-executor"]).not.toEqual( + complex.routing["slice-executor"] + ); + }); + + it("does not mutate the input tier", () => { + const tier = VALID_CONFIG_V2.tiers.trivial; + const before = JSON.parse(JSON.stringify(tier)); + ensureSliceExecutorDefault(tier, "trivial"); + expect(tier).toEqual(before); + }); +}); + +describe("applyLabels carries slice-executor through untouched when not targeted", () => { + it("leaves a pre-filled slice-executor entry unchanged when no label targets it", () => { + const tier: TierRoutingV2 = { + ...VALID_CONFIG_V2.tiers.standard, + "slice-executor": { model: "sonnet", variant: "standard" }, + }; + const r = applyLabels(tier, ["route:fable"], { + "route:fable": { + roles: ["implementer"], + set: { model: "fable", variant: "deep" }, + }, + }); + + expect(r.error).toBeUndefined(); + expect(r.routing["slice-executor"]).toEqual(tier["slice-executor"]); + }); +}); + +describe("resolveRoutingV2FromConfig — slice-executor (#356)", () => { + it("resolves slice-executor for all three tiers from a config that declares it explicitly", () => { + const withExecutor: RoutingConfigV2 = { + ...V2_CONFIG_WITH_LABELS, + tiers: { + trivial: { + ...V2_CONFIG_WITH_LABELS.tiers.trivial, + "slice-executor": { model: "haiku", variant: "standard" }, + }, + standard: { + ...V2_CONFIG_WITH_LABELS.tiers.standard, + "slice-executor": { model: "sonnet", variant: "standard" }, + }, + complex: { + ...V2_CONFIG_WITH_LABELS.tiers.complex, + "slice-executor": { model: "opus", variant: "deep" }, + }, + }, + }; + const dir = project(withExecutor); + + const trivial = resolveRoutingV2FromConfig({ tier: "trivial", repoPath: dir }); + const standard = resolveRoutingV2FromConfig({ tier: "standard", repoPath: dir }); + const complex = resolveRoutingV2FromConfig({ tier: "complex", repoPath: dir }); + + expect(trivial.status).toBe("ok"); + expect(standard.status).toBe("ok"); + expect(complex.status).toBe("ok"); + expect(trivial.routing!["slice-executor"]).toEqual({ + model: "haiku", + variant: "standard", + }); + expect(standard.routing!["slice-executor"]).toEqual({ + model: "sonnet", + variant: "standard", + }); + expect(complex.routing!["slice-executor"]).toEqual({ + model: "opus", + variant: "deep", + }); + // No slice-executor warning when the config already declares it. + expect( + trivial.warnings!.some((w) => w.includes("slice-executor")) + ).toBe(false); + }); + + it("defaults slice-executor to the implementer entry with a warning when the config predates the role", () => { + const dir = project(V2_CONFIG_PREDATING_SLICE_EXECUTOR); + const r = resolveRoutingV2FromConfig({ tier: "standard", repoPath: dir }); + + expect(r.status).toBe("ok"); + expect(r.routing!["slice-executor"]).toEqual( + V2_CONFIG_PREDATING_SLICE_EXECUTOR.tiers.standard.implementer + ); + expect(r.warnings!.some((w) => w.includes("slice-executor"))).toBe(true); + }); + + it("a route:* label naming slice-executor patches it, overriding the tier's explicit entry", () => { + const withLabel: RoutingConfigV2 = { + ...V2_CONFIG_WITH_LABELS, + labels: { + ...V2_CONFIG_WITH_LABELS.labels, + "route:strong-exec": { + roles: ["slice-executor"], + set: { model: "opus", variant: "deep" }, + }, + }, + }; + const dir = project(withLabel); + const r = resolveRoutingV2FromConfig({ + tier: "trivial", + repoPath: dir, + labels: ["route:strong-exec"], + }); + + expect(r.status).toBe("ok"); + expect(r.routing!["slice-executor"]).toEqual({ + model: "opus", + variant: "deep", + }); + }); + + // The two tests below pin the ORDER of ensureSliceExecutorDefault vs + // applyLabels. Both use the fixture that omits the role, so the default-fill + // path actually runs — the test above uses a config that declares it + // explicitly, so no default is derived there. + it("a route:* label naming slice-executor beats the implementer-derived default", () => { + const withLabel: RoutingConfigV2 = { + ...V2_CONFIG_PREDATING_SLICE_EXECUTOR, + labels: { + ...V2_CONFIG_PREDATING_SLICE_EXECUTOR.labels, + "route:strong-exec": { + roles: ["slice-executor"], + set: { model: "opus", variant: "deep" }, + }, + }, + }; + const dir = project(withLabel); + const r = resolveRoutingV2FromConfig({ + tier: "trivial", + repoPath: dir, + labels: ["route:strong-exec"], + }); + + expect(r.status).toBe("ok"); + expect(r.routing!["slice-executor"]).toEqual({ + model: "opus", + variant: "deep", + }); + // Not the passive default the fill would otherwise have produced. + expect(r.routing!["slice-executor"]).not.toEqual( + V2_CONFIG_PREDATING_SLICE_EXECUTOR.tiers.trivial.implementer + ); + }); + + it("a label patching only implementer does not drag the defaulted slice-executor with it", () => { + const dir = project(V2_CONFIG_PREDATING_SLICE_EXECUTOR); + const r = resolveRoutingV2FromConfig({ + tier: "standard", + repoPath: dir, + labels: ["route:fable"], + }); + + expect(r.status).toBe("ok"); + expect(r.routing!.implementer).toEqual({ model: "fable", variant: "deep" }); + // The default is seeded from the CONFIGURED implementer, before the label + // patched it — route:fable names only `implementer`. Reversing the order of + // the default-fill and the label pass would make this fable/deep instead. + expect(r.routing!["slice-executor"]).toEqual( + V2_CONFIG_PREDATING_SLICE_EXECUTOR.tiers.standard.implementer + ); + }); + + it("two labels both patching slice-executor is a structured LABEL_CONFLICT error, no precedence rule", () => { + const withConflict: RoutingConfigV2 = { + ...V2_CONFIG_WITH_LABELS, + labels: { + ...V2_CONFIG_WITH_LABELS.labels, + "route:exec-a": { + roles: ["slice-executor"], + set: { model: "opus", variant: "deep" }, + }, + "route:exec-b": { + roles: ["slice-executor"], + set: { model: "sonnet", variant: "standard" }, + }, + }, + }; + const dir = project(withConflict); + const r = resolveRoutingV2FromConfig({ + tier: "trivial", + repoPath: dir, + labels: ["route:exec-a", "route:exec-b"], + }); + + expect(r.status).toBe("error"); + expect(r.errorCode).toBe("LABEL_CONFLICT"); + expect(r.errorMessage).toContain("slice-executor"); + expect(r.errorMessage).toContain("route:exec-a"); + expect(r.errorMessage).toContain("route:exec-b"); + }); + + it("an unconfigured route:* label naming slice-executor still produces the generic structured warning", () => { + const dir = project(V2_CONFIG_WITH_LABELS); + const r = resolveRoutingV2FromConfig({ + tier: "trivial", + repoPath: dir, + labels: ["route:no-such-executor-label"], + }); + + expect(r.status).toBe("ok"); + expect( + r.warnings!.some((w) => w.includes("route:no-such-executor-label")) + ).toBe(true); + }); +}); diff --git a/plugins/orchestrate/orchestrate-mcp/test/run-dir.test.ts b/plugins/orchestrate/orchestrate-mcp/test/run-dir.test.ts index 920ef292..c4001721 100644 --- a/plugins/orchestrate/orchestrate-mcp/test/run-dir.test.ts +++ b/plugins/orchestrate/orchestrate-mcp/test/run-dir.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from "vitest"; import * as path from "path"; -import { resolveRunDir, isValidRunId } from "../src/run-dir.js"; +import { + resolveRunDir, + isValidRunId, + isValidIssueId, + resolveSliceProgressPath, +} from "../src/run-dir.js"; // `run-dir` is a pure module — every test exercises path computation only, with // no filesystem I/O. The resolver maps a (repoPath, runId) pair to the run's @@ -98,6 +103,7 @@ describe("resolveRunDir — valid runId", () => { expect(result.paths.dashboardPath).toBeDefined(); expect(result.paths.graphPath).toBeDefined(); expect(result.paths.reportPath).toBeDefined(); + expect(result.paths.spawnLogPath).toBeDefined(); } }); @@ -124,6 +130,16 @@ describe("resolveRunDir — valid runId", () => { } }); + it("places the spawn log inside the run directory (removed with the run)", () => { + const result = resolveRunDir("/repo", "20260521-015143"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.paths.spawnLogPath).toBe( + path.join(result.paths.runDir, "spawn-log.jsonl") + ); + } + }); + it("places the three HTML artifacts inside the run directory", () => { const result = resolveRunDir("/repo", "20260521-015143"); expect(result.ok).toBe(true); @@ -195,3 +211,138 @@ describe("resolveRunDir — per-run path isolation", () => { } }); }); + +// ─── isValidIssueId ────────────────────────────────────────────────────────── + +describe("isValidIssueId", () => { + it("accepts a positive integer issue number", () => { + expect(isValidIssueId(355)).toBe(true); + expect(isValidIssueId(1)).toBe(true); + }); + + it("rejects zero and negatives — GitHub issue numbers start at 1", () => { + expect(isValidIssueId(0)).toBe(false); + expect(isValidIssueId(-355)).toBe(false); + }); + + it("rejects a non-integer", () => { + expect(isValidIssueId(1.5)).toBe(false); + expect(isValidIssueId(NaN)).toBe(false); + expect(isValidIssueId(Infinity)).toBe(false); + }); + + it("rejects a traversal-y value arriving across the MCP process boundary", () => { + // The tool's zod input types `issue` as a number, but MCP input crosses a + // process boundary — a string that would traverse must still be rejected. + expect(isValidIssueId("../../etc/passwd" as unknown as number)).toBe(false); + expect(isValidIssueId("355" as unknown as number)).toBe(false); + }); +}); + +// ─── resolveSliceProgressPath ──────────────────────────────────────────────── + +describe("resolveSliceProgressPath — invalid inputs", () => { + it("returns ok:false with RUN_ID_INVALID for a path-traversal runId", () => { + const result = resolveSliceProgressPath("/repo", "../../etc", 355); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorCode).toBe("RUN_ID_INVALID"); + expect(result.errorMessage).toContain("../../etc"); + } + }); + + it("returns ok:false with ISSUE_INVALID for a path-traversal issue id", () => { + const result = resolveSliceProgressPath( + "/repo", + "20260521-015143", + "../../etc/passwd" as unknown as number + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorCode).toBe("ISSUE_INVALID"); + } + }); + + it("returns ok:false with ISSUE_INVALID for a non-positive-integer issue id", () => { + for (const issue of [0, -1, 2.5]) { + const result = resolveSliceProgressPath("/repo", "20260521-015143", issue); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorCode).toBe("ISSUE_INVALID"); + } + } + }); +}); + +describe("resolveSliceProgressPath — valid inputs", () => { + it("places the record inside the run directory under its per-slice filename", () => { + const result = resolveSliceProgressPath("/repo", "20260521-015143", 355); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.path).toBe( + path.join( + "/repo", + ".orchestrate", + "runs", + "20260521-015143", + "slice-355-progress.json" + ) + ); + } + }); + + it("resolves deterministically for the same (repoPath, runId, issue)", () => { + const first = resolveSliceProgressPath("/repo", "20260521-015143", 355); + const second = resolveSliceProgressPath("/repo", "20260521-015143", 355); + expect(first).toEqual(second); + }); + + it("stays inside the run directory resolveRunDir computes", () => { + const runDir = resolveRunDir("/repo", "20260521-015143"); + const progress = resolveSliceProgressPath("/repo", "20260521-015143", 355); + expect(runDir.ok && progress.ok).toBe(true); + if (runDir.ok && progress.ok) { + expect(path.dirname(progress.path)).toBe(runDir.paths.runDir); + } + }); + + it("never collides with the run-state checkpoint sharing the directory", () => { + const runDir = resolveRunDir("/repo", "20260521-015143"); + const progress = resolveSliceProgressPath("/repo", "20260521-015143", 355); + if (runDir.ok && progress.ok) { + for (const p of Object.values(runDir.paths)) { + expect(progress.path).not.toBe(p); + } + } + }); +}); + +// ─── Per-slice AND per-run isolation (ADR-0012) ────────────────────────────── + +describe("resolveSliceProgressPath — no-collision invariant", () => { + it("resolves two runs x two issues to four distinct paths", () => { + const runs = ["prd195-20260521-015143", "backlog-20260521-022540"]; + const issues = [355, 356]; + const paths: string[] = []; + for (const runId of runs) { + for (const issue of issues) { + const result = resolveSliceProgressPath("/repo", runId, issue); + expect(result.ok).toBe(true); + if (result.ok) paths.push(result.path); + } + } + expect(new Set(paths).size).toBe(4); + }); + + it("gives each slice of ONE parallel wave its own record path", () => { + // The intra-run hazard: sibling slices share a run directory, so a single + // `progress.json` would have them clobber each other. + const a = resolveSliceProgressPath("/repo", "prd352-20260803-015333", 355); + const b = resolveSliceProgressPath("/repo", "prd352-20260803-015333", 356); + expect(a.ok && b.ok).toBe(true); + if (a.ok && b.ok) { + expect(a.path).not.toBe(b.path); + expect(path.dirname(a.path)).toBe(path.dirname(b.path)); + } + }); +}); diff --git a/plugins/orchestrate/orchestrate-mcp/test/run-wave.test.ts b/plugins/orchestrate/orchestrate-mcp/test/run-wave.test.ts index 892810e1..f063f9ef 100644 --- a/plugins/orchestrate/orchestrate-mcp/test/run-wave.test.ts +++ b/plugins/orchestrate/orchestrate-mcp/test/run-wave.test.ts @@ -459,3 +459,167 @@ describe("runWave — operation 'integration-gate'", () => { expect(r.verdict).toBe("tolerate"); }); }); + +// ─── plan-wave-width ────────────────────────────────────────────────────────── +// +// A parallel wave holds roughly twice as many live agents as it has slices — +// the slice executor plus the one worker it currently has running — so the +// wave's width is computed against the platform's concurrent-subagent limit +// instead of assumed to be "every processable slice". + +describe("runWave — operation 'plan-wave-width'", () => { + it("caps the wave at half the concurrency limit (two agent slots per slice)", async () => { + const r = await runWave({ + operation: "plan-wave-width", + processableCount: 30, + concurrencyLimit: 20, + }); + + expect(r.status).toBe("ok"); + expect(r.verdict).toBe("width-planned"); + expect(r.waveWidth).toBe(10); + expect(r.deferredCount).toBe(20); + }); + + it("defaults to the platform's own default limit of 20 (width 10)", async () => { + const r = await runWave({ + operation: "plan-wave-width", + processableCount: 12, + }); + + expect(r.waveWidth).toBe(10); + expect(r.deferredCount).toBe(2); + }); + + it("defers nothing when the processable slices already fit", async () => { + const r = await runWave({ + operation: "plan-wave-width", + processableCount: 4, + concurrencyLimit: 20, + }); + + expect(r.waveWidth).toBe(4); + expect(r.deferredCount).toBe(0); + }); + + it("floors the width at 1 so a limit of 1 cannot deadlock the wave", async () => { + const r = await runWave({ + operation: "plan-wave-width", + processableCount: 5, + concurrencyLimit: 1, + }); + + expect(r.waveWidth).toBe(1); + expect(r.deferredCount).toBe(4); + }); + + it("scales with a raised concurrency limit", async () => { + const r = await runWave({ + operation: "plan-wave-width", + processableCount: 50, + concurrencyLimit: 40, + }); + + expect(r.waveWidth).toBe(20); + expect(r.deferredCount).toBe(30); + }); + + it("returns verdict error when processableCount is missing", async () => { + const r = await runWave({ operation: "plan-wave-width" }); + + expect(r.status).toBe("failed"); + expect(r.verdict).toBe("error"); + expect(r.errorCode).toBe("INVALID_INPUT"); + }); + + it("returns verdict error for a non-positive processableCount", async () => { + const r = await runWave({ + operation: "plan-wave-width", + processableCount: 0, + }); + + expect(r.verdict).toBe("error"); + expect(r.errorCode).toBe("INVALID_INPUT"); + }); +}); + +// ─── classify-spawn-outcome ─────────────────────────────────────────────────── +// +// A concurrency-limit refusal is BACKPRESSURE, never a slice failure: nothing +// is wrong with the slice, so it returns to the queue with its state unchanged. +// The session spawn budget is a different animal — it is spent, not busy — and +// the two must never be conflated. + +describe("runWave — operation 'classify-spawn-outcome'", () => { + it("classifies a concurrent-subagent-limit refusal as backpressure", async () => { + const r = await runWave({ + operation: "classify-spawn-outcome", + spawnFailureText: + "Concurrent subagent limit reached (20). Do not retry this spawn.", + }); + + expect(r.verdict).toBe("backpressure"); + expect(r.limitSignal).toBe("concurrent-subagent-limit"); + }); + + it("never reports a failed status for backpressure", async () => { + const r = await runWave({ + operation: "classify-spawn-outcome", + spawnFailureText: "Concurrent subagent limit reached", + }); + + // A backpressure classification must not read as a slice failure anywhere + // in the structured result — no failed status, no error code, no message. + expect(r.status).toBe("ok"); + expect(r.errorCode).toBeUndefined(); + expect(r.errorMessage).toBeUndefined(); + }); + + it("matches the refusal literal case-insensitively", async () => { + const r = await runWave({ + operation: "classify-spawn-outcome", + spawnFailureText: "Error: CONCURRENT SUBAGENT LIMIT REACHED", + }); + + expect(r.verdict).toBe("backpressure"); + }); + + it("keeps the session spawn budget distinguishable from backpressure", async () => { + const r = await runWave({ + operation: "classify-spawn-outcome", + spawnFailureText: "Subagent spawn limit reached (200 per session).", + }); + + expect(r.status).toBe("failed"); + expect(r.verdict).toBe("spawn-error"); + expect(r.limitSignal).toBe("session-spawn-limit"); + }); + + it("defaults an unrecognized failure to spawn-error, never to backpressure", async () => { + const r = await runWave({ + operation: "classify-spawn-outcome", + spawnFailureText: "ECONNRESET while starting the subagent", + }); + + expect(r.status).toBe("failed"); + expect(r.verdict).toBe("spawn-error"); + expect(r.limitSignal).toBe("unrecognized"); + }); + + it("returns verdict error when spawnFailureText is missing", async () => { + const r = await runWave({ operation: "classify-spawn-outcome" }); + + expect(r.verdict).toBe("error"); + expect(r.errorCode).toBe("INVALID_INPUT"); + }); + + it("is pure — it needs no repoPath and touches no git state", async () => { + const r = await runWave({ + operation: "classify-spawn-outcome", + spawnFailureText: "Concurrent subagent limit reached", + }); + + expect(r.verdict).toBe("backpressure"); + expect(r.sha).toBeUndefined(); + }); +}); diff --git a/plugins/orchestrate/orchestrate-mcp/test/spawn-successor.test.ts b/plugins/orchestrate/orchestrate-mcp/test/spawn-successor.test.ts index bbb0780a..322b621f 100644 --- a/plugins/orchestrate/orchestrate-mcp/test/spawn-successor.test.ts +++ b/plugins/orchestrate/orchestrate-mcp/test/spawn-successor.test.ts @@ -166,6 +166,50 @@ describe("loadHandoffConfig", () => { }); }); +// ─── loadHandoffConfig — the spawn-budget threshold ─────────────────────────── +// +// Both spawn fields carry a `.default()`, so the loader needs no change to +// serve them: an absent, malformed, or off-schema file resolves to a complete +// config exactly as it did before, and the `warning` shape is unchanged. + +describe("loadHandoffConfig — spawn-budget defaults", () => { + it("defaults the spawn budget and threshold when handoff.json is absent", () => { + const dir = project(); + const { config, warning } = loadHandoffConfig(dir); + expect(warning).toBeNull(); + expect(config.watchdog.sessionSpawnBudget).toBe(200); + expect(config.watchdog.spawnThresholdPercent).toBe(40); + }); + + it("keeps the spawn defaults when handoff.json is malformed JSON", () => { + const dir = project("{ not valid json"); + const { config, warning } = loadHandoffConfig(dir); + expect(warning).not.toBeNull(); + expect(typeof warning).toBe("string"); + expect(config.watchdog.sessionSpawnBudget).toBe(200); + expect(config.watchdog.spawnThresholdPercent).toBe(40); + }); + + it("keeps the spawn defaults when handoff.json is off-schema", () => { + const dir = project({ watchdog: { thresholdPercent: "high" } }); + const { config, warning } = loadHandoffConfig(dir); + expect(warning).not.toBeNull(); + expect(config.watchdog.sessionSpawnBudget).toBe(200); + expect(config.watchdog.spawnThresholdPercent).toBe(40); + expect(config.watchdog.thresholdPercent).toBe(40); + }); + + it("merges a partial spawn config over the defaults", () => { + const dir = project({ watchdog: { sessionSpawnBudget: 400 } }); + const { config, warning } = loadHandoffConfig(dir); + expect(warning).toBeNull(); + expect(config.watchdog.sessionSpawnBudget).toBe(400); + // Untouched fields — including the other spawn knob — keep their defaults. + expect(config.watchdog.spawnThresholdPercent).toBe(40); + expect(config.watchdog.contextWindowTokens).toBe(200000); + }); +}); + // ─── spawnSuccessor ─────────────────────────────────────────────────────────── describe("spawnSuccessor", () => { diff --git a/plugins/orchestrate/orchestrate-mcp/test/validate-envelope.test.ts b/plugins/orchestrate/orchestrate-mcp/test/validate-envelope.test.ts index 39e48c4f..fba666b2 100644 --- a/plugins/orchestrate/orchestrate-mcp/test/validate-envelope.test.ts +++ b/plugins/orchestrate/orchestrate-mcp/test/validate-envelope.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from "vitest"; -import { validateEnvelope } from "../src/tools/validate-envelope.js"; +import { + SLICE_EXECUTOR_FAILURE_CLASSES, + validateEnvelope, +} from "../src/tools/validate-envelope.js"; // ─── Test helpers ───────────────────────────────────────────────────────────── @@ -66,6 +69,30 @@ function investigatorEnvelope() { }; } +/** + * A complete, valid slice-executor envelope — the literal shape from issue + * #354, reproduced verbatim (placeholders filled) because that shape is the + * settled contract, not merely an illustration. + */ +function sliceExecutorEnvelope() { + return { + role: "slice-executor" as const, + status: "failed" as const, + failedStage: "implementer" as const, + failureClass: "incomplete-budget-exhausted" as const, + failureReason: + "The nested continuation loop exhausted its budget before the " + + "implementer reached a verified changeset.", + reportPath: "reports/slice-354.md", + nextTaskBriefing: + "Done: schema groundwork. Left: none for this slice. Resume by " + + "re-spawning the implementer in the same worktree.", + filesChanged: ["src/tools/foo.ts"], + verification: { tests: "passed" as const, build: "passed" as const }, + fallbackTaken: true, + }; +} + // ─── valid envelopes, one per role ──────────────────────────────────────────── describe("validateEnvelope — valid envelopes", () => { @@ -110,6 +137,17 @@ describe("validateEnvelope — valid envelopes", () => { expect(r.envelope).toEqual(investigatorEnvelope()); }); + it("accepts a valid slice-executor envelope and returns the parsed object as the single source of the slice's outcome", () => { + const r = validateEnvelope({ + text: fenced(sliceExecutorEnvelope()), + role: "slice-executor", + }); + + expect(r.status).toBe("valid"); + expect(r.envelope).toEqual(sliceExecutorEnvelope()); + expect(r.errorCode).toBeUndefined(); + }); + it("accepts an implementer envelope with an empty filesChanged list", () => { const env = { ...implementerEnvelope(), filesChanged: [] }; const r = validateEnvelope({ text: fenced(env), role: "implementer" }); @@ -377,6 +415,128 @@ describe("validateEnvelope — malformed and schema-invalid envelopes", () => { }); }); +// ─── slice-executor role (#354) ─────────────────────────────────────────────── + +describe("validateEnvelope — slice-executor role", () => { + it("accepts a slice-executor envelope reporting a successful slice", () => { + // A 'completed' outcome carries none of the failure-only fields. + const env = { + role: "slice-executor" as const, + status: "completed" as const, + reportPath: "reports/slice-354.md", + nextTaskBriefing: + "Schema groundwork landed; the next slice can build the executor " + + "agent against it.", + filesChanged: ["src/tools/foo.ts", "test/foo.test.ts"], + verification: { tests: "passed", typecheck: "passed", build: "passed" }, + fallbackTaken: false, + }; + const r = validateEnvelope({ text: fenced(env), role: "slice-executor" }); + + expect(r.status).toBe("valid"); + expect(r.envelope).toEqual(env); + expect(r.errorCode).toBeUndefined(); + }); + + it("reports a truncated slice-executor envelope (unclosed fence) as invalid", () => { + const text = [ + "Some prose.", + "", + "```orchestrate-envelope", + '{\n "role": "slice-executor",\n "status": "fail', + ].join("\n"); + + const r = validateEnvelope({ text, role: "slice-executor" }); + + expect(r.status).toBe("invalid"); + expect(r.errorCode).toBeDefined(); + expect(r.envelope).toBeUndefined(); + }); + + it("reports an off-schema slice-executor envelope (missing reportPath) as invalid", () => { + const { reportPath: _reportPath, ...withoutReportPath } = + sliceExecutorEnvelope(); + const r = validateEnvelope({ + text: fenced(withoutReportPath), + role: "slice-executor", + }); + + expect(r.status).toBe("invalid"); + expect(r.errorCode).toBe("SCHEMA_MISMATCH"); + expect(r.envelope).toBeUndefined(); + }); + + it("reports a slice-executor envelope whose failureClass falls outside the closed set as invalid", () => { + const env = { ...sliceExecutorEnvelope(), failureClass: "gave-up" }; + const r = validateEnvelope({ text: fenced(env), role: "slice-executor" }); + + expect(r.status).toBe("invalid"); + expect(r.errorCode).toBe("SCHEMA_MISMATCH"); + expect(r.envelope).toBeUndefined(); + }); + + it("reports a slice-executor envelope with an unknown status value as invalid", () => { + const env = { ...sliceExecutorEnvelope(), status: "loop-continue" }; + const r = validateEnvelope({ text: fenced(env), role: "slice-executor" }); + + expect(r.status).toBe("invalid"); + expect(r.envelope).toBeUndefined(); + }); + + it("reports a slice-executor envelope validated against a mismatched role as invalid", () => { + const r = validateEnvelope({ + text: fenced(sliceExecutorEnvelope()), + role: "implementer", + }); + + expect(r.status).toBe("invalid"); + expect(r.envelope).toBeUndefined(); + }); + + it("reports text with no envelope fence at all as missing for slice-executor", () => { + const text = "The executor wrote a prose summary and forgot the envelope."; + const r = validateEnvelope({ text, role: "slice-executor" }); + + expect(r.status).toBe("missing"); + expect(r.envelope).toBeUndefined(); + }); + + it("accepts every closed-set failureClass value paired with its natural failedStage", () => { + // Driven by the exported closed set rather than a restated literal list — + // the set is defined in exactly one place, and this test reads that one + // definition instead of duplicating it. `test/` is excluded from + // tsconfig, so the Record's exhaustiveness is NOT enforced at compile + // time; the assertion inside the loop enforces it at run time, so an + // added class with no stage mapped here fails loudly instead of silently + // dropping to an absent (optional) `failedStage`. + const naturalStage: Record< + (typeof SLICE_EXECUTOR_FAILURE_CLASSES)[number], + "investigator" | "implementer" | "capability-gate" | "reviewer" + > = { + "unrecoverable-obstacle": "capability-gate", + "incomplete-budget-exhausted": "implementer", + "no-progress-stall": "investigator", + "invalid-or-missing-worker-envelope": "implementer", + "changeset-mismatch": "implementer", + "empty-changeset": "implementer", + "model-refusal": "reviewer", + }; + + for (const failureClass of SLICE_EXECUTOR_FAILURE_CLASSES) { + expect(naturalStage[failureClass]).toBeDefined(); + + const env = { + ...sliceExecutorEnvelope(), + failureClass, + failedStage: naturalStage[failureClass], + }; + const r = validateEnvelope({ text: fenced(env), role: "slice-executor" }); + + expect(r.status).toBe("valid"); + } + }); +}); + // ─── missing envelopes ──────────────────────────────────────────────────────── describe("validateEnvelope — missing envelopes", () => { diff --git a/plugins/orchestrate/skills/orchestrate/SKILL.md b/plugins/orchestrate/skills/orchestrate/SKILL.md index 3f27602a..c3cc79ab 100644 --- a/plugins/orchestrate/skills/orchestrate/SKILL.md +++ b/plugins/orchestrate/skills/orchestrate/SKILL.md @@ -1,6 +1,6 @@ --- name: orchestrate -description: Implement a backlog of ready-for-agent GitHub issues end to end — order them into dependency waves, run implementer and reviewer subagents in isolated worktrees, merge slice pull requests into an umbrella branch, and checkpoint progress so an interrupted run resumes. Use when the user wants to autonomously orchestrate agent-driven implementation of tracked issues, or invokes /orchestrate in one of its three modes — a normal run (/orchestrate or /orchestrate ); /orchestrate clean (including --force, or --failed ) to remove the footprint of concluded or crashed runs; or /orchestrate preflight , the pre-flight pass that stages and inspects a run's setup before the wave loop. +description: Implement a backlog of ready-for-agent GitHub issues end to end — order them into dependency waves, delegate each slice to a slice-executor subagent in an isolated worktree, merge slice pull requests into an umbrella branch, and checkpoint progress so an interrupted run resumes. Use when the user wants to autonomously orchestrate agent-driven implementation of tracked issues, or invokes /orchestrate in one of its three modes — a normal run (/orchestrate or /orchestrate ); /orchestrate clean (including --force, or --failed ) to remove the footprint of concluded or crashed runs; or /orchestrate preflight , the pre-flight pass that stages and inspects a run's setup before the wave loop. --- # Orchestrate @@ -15,12 +15,20 @@ orchestration judgment that cannot be extracted to an `orchestrate-mcp` tool or subagent. Deterministic procedure lives in the MCP tools; the step-by-step operational mechanics of each phase live in on-demand `references/*.md`, loaded only when that phase runs. Read the spine top-to-bottom for the decision -narrative; follow each pointer into its reference for the mechanics. (The spine -is the irreducible judgment residue; after the #275 procedural-prose relocation -it lands near ~425 lines, **within** the project's 500-line `SKILL.md` cap. A -documented over-cap exception for this spine remains on record — see `CONTEXT.md` -and ADR-0013 — so the spine is never flagged as bloat should its judgment grow -back over the cap; do **not** generalize that exception to any other skill.) +narrative; follow each pointer into its reference for the mechanics. + +**On this file's length.** The spine is the irreducible judgment residue, and it +is **over** the project's 500-line `SKILL.md` cap: **542 lines of body** (546 +total, less 4 lines of frontmatter — the cap is on the body, so that is the +number being reported). It therefore **invokes the documented over-cap exception +recorded in ADR-0013 and `CONTEXT.md`**, explicitly and at that measured figure. +The ADR-0017 delegation is what put it here: handing the intra-slice procedure to +the slice executor removed the mechanics from section 3, but the spine acquired +three things it never carried before — the executor's briefing contract, the +structured recovery path for an unusable envelope, and the read boundary that +keeps slice-internal artifacts closed to the orchestrator. Those are judgment, +not procedure, so they belong here rather than in a reference. Do **not** +generalize this exception to any other skill. ## Roles and the safety boundary @@ -28,37 +36,44 @@ back over the cap; do **not** generalize that exception to any other skill.) shell operation: branches, worktrees, commits, pushes, pull requests, merges, and the `run-state.json` checkpoint. You also assess each issue's complexity tier and route each role accordingly. -- **Investigator** — the `investigator` subagent. For higher-complexity issues - only, it explores the codebase read-only and returns a research brief the - implementer builds on. -- **Implementer** — the `implementer` subagent. It edits code in an isolated - worktree and verifies it through the orchestrate capability tools. -- **Reviewer** — the `reviewer` subagent. It reviews the implemented slice in - the same worktree, fixes issues inline, re-runs the capability tools, and - gates the auto-merge. -- **Conflict-resolver** — the `conflict-resolver` subagent. When a slice - conflicts with the umbrella branch, it edits the conflicted files to a - correct merged state. It is spawned once per conflicting slice. +- **Slice executor** — the `slice-executor` subagent, and the only one you spawn + per slice. It owns one issue from investigation through to a verified + changeset, inside the worktree you created for it. Its operating procedure is + the `slice-pipeline` skill preloaded by its own definition, so none of that + procedure lives here. +- **Investigator**, **Implementer**, **Reviewer** — the workers the *executor* + spawns, not you. The investigator explores read-only and returns a research + brief; the implementer edits code in the worktree; the reviewer reviews it + there, fixes issues inline, and re-runs the capability tools. +- **Conflict-resolver** — the `conflict-resolver` subagent, spawned by **you**, + once per conflicting slice. When a slice conflicts with the umbrella branch it + edits the conflicted files to a correct merged state. It stays yours because a + conflict is between two branches, and branches are git. Every role except the orchestrator exists in two variants — `-standard` and `-deep`. The `resolve_routing` tool picks the variant and model per role from the issue's complexity tier **and its routing labels** (section 3, step 2). Each role is spawned by -its **namespaced** subagent type — `orchestrate:investigator-`, -`orchestrate:implementer-`, `orchestrate:reviewer-`, and +its **namespaced** subagent type — `orchestrate:slice-executor-`, +`orchestrate:investigator-`, `orchestrate:implementer-`, +`orchestrate:reviewer-`, and `orchestrate:conflict-resolver-`, where `` is `standard` or `deep`. The `orchestrate:` prefix is required: the plugin registers its bundled subagents under that namespace, so a bare, un-namespaced name fails to resolve. -All four subagents have **no Bash and no git access** — they are sandboxed to +All five subagents have **no Bash and no git access** — they are sandboxed to one worktree (the investigator is read-only). Only the orchestrator touches -branches, remotes, and the tracker. +branches, remotes, and the tracker; the executor's own instructions bind it to +the same boundary, and it never writes this run's checkpoint. **Routing labels — read once, frozen, suggested never applied.** A slice issue may carry a `route:*` label that patches its routing (e.g. `route:fable`, the label-gated implementer-only premium lane). `resolve_routing` reads those labels **exactly once**, at slice creation (section 3, step 2): the orchestrator passes the issue's labels to the tool and **freezes** the returned `{model, variant, -optional fallback}` into the slice's `resolvedRouting` checkpoint field. A +optional fallback}` into the slice's `resolvedRouting` checkpoint field. That +frozen block is exactly what the executor's briefing carries (section 3, step 3): +the executor never resolves routing itself, so freezing here is what keeps a +slice's routing from drifting mid-run. A resumed run routes from that frozen checkpoint, **never** from live GitHub labels — relabelling an issue mid-run changes nothing. The orchestrator may **suggest** a `route:*` label for a slice in its report but **never applies one @@ -79,9 +94,10 @@ tests pass; trust their result, never an editor's inline diagnostic. Check the prerequisites (`gh` authenticated, an `origin/development` integration base, the MCP server available, branch protection open on the orchestrate branches) before starting; if one is missing, report it and stop. The target -project's `.orchestrate/` config may be bootstrapped on the first run by -`bootstrap_config` or committed ahead of time. See -`references/prerequisites.md`. +project's `.orchestrate/` config is completed on every run by `bootstrap_config` +(called unconditionally, never gated on whether the directory already exists) +or may be committed ahead of time; a `falseGreenRisk` result is itself a +"report it and stop" condition. See `references/prerequisites.md`. ## 0. Modes — run, clean, preflight @@ -104,8 +120,14 @@ This skill has three modes, selected by the invocation argument. section 1's `in-progress`-only run-discovery — a concluded-but-uncleaned run still counts). If any match exists → report that run and **stop**; never fall through to section 1. On **zero matches**, run **only** `references/preflight-mode.md` - (fresh-run steps 1–6, then the resumable checkpoint), then **stop before the - wave loop** — do not create worktrees or slice branches, do not enter section 2. + (fresh-run steps 1–6, the resumable checkpoint, then the **capability probe** — + which executes the configured verbs once in a throwaway dependency-free + checkout and reports, per verb, what the gate will actually verify), then + **stop before the wave loop** — no slice branch and no slice worktree is + created, do not enter section 2. A failing probe is a loud report, never a + stop: the checkpoint is already written and the operator decides whether to + resume. `bootstrap_config`'s `falseGreenRisk` stop (Prerequisites, above) is + unchanged and fires earlier. ## 1. Start or resume the run @@ -169,52 +191,53 @@ time. Do **not** re-fetch the backlog, re-call `filter_to_one_parent_prd`, or re-call `partition_backlog`: a resumed run never widens or re-derives its own scope. Every slice in a terminal state (`passed`, `failed`, `skipped`) is left untouched — completed work is never redone. Every slice still `in-progress` was -interrupted before finishing; resume it **from its recorded `subState`** -(section 3 writes this at every per-slice transition) rather than re-processing -from scratch: **preserve** its `worktreePath` and `sliceBranch`, reconstruct its -changed-file set with `recover_changed_files`, **re-validate** the resume point -per the matrix below, then resume section 3 at the next uncompleted step -**without** re-spawning the subagents whose work the recorded `subState` already -captures. An in-progress slice with **no `subState`** (a legacy checkpoint) uses -the old discard path instead — discard its worktree+branch and coerce it back to -`pending`. The full discard mechanics and the run-discovery scan live in -`references/run-lifecycle.md`. Checkpoint the refreshed `run-state.json`, then -skip to section 2. - -**Resume re-validate matrix** — per the recorded `subState`; the worktree is -always preserved and the changed-file set always reconstructed via -`recover_changed_files` (accurate under #236's `-uall` recovery): - -| Recorded `subState` | Skip these subagents | Re-validate (cheap) | Resume at | -|---|---|---|---| -| (absent / legacy) | — | — | discard worktree+branch, coerce to `pending`, reprocess | -| `implemented` | investigator, implementer | run the capability gate (may have crashed mid-run) | step 5 (reviewer) after the `verified` gate | -| `verified` | investigator, implementer | re-run the capability gate (confirms worktree intact) | step 5 (reviewer) | -| `reviewed` | investigator, implementer, reviewer | re-run the capability gate | step 6 (commit + push) | -| `pushed` | implementer, reviewer | `git ls-remote --heads origin orchestrate/slice-` confirms the branch | step 7 (open PR) | -| `pr-open` | implementer, reviewer | `gh pr view` confirms the PR; `git ls-remote` confirms the branch | step 8 (merge) | -| `merged` | all subagents | — (merge already landed in umbrella) | step 9 only (label transition + `remove_worktree`) | +interrupted before finishing; resume it per the matrix below rather than +re-processing from scratch: **preserve** its `worktreePath` and `sliceBranch`, +and resume section 3 at the next uncompleted step. The full discard mechanics +and the run-discovery scan live in `references/run-lifecycle.md`. Checkpoint the +refreshed `run-state.json`, then skip to section 2. + +**Resume re-anchors on the slice's progress record, not on a fine-grained +`subState`.** The three intra-slice subStates you used to write — +`implemented`, `verified`, `reviewed` — mark stages you can no longer observe, so +a slice interrupted mid-execution carries **no `subState` at all** and its resume +point lives in the executor's own **slice progress record**. Ask for it through +`recover_slice_progress` (`runId` + issue number; it derives the path, and you +never open the file). The three that remain are the integration tail's, and they +resume exactly as they always did. + +| Recorded `subState` | Resume by | +|---|---| +| (absent) | Call `recover_slice_progress`. On `status: "ok"` a record exists, so the worktree holds real work — **preserve it** and re-spawn the slice executor with the same frozen `resolvedRouting`, the same progress-record path, and `executorContinuationIndex` incremented. The executor reads its own record and resumes from its `lastCompletedStage`; you do not tell it where to restart, and you do not re-run its capability gate. On `PROGRESS_NOT_FOUND` — no stage ever completed, or a legacy pre-delegation checkpoint — take the old discard path: discard worktree+branch, coerce to `pending`, reprocess. On `PROGRESS_INVALID` the record cannot be trusted: the slice has **FAILED**, with the tool's `errorMessage` in the `failureReason`. | +| `pushed` | `git ls-remote --heads origin orchestrate/slice-` confirms the branch → step 7 (open PR) | +| `pr-open` | `gh pr view` confirms the PR; `git ls-remote` confirms the branch → step 8 (merge) | +| `merged` | — (merge already landed in umbrella) → step 9 only (label transition + `remove_worktree`) | + +A **legacy intra-slice `subState`** — `implemented`, `verified`, `reviewed`, from +a checkpoint written before the delegation layer — still validates, and is +handled exactly as `(absent)`: those stages are no longer yours to resume into. **Resume routing is frozen, not re-derived (the fallback-aware dimension).** Orthogonal to the `subState` row above: when a resumed in-progress slice -**re-spawns** any subagent (the rows that do not skip the implementer/reviewer), -it routes **only** from the slice's frozen `resolvedRouting` checkpoint — its +**re-spawns** the executor, it routes **only** from the slice's frozen +`resolvedRouting` checkpoint — its recorded `model`, `variant`, and `fallback` — never from live GitHub labels and never by re-calling `resolve_routing`. A slice whose `resolvedRouting.fallbackTaken` -is `true` (a premium-spawned implementer that already failed over to the fallback -model in the prior session, section 3) resumes on that **frozen fallback model** +is `true` (a premium lane that already failed over to the fallback +model in a prior session) resumes on that **frozen fallback model** — the premium lane is **not** re-applied and the one-time fallback is **not** re-armed. This keeps routing deterministic across a handoff: the label was read once at slice creation, and the checkpoint — not the issue's current labels — is -the source of truth for every re-spawn. +the source of truth for every re-spawn. The executor's own record carries a +second `fallbackTaken` copy, because an executor cannot write your checkpoint; +the duplication is deliberate and the two are not to be unified. -On resume the implementer's *declared* `filesChanged` is gone, so the -reconstructed `recover_changed_files` set feeds the reviewer prompt and the -step-6 commit staging exactly as the live path uses the declared set. Do -**not** route reconstruction through `verify_changeset` (it needs a -`declaredFiles` argument that no longer exists on resume). For pre-push -subStates the capability gate is the re-validate; for `pushed`/`pr-open` it is -`git ls-remote` / `gh pr view`. +On a slice that failed without a usable envelope, the executor's declared +`filesChanged` is unavailable, so reconstruct the changed-file set with +`recover_changed_files` (accurate under #236's `-uall` recovery) — it is what +the failure record reports and, where the slice still integrates, what step 6 +stages. Do **not** route reconstruction through `verify_changeset`: that tool is +the executor's, and it needs a `declaredFiles` argument you do not have. ## 2. The wave loop @@ -232,12 +255,19 @@ run policy. different guarantees, and the choice is a deliberate judgment: - **`parallel` (the default)** processes a wave's independent slices - concurrently — each in its own worktree, spawned in a single message at each - shared subagent stage — then integrates them **sequentially** (merges into the + concurrently — one slice executor per slice, all spawned in a single message, + each in its own worktree — then integrates them **sequentially** (merges into the umbrella must not race). Because every slice branches from the wave's *starting* umbrella, a parallel wave needs the per-slice post-merge unit re-verify and may need the conflict-resolver when two slices touch the same - region. + region. How many of a wave's slices run at once is **capped**, not unbounded: + each in-flight slice holds two live agents (its executor plus the one worker + that executor is running), so the wave's width is planned against the + session's concurrent-subagent limit (`run_wave` `plan-wave-width`) and the + remainder is deferred, keeping its state. A refusal that slips through anyway + is **backpressure, never a slice failure** — the slice returns to the queue + unchanged (`run_wave` `classify-spawn-outcome`). Both are in + `references/wave-loop.md` step 3. - **`sequential`** processes the wave's slices **one at a time, in issue-id ascending order**, each branching from `base + slice1..N-1` — the integrated state of every earlier slice in the wave. This imposes a deterministic order @@ -262,46 +292,96 @@ starting the next slice. ## 3. Processing one slice -These are the per-slice steps the wave loop invokes. Update the slice's entry in -`run-state.json` and write the file at every state change. The step-by-step -procedure — create worktree, resolve routing, the investigator/implementer/ -reviewer spawn mechanics, the changeset scope check, the pre-merge capability -gate, commit+push via `finalize_slice`, the slice PR, merge, conflict resolution -via `resolve_merge_conflict`, and finishing via `finalize_slice` `post-merge` — -is in `references/slice-pipeline.md`. - -**The result-envelope trust chain.** Every subagent ends its turn with a **result -envelope** — a fenced ` ```orchestrate-envelope ` JSON block conforming to a -defined schema. The orchestrator determines a subagent's status and -changed-file set **only** from this validated envelope; it never reads the -subagent's prose. After each subagent (investigator, implementer, reviewer, -conflict-resolver) returns, call the `validate_envelope` MCP tool with the -subagent's verbatim returned text and its `role`: - -- `status: "valid"` — use the parsed `envelope` as the single source of the - subagent's outcome and `filesChanged`. +A slice is **five steps**, because you no longer run one. You prepare the ground, +delegate the whole slice to one executor, and act on the single envelope it +returns. Update the slice's entry in `run-state.json` and write the file at every +state change; the mechanics of steps 1, 2 and the integration tail are in +`references/slice-pipeline.md`. + +1. **Create the worktree** — `create_worktree`, at step 1 of the reference. + An error, including a failed `install`, is a FAILED slice. +2. **Resolve routing and freeze it** — `resolve_routing`, at step 2. Freeze the + per-role `{model, variant}` blocks (the `slice-executor` role among them), + the implementer's `fallback`, and `fallbackTaken: false` into + `resolvedRouting`, and keep the returned `continuationBudget`. +3. **Spawn the slice executor** — subagent type + `orchestrate:slice-executor-`, with the Agent `model` override taken + from `resolvedRouting["slice-executor"]`. Its briefing is below. +4. **Validate the returned envelope** — `validate_envelope` with the executor's + **verbatim** returned text and role `slice-executor`. +5. **Act on the result** — integrate it, or classify and label the failure. + +**Nothing happens between steps 3 and 4.** The executor returns exactly one +envelope, and you have no visibility into the stages that produced it, so you +write **no** `subState` while it runs — its own progress record holds that +granularity now. Resist the urge to narrate its stages; you do not know them. +(The one path that is not a return at all is a **spawn refusal**, which never +produces an envelope: `run_wave` `classify-spawn-outcome` decides +backpressure-versus-error, and that belongs to the wave loop.) + +**The briefing (step 3).** The executor starts with an empty context and is never +invoked by a person, so everything it needs must arrive here: the **issue** +number, title and body; the **acceptance criteria**, explicitly named as the hard +scope boundary, which it forwards to every worker it spawns; the **worktree +path**; the **run id** and issue number, which make its record self-identifying; +the frozen **`resolvedRouting`** with its `fallback` (a null `investigator` entry +means skip investigation); the **`continuationBudget`** from step 2; the **run +directory** to write its report into; the **progress-record path**; and its +**`executorContinuationIndex`** — 1-based, `1` on your first spawn for this +slice, incremented on each re-spawn. + +Two carry a reason worth stating, because getting them wrong is silent. +**Deriving the record's path is not reading it** — you compose +`.orchestrate/runs//slice--progress.json` and pass the string. +And the **index exists because neither party holds both factors of the +continuation bound**: two loops nest — yours re-spawning the executor, its own +re-spawning the implementer — and the bound is on their product. You know the +outer index and cannot know the inner count; a freshly spawned executor knows its +count and cannot know it is your second. Passing the index makes it the only +party that can evaluate the bound, which is why the formula lives there and not +here. + +**The result-envelope trust chain (step 4).** Every subagent ends its turn with a +**result envelope** — a fenced ` ```orchestrate-envelope ` JSON block conforming +to a defined schema. You determine its status and changed-file set **only** from +that validated envelope; you never read its prose. + +- `status: "valid"` — the parsed `envelope` is the single source of the slice's + outcome and `filesChanged`. - `status: "invalid"` (truncated, malformed, or off-schema) or - `status: "missing"` (no envelope emitted) — the subagent's result cannot be - trusted. The slice has **FAILED** (see *Failure handling*). A truncated - envelope is never silently accepted. - -This validated-envelope chain extends through the whole pipeline: the implementer -envelope is cross-checked against the worktree by `verify_changeset` (step 4a), -and after the reviewer returns `passed` the orchestrator runs its **own** -deterministic pre-merge capability gate (step 5a) rather than trusting the -reviewer's self-reported `verification` — the last link in the -`implementer → reviewer → orchestrator` trust chain. Each link's mechanics are -in `references/slice-pipeline.md`. + `status: "missing"` — the result cannot be trusted, but this is **not** an + immediate discard: the worktree may hold a finished investigation and review. + Recover through `recover_slice_progress` and branch exactly as the resume + matrix's `(absent)` row does. A truncated envelope is never accepted as + success. + +The chain is now two links, not four — the executor gates its own workers and +reports one settled outcome; validating that report is yours. Its +`nextTaskBriefing` is **advice only**, never a selection of what runs next. + +**You never open a slice-internal artifact.** Not the executor's report, not its +progress record. Use the envelope's fields for the outcome, pass `reportPath` +forward without opening it, and go through `recover_slice_progress` when the +envelope fails you — it derives the path from `(runId, issue)` and returns +validated structured data. A `PreToolUse` read guard enforces this, but it is +**defence in depth, not the rule itself**: enterprise policy can disable plugin +hooks, so this paragraph stays load-bearing and must not be deleted on the +grounds that the hook covers it. ## 4. Context handoff -A long run can fill this session's context before every wave is done. The -`context-watchdog` hook bundled with this plugin watches token usage and writes -`.orchestrate/runs//context-flag.json` past a configurable threshold. -When the wave loop (section 2, step 4) sees that flag, hand the run off to a -fresh Claude Code session instead of continuing — the successor resumes from -the `run-state.json` checkpoint exactly as section 1 describes. See -`references/context-handoff.md` for the full mechanism. +A long run can exhaust this session before every wave is done — by filling its +context **or** by spending its subagent-spawn budget. The `context-watchdog` +hook bundled with this plugin watches **both**: token usage against the context +window, and **this session's** recorded spawn count against the session spawn +budget (default 200, at roughly five spawns per slice — the spawn log is kept +per run but counted per session, so a successor starts from a fresh budget). It writes +`.orchestrate/runs//context-flag.json` past whichever threshold is +reached first — at most once per run either way — recording which one in the +flag's `trigger` field. When the wave loop (section 2, step 4) sees that flag, +hand the run off to a fresh Claude Code session instead of continuing — the +successor resumes from the `run-state.json` checkpoint exactly as section 1 +describes. See `references/context-handoff.md` for the full mechanism. The watchdog binds to the correct run by matching this session's identity: it compares the hook event's `session_id` against each in-progress run's @@ -344,68 +424,45 @@ To hand off: ## Failure handling -A slice **FAILS** when `create_worktree` errors, a subagent's result envelope -is invalid or missing (`validate_envelope` returns `invalid` or `missing`), a -validated implementer envelope has `status: "blocked"` — or `status: "incomplete"` -**after** the continue-in-place loop exhausts the continuation budget or trips -the no-progress guard (a single `incomplete` no longer FAILs immediately; see -§3 step 4) — a validated reviewer envelope has `status: "failed"`, -`verify_changeset` reports -the implementer's declared file set does not match the worktree -(`empty-but-declared` or `suspiciously-empty`, or a `status: "error"`), the -staged changeset is empty, or a merge conflict the `conflict-resolver` cannot -fix. The orchestrator decides FAILURE **only** from the validated envelope and -tool results — never from a subagent's prose. An invalid or missing envelope is -always a FAILED slice; it is never treated as success. - -**Model fallback — one premium-spawn interception before FAILED.** A -**premium-spawned** implementer (one whose `resolvedRouting` carried a `fallback` -because a `route:*` label patched it — e.g. `route:fable`) gets **one** rescue -before the slice is declared FAILED. When such an implementer fails in a way the -fallback covers — a model **refusal**, a retention/safety **400**, or an -**invalid/missing envelope** — and the slice's `resolvedRouting.fallbackTaken` is -not yet set, the orchestrator **re-spawns it exactly once on the fallback model** -(`resolvedRouting.fallback.model`, e.g. `opus`) in the **same** worktree, sets -`resolvedRouting.fallbackTaken: true`, and narrates the swap in the final report -("fable declined → served by opus"). This swap is a **model exchange**, distinct -from the same-model continue-in-place loop: it does **not** consume or increment -`continuationBudget`, and `fallbackTaken` is a **persisted slice-level** once-only -guard (set in the checkpoint, surviving a handoff) — so the fallback fires at most -once across the initial spawn and every continuation. A fallback that is absent -(no premium label), already spent (`fallbackTaken` already `true`), or that does -not apply (a *valid* `blocked` envelope is a genuine obstacle the fallback model -would not fix) leaves the ordinary FAILED taxonomy above unchanged. The mechanics -— where the re-spawn runs and how the checkpoint is written — are in -`references/slice-pipeline.md` (the model-fallback step adjacent to step 4). - -The implementer envelope's `incomplete` status is the implementer's graceful -turn-budget self-report — partial, resumable work, carrying a `remainingWork` -handoff — as opposed to `blocked` (an unrecoverable obstacle) or an `invalid` -envelope (a hard turn-limit cutoff that truncated the envelope). Unlike `blocked` -and `invalid`, a single `incomplete` does **not** FAIL the slice: it drives the -bounded continue-in-place loop (§3 step 4), where the orchestrator re-spawns the -implementer in the same preserved worktree with the `remainingWork` until it -returns `completed` or the loop terminates. An `incomplete` slice FAILs **only** -when one of two terminal causes is reached: - -- **Budget exhausted** (resumable) — `continuationsUsed === continuationBudget` - and the last envelope is still `incomplete`. The `failureReason` names the - budget exhaustion ("implementer reported `incomplete` after exhausting the - continuation budget of N; partial work preserved in the worktree for - resumption"); label `needs-info`. -- **No progress** — a continuation returned `incomplete` whose worktree - content-fingerprint equals the prior one (the re-spawn changed nothing). The - `failureReason` names the no-progress stall; label `needs-triage`. - -In both terminal cases the `failureReason` must name the cause precisely so a -developer can tell a resumable budget exhaustion apart from a genuine stall. An -`incomplete` slice's worktree holds usable partial work — preserve it (as every -FAILED slice's worktree is preserved) so the slice can be resumed. - -Once the taxonomy above has classified a slice as FAILED, the orchestrator's -mechanical actions on it — surfacing the envelope's `rootCause`, setting `state` -to `failed` with a `failureReason` and the right `needs-info`/`needs-triage` -label, preserving the worktree, recovering the changed-file set via +**Classification descends; policy stays.** You no longer diagnose *why* a slice +failed — the executor does, because that is where the evidence was, and it +reports the diagnosis as a **failure class** drawn from a closed set it owns. You +map that class to a tracker label, because you are the single writer of tracker +state. Neither half is duplicated: the class set is defined once in +`validate_envelope`'s schema, and the class-to-label mapping once in +`references/failure-handling.md`, beside the `gh` commands that apply it. Do not +restate either here. + +So a slice **FAILS** in exactly three ways now: + +- **Before the executor** — `create_worktree` errors, or `resolve_routing` + returns `LABEL_CONFLICT` / `CONFIG_INVALID`. +- **From the executor** — a validated envelope whose `status` is not + `completed`. Read its `failureClass` and apply the mapping. A validated + `incomplete` is already terminal by the time it reaches you: the executor + exhausted its own bounded continuation before reporting, so there is nothing + for you to re-spawn on its behalf. +- **After the executor** — an envelope that will not validate and that + `recover_slice_progress` cannot rescue (the step-4 branch above), a staged + changeset that is empty, or a merge conflict the `conflict-resolver` cannot + fix. + +You decide FAILURE **only** from the validated envelope and tool results, never +from a subagent's prose. Two cases fall outside a plain class-to-label lookup — +a failure with no surviving `failureClass`, and the one class-plus-reason +combination that is an environment fault rather than a slice fault (the executor +reporting its own operating procedure was never preloaded). Both are rows of the +same `references/failure-handling.md` mapping; do not re-derive either here. + +**Model fallback surfaces, it does not run here.** The one-time premium swap now +happens **inside** the executor, which reports it as `fallbackTaken`. Narrate the +swap in the final report ("fable declined → served by opus") and carry the flag +into `resolvedRouting.fallbackTaken` so a resumed slice does not re-arm a rescue +already spent. + +Once a slice is classified FAILED, the mechanical actions on it — surfacing the +envelope's `rootCause`, setting `state` to `failed` with a `failureReason` and +the mapped label, preserving the worktree, recovering the changed-file set via `recover_changed_files` when the envelope was the failure cause, posting the triage comment, and continuing the wave — are in `references/failure-handling.md`. @@ -475,12 +532,15 @@ the `gh`-op half — together they close #230. Write the run's `run-state.json` — at `.orchestrate/runs//run-state.json` — after every slice state change and after every wave. In addition, write a -slice's `subState` at **every** section-3 per-slice transition -(`implemented` → `verified` → `reviewed` → `pushed` → `pr-open` → `merged`); -that fine-grained checkpoint is the **resume anchor** an interrupted in-progress -slice continues from (section 1), alongside the coarse-`state` and wave -checkpoints. Every write refreshes the top-level `updatedAt`, and a slice's own +slice's `subState` at every **integration-tail** transition +(`pushed` → `pr-open` → `merged`) — and at no other point, because the stages +between spawning the executor and validating its envelope are not yours to +observe. The schema still accepts the three retired intra-slice values +(`implemented`, `verified`, `reviewed`) so a checkpoint written before the +delegation layer still loads; a delegating orchestrator never writes one. +Every write refreshes the top-level `updatedAt`, and a slice's own `updatedAt` whenever its entry changes, so an artifact rendered from the file has accurate timestamps. The checkpoint is what makes a run resumable: an interrupted run, re-invoked, skips every terminal-state slice and resumes every -in-progress slice from its recorded `subState`. +in-progress slice per the resume matrix — from its recorded `subState` in the +integration tail, and from its progress record before that. diff --git a/plugins/orchestrate/skills/orchestrate/references/context-handoff.md b/plugins/orchestrate/skills/orchestrate/references/context-handoff.md index f08e46b4..e6e78e4e 100644 --- a/plugins/orchestrate/skills/orchestrate/references/context-handoff.md +++ b/plugins/orchestrate/skills/orchestrate/references/context-handoff.md @@ -1,7 +1,8 @@ # Context handoff — watchdog, flag, and successor session -A long orchestration run can fill the orchestrator session's context window -before every wave is done. Rather than degrade or stop, the run **hands off**: +A long orchestration run can exhaust the orchestrator session before every wave +is done — either by filling its **context window** or by spending its +**subagent-spawn budget**. Rather than degrade or stop, the run **hands off**: a fresh Claude Code session is launched, it resumes from the `run-state.json` checkpoint, and the predecessor exits. This file documents the three pieces. @@ -13,7 +14,8 @@ checkpoint, and the predecessor exits. This file documents the three pieces. The hook event carries the session `cwd`, `transcript_path`, and `session_id` — never a runId. The watchdog therefore first **discovers the run this session -drives**: +drives**, via a shared run-discovery module any hook can import (not +watchdog-specific internals): - It scans `.orchestrate/runs/*/run-state.json` and considers **only** runs whose `status` is `in-progress` — a completed run is never selected. @@ -43,15 +45,63 @@ sessions even though the plugin is always enabled. When a run is selected it: -1. Reads the session transcript and finds the latest assistant turn's token +1. **Records the spawn**, when the event's `tool_name` is `Agent`: one line is + appended to the run's `spawn-log.jsonl` (see below), tagged with the event's + `session_id`. +2. Reads the session transcript and finds the latest assistant turn's token usage (`input_tokens` + `cache_creation_input_tokens` + `cache_read_input_tokens`). -2. Compares that against the context window and threshold from - `.orchestrate/handoff.json` (defaults: 40% of a 200000-token window). -3. On the first sample that reaches the threshold, writes the selected run's - `.orchestrate/runs//context-flag.json` — under that run's own run - directory, never another's — and surfaces a `systemMessage`. The flag is - written **at most once per run** — once it exists, later samples are no-ops. +3. Compares **two budgets** against their thresholds from + `.orchestrate/handoff.json`: + - **Context window** — used tokens against `contextWindowTokens` and + `thresholdPercent` (defaults: 40% of a 200000-token window). + - **Session spawn budget** — the spawn log's line count **for this session** + against `sessionSpawnBudget` and `spawnThresholdPercent` (defaults: 40% of + 200 spawns). At roughly five spawns per slice, a long run can spend its + spawn budget well before it fills its context, and a watchdog that only + watched tokens would report all-clear while the run walked into an + unrecoverable spawn error. +4. On the first sample where **either** budget reaches its threshold, writes the + selected run's `.orchestrate/runs//context-flag.json` — under that + run's own run directory, never another's — and surfaces a `systemMessage` + naming the budget that raised it. The flag is written **at most once per + run**: `context-flag.json`'s existence is checked before every raise, so + whichever threshold arrives first suppresses every later one, no matter which + budget crosses next. When both cross on the same sample, the flag reports + `trigger: "tokens"` — a deterministic, documented tiebreak, since the flag is + written once and can name only one reason. + +**The two thresholds are independent.** Token usage may be unknown — there is no +transcript, it is unreadable, or its latest turns carry no usage yet, since the +transcript is written asynchronously and may lag. Unknown usage skips only the +**token** comparison; the spawn comparison still runs, and the flag records +`usedTokens: null` rather than pretending usage was zero. + +**Why a spawn log rather than a transcript scan.** The watchdog counts spawns +from a file it writes itself, for two structural reasons. The transcript is read +tail-capped (the watchdog only needs the latest turn), so a tail scan would miss +most of a long run's spawns — and lifting the cap would mean an unbounded +full-file read on every tool call. More decisively, **nested spawns never appear +in the orchestrator's transcript at all**, yet they count fully toward the +platform's session budget — and plugin hooks *do* fire inside subagents, so a +nested spawn is recorded whenever its event resolves to the same active run, +which a transcript scan could never manage. That resolution has two conditions, +and neither is guaranteed for a subagent-fired event: it runs through the +event's `cwd` (the run is discovered by scanning `/.orchestrate/runs/`, +and the vendor documents `cwd` only as the working directory the hook was +invoked in, never stating what a subagent-fired event carries), and it then +needs either a `session_id` match against `driverSessionId` or exactly one +in-progress run — so with two concurrent runs an unmatched event resolves to +nothing. An unresolved event is a silent no-op, so the recorded count is a +**lower bound** on the platform's: the watchdog may raise later than ideal, +never on a spawn that did not happen. + +Two properties of that counting are worth knowing: `PostToolUse` fires after a +tool **succeeds**, so a synchronous `Agent` call is recorded when the subagent +finishes rather than when it starts (acceptable for a cumulative, monotonic +budget — the platform counts a finished subagent too), and spawns made **before +the run started** are not counted, since the watchdog is a no-op until a run is +in progress. The hook never throws and never blocks a tool call; a watchdog that disrupts the session would be worse than one that misses. @@ -64,10 +114,15 @@ the run's per-run directory, at `.orchestrate/runs//context-flag.json`: ```json { "raisedAt": "2026-05-21T10:24:00Z", + "trigger": "tokens", "usedTokens": 134750, "contextWindowTokens": 200000, "thresholdPercent": 40, - "usagePercent": 67.4 + "usagePercent": 67.4, + "spawnCount": 62, + "sessionSpawnBudget": 200, + "spawnThresholdPercent": 40, + "spawnPercent": 31 } ``` @@ -76,8 +131,29 @@ integrates (SKILL.md section 2); the successor **deletes it on startup** (SKILL.md section 1) once consumed — a stale flag would make the successor hand off again immediately, an infinite spawn loop. +`trigger` records **which** budget raised the flag — `"tokens"` or `"spawns"` — +so which threshold arrived first is readable after the fact rather than +inferred. Both budgets' figures are recorded either way. On a spawn-triggered +raise whose token usage was never observable, `usedTokens` and `usagePercent` +are `null`: unknown usage is recorded as unknown, never as zero. + It is ephemeral run state, not config — it lives in `.orchestrate/runs//` -alongside `run-state.json`, which the target project should gitignore. +alongside `run-state.json` and the run's `spawn-log.jsonl`, which the target +project should gitignore. `spawn-log.jsonl` is the watchdog's own append-only +record, one line per observed spawn; it is removed with the run directory. + +**The log is stored per run but counted per session.** The platform's spawn cap +is a *session* cap that resets in a new session, while the log survives a +handoff along with the `runId`. Each line therefore carries the `session` that +wrote it, and only the current session's lines are counted — so a successor +starts from its own fresh budget. This is why nothing deletes the log on +startup, unlike `context-flag.json`: a run is also resumed **without** a handoff, +in the same session whose budget did *not* reset, and clearing the log there +would under-count a session that had already spent part of its budget. A line +with no session tag — one written by an older version of the plugin, or a torn +line — is counted toward whichever session is asking: it cannot be attributed, +and over-counting only hands off early, while under-counting is what walks a run +into the unrecoverable error. ## 3. `.orchestrate/handoff.json` — configuration (optional) @@ -86,10 +162,19 @@ file is absent, built-in defaults apply; when it is present but malformed, the defaults apply and `spawn_successor` reports a `configWarning`. Copy the plugin's `templates/handoff.json` to `.orchestrate/handoff.json` to customize. +One consequence of adding a field is worth knowing: a key the schema did not +recognize was silently **stripped**, so any value was tolerated. Once that key +becomes a real field it is **validated**, and an off-schema value now sends the +whole file down the defaults-plus-warning path. That is the correct behaviour — +a `spawnThresholdPercent` of `150` should not be honoured silently — but a +config that "worked" before can start reporting a warning after an upgrade. + | Field | Default | Meaning | | --- | --- | --- | | `watchdog.thresholdPercent` | `40` | Raise the flag at this percentage of the window. | | `watchdog.contextWindowTokens` | `200000` | Window the percentage measures against. Set to `1000000` for a 1M-context session. | +| `watchdog.spawnThresholdPercent` | `40` | Raise the flag at this percentage of the spawn budget. Mirrors `thresholdPercent`. | +| `watchdog.sessionSpawnBudget` | `200` | Total subagent spawns the session may make — the platform's own per-session default, which `CLAUDE_CODE_MAX_SUBAGENTS_PER_SESSION` changes. Nested and background subagents count toward it, and a finished subagent still counts. | | `successor.claudeArgs` | `["--remote-control", "orchestrate-successor", "--permission-mode", "auto"]` | Flags for the successor's `claude` CLI invocation. | | `successor.resumePrompt` | `"/orchestrate"` | Initial prompt — appended last, as a positional argument. | | `successor.terminals` | Windows Terminal, then Warp | Ordered terminal fallback chain. | diff --git a/plugins/orchestrate/skills/orchestrate/references/failure-handling.md b/plugins/orchestrate/skills/orchestrate/references/failure-handling.md index 68358bed..4baa2afe 100644 --- a/plugins/orchestrate/skills/orchestrate/references/failure-handling.md +++ b/plugins/orchestrate/skills/orchestrate/references/failure-handling.md @@ -1,28 +1,52 @@ # Failure handling — the FAILED-slice mechanical actions -The spine retains the failure-cause taxonomy and narration — the FAILED -definition, the `incomplete`-vs-`blocked`-vs-`invalid` distinction, the two -terminal `incomplete` causes (budget-exhausted / no-progress), and the SKIPPED -and other-stop-condition narration. This file holds the mechanical actions the -orchestrator performs **on a FAILED slice**, once the spine's taxonomy has -classified it. The continue-in-place loop mechanics that decide when an -`incomplete` slice FAILs live in `references/slice-pipeline.md` (section 3, -step 4). +The spine retains the failure-cause narration — where a slice can fail, the rule +that a verdict comes only from a validated envelope, and the SKIPPED and +other-stop-condition narration. This file holds the mechanical actions the +orchestrator performs **on a FAILED slice**: first the deterministic +failure-class-to-label mapping, then the actions that apply it. The bounded +continuation loop that decides when an `incomplete` slice fails is the slice +executor's, and lives in its own operating procedure. + +## The failure-class to triage-label mapping + +A slice executor classifies its own failure — that is where the evidence is — and +reports one **failure class** on its envelope. The orchestrator maps that class +to a tracker label, because it is the single writer of tracker state. This table +is that mapping's **only** home; the class set itself is defined once in +`validate_envelope`'s schema and is not restated here. + +| `failureClass` | Label | Why | +|---|---|---| +| `incomplete-budget-exhausted` | `needs-info` | Resumable partial work is preserved in the worktree — "resume me", not "diagnose me". | +| `no-progress-stall` | `needs-triage` | Repeated attempts converged on nothing; a human has to look. | +| `unrecoverable-obstacle` | `needs-triage` | A blocker with no safe workaround. | +| `invalid-or-missing-worker-envelope` | `needs-triage` | A worker's result could not be trusted. | +| `changeset-mismatch` | `needs-triage` | Declared files did not match the worktree. | +| `empty-changeset` | `needs-triage` | The slice produced no file changes. | +| `model-refusal` | `needs-triage` | A spawned model refused the task. | + +When **no class exists at all** — the executor's envelope was itself invalid or +missing and `recover_slice_progress` could not rescue the slice — use +`needs-triage`. The one case that is **not** a slice failure and must not be +labelled: an `unrecoverable-obstacle` whose `failureReason` names a missing +operating procedure is an environment fault; report it to the operator instead. + +The label vocabulary is the project's, not this tool's — swap the label column if +a project uses different triage labels. + +## The mechanical actions On a FAILED slice: -- When the failure cause is a validated worker envelope with `status: "blocked"` - (implementer) or `status: "failed"` (reviewer), that envelope now carries a - validated `rootCause` (`verified` | `hypothesis` + `claim` + optional - `evidence`) — surface it in the failure artifact alongside the `failureReason` - so a developer reads the subagent's own labelled diagnosis. +- When the failure cause is a validated envelope carrying a `rootCause` + (`verified` | `hypothesis` + `claim` + optional `evidence`) — surface it in the + failure artifact alongside the `failureReason` so a developer reads the + subagent's own labelled diagnosis. - Set its `state` to `failed` with a `failureReason`, checkpoint, and - transition the issue's tracker label. For a slice that failed because the - continue-in-place loop **exhausted the continuation budget** — partial, - resumable work — `needs-info` better signals "resume me" than `needs-triage`: - `gh issue edit --remove-label ready-for-agent --add-label needs-info`. - For the **no-progress** terminal cause (a continuation that changed nothing) - and every other failure cause, use `needs-triage`: + transition the issue's tracker label to the one the mapping above yields: + `gh issue edit --remove-label ready-for-agent --add-label needs-info` + or `gh issue edit --remove-label ready-for-agent --add-label needs-triage`. - Do **not** merge it. **Preserve its worktree** — leave it on disk for a developer to inspect. Do not call `remove_worktree`. diff --git a/plugins/orchestrate/skills/orchestrate/references/preflight-mode.md b/plugins/orchestrate/skills/orchestrate/references/preflight-mode.md index 747b886c..c54fe053 100644 --- a/plugins/orchestrate/skills/orchestrate/references/preflight-mode.md +++ b/plugins/orchestrate/skills/orchestrate/references/preflight-mode.md @@ -5,9 +5,12 @@ dispatched from spine section 0 (Modes). The pre-flight pass runs a run's one-time setup — `references/run-lifecycle.md` "Fresh run" **steps 1–6** — and then **stops before the wave loop** (spine section 2), leaving an `in-progress` run whose slices are all `pending`, `completedWaves: 0`, and whose first -`run-state.json` checkpoint is `validate_run_state`-valid. The operator reviews -that partition and wave plan at the checkpoint; a later `/orchestrate ` in -a fresh session resumes the same run and runs the waves. Its value is this +`run-state.json` checkpoint is `validate_run_state`-valid. It then **probes the +capability gate** (§3), executing the project's configured verbs once in a +throwaway dependency-free checkout, so the operator reviews not only the +partition and wave plan at the checkpoint but what the gate will actually +verify; a later `/orchestrate ` in a fresh session resumes the same run +and runs the waves. Its value is this **staged-inspection gate**, not token relocation (post-#293 the bootstrap residue it shifts is ~2% of a ~1M execution window — see ADR-0014). @@ -56,8 +59,8 @@ umbrella branch create-and-push (step 5), and the first `run-state.json` checkpoint with its immediate `validate_run_state` (step 6). Do **not** re-narrate those steps here; follow them in `run-lifecycle.md`. -Two — and only two — things differ from a normal fresh run, both at the step-6 -checkpoint write: +Two — and only two — things differ from a normal fresh run, both at the +fresh-run step-6 checkpoint write: 1. **Force `driverSessionId: null`.** The pre-flight session does **not** drive the run — it only stages the checkpoint and stops. Write `driverSessionId` as @@ -66,7 +69,7 @@ checkpoint write: `driverSessionId` from its own `$ORCHESTRATE_SESSION_ID`). This `null` is **by design**, not a degradation — do **not** emit the fresh-run step-6 "no automatic context-handoff, resume manually" operator notice; the - pre-flight handoff doc (step 4) *is* the resume instruction, and the resuming + pre-flight handoff doc (§5) *is* the resume instruction, and the resuming driver session restores the watchdog binding. 2. **Confirm pending slices OMIT `subState`.** Every slice is written `state: "pending"` with **no** `subState` key — exactly as fresh-run step 6 @@ -84,16 +87,219 @@ semantics — its `status: "in-progress"`, all-`pending` slices, `completedWaves refreshes `driverSessionId` and enters wave 0. **Do not modify that resume path** — pre-flight depends on it being unchanged. -## 3. Stop point — after the valid checkpoint, before the wave loop +## 3. Capability probe — prove the gate before the run commits to it -Once step-6's `validate_run_state` returns `valid`, write the pre-flight handoff -doc (step 4 below), then **stop**. Do **not** proceed into spine section 2 (the -wave loop). At this stop point exactly one branch exists — the umbrella branch -from fresh-run step 5; **no worktrees and no slice branches** are created (those -are section-3 per-slice work the pass never reaches). The run is left -`in-progress`, fully resumable. +The staged-inspection gate is the pass's whole value, and the one thing that +decides whether every slice merges **verified** or merely **green** is whether +the project's configured capability commands actually run. Fresh-run step 1's +`bootstrap_config` reports which verbs are *configured*; only executing them +reports whether they *work*. This probe supplies that second half, and it +belongs here because this is the last moment before the run commits — and +because the evidence it needs is exactly what a slice worktree is: a checkout of +tracked files only, with **no installed dependencies**. A command that works in +the developer's main checkout can still fail there. -## 4. Write the pre-flight handoff doc +**Run the probe after fresh-run step 6's checkpoint is written and +`validate_run_state` returns `valid`, and before the handoff doc (§5).** That +placement is +structural, not incidental: the checkpoint is already on disk, so **a failing +probe can never prevent it** — the probe reports, the operator decides whether +to resume — and the handoff doc written next can carry the result. The probe is +**not** a third divergence from fresh-run steps 1–6 (section 2's "two — and only +two — things differ" stands unchanged); it is a pre-flight-only step that runs +after those steps have completed. + +**Keep this distinct from the `falseGreenRisk` stop (they pull in opposite +directions on purpose).** `bootstrap_config`'s `falseGreenRisk` — both `tests` +and `build` unconfigured — **retains** its "report it and stop BEFORE planning +the backlog" behaviour from `references/prerequisites.md`. Such a run never +reaches this probe at all, and nothing here weakens that stop; re-enabling the +false-green it prevents is exactly the failure it exists to catch. The probe +answers the *different* question — of the verbs that **are** configured, which +ones actually execute — and a probe failure is **non-blocking**. + +### 3.1 Create the probe checkout + +``` +git worktree add --detach /.orchestrate-worktrees//preflight-probe origin/development +``` + +- **`--detach` creates no branch.** The stop point's branch invariant (§4) + is preserved exactly: the umbrella branch stays the only branch this pass + creates. Creation and teardown are deliberately **orchestrator shell**: + `create_worktree`'s `branch` field is required and it always runs + `git worktree add -b `, so there is no branch-free path through it and + no lower-level checkout tool exists. Running git here is the orchestrator's + own remit (ADR-0009, ADR-0014), not a new privilege. Only the **execution** + below goes through MCP. +- **Name the ref explicitly.** `origin/development` is the integration base + fresh-run step 1 verified and where every slice worktree starts, so it is + "the run's starting point" the baseline inventory (3.5) is about. Bare + `git worktree add --detach ` would default to the current HEAD — a + different tree. +- **It must be a linked worktree — never a clone or a `git archive` extract.** + The capability tools resolve `.orchestrate/commands.json` by running + `git rev-parse --git-common-dir` in the exec directory and taking its parent, + falling back to the exec directory on any git failure. A linked worktree + resolves to the main repository root and finds the config; a detached copy + does not. Since `commands.json` may be gitignored (it is in this repository), + a copy would find no config and **every verb would report `not-configured`** — + a broken probe indistinguishable from "this project has no tests", precisely + the mis-report 3.4 forbids. +- The path is deterministic and `runId`-derived so a leaked probe is findable + (see 3.6). It cannot collide with a slice worktree: those are + `/.orchestrate-worktrees//slice-`. +- **On failure, report and skip to §5.** `git worktree add` fails if the path + already exists — the signature of a leaked probe from an earlier crashed pass + (3.6 has the manual clearance). Report the probe as **not run**, giving the + git error, and go straight to the handoff doc. The probe is non-blocking, so a + probe that cannot even be created must not stop the pass either. + +### 3.2 Install, then check the checkout is clean — in that order + +Call `run_install` with the probe path as `repoPath`. Then, **immediately after +install and before any other verb**, inspect the checkout: + +``` +git -C status --porcelain +``` + +The ordering is load-bearing. This check is about what **install** did, and a +build regenerates tracked build output in repositories that commit their bundle +— running `run_build` first would contaminate the verdict and blame install for +a build artifact. + +Report `install` as a verb of its own under the same 3.4 classification, with +one mapping to apply first: `run_install`'s success status is **`installed`**, +not `passed` (its enum is `installed | failed | not-configured | error`). Read +`installed` wherever 3.4's table says `passed`; its other three values match the +table as written, and `capabilities.install` supplies the configuredness half. A +`not-configured` install is legitimate for a project whose commands need no +dependency step (Maven and Gradle projects are bootstrapped without one), but a +configured install that **fails** is a broken configuration that invalidates +everything after it: say so explicitly, because every verb below will then fail +for a reason that is not its own. + +Report a dirty checkout: `create_worktree` runs the same install in **every** +slice worktree, so an install form that rewrites a lockfile puts an unrelated +change into every slice's diff before the slice has done any work. Corroborate a +`git status` hit against a non-empty `git -C diff` before calling it +dirty — with `core.autocrlf` on, a regenerated file can surface as modified in +`git status` while its diff is empty. + +### 3.3 Execute each configured verb + +Call `run_typecheck`, `run_build`, `run_tests`, and `run_lint` with the probe +path as `repoPath`. **All four** — not the `run_build` + `run_tests` subset the +slice executor's own **Capability gate** uses. That subset +deliberately narrows a gate on a worktree the reviewer has already seen; the +probe is answering whether verification happens **at all**, so every verb the +project relies on has to be exercised. + +### 3.4 Classify each verb — configuredness × outcome + +Report the verbs **individually**. Cross `bootstrap_config`'s per-verb +`capabilities` map (configuredness) with the probe's status (outcome) — do not +re-derive either half. The probe status is the capability tools' own enum — +`passed | failed | not-configured | error` — except for `install`, which reports +`installed` for `passed` (3.2): + +| `capabilities.` | probe status | report as | +| --- | --- | --- | +| `true` | `passed` (`installed` for `install`) | **passed** | +| `true` | `failed` | **failed** — its cases feed the baseline inventory (3.5) | +| `true` | `error` *or* `not-configured` | **broken configuration** | +| `false` | `not-configured` | **not configured** — the project genuinely lacks this verb | + +**The bottom two rows are the whole point.** A verb whose command cannot execute +in a dependency-free checkout is a **broken configuration**, never a project +lacking that capability — a configured verb coming back `not-configured` or +`error` is the signature. The usual causes: the command's package is not at the +repository root and the argv never targets it, the binary is absent until the +install step has run, or the config is not resolvable from the exec directory. + +### 3.5 Capture the baseline failure inventory + +For every verb reporting **failed**, extract an identifier for **each failing +case** from the captured output — the **test title (or case name), not the +file**. A file-keyed pattern masks a later, genuinely new failure in the same +file; a title-keyed one does not. These identifiers are what the operator turns +into a `knownFailures` pattern list in `.orchestrate/commands.json`, so a +pre-existing failure is not re-reported by every slice as its own regression. + +Read the result defensively. A large `run_tests` result may exceed the harness's +tool-result limit and be **spilled to a file** instead of arriving inline — read +`status`, the captured output, `truncated`, and `knownFailureMatches` from that +file (e.g. with `jq`) rather than assuming an inline result. + +Two properties of that result decide where the inventory comes from: + +- **`knownFailureMatches` is a shortcut, not the source.** It is present only + when the command exited non-zero **and** `commands.json` already configures a + `knownFailures` list. On a first pre-flight of a project being onboarded — the + probe's whole use case — no patterns exist yet, so the field is **absent** and + the inventory must be parsed from the captured output. When it *is* present it + is the better source: it is matched against the untruncated output. +- **The captured output is capped at 64,000 characters per stream**, and a + verbose test reporter reaches that. When `truncated` is `true` and the + inventory came from the output, say so in the handoff (3.7) — a partial + inventory presented as complete becomes a `knownFailures` list that silently + under-covers, and the slices it fails to cover report a pre-existing failure as + their own regression. + +### 3.6 Tear the probe down + +Call `remove_worktree` with the probe path as `worktreePath` and — unlike 3.2 +and 3.3, which take the probe path — the **main repository** as `repoPath`: this +call is addressed to the repo that *owns* the worktree, not to the worktree. + +Without `force` it refuses on any uncommitted or untracked change and returns +`dirtyFiles` — and the probe will normally have left exactly that: install's +rewritten lockfile and installed dependency directory (3.2), plus regenerated +build output in a repository that commits its bundle (3.3). **Retry once with +`force: true`**: the probe must remove whatever it created. + +**Nothing else will ever sweep it.** Both cleanup paths derive their deletion +set from `run-state.json` — the status-gated `clean_runs` sweep and +`reclaim_run`, whose set is every slice's `worktreePath`, the umbrella branch, +every slice branch, and the run directory. The probe is not a slice and appears +nowhere in that state. If the pass crashes between 3.1 and 3.6, the leak is +cleared by hand: + +``` +git worktree remove --force /.orchestrate-worktrees//preflight-probe +git worktree prune +``` + +Because the probe carries **no branch**, such a leak can pollute +`git worktree list` only — it can never violate the stop point's branch +invariant. + +### 3.7 Report the probe, and keep it out of `run-state.json` + +Write the probe result into the handoff doc (§5) and the operator report — +**never** into `run-state.json`. The fresh-run step-6 checkpoint stays byte-identical to a +normal fresh-run checkpoint except `driverSessionId: null` (section 2), and that +identity is exactly what lets the **unchanged** resume path resume it. + +Report **loudly** when the probe found nothing that verifies: no verb reported +**passed**, every configured verb broken or failing. That is the same +false-green shape `falseGreenRisk` guards structurally, arriving through a +different door — a gate that exists on paper and verifies nothing in practice. +It is a loud report, not a stop; the operator decides whether to resume. + +## 4. Stop point — after the valid checkpoint, before the wave loop + +Once fresh-run step 6's `validate_run_state` returns `valid`, run the capability +probe (§3), write the pre-flight handoff doc (§5 below), then **stop**. Do +**not** proceed into spine section 2 (the wave loop). At this stop point exactly +one branch exists — the umbrella branch from fresh-run step 5 — and **no +worktree remains**: **no slice branch and no slice worktree is ever created** +(those are *spine* section 3 per-slice work the pass never reaches), and the +probe's detached checkout is created and removed entirely within §3 of this +file, carrying no branch of its own. The run is left `in-progress`, fully resumable. + +## 5. Write the pre-flight handoff doc Write `.orchestrate/runs//preflight-handoff.md` — a human-readable, per-run handoff that tells the operator how to resume. It carries: @@ -106,6 +312,19 @@ per-run handoff that tells the operator how to resume. It carries: pushed, the checkpoint is `validate_run_state`-valid, all slices are `pending` with `completedWaves: 0`, and `driverSessionId` is `null` until the execution session sets it on resume; +- the **capability probe result** (§3) — this is what tells whoever resumes + the run what the gate will actually verify: + - each verb reported individually as **passed**, **failed**, **broken + configuration**, or **not configured** (the 3.4 classification); + - whether the install step left the checkout **dirty**, and which paths, since + every slice would then start with that unrelated change in its diff; + - the **baseline failure inventory** — one entry per failing *case*, keyed to + the test title rather than the file, ready to paste into `commands.json` as + `knownFailures` patterns, and flagged **partial** if it was parsed from + truncated output (3.5); + - the explicit note that a **failing probe did not block this checkpoint** — + the run is resumable either way and the decision to proceed is the + operator's; - the **known risks** — e.g. the partition or wave plan may be stale if the backlog changed after this pass (a slice's `ready-for-agent` label was added or removed since the checkpoint was minted), and the run blocks a second diff --git a/plugins/orchestrate/skills/orchestrate/references/prerequisites.md b/plugins/orchestrate/skills/orchestrate/references/prerequisites.md index c5adc0a3..ee88db31 100644 --- a/plugins/orchestrate/skills/orchestrate/references/prerequisites.md +++ b/plugins/orchestrate/skills/orchestrate/references/prerequisites.md @@ -10,17 +10,32 @@ Check these before starting. If one is missing, report it and stop. `orchestrate/slice-*` branches — the auto-merge needs them open. The target project's `.orchestrate/` configuration — `commands.json`, -`routing.json`, and the optional `handoff.json` — may be **bootstrapped on the -first run** by the `bootstrap_config` MCP tool (section 1, Fresh run, step 1), -or committed ahead of time from the plugin's `templates/`. Without +`routing.json`, and the optional `handoff.json` — is **completed on every run** +by the `bootstrap_config` MCP tool (section 1, Fresh run, step 1; see +"Fresh-run config bootstrap" below for exactly when and how it is called), or +may be committed ahead of time from the plugin's `templates/`. Without `commands.json` the capability tools return `not-configured`, which is -tolerated. **When the bootstrapper writes an empty `commands.json` (`{}`)** — -because no recognized project type was detected — it emits a `warnings[]` field -in its result and surfaces the warning in its human-readable output. This means -`run_tests` and `run_build` will report `not-configured`, and a slice can merge -green with no verification. If you see this warning, edit -`.orchestrate/commands.json` to add your project's test and build commands -before starting the run. When the project's capability commands need installed dependencies, +tolerated **per verb** — a project that skips `lint` or needs no `install` +step is normal. + +**`tests` and `build` being unconfigured together is different.** It is the +specific conjunction that lets a slice merge green with nothing ever executed, +so `bootstrap_config` reports it structurally on every call: `capabilities` +names which of `tests`, `typecheck`, `build`, `lint`, `install` resolve to a +command in the FINAL `commands.json` — whether this call just wrote it or it +was already on disk — and `falseGreenRisk` is `true` exactly when both `tests` +and `build` are unconfigured. A verb counts as unconfigured when its key is +absent *or* its argv array is empty — exactly what the capability tools +themselves treat as `not-configured`, so this report always predicts the real +gate. **When `falseGreenRisk` is `true`, report it and +stop** — the same "check before starting" posture as the prerequisites above — +and have the operator add test and build commands to +`.orchestrate/commands.json` before the run proceeds. This is not limited to a +freshly-written empty file: a `.orchestrate/` directory left over from a +partial setup (e.g. `routing.json` and `handoff.json` present but no +`commands.json`), or a hand-authored `commands.json` that never got +`tests`/`build` filled in, carries the exact same risk and is reported the +exact same way. When the project's capability commands need installed dependencies, `commands.json` must also set an `install` command — `create_worktree` runs it in every fresh worktree, which checks out only tracked files and so has no dependency directory of its own, and the implementer/conflict-resolver @@ -70,6 +85,11 @@ spine). The orchestrator may **suggest** a `route:*` label in its report but slice would only burn the spawn and fall through to the `opus` fallback. Do **not** apply (or suggest) `route:fable` on a security/cyber slice; route it through the ordinary complexity tiers instead. +- **`slice-executor` back-compat (ADR-0017, #356)** — a `routing.json` written + before this role existed simply omits `slice-executor` per tier; it still + loads. `resolve_routing` fills the gap from that tier's own `implementer` + entry and surfaces a warning naming the tier, the same shape as the v1 + deprecation warning above. An optional `.orchestrate/handoff.json` tunes the context-watchdog threshold and the successor launcher; without it, built-in defaults apply (see @@ -77,21 +97,43 @@ and the successor launcher; without it, built-in defaults apply (see it enables the investigator and reviewer subagents' structural code search, which otherwise falls back to text search. +**Configured is not the same as working.** `bootstrap_config` reports which +verbs *resolve to a command*; it never executes one. A command can be present +and still fail in a slice worktree — which checks out tracked files only and has +no installed dependencies — because it targets a package that is not at the +repository root, or because its binary only exists after the install step. That +gap is what the pre-flight capability probe closes, by running the install step +and each configured verb once in a throwaway dependency-free checkout and +reporting each verb's **outcome** (passed / failed / broken configuration / not +configured) alongside `bootstrap_config`'s configuredness. See +`references/preflight-mode.md` step 3. + To run the one-time setup and inspect the partition and wave plan before committing the full execution, use the pre-flight mode: `/orchestrate preflight -` runs Fresh-run steps 1–6 (including this bootstrap), then stops before -the wave loop; `/orchestrate ` in a fresh session resumes it. +` runs Fresh-run steps 1–6 (including this bootstrap), probes the +capability gate, then stops before the wave loop; `/orchestrate ` in a +fresh session resumes it. ## Fresh-run config bootstrap (`bootstrap_config`) -On a fresh run (section 1, Fresh run, step 1), bootstrap the configuration if -this is a first-ever run. If the repository has no `.orchestrate/` directory, -call the `bootstrap_config` MCP tool with the repository root as `repoPath` and -this session's model id as `model` (or an explicit `contextWindowTokens`). It -detects the project type, writes a project-appropriate `commands.json`, -`routing.json` (per-tier routing plus the run-wide `intraWaveConcurrency` -policy, defaulting to `parallel`), and `handoff.json`, creates -`.orchestrate/runs/`, and adds `.orchestrate/runs/` to the repository's -`.gitignore`. Every step is -idempotent — an existing committed config is never overwritten — so this -is also a safe no-op on a repository already configured by hand. +On a fresh run (section 1, Fresh run, step 1), **always** call the +`bootstrap_config` MCP tool with the repository root as `repoPath` and this +session's model id as `model` (or an explicit `contextWindowTokens`) — **never +gate the call on whether `.orchestrate/` already exists.** A repository may +have the directory (from an earlier run, or a hand-authored partial setup) yet +be missing one of its three files — `commands.json` in particular — and every +write inside the tool is individually idempotent at the FILE level, so calling +it unconditionally is a safe no-op wherever a file is already present and a +completing action wherever one is missing. It detects the project type, writes +whichever of `commands.json`, `routing.json` (per-tier routing plus the +run-wide `intraWaveConcurrency` policy, defaulting to `parallel`), and +`handoff.json` are absent, creates `.orchestrate/runs/`, and adds +`.orchestrate/runs/` to the repository's `.gitignore`. Every step is +idempotent — an existing committed config file is never overwritten — so this +is also a safe no-op on a repository already fully configured by hand. + +**Check its result before proceeding — never start the run on its silence.** +Read `capabilities` and `falseGreenRisk` from the result (see above). When +`falseGreenRisk` is `true`, report it and stop before planning the backlog — +regardless of whether `commands.json` was freshly written this call or was +already on disk. diff --git a/plugins/orchestrate/skills/orchestrate/references/run-lifecycle.md b/plugins/orchestrate/skills/orchestrate/references/run-lifecycle.md index 27016851..a0f087b1 100644 --- a/plugins/orchestrate/skills/orchestrate/references/run-lifecycle.md +++ b/plugins/orchestrate/skills/orchestrate/references/run-lifecycle.md @@ -90,7 +90,9 @@ is what `/orchestrate clean` (section 0) runs on demand. Every run keeps its ephemeral state in a **per-run directory**, `.orchestrate/runs//`, holding that run's `run-state.json`, -`context-flag.json`, and rendered HTML artifacts. The committed config files +`context-flag.json`, `spawn-log.jsonl` (the watchdog's spawn record), one +`slice--progress.json` slice progress record per slice, and rendered HTML +artifacts. The committed config files (`commands.json`, `routing.json`, `handoff.json`) stay flat at the `.orchestrate/` top level. `routing.json` carries both per-tier subagent routing and run-wide run policy (the optional `intraWaveConcurrency` knob — see section @@ -180,9 +182,10 @@ prefixes are the only match keys. Then act on the count of matches: 1. Resolve the run context: - Repository root: `git rev-parse --show-toplevel`. - - **Bootstrap the configuration if this is a first-ever run** with the - `bootstrap_config` MCP tool — see `references/prerequisites.md` for the - full bootstrap detail. + - **Always bootstrap the configuration** with the `bootstrap_config` MCP + tool — never gate the call on whether `.orchestrate/` already exists; + see `references/prerequisites.md` for the full bootstrap detail and the + `falseGreenRisk` stop condition. - Fetch so branch operations use current refs: `git fetch origin`. - Confirm the integration base: `git rev-parse --verify origin/development`. - Generate a `runId` by joining the invocation prefix to the current diff --git a/plugins/orchestrate/skills/orchestrate/references/run-state.md b/plugins/orchestrate/skills/orchestrate/references/run-state.md index eaed7ee1..1c894769 100644 --- a/plugins/orchestrate/skills/orchestrate/references/run-state.md +++ b/plugins/orchestrate/skills/orchestrate/references/run-state.md @@ -11,10 +11,16 @@ Every run keeps its ephemeral state in a **per-run directory**, `prd-` for a **partitioned run** scoped to one parent PRD's children (`/orchestrate `), and `backlog-` for a **whole-backlog run** (`/orchestrate` with no argument). That directory holds the run's -`run-state.json`, its `context-flag.json` (the context-handoff signal), and the -rendered HTML artifacts (`dashboard.html`, `graph.html`, `report.html`). Two -distinct runs never share a directory, so their ephemeral state never collides — -the per-run layout is the structural foundation for concurrent runs. +`run-state.json`, its `context-flag.json` (the context-handoff signal), its +`spawn-log.jsonl` (the watchdog's append-only spawn record, one line per +observed subagent spawn, each tagged with the session that made it — stored per +run but counted per session, since the platform's spawn cap resets in a new +session while the log survives a handoff), the +rendered HTML artifacts (`dashboard.html`, `graph.html`, `report.html`), and one +**slice progress record** per slice — `slice--progress.json` (see *Slice +progress record* below). Two distinct runs never share a directory, so their +ephemeral state never collides — the per-run layout is the structural foundation +for concurrent runs. The committed config files — `commands.json`, `routing.json`, and `handoff.json` — stay flat at the `.orchestrate/` top level; they are @@ -35,6 +41,9 @@ metadata, not source — the target project should gitignore ├── prd195-20260521-015143/ # a partitioned run (PRD #195's children) │ ├── run-state.json # the run checkpoint │ ├── context-flag.json # the context-handoff signal (when raised) + │ ├── spawn-log.jsonl # one line per subagent spawn (spawn budget) + │ ├── slice-157-progress.json # one slice progress record PER SLICE + │ ├── slice-158-progress.json # siblings of one wave never collide │ └── dashboard.html, graph.html, report.html # rendered artifacts └── backlog-20260521-022540/ # a concurrent whole-backlog run └── run-state.json @@ -119,14 +128,20 @@ metadata, not source — the target project should gitignore - `blockedBy` — issue-id strings this slice depends on (drives the graph view). - `state` — see *Slice states* below. - `subState` — fine-grained position **within** §3 processing of an - `in-progress` slice, one of - `implemented|verified|reviewed|pushed|pr-open|merged`, written at every §3 - transition; the key is **absent before §3 step 4 completes — omit the key + `in-progress` slice. The schema accepts + `implemented|verified|reviewed|pushed|pr-open|merged`, but a **delegating** + orchestrator (ADR-0017) only ever *writes* the last three — + `pushed`, `pr-open` and `merged`. The first three, `implemented`, + `verified` and `reviewed`, marked intra-slice stages it no longer performs or + observes, and are retained in the enum solely so a checkpoint written before + the delegation layer still validates. The key is **absent for the whole span + between spawning the slice executor and its envelope validating — omit it entirely; an explicit `null` is rejected** (the schema `subState: subStateEnum.optional()` accepts an absent key but rejects a - literal `null`, so `"subState": null` fails `validate_run_state`). It is - the **resume anchor** for an interrupted in-progress slice - (see *Resume*). `pushed` is + literal `null`, so `"subState": null` fails `validate_run_state`). Across that + span the resume anchor is not this field but the slice's **progress record** + (below), reached through `recover_slice_progress`; from `pushed` onward + `subState` is the resume anchor again (see *Resume*). `pushed` is recorded **only after** `git ls-remote` confirms the branch landed; `merged` (slice PR squash-merged into the umbrella, step 8) precedes the slice reaching coarse `state: passed` (step 9). @@ -140,6 +155,9 @@ metadata, not source — the target project should gitignore field was introduced (backward-compatible). When present, it captures the routing that was frozen at slice creation so a resumed run routes the slice from the checkpoint rather than from live GitHub labels. Shape: + - `slice-executor` — `{model, variant}` for the slice executor, the role the + orchestrator actually spawns per slice (ADR-0017). A resumed run re-spawns + the executor from this entry rather than re-resolving routing. - `investigator` — `{model, variant}` for the investigator role, or `null` when this tier skips the investigation pass. - `implementer` — `{model, variant}` for the implementer role. @@ -169,9 +187,99 @@ An implementer `incomplete` envelope drives an **in-session continuation loop** the `remainingWork` handoff until it returns `completed` or the continuation budget is exhausted) and does **not** introduce a new slice `state` — the enum stays `pending` / `in-progress` / `passed` / `failed` / `skipped`. The loop's -continuation counter and worktree fingerprint are within-session loop state, -never persisted to `run-state.json`; a mid-continuation handoff/resume rebuilds -the worktree and restarts the slice clean. +continuation counter and worktree fingerprint are **never persisted to +`run-state.json`** — but what follows from that differs by path. On the +**orchestrator-driven** implementer loop they are within-session loop state only, +so a mid-continuation handoff/resume rebuilds the worktree and restarts the slice +clean. On the **slice-executor** path (ADR-0017) that consequence no longer +holds: the executor persists both to its own *slice progress record*, so a fresh +executor resumes the slice from its last completed stage instead of restarting +it. `run-state.json` itself is untouched either way — the record is a separate, +executor-owned file. + +## Slice progress record + +Each slice a **slice executor** runs gets its own progress record at +`.orchestrate/runs//slice--progress.json` (ADR-0017). It is the +resume anchor for the delegation layer: one executor spawn spans investigation, +implementation, and review, so without the record a session that died +mid-executor would throw away a finished investigation and a finished review. +The executor writes it at each completed stage. + +The filename carries the **issue number** because a parallel wave processes +several slices concurrently inside one run directory — a single shared +`progress.json` would have siblings clobber each other. Combined with the per-run +directory, two runs × two slices resolve to four distinct paths, so records never +collide (ADR-0012). Like everything else under `.orchestrate/runs/`, the record +is gitignored by the single `.orchestrate/runs/` line and never reaches version +control. + +Fields: + +- `runId` / `issue` — the record self-identifies, so one read from the wrong + path is **detected** rather than silently trusted; a mismatch is rejected. +- `lastCompletedStage` — **optional**; the last inner stage that FINISHED, one of + `investigator|implementer|capability-gate|reviewer`. This is not the envelope's + `failedStage`, which names the stage that was RUNNING when a failure occurred — + the value set is shared, the meaning is not. The set carries **no ordering**: + which stages run, in what sequence, and which are skipped are the executor's + decisions. The key is **absent before any stage completes — omit it entirely; + an explicit `null` is rejected** (the same `.optional()`-not-`.nullable()` + precedent as `subState`). +- `investigatorBrief` — **optional**; the investigator's brief carried forward so + a resumed executor does not re-run a finished investigation. Absent when the + slice's tier skips investigation (`resolvedRouting.investigator` is `null`). +- `continuationsUsed` — how many continuations the executor's continue-in-place + loop has spent. Persisted because two continuation loops now nest and ADR-0017 + caps the **product** of their budgets at 6; a counter that reset on every + handoff could not enforce that bound across sessions. +- `worktreeFingerprint` — **optional**; the opaque content-level fingerprint of + the worktree's uncommitted state, used by the no-progress guard. The record + fixes how it is carried, never how it is computed — that stays the executor's. +- `fallbackTaken` — the once-only **model fallback** guard; `true` when the + premium-lane retry has already been spent on this slice. **Required, with no + default**: an absent key must never read as `false`, which would re-arm a + fallback already used. This deliberately duplicates + `resolvedRouting.fallbackTaken` above, and the duplication is ADR-sanctioned — + the run-state field is orchestrator-owned and drives the non-executor path, + while this copy is executor-owned, because the executor cannot write the + orchestrator's checkpoint. Neither is redundant; do not unify them. +- `updatedAt` — ISO-8601 UTC timestamp of the last write. + +**The orchestrator never opens this file.** It passes the path forward, and a +fresh executor reads its own record to resume itself. When an executor's result +envelope comes back missing or invalid, the orchestrator obtains the record's +contents through the `recover_slice_progress` MCP tool, which derives the path +from `(runId, issue)` — it accepts no file path, so it can never read outside +the run's own directory — and returns validated, structured data. It reports a +**missing** record (`PROGRESS_NOT_FOUND`: no stage recorded yet) distinctly from +a **malformed** one (`PROGRESS_INVALID`: bad JSON, a schema mismatch, or a record +naming a different run or slice), and never throws. This is the same +structured-recovery posture as `recover_changed_files`, where the worktree is +ground truth recovered through a tool rather than by reading prose. The slice +**report** the executor writes beside this record falls under the same boundary: +the orchestrator passes its path forward and never opens it. + +**A hook enforces this too — as defence in depth, not as a replacement.** The +plugin ships a `PreToolUse` read guard that denies the orchestrator a `Read` (or +an obvious shell read) of `slice--progress.json` and +`slice--report.md`, and returns a reason naming this behaviour instead. +**The rule above stays load-bearing regardless**, for four reasons, and a later +refactor must not delete it on the grounds that the hook covers it: + +- An enterprise administrator can set `allowManagedHooksOnly`, which blocks + user, project, and plugin hooks alike — only plugins force-enabled in managed + settings are exempt. +- Any user can set `disableAllHooks: true`. There is **no way to disable one + hook while keeping the others**, so opting out of this guard also gives up the + `context-watchdog` hook, and with it automatic context handoff. +- The plugin itself can simply be disabled. +- Even fully enabled, the hook **cannot see every read**. A file referenced with + `@` in a prompt is inserted while the prompt is built, with no tool call, so no + `PreToolUse` hook fires for it — including hooks matching `Read`. The + documented closure is a permission deny rule, which ADR-0017 rejected: it + would apply to the whole session and restrict the executor too, and it carries + no corrective message back to the model. ## Resume diff --git a/plugins/orchestrate/skills/orchestrate/references/slice-pipeline.md b/plugins/orchestrate/skills/orchestrate/references/slice-pipeline.md index bfe0635a..fc5b50b5 100644 --- a/plugins/orchestrate/skills/orchestrate/references/slice-pipeline.md +++ b/plugins/orchestrate/skills/orchestrate/references/slice-pipeline.md @@ -1,16 +1,32 @@ -# Processing one slice — the per-slice pipeline - -These are the per-slice steps the wave loop invokes (section 3). The spine -retains the Result-envelope trust-chain narration (how every subagent outcome is -read **only** from its validated envelope); this file holds the step-by-step -procedure. Update the slice's entry in `run-state.json` and write the file at -every state change. - -Every subagent ends its turn with a result envelope; after each subagent -(investigator, implementer, reviewer, conflict-resolver) returns, call the -`validate_envelope` MCP tool with the subagent's verbatim returned text and its -`role`, then act on the validated `status`/`envelope` as the trust-chain -narration in the spine describes. +# Processing one slice — worktree, routing, and integration + +These are the per-slice steps the **orchestrator** performs around the slice +executor (section 3): the two that run **before** the executor is spawned — +creating the worktree and freezing the slice's routing — and the integration tail +that runs **after** its envelope validates. + +The intra-slice stages — investigation, implementation and its bounded +continuation loop, the changeset scope check, review, and the capability gate — +are **no longer here**. They belong to the slice executor, whose operating +procedure is the `slice-pipeline` skill preloaded by its subagent definition. The +orchestrator does not perform them and learns their outcome only from the +executor's validated result envelope. The step numbers are therefore +**deliberately non-contiguous**: steps 6–9 keep the numbers they have always had, +because other references cite them by number, and the gap at 3–5a is where the +executor's stages went. + +Update the slice's entry in `run-state.json` and write the file at every state +change. + +## Contents + +- **Step 1 — Create the worktree** — `create_worktree`, the bundled install +- **Step 2 — Resolve routing** — `resolve_routing`, and freezing the result +- **Step 6 — Commit and push** — `finalize_slice` phase `commit-push` +- **Step 7 — Open the slice pull request** — `gh pr create` +- **Step 8 — Merge the slice** — the mergeability gate +- **Step 8a — Resolve a merge conflict (once)** — `resolve_merge_conflict` +- **Step 9 — Finish the slice** — `finalize_slice` phase `post-merge`, pass label 1. **Create the worktree.** Set the slice `state` to `in-progress`, write its `sliceBranch` (`orchestrate/slice-`) and the `worktreePath` you will use @@ -30,16 +46,21 @@ narration in the spine describes. spawn, plus any label-resolved `fallbacks` and `warnings`: - `status: "ok"` — use the returned `routing`. **Freeze it into the slice's `resolvedRouting` checkpoint field** in `run-state.json` at slice creation: - the per-role `{model, variant}` blocks, and — because the Fable lane is - implementer-only — the implementer's entry from the returned `fallbacks` - array mapped into the single `resolvedRouting.fallback` `{model, maxRetries}` - (with `fallbackTaken: false`). Every later spawn and every resume routes - from this frozen checkpoint, never by re-reading labels (the resume-routing - principle in the spine's resume matrix). + the per-role `{model, variant}` blocks — **including the `slice-executor` + role**, which is what step 3 spawns and what a resumed run must re-spawn + without re-resolving — and, because the Fable lane is implementer-only, the + implementer's entry from the returned `fallbacks` array mapped into the + single `resolvedRouting.fallback` `{model, maxRetries}` (with + `fallbackTaken: false`). Every later spawn and every resume routes from this + frozen checkpoint, never by re-reading labels (the resume-routing principle + in the spine's resume matrix). Capture the result's `continuationBudget` + too — the executor's briefing carries it. - **`warnings[]`** (present on `ok`) — surface each in the run report. An unconfigured `route:*` label present on the slice (in the config's `labels` block) yields a loud **WARNING** here: the label had no effect, so the - operator can fix `routing.json` or drop the label. + operator can fix `routing.json` or drop the label. A routing config written + before the `slice-executor` role existed also warns here, having had that + role defaulted from the tier's own `implementer` entry. - `errorCode: "LABEL_CONFLICT"` — **two applied labels patch the same role** (there is no precedence rule). This is a loud **ERROR**: the slice has **FAILED**; report the conflicting labels so the operator resolves it in @@ -50,217 +71,11 @@ narration in the spine describes. no `fallback` (an unrouted slice has no premium lane). - `errorCode: "CONFIG_INVALID"` — the routing config is broken; the slice has **FAILED**. -3. **Run the investigator (higher tiers only).** If `routing.investigator` is - non-null, spawn the `orchestrate:investigator-` subagent — `` - and the Agent `model` override both come from `routing.investigator`. Its - prompt must carry the issue number/title/body **and the slice's acceptance - criteria explicitly named as the hard scope boundary** — the canonical per- - slice scope established by the backlog partitioner. The investigator must - not propose work that falls outside those acceptance criteria. Validate its - returned text with `validate_envelope` (role `investigator`); on `valid`, - **diff the returned brief against the acceptance criteria before forwarding - it to the implementer**: inspect the brief's `relevantFiles`, `approach`, - and `notes` for any work that does not trace to at least one acceptance - criterion. If the brief includes work from a sibling or downstream slice — - files, approaches, or recommendations that the acceptance criteria do not - require — the brief is over-scoped: treat it as a failed investigation pass - (the slice has **FAILED**). A brief that is correctly scoped to the - acceptance criteria is forwarded to the implementer as the research brief. - An `invalid` or `missing` envelope is a failed investigation pass — the - slice has **FAILED** (the investigator is read-only, so no worktree fallback - applies). If `routing.investigator` is null, skip this step. -4. **Run the implementer.** Spawn the `orchestrate:implementer-` - subagent — `` and the `model` override from `routing.implementer`. Its - prompt must carry the issue number/title/body, the worktree path (every - change goes there), the investigator's brief if one was produced, an - instruction to verify with the capability tools using the worktree path as - `repoPath`, a note that it MAY call `run_install` (worktree path as - `repoPath`) to fetch a newly-added dependency before re-verifying — and that - any lockfile that install mutates MUST be reported in `filesChanged` so it - lands in the slice diff — and a reminder not to commit, push, or run git. - Validate its returned text with `validate_envelope` (role `implementer`). On - a `valid` envelope, classify the envelope `status`: - - `completed` — proceed to the worktree scope check in step 4a. - - `incomplete` — the implementer's graceful turn-budget self-report: it - foresaw it could not finish within its remaining turns and stopped cleanly - with partial work recorded **and a `remainingWork` handoff**. Do **not** - fail the slice immediately. Instead run the **bounded continue-in-place - loop** below: re-spawn the implementer in the *same* worktree carrying the - `remainingWork`, until it returns `completed` or the continuation budget is - exhausted. The slice FAILs from `incomplete` only when the budget runs out - or the no-progress guard trips — see the loop and *Failure handling*. - - `blocked` — the implementer hit an unrecoverable obstacle: the slice has - **FAILED**. - - **Continue-in-place loop (on a `valid` `incomplete` envelope):** - - Read `budget = continuationBudget` from the step-2 `resolve_routing` result - (the resolved run-wide budget, default `2`; `0` disables continuation — - legacy immediate-FAIL). When `resolve_routing` returned `CONFIG_NOT_FOUND` - (no routing configured), there is no budget — use **0**. - - Initialize an in-session `continuationsUsed = 0` and capture a - **content-level fingerprint** of the worktree's uncommitted state: a hash - of `git -C diff HEAD` concatenated with the contents of the - untracked files listed by - `git -C ls-files --others --exclude-standard`. A filename-set - comparison is insufficient — the same file may be rewritten with real - progress or returned byte-identical. - - While `continuationsUsed < budget`: re-spawn `orchestrate:implementer-` - in the **same** worktree (same routing/model/variant) with a continuation - prompt = the issue, the worktree path, the PRIOR envelope's `remainingWork`, - the standard verify/no-git reminders, and an explicit "partial work is - already in the worktree — continue it, do not restart." Validate the - returned text with `validate_envelope` (role `implementer`). - - `completed` — proceed to the worktree scope check in step 4a. Loop done. - - `blocked`, or an `invalid`/`missing` envelope — the slice has **FAILED** - (record the precise cause). Loop done. - - `incomplete` again — recompute the fingerprint. If it **equals** the - prior fingerprint, the **no-progress guard trips**: the slice FAILs, its - `failureReason` names the no-progress stall, label `needs-triage`. - Otherwise increment `continuationsUsed`, update the stored fingerprint and - `remainingWork`, and loop. - - When `continuationsUsed === budget` and the last envelope is still - `incomplete`: the slice FAILs with a budget-exhausted `failureReason` - ("implementer reported `incomplete` after exhausting the continuation budget - of N; partial work preserved in the worktree for resumption"), label - `needs-info` (resumable). - - The counter and fingerprint are **loop-local** — nothing is persisted to - `run-state.json`. A mid-continuation context handoff/resume discards the - in-progress slice and rebuilds its worktree (§1), restarting the slice - clean; this is intentional. - - An `invalid` or `missing` envelope also means the slice has **FAILED** — a - hard turn-limit cutoff that truncates the envelope mid-emission lands here as - `invalid`, distinct from the graceful `incomplete` self-report above. - - **Model fallback (premium spawns only — runs BEFORE the FAILED verdict).** - This interception is **separate from** the continue-in-place loop above: the - loop re-spawns the *same* model on `incomplete` and counts against - `continuationBudget`; this step **swaps** the model exactly once on a *model - failure* and does **not** touch `continuationBudget`. It applies only to a - **premium-spawned** implementer — one whose frozen `resolvedRouting.fallback` - is set because a `route:*` label (e.g. `route:fable`) patched the implementer - in step 2. Before declaring such a slice FAILED from a step-4 or loop failure, - check whether the failure is one the fallback covers and whether the rescue is - still available: - - **Trigger set** — the implementer **refused**, returned a retention/safety - **400**, or emitted an **invalid/missing envelope**. A *valid* `blocked` - envelope is **excluded**: it is a genuine obstacle (e.g. a missing - dependency) the fallback model would not fix — keep it on the immediate - FAILED path. - - **Guard** — proceed only when `resolvedRouting.fallback` is set **and** - `resolvedRouting.fallbackTaken` is still `false`. If there is no fallback - (an ordinary, non-premium spawn) or it is already spent - (`fallbackTaken: true`), skip this step and apply the ordinary FAILED - taxonomy. - - **Re-spawn** — spawn `orchestrate:implementer-` **once** in the - **same** worktree, overriding the Agent `model` to - `resolvedRouting.fallback.model` (e.g. `opus`), with the standard prompt - (issue, worktree path, the investigator brief if any, verify/no-git - reminders; carry the prior `remainingWork` if the failure came from the - continuation loop). Set `resolvedRouting.fallbackTaken: true` in the slice - checkpoint and write `run-state.json` **before** the re-spawn, so the - once-only guard survives a mid-spawn handoff — `fallbackTaken` is a - **persisted slice-level** flag, not a loop-local counter, and the fallback - fires at most once across the initial spawn and every continuation. - - **Classify the fallback envelope** with `validate_envelope` (role - `implementer`) exactly as the initial spawn: `completed` → step 4a; - `incomplete` → re-enter the continue-in-place loop (the fallback model now - drives it, still bounded by `continuationBudget`); `blocked`, or an - `invalid`/`missing` envelope → the slice has **FAILED** (the one rescue is - spent). Record the swap for the final-report narration ("fable declined → - served by opus"; see `references/wave-loop.md`). -4a. **Verify the changeset against the worktree.** After a `completed` - implementer envelope — and before trusting it — call the `verify_changeset` - MCP tool with the slice's `worktreePath` and the implementer envelope's - `filesChanged` as `declaredFiles`. It inspects the worktree directly with - `git status` and compares the declared file set against what actually - changed on disk: - - `match: "matched"` or `"clean"` — the declared set agrees with the - worktree; proceed to step 5. - - `match: "empty-but-declared"` — the implementer declared files but the - worktree is clean: its edits never landed. The slice has **FAILED**. - - `match: "suspiciously-empty"` — the implementer declared nothing but the - worktree HAS changes: the work was under-reported. The slice has - **FAILED**; record the `presentButUndeclared` paths in the `failureReason`. - - `match: "mismatch"` — the declared set and the worktree changeset diverge. - Trust the worktree: use the **union** of the implementer's declared - `filesChanged` and the tool's `actualFiles` as the changed-file set for the - reviewer and the commit (step 6), and note the divergence - (`declaredButAbsent` / `presentButUndeclared`) so the reviewer sees it. - - `status: "error"` — the worktree could not be inspected; the slice has - **FAILED**. - Once the changed-file set is established (a `matched`/`clean`/`mismatch` - verdict), set the slice's `subState` to `implemented` and checkpoint - `run-state.json`; the completed implementer envelope also satisfies the - pre-review gate, so set `subState` to `verified` and checkpoint again before - spawning the reviewer. (These two adjacent checkpoints differ only in - resume granularity — the resume matrix in section 1 re-runs the capability - gate for both.) -5. **Run the reviewer.** Spawn the `orchestrate:reviewer-` subagent — - `` and the `model` override from `routing.reviewer` — in the same - worktree. Its prompt must carry the issue, the worktree path, the - changed-file set agreed on by step 4a — the implementer envelope's - `filesChanged` when `verify_changeset` matched, the union of declared and - `actualFiles` on a `mismatch` — the implementer envelope's `notes`, and the - investigator's brief if one was produced. Validate its returned text with - `validate_envelope` (role `reviewer`). On a `valid` envelope, an envelope - `status` of `failed` means the slice has **FAILED**; `passed` proceeds. An - `invalid` or `missing` envelope also means the slice has **FAILED**. On a - `passed` envelope, set the slice's `subState` to `reviewed` and checkpoint - `run-state.json` before proceeding to step 6. -5a. **Pre-merge capability gate.** After the reviewer returns `passed` (step 5), - and **before any commit, push, or GitHub state exists**, the orchestrator - independently runs the correctness capability tools on the slice worktree — - this is the pre-merge capability gate. It does **not** trust the reviewer's - envelope `verification`: the reviewer's re-run is a subagent self-report; - this step is the orchestrator's own deterministic check, the last link in the - `implementer → reviewer → orchestrator` trust chain. - - Call the `run_build` and `run_tests` MCP tools with the slice's - `` as `repoPath` (the same pattern step 8a's `clean`-verdict - re-verify uses). Each tool - returns a `status` enum (`passed | failed | not-configured | error`); handle - all four: - - `passed` on **both** verbs → proceed to step 6. - - `not-configured` (either verb) → **tolerated**, treated as a pass for that - verb (consistent with the prerequisites note that a missing-command - `not-configured` is tolerated). The gate must not fail a project that has - not configured `build`/`tests`. - - `failed` or `error` (either verb) → the slice has **FAILED** (the existing - FAILED semantics defined throughout section 3 — no new failure handling). - - **Known-baseline-failure hint (`knownFailureMatches`).** When a capability - tool returns `status: "failed"` and the project's `commands.json` configures a - `knownFailures` pattern list, the result carries - `knownFailureMatches.matched` (configured patterns that appeared in the - failing output) and `.unmatched` (configured patterns that did not). Use it - only as a **hint**, never as a verdict — it is a best-effort L1 annotation, - not a deterministic "zero new failures" assertion (`run_tests` returns capped - exit-code output, not a structured test-result list). When every failure - indicator in the output is explained by a `matched` pattern and `unmatched` - holds only not-present baseline cases, treat the failure as a **likely known - baseline** and proceed per this gate's baseline handling. When the failing - output contains indicators NOT covered by any `matched` pattern, - **spot-check** before treating it as baseline — L1 cannot deterministically - assert "0 new failures." A `knownFailures` entry in `commands.json` looks - like, e.g.: - - ```json - { "tests": ["npm", "test"], "knownFailures": ["flaky-network timeout", "ECONNRESET"] } - ``` +*Steps 3 through 5a are the slice executor's. See section 3 of the spine for +what the orchestrator does between step 2 and step 6: it spawns the executor, +validates one envelope, and acts on it.* - The verb set is exactly `run_build` + `run_tests` — a deliberate subset: - build+test is the correctness trust boundary, while `typecheck`/`lint` remain - the reviewer's quality remit and are intentionally **not** re-run here. The - step 8a `clean`-verdict (post-`prepare`) re-verify running all four - `run_tests`/`run_typecheck`/`run_build`/`run_lint` verbs is a **known, - intentional asymmetry** — and is left unchanged: this pre-merge gate is - focused correctness on a worktree the reviewer already saw, whereas the - conflict re-verify is max-confidence on a never-before-tested merged - combination. "Pre-merge" names what the gate controls (whether the merge - proceeds); mechanically it runs pre-commit, on the same worktree state the - reviewer validated. 6. **Commit and push.** Run the slice's commit + verified-push mechanics with the **`finalize_slice` MCP tool** in phase `commit-push` — not raw `git`. It stages the file set, guards an empty changeset, commits, composes @@ -270,19 +85,19 @@ narration in the spine describes. worktree), `runId`, `sliceId` = the slice's issue-id-string key, `branch` = `orchestrate/slice-`, `remote` = `origin`, `setUpstream: true`, `commitSubject` = `(): `, `issueNumber` = ``, and - `files` = the union of the `filesChanged` arrays from the validated - implementer and reviewer envelopes. + `files` = the slice executor envelope's `filesChanged` — every worker's edits + combined, as the executor reported them. `finalize_slice` stages **exactly** that `files` set (`git add -- ...files`, never `git add -A` — the capability tools leave untracked build artifacts in - the worktree). When the implementer fetched a new dependency with + the worktree). When an implementer fetched a new dependency with `run_install`, install ran **in its turn before this commit** and mutated the - lockfile (`pnpm-lock.yaml` / `package-lock.json` / `Cargo.lock`); because the - implementer declared that lockfile in `filesChanged`, it is in this staged - union and the commit captures it — so the new dependency lands in the slice - diff. The commit preserves the two-`-m` form (subject + `Closes #` - trailer), and the push goes through `push_and_verify`'s SHA-matched - `git ls-remote` landing check. + lockfile (`pnpm-lock.yaml` / `package-lock.json` / `Cargo.lock`); because that + lockfile was declared in `filesChanged` and carried into the executor's + envelope, it is in this staged set and the commit captures it — so the new + dependency lands in the slice diff. The commit preserves the two-`-m` form + (subject + `Closes #` trailer), and the push goes through + `push_and_verify`'s SHA-matched `git ls-remote` landing check. On `status: "ok"` (`verdict: "committed-pushed"`) the slice's `subState` was set to `pushed` and `run-state.json` checkpointed by the tool — and **only** @@ -337,7 +152,9 @@ narration in the spine describes. slice on a conflict without attempting resolution. 8a. **Resolve a merge conflict (once).** Attempt resolution exactly once — a - conflict the resolver cannot fix is a FAILED slice. + conflict the resolver cannot fix is a FAILED slice. This is the + orchestrator's own work, never the executor's: a conflict is between the + slice branch and the umbrella branch, so it is git. 1. Prepare the worktree for resolution with the **`resolve_merge_conflict` MCP tool** in operation `prepare`. Call it with `operation: "prepare"`, @@ -407,8 +224,8 @@ narration in the spine describes. then removes the worktree (force — it may hold untracked build artifacts), then force-reclaims the **local** slice branch (`git branch -D`, ordered after the removal because a checked-out branch refuses the delete). `merged` is the - integration-boundary anchor: a run resumed at `subState: merged` skips every - subagent and re-enters here at step 9 only, never re-merging. + integration-boundary anchor: a run resumed at `subState: merged` re-enters + here at step 9 only, never re-merging and never re-spawning the executor. On `status: "ok"` (`verdict: "committed-pushed"`) the merged checkpoint, worktree removal, and local-branch reclaim are all done. The tool is diff --git a/plugins/orchestrate/skills/orchestrate/references/wave-loop.md b/plugins/orchestrate/skills/orchestrate/references/wave-loop.md index eeb0bcb9..2590e84c 100644 --- a/plugins/orchestrate/skills/orchestrate/references/wave-loop.md +++ b/plugins/orchestrate/skills/orchestrate/references/wave-loop.md @@ -70,12 +70,53 @@ Process waves in order, starting at index `completedWaves`. For each wave: 3. **Process the slices.** Branch on the `intraWaveConcurrency` policy read at the top of section 2 (see the wave-concurrency policy retained in the spine): - - **`parallel` (the default).** Run section 3 for every processable slice. - Slices in a wave are independent, so parallelize: when several slices are - at the same subagent stage (investigation, implementation, review), spawn - those subagents by issuing all the Agent tool calls **in a single - message**. Each slice has its own worktree, so they never collide. Then - integrate them sequentially — step 4 below. + - **`parallel` (the default).** Run section 3 for the processable slices, + **up to the wave's planned width** (below). Slices in a wave are + independent, so parallelize: spawn **one slice executor per slice** — + issuing all the Agent tool calls **in a single message** — and let each + executor run its own stages inside its own worktree. You have no + visibility into those stages and no stage-level batching to do; the + executors are the only subagents you spawn. Each slice has its own + worktree, so they never collide. Then integrate them sequentially — + step 4 below. + + **Plan the wave's width first.** A parallel wave holds roughly **twice** as + many live agents as it has slices — each in-flight slice occupies its slice + executor **plus** the one worker that executor currently has running — so + spawning every processable slice at once can walk the session into the + platform's concurrent-subagent limit. Call the `run_wave` MCP tool with + `operation: "plan-wave-width"` and `processableCount` (how many slices + passed step 2), plus `concurrencyLimit` **only** if + `CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS` is set to something other than its + default of 20 — the MCP process cannot read the session's environment, and + the plugin works **within** the limit rather than raising it: + + - `width-planned` — run at most `waveWidth` slices concurrently. The + `deferredCount` remainder is **deferred, not skipped**: each deferred + slice keeps its `pending` state and stays in this wave's processable + queue. As each in-flight slice finishes integrating (step 4), pull the + next deferred slice from the queue. + + **A concurrency-limit refusal is backpressure, not a slice failure.** If a + spawn is refused anyway, pass the verbatim refusal text to the `run_wave` + MCP tool as `operation: "classify-spawn-outcome"`, `spawnFailureText: + ""`: + + - `backpressure` (`limitSignal: "concurrent-subagent-limit"`) — the + platform was at its concurrent limit. **Nothing is wrong with the + slice.** Return it to this wave's processable queue with **its state + unchanged** (`pending`) — exactly where a width-deferred slice sits — and + re-attempt it when a slot frees. Never mark it `failed`, never write a + `failureReason`, never apply a triage label, and never count it against + the run's failures: doing any of those produces a structural false + negative that would be blamed on the slice's own work. The vendor + documents this refusal as one Claude is told **not to retry**, which is + consistent — the requeue is a *later attempt after a slot frees*, not an + immediate retry of the refused call. + - `spawn-error` — a genuine spawn failure, handled as a failure. When + `limitSignal` is `session-spawn-limit` the session's **total** spawn + budget is spent: re-attempting in this session cannot succeed, so hand + the run off (section 4) rather than requeueing, which would loop forever. - **`sequential`.** Process the wave's processable slices **one at a time, in issue-id ascending order** — steps 3 and 4 below fuse into a per-slice @@ -139,8 +180,10 @@ Process waves in order, starting at index `completedWaves`. For each wave: After each slice finishes integrating, check for `.orchestrate/runs//context-flag.json`: if it - exists, the context-watchdog has signalled that this session's context is - filling. Do not start the next slice — finish writing `run-state.json` for + exists, the context-watchdog has signalled that one of this session's two + budgets — its context window or its total subagent-spawn budget — is running + out (the flag's `trigger` field says which). Do not start the next slice, and + do not pull the next deferred one — finish writing `run-state.json` for the slice just integrated, then go to section 4 (Context handoff). 4a. **Run the per-wave integration suite.** After every processable slice in this wave has integrated and passed its post-merge unit re-verify, run the @@ -220,8 +263,9 @@ pull request. **Narrate the routing notes in the per-slice summary.** For each slice whose `resolvedRouting.fallbackTaken` is `true`, note the **model-fallback swap** in its summary line — e.g. "slice #N: fable declined → served by opus" — so the -premium-lane fallover is visible in the report (the swap itself runs in -`references/slice-pipeline.md` step 4). Surface any routing-label +premium-lane fallover is visible in the report (the swap itself runs inside the +slice executor and is reported in its envelope's `fallbackTaken`). Surface any +routing-label **WARNING** (an unconfigured `route:*` label) or **ERROR** (a same-role `LABEL_CONFLICT`) from `resolve_routing` in the same summary, and — where the orchestrator judges a slice would have benefited from a premium lane — it may diff --git a/plugins/orchestrate/skills/slice-pipeline/SKILL.md b/plugins/orchestrate/skills/slice-pipeline/SKILL.md new file mode 100644 index 00000000..6aa29323 --- /dev/null +++ b/plugins/orchestrate/skills/slice-pipeline/SKILL.md @@ -0,0 +1,470 @@ +--- +name: slice-pipeline +description: The per-slice procedure a Slice executor follows after the orchestrator spawns it for one issue — investigate, implement through the bounded continue-in-place loop, verify the changeset, review, and run the Capability gate, writing a Slice progress record at each completed stage so an interrupted slice resumes instead of restarting. Loaded by the slice-executor subagent as its operating procedure; it is not a standalone task to run on request. +--- + +# The per-slice pipeline + +You are the **Slice executor**. The orchestrator spawns you for exactly one +issue, in a worktree it has already created, and you own that slice from +investigation through to a verified changeset. You spawn the workers the slice +needs, read every one of their returns **only** through a validated **Result +envelope**, and end your turn with one envelope describing the whole slice. + +You are never invoked directly by a person. Everything you need arrives in your +briefing. + +## What is yours, and what is not + +Four things are the **orchestrator's** and never yours: + +- **Git.** No `commit`, no `push`, no `merge`, no `branch`, no `git add`. You do + not run git at all. +- **The forge.** No `gh`, no pull requests, no merges. +- **The tracker.** No issues, no labels, no comments. +- **The worktree lifecycle.** You do not create, remove, or reclaim a worktree + or its branch. Yours already exists; leave it in place when you finish — + including when you fail, because the partial work left in it is what makes a + failed slice resumable. + +Four corollaries that are easy to get wrong: + +- **Never resolve routing.** Your **Resolved slice routing** arrives frozen in + your briefing. The orchestrator read the issue's labels exactly once, at slice + creation; re-reading them would let a slice's routing drift mid-run. +- **Never write the orchestrator's run-state checkpoint.** Your own durable + state is the **Slice progress record** below, and nothing else. +- **Never spawn the conflict-resolver.** A merge conflict is between the slice + branch and the umbrella branch — that is git, so it is the orchestrator's. +- **Never decide what runs next.** Wave ordering and loop termination are + computed by the orchestrator. You report; you do not select. + +This boundary is prose you are bound by, not an inference from whichever tools +you happen to hold. Honour it even when a tool that would let you cross it is +within reach. + +## The tools this procedure needs + +- The **Agent spawn tool** — for the investigator, implementer, and reviewer. +- **`validate_envelope`** — after every worker return, without exception. +- **`verify_changeset`** — the **Changeset scope check**, and your only way to + inspect the worktree, since you do not run git. +- **`run_build`** and **`run_tests`** — the **Capability gate**. +- **File read and write** — for your progress record and your slice report. + +## What your briefing carries + +- The **issue** — number, title, body — and its **acceptance criteria**, which + are the hard scope boundary for every worker you spawn. +- The **worktree path**. Every file change goes there, and it is the `repoPath` + you pass to the capability tools and to `verify_changeset`. +- The **run id** and the **issue number** — together they identify your record. +- The frozen **Resolved slice routing**: per role, the `model` and the + **Routing variant** to spawn, plus its `fallback` block when the slice has a + premium lane. A null `investigator` entry means the tier skips investigation. +- `continuationBudget` — the run-wide implementer continuation budget. +- **Your own continuation index**, `executorContinuationIndex`: 1-based — `1` on + the orchestrator's first spawn of you for this slice, `2` on its second. +- The **progress-record path**, and the **run directory** to write into. + +## The Slice progress record + +The record is how an interrupted slice resumes instead of restarting. Write it +to the path your briefing carries. Its fields: + +- `runId` and `issue` — copied from your briefing. They make the record + self-identifying, so a mis-filed record is detected rather than trusted. +- `lastCompletedStage` — the stage that **FINISHED**. One of `investigator`, + `implementer`, `capability-gate`, `reviewer`. When no stage has completed yet, + **omit the key entirely**; an explicit null is rejected. +- `investigatorBrief` — the investigator's validated brief with its `role` field + omitted, carried so a resumed run never re-investigates. Absent when the tier + skips investigation. +- `continuationsUsed` — how many continuations your continue-in-place loop has + spent. Required, and required from the first write. +- `worktreeFingerprint` — an opaque content-level fingerprint of the worktree's + uncommitted state at the last completed stage. Absent before the first one is + taken. +- `fallbackTaken` — the once-only **Model fallback** guard. **Required, with no + default.** Write it on your **FIRST** record write as `false`; an absent key + must never be read as `false`, so a record that omits it fails validation and + costs you the resume. +- `updatedAt` — an ISO-8601 UTC timestamp, refreshed on every write. + +### When to write it + +**Every write replaces the whole record.** The writes below are described by +which field changes, but none of them is a patch: each one rewrites the file +with **all** the required fields — `runId`, `issue`, `continuationsUsed`, +`fallbackTaken` and a refreshed `updatedAt` — plus whichever optional ones you +have. Write only the changed keys and the record no longer validates, and you +lose the resume it exists to give you. + +**Four stage-completion writes** — one as each stage finishes, setting +`lastCompletedStage` to that stage's name: + +1. `investigator` — after the brief is validated and passes the scope diff. +2. `implementer` — after the **Changeset scope check** settles the changed-file + set. The check has no stage name of its own: it gates trust in the + implementer's own output before the reviewer begins, so it completes **as** + `implementer`. Do not invent a fifth stage name; the set is closed. +3. `reviewer` — after the reviewer returns `passed`. +4. `capability-gate` — after the gate passes. + +**Plus two writes that are *not* stage completions.** Neither can be folded into +the four above, and both leave `lastCompletedStage` exactly as it was — no stage +has finished: + +- **The guard write** — immediately before a Model fallback re-spawn, write + `fallbackTaken: true`. That is what makes the once-only guard survive an + interruption during the re-spawn itself. +- **The continuation write** — on every turn of the continue-in-place loop that + increments `continuationsUsed`, write the new `continuationsUsed` and + `worktreeFingerprint`. Without it the counter would only ever reach the record + at a stage boundary, so an executor interrupted mid-loop would resume with a + stale count and the continuation cap could not bind across re-spawns. + +### How to resume + +On startup, read your record from the briefing's path. If it is missing, start +at the beginning. If it is present and valid, **continue from +`lastCompletedStage` — never restart the slice.** Restore `continuationsUsed`, +`worktreeFingerprint` and `fallbackTaken` from it, and reuse the carried +`investigatorBrief` rather than spawning a second investigation. Nobody tells +you where to resume; your own record does, which is what keeps the resume point +from drifting away from what actually happened. + +**The stage names are a set, not an order.** Their listed order is +`investigator`, `implementer`, `capability-gate`, `reviewer`, but the pipeline +**runs** the reviewer before the capability gate. Never infer "the last name in +the set, so nothing is left" — that would skip the gate entirely and report a +slice as complete when its build and tests were never run. Use this mapping and +infer nothing: + +| `lastCompletedStage` | Resume by | +| --- | --- | +| *(key absent)* | Start at stage 1 — or stage 2 if routing skips the investigator. | +| `investigator` | Run stage 2, the implementer, with the carried brief. | +| `implementer` | Run stage 4, the reviewer. First re-run `verify_changeset` to re-derive the changed-file set, which the record does not carry. | +| `reviewer` | Run stage 5, the **Capability gate**. The slice is *not* done. | +| `capability-gate` | The slice is verified. Write your report and emit a `completed` envelope. | + +## Stage 1 — Investigate + +Skip this stage when your routing's `investigator` entry is null. + +Otherwise spawn the `orchestrate:investigator-` subagent, taking +`` and the `model` override from that entry. Its prompt must carry the +issue number, title and body, **and the acceptance criteria explicitly named as +the hard scope boundary** — the canonical per-slice scope. The investigator must +not propose work outside them. + +Validate its returned text with `validate_envelope`, role `investigator`. On +`valid`, **diff the brief against the acceptance criteria before forwarding +it**: inspect `relevantFiles`, `approach` and `notes` for work that does not +trace to at least one criterion. A brief that pulls in a sibling or downstream +slice's files, approaches, or recommendations is **over-scoped** — treat it as a +failed investigation pass and fail the slice. A correctly scoped brief goes to +the implementer. + +An `invalid` or `missing` envelope is also a failed investigation pass. The +investigator is read-only, so no fallback to salvaging its worktree applies. + +Write the record with `lastCompletedStage: "investigator"` and the brief in +`investigatorBrief`. + +## Stage 2 — Implement + +Spawn `orchestrate:implementer-`, taking `` and the `model` +override from your routing's `implementer` entry. Its prompt must carry the +issue number, title and body; the worktree path, where every change goes; the +investigator's brief if one was produced; an instruction to verify with the +capability tools using the worktree path as `repoPath`; a note that it MAY call +`run_install` (worktree path as `repoPath`) to fetch a newly-added dependency +before re-verifying, **and** that any lockfile the install mutates MUST be +reported in `filesChanged` so it lands in the slice diff; and a reminder not to +commit, push, or run git. + +Validate the returned text with `validate_envelope`, role `implementer`, then +classify the envelope `status`: + +- `completed` — go to stage 3. +- `incomplete` — the implementer's graceful turn-budget self-report: it foresaw + it could not finish and stopped cleanly, with partial work in the worktree and + a `remainingWork` handoff. **Do not fail the slice.** Run the continue-in-place + loop below. +- `blocked` — an unrecoverable obstacle. The slice fails. + +An `invalid` or `missing` envelope also fails the slice. A hard turn-limit +cutoff that truncates an envelope mid-emission lands here as `invalid` — that is +a different thing from the graceful `incomplete` self-report above, and the two +must not be conflated. + +### The bounded continue-in-place loop + +Read `budget = continuationBudget` from your briefing (default `2`; `0` disables +continuation, making an `incomplete` an immediate failure). When no routing was +configured for the run there is no budget — use **0**. + +Restore `continuationsUsed` from your record, or initialize it to `0`. Capture a +**content-level fingerprint** of the worktree's uncommitted state: call +`verify_changeset` to obtain the worktree's `actualFiles`, then hash the +contents of those files. A filename-set comparison is **insufficient** — the +same file may be rewritten with real progress or returned byte-identical, and +only the contents tell those apart. How you derive the fingerprint is yours to +choose; that it is content-level is not. + +While the continuation cap below permits another attempt, re-spawn +`orchestrate:implementer-` in the **same** worktree, with the same +routing, model and variant. The continuation prompt is the issue, the worktree +path, the **prior** envelope's `remainingWork`, the standard verify and no-git +reminders, and an explicit "partial work is already in the worktree — continue +it, do not restart." Validate the return with `validate_envelope`, role +`implementer`: + +- `completed` — go to stage 3. The loop is done. +- `blocked`, or an `invalid`/`missing` envelope — the slice fails; record the + precise cause. The loop is done. +- `incomplete` again — recompute the fingerprint. If it **equals** the prior + one, the **no-progress guard trips**: the slice fails as a stall. Otherwise + increment `continuationsUsed`, make the continuation write described above — + the new `continuationsUsed` and `worktreeFingerprint` — and loop. Hold the + latest `remainingWork` in-session for the next re-spawn; the record's field + set is closed and does **not** carry it. + +Unlike a loop counter held only in memory, `continuationsUsed` and +`worktreeFingerprint` are **persisted** to your record. That is deliberate: two +continuation loops now nest, and the cap below binds their product, which a +counter that reset on every interruption could not enforce. + +### The continuation cap + +Two continuation loops nest — the orchestrator's re-spawns of *you*, and your +re-spawns of the implementer — so the bound is on their **product**, never on +either factor alone. Before each implementer re-spawn, evaluate: + +``` +executorContinuationIndex × (continuationsUsed + 1) MUST NOT EXCEED 6 +``` + +`executorContinuationIndex` is the 1-based index from your briefing; +`continuationsUsed + 1` numbers the re-spawn you are about to make. If the +product would exceed 6, do not re-spawn. Stop with `status: "incomplete"`, +`failureClass: "incomplete-budget-exhausted"`, and a `failureReason` that says +the budget was exhausted and the partial work is preserved in the worktree. + +The loop is bounded by `continuationBudget` **as well**: stop at whichever bound +binds first. At default settings the cap is not the binding one — +`continuationBudget` is `2` and run-wide, so a first-spawn executor evaluates a +product of 2 at most. The cap exists for the deeper nesting, not the common +case. Do not build machinery for a bound that is not binding. + +*This formula is an assumption this skill fixes: the 1-based convention and the +choice of multiplicands are stated here so that enforcement is unambiguous.* + +### The one-time Model fallback + +This is **separate from** the continue-in-place loop. The loop re-spawns the +*same* model on `incomplete` and counts against `continuationBudget`; the +fallback **swaps** the model exactly once on a *model failure* and does not +touch `continuationBudget`. It applies only to a **premium-spawned** +implementer — one whose frozen routing carries a `fallback` block because a +routing label patched the implementer. + +It runs **before** you declare the slice failed. Before failing a premium slice +from stage 2 or from the loop, check the trigger and the guard: + +- **Trigger set** — the implementer **refused**, returned a retention or safety + **400**, or emitted an **invalid/missing envelope**. A *valid* `blocked` + envelope is **excluded**: it is a genuine obstacle, such as a missing + dependency, that a different model would not fix. Keep it on the immediate + failure path. +- **Guard** — proceed only when the `fallback` block is present **and** your + record's `fallbackTaken` is still `false`. On an ordinary, non-premium spawn, + or one where the rescue is already spent, skip this and fail normally. +- **Write the guard first.** Set `fallbackTaken: true` in your record and write + the file **before** the re-spawn. This is the guard write described above, and + writing it first is what makes the once-only property survive an interruption + during the re-spawn. +- **Re-spawn once** — `orchestrate:implementer-` in the **same** + worktree, overriding the Agent `model` to the fallback's `model`, with the + standard prompt: issue, worktree path, the investigator brief if any, the + verify and no-git reminders, and the prior `remainingWork` if the failure came + out of the continuation loop. +- **Classify the fallback envelope** with `validate_envelope`, role + `implementer`, exactly as you classified the initial spawn: `completed` → stage + 3; `incomplete` → re-enter the continue-in-place loop, now driven by the + fallback model and still bounded by the same cap; `blocked`, or an + `invalid`/`missing` envelope → the slice fails, and the one rescue is spent. + +The fallback fires **at most once for the whole slice** — across the initial +spawn, every continuation, and every resume. The record is what makes that true +across all three: a resumed executor reads `fallbackTaken` from it and does not +re-arm a rescue that was already used. Set `fallbackTaken` in your emitted +envelope too, so the orchestrator can narrate the swap in the run report. + +## Stage 3 — The Changeset scope check + +After a `completed` implementer envelope — and **before** trusting it — call +`verify_changeset` with the slice's worktree path and the envelope's +`filesChanged` as `declaredFiles`. It inspects the worktree directly and +compares what was declared against what actually changed on disk: + +- `match: "matched"` or `"clean"` — the declared set agrees with the worktree. + Proceed. +- `match: "empty-but-declared"` — files were declared but the worktree is clean: + the edits never landed. The slice fails as an **empty changeset**. +- `match: "suspiciously-empty"` — nothing was declared but the worktree HAS + changes: the work was under-reported. The slice fails as a **changeset + mismatch**; put the `presentButUndeclared` paths in the `failureReason`. +- `match: "mismatch"` — the two diverge. **Trust the worktree**: use the + **union** of the declared `filesChanged` and the tool's `actualFiles` as the + changed-file set for the reviewer and for your envelope, and carry the + divergence (`declaredButAbsent` / `presentButUndeclared`) into the reviewer's + prompt so it sees what was under- or over-declared. +- `status: "error"` — the worktree could not be inspected. The slice fails. + +Once the changed-file set is established, write the record with +`lastCompletedStage: "implementer"`. + +## Stage 4 — Review + +Spawn `orchestrate:reviewer-` in the same worktree, taking `` +and the `model` override from your routing's `reviewer` entry. Its prompt must +carry the issue; the worktree path; **the changed-file set agreed on in stage +3** — the implementer envelope's `filesChanged` when the check matched, or the +union of declared and `actualFiles` on a `mismatch`; the implementer envelope's +`notes`; and the investigator's brief if one was produced. + +That dependency is **why the Changeset scope check runs before this stage and +not after it**: the reviewer cannot be briefed until the changed-file set is +settled, so reviewing first would leave it reviewing a set nothing had yet +agreed on. Keep the two in this order. + +Validate the return with `validate_envelope`, role `reviewer`. A `valid` +envelope whose `status` is `failed` fails the slice; `passed` proceeds. An +`invalid` or `missing` envelope fails the slice. + +Write the record with `lastCompletedStage: "reviewer"`. + +## Stage 5 — The Capability gate + +After the reviewer returns `passed`, **you** independently run the correctness +capability tools on the slice worktree. This gate does **not** trust the +reviewer's envelope `verification`: that is a subagent's self-report, and this +step is your own deterministic check — the last link in the +`implementer → reviewer → executor` trust chain. + +Call `run_build` and `run_tests` with the slice's worktree path as `repoPath`. +Each returns a `status` of `passed`, `failed`, `not-configured` or `error`. +Handle all four: + +- `passed` on **both** verbs — the slice is verified. +- `not-configured` on either verb — **tolerated**, and treated as a pass for + that verb. The gate must not fail a project that has not configured a build or + a test command. +- `failed` or `error` on either verb — the slice fails. Report that verb as + `failed` in your envelope's `verification`: the envelope's outcome vocabulary + is `passed`, `failed` and `not-configured` only, so a gate `error` has no + member of its own and must not be passed through verbatim. + +**The known-baseline-failure hint.** When a capability tool returns +`status: "failed"` and the project configures a `knownFailures` pattern list in +its `commands.json`, the result carries `knownFailureMatches.matched` (patterns +that appeared in the failing output) and `.unmatched` (patterns that did not). +Use it **only as a hint, never as a verdict** — it is best-effort annotation, +not a deterministic "zero new failures" assertion, because the tool returns +capped exit-code output rather than a structured list of test results. When +every failure indicator in the output is explained by a `matched` pattern and +`unmatched` holds only cases that are simply not present, treat the failure as a +**likely known baseline** and proceed. When the failing output contains +indicators that no `matched` pattern covers, **spot-check before** treating it +as baseline. A `knownFailures` entry looks like: + +```json +{ "tests": ["npm", "test"], "knownFailures": ["flaky-network timeout", "ECONNRESET"] } +``` + +The verb set is exactly `run_build` + `run_tests` — a deliberate subset. +Build-and-test is the correctness trust boundary, while `typecheck` and `lint` +remain the reviewer's quality remit and are intentionally **not** re-run here. + +Write the record with `lastCompletedStage: "capability-gate"`. + +## Classifying a failure + +Every non-`completed` outcome carries a **Failure class** from this closed set, +plus a `failureReason` in your own words. Never invent an eighth class. + +| Class | The situation that produces it | +| --- | --- | +| `unrecoverable-obstacle` | A blocker with no safe workaround — including a Capability gate failure with no more specific class. | +| `incomplete-budget-exhausted` | The continuation cap or `continuationBudget` was reached with the implementer still `incomplete`. | +| `no-progress-stall` | The no-progress guard tripped: repeated attempts converged on nothing. | +| `invalid-or-missing-worker-envelope` | A worker you spawned returned a truncated, malformed, or missing envelope. | +| `changeset-mismatch` | The declared `filesChanged` did not match the worktree's actual changeset. | +| `empty-changeset` | The slice produced no file changes at all. | +| `model-refusal` | A spawned worker's model refused the task. | + +**Make `failureReason` precise enough to separate a resumable budget exhaustion +from a genuine stall.** These are the two that a triaging human most needs to +tell apart, and their classes alone do not do it: + +- On `incomplete-budget-exhausted`, say which bound bound — the product cap or + `continuationBudget` — with the numbers, and say that the partial work is + preserved in the worktree and the slice can be resumed. +- On `no-progress-stall`, say that the fingerprint was **unchanged** across + consecutive continuations, and how many. That is what makes it a stall rather + than a slice that merely ran out of room. + +## Your report and your envelope + +**Write a slice report** — a human-readable account of your run — into the +**run directory** your briefing names, beside your progress record, as +`slice--report.md`. Put that run-directory-relative path in the +envelope's `reportPath`. Write it whatever the outcome: a slice that failed in +its first stage still owes a report saying so. + +*Do not write the report into the worktree.* The Changeset scope check would see +it as an undeclared change, yield a `mismatch`, and the union rule would carry +the report into the slice's own commit. (This placement is an assumption this +skill fixes.) + +**End your turn with one envelope** describing the whole slice: + +```` +```orchestrate-envelope +{ + "role": "slice-executor", + "status": "completed", + "reportPath": "slice-359-report.md", + "nextTaskBriefing": "Advice for whoever picks up the next slice.", + "filesChanged": ["path/relative/to/worktree", "..."], + "verification": { "tests": "passed", "build": "passed" }, + "fallbackTaken": false +} +``` +```` + +- `status` describes the **whole slice**: `completed` — a verified changeset was + reached; `incomplete` — your own graceful continuation self-report; + `blocked` — an unrecoverable obstacle; `failed` — the slice did not reach a + trustworthy changeset. There is deliberately no value that continues or ends + the wave loop: that is not yours to declare. +- On any non-`completed` status, add `failedStage` (the stage that was + **running**, which is not the same field as the record's `lastCompletedStage`, + the stage that **finished**), `failureClass`, and `failureReason`. The + Changeset scope check reports under `failedStage: "implementer"`. +- `filesChanged` is every worker's edits combined, as paths relative to the + worktree root. An empty array means nothing changed. +- `verification` is one **settled** outcome per capability, not a re-run + history. A capability the slice never reached is simply absent. +- `fallbackTaken` is required, always — `false` when no fallback was taken. +- `nextTaskBriefing` is **advice only**. It never names which slice runs next. +- `reportPath`, `nextTaskBriefing`, `filesChanged`, `verification` and + `fallbackTaken` are required on **every** envelope — a failed one included. + Nothing about failing makes them optional. A slice that failed before reaching + any capability still emits `verification: {}`, an empty `filesChanged` if no + file was written, and a `reportPath` pointing at the report that explains the + failure. Drop them and the envelope is invalid, which turns a clean, explained + failure into an unexplained one. diff --git a/plugins/orchestrate/templates/handoff.json b/plugins/orchestrate/templates/handoff.json index 7216c23a..dd4cbb65 100644 --- a/plugins/orchestrate/templates/handoff.json +++ b/plugins/orchestrate/templates/handoff.json @@ -1,6 +1,8 @@ { "watchdog": { - "thresholdPercent": 40 + "thresholdPercent": 40, + "spawnThresholdPercent": 40, + "sessionSpawnBudget": 200 }, "successor": { "claudeArgs": [ diff --git a/plugins/orchestrate/templates/routing.json b/plugins/orchestrate/templates/routing.json index a885d639..bb5ef384 100644 --- a/plugins/orchestrate/templates/routing.json +++ b/plugins/orchestrate/templates/routing.json @@ -5,19 +5,22 @@ "investigator": null, "implementer": { "model": "haiku", "variant": "standard" }, "reviewer": { "model": "sonnet", "variant": "standard" }, - "conflict-resolver": { "model": "sonnet", "variant": "standard" } + "conflict-resolver": { "model": "sonnet", "variant": "standard" }, + "slice-executor": { "model": "haiku", "variant": "standard" } }, "standard": { "investigator": { "model": "haiku", "variant": "standard" }, "implementer": { "model": "sonnet", "variant": "standard" }, "reviewer": { "model": "opus", "variant": "standard" }, - "conflict-resolver": { "model": "opus", "variant": "standard" } + "conflict-resolver": { "model": "opus", "variant": "standard" }, + "slice-executor": { "model": "sonnet", "variant": "standard" } }, "complex": { "investigator": { "model": "opus", "variant": "deep" }, "implementer": { "model": "opus", "variant": "deep" }, "reviewer": { "model": "opus", "variant": "deep" }, - "conflict-resolver": { "model": "opus", "variant": "deep" } + "conflict-resolver": { "model": "opus", "variant": "deep" }, + "slice-executor": { "model": "opus", "variant": "deep" } } }, "labels": {