From b3137fd1ddde9be8f501b010acfc1746c5b19310 Mon Sep 17 00:00:00 2001 From: aimsise Date: Thu, 25 Jun 2026 03:27:07 +0900 Subject: [PATCH 01/10] feat(autopilot): parallel= opt-in plumbing + ticket-executor subagent (concurrency 1, default off) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T-001 of the parallel-autopilot feature (Wave 0). Adds the run-scoped `parallel=` argument + `parallel_mode:` state field (a mirror of the `uc=` / `ultracode_mode:` machinery), a new `ticket-executor` subagent, wave-list emission, and execution-path routing — all at concurrency 1 (serial-equivalent), default OFF. - agents/ticket-executor.md (new): runs one ticket's /scout->/impl->/ship pipeline as a subagent; `tools:` omitted (full inherit incl Agent); MUST NOT write autopilot-state.yaml (main loop is the single writer); returns a fixed `[TICKET-EXECUTOR-RESULT]` envelope. - skills/autopilot/SKILL.md: parallel= parse (default off, unknown->off), parallel_mode write/resume, wave-list emit, and per-ticket execution routing (off->inline serial; !=off->one executor per ticket, concurrency 1). - skills/brief/SKILL.md: parallel= parse + chain=off WARNING + forward to the chained /autopilot (composes with uc=). - split-plan-parsing.md: level-synchronous Kahn wave layering (honest relationship to the linear Processing order — no false universal-equality). - state-file.md: parallel_mode field + the envelope / single-writer contract. - tests/test-skill-contracts.sh: CT-PARALLEL-1..6. - CLAUDE.md: SW_PARALLEL_TICKETS_MODE kill switch ((B) harness-own). Byte-identical opt-out (AC-1, meet-or-beat): parallel=off / absent emits NO [PARALLEL-MODE] line, writes NO parallel_mode state field, spawns no executor, and enters the unchanged inline loop. This is deliberately stricter than the uc= peer (which emits its marker + writes its field even for off) so the parallel=off lane is a truly silent, byte-identical rollback path. No version bump / CHANGELOG: this is a continuous build toward a single v9.0.0 release (T-009 owns the version, CHANGELOG, and migration). README user-facing docs are deferred to T-009 because parallel only becomes user-facing-useful (real parallelism + default-on) at v9.0.0; the dev-facing CLAUDE.md knob is documented here. Verification: test-skill-contracts 871/871, test-path-consistency 145/145, test-accept-set-verify 32/32, ShellCheck --severity=warning clean. Adversarially verified (4 read-only lenses): byte-identity PASS (6/6 sites gated), mirror-fidelity PASS, executor-contract PASS, governance PASS (README deferral is intentional + recorded). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Mw2bH4wbEPeebXsvSG6rWe --- CLAUDE.md | 1 + agents/ticket-executor.md | 66 ++++++++++ skills/autopilot/SKILL.md | 28 ++++- .../references/split-plan-parsing.md | 47 ++++++++ skills/autopilot/references/state-file.md | 64 +++++++++- skills/brief/SKILL.md | 12 +- tests/test-skill-contracts.sh | 114 ++++++++++++++++++ 7 files changed, 323 insertions(+), 9 deletions(-) create mode 100644 agents/ticket-executor.md diff --git a/CLAUDE.md b/CLAUDE.md index c50468c..fcacfb3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,7 @@ This rule was distilled from a v6.7.0 dogfood incident in which a verify hook ne - `SW_STATE_FIELD_GUARD_MODE` — default `metric-only`. Gates the **HOOK_OWNED_FIELDS enforcement** in `hooks/pre-write-safety.sh` / `hooks/pre-edit-safety.sh`. The registry now ships with `.runtime_metrics` (an append-only telemetry list written exclusively by the six runtime-metrics writer hooks — Foundation 3 / ST-03), so a model Write/Edit that would clobber hook-appended `runtime_metrics` entries (a lost-update) is detectable. Values: `on` (emit `decision:block` whose reason NAMES the violated field — e.g. `.runtime_metrics` — and references `docs/state-schema.md`), `metric-only` (default — log `[STATE-FIELD-GUARD] metric-only: would block ...` to stderr and ALLOW, so populating the registry does NOT change the shipped allow-by-default behaviour), `off` (allow silently). Unknown values collapse to `metric-only`. **Promotion**: ship at `metric-only`; after one dogfood confirms no false trips, set `=on` to enforce. Detection covers the inline `runtime_metrics: []` → `[…]` (and blank-out) form; a multi-line list mutation expressed without an inline value change is not detected (acceptable under the metric-only default). - `SW_ACCEPT_SET_CONFORMANCE_MODE` — default `on` (v8.5.0). Controls `hooks/accept-set-verify.sh`, the PostToolUse(Write|Edit) gate that deterministically verifies the persisted `## Accept-set sweep` section of an `eval-round-{n}.md` report (the Advertised-Accept-Set Conformance observability line written by `agents/ac-evaluator.md`'s Persistence-First terminal rewrite). The hook reads the EMITTED line and applies the lens's OWN self-incrimination rule with zero model recall — BLOCKING on a triggered boundary not run (P1), an alphabet/unicode (A/U-axis) sweep that skipped the astral complement (P2), or an authoritative divergence not driven to FAIL (P4); plus a non-blocking ADVISORY note when an A/U-axis corpus falls below `SW_AASC_CORPUS_FLOOR` (P3 — corpus-size is a weak depth proxy, so a thin corpus is surfaced, NEVER blocked; the dogfood51 confirmation run showed flooring it false-trips a legitimately-thin-but-conformant sweep, so P3 was demoted to advisory). The keyed (K) / canonical-writer (W) axes are exempt from the astral/corpus checks (a reflection-derived key corpus is legitimately small), `caveat=no-runnable-artifact` exempts P1/P2 and the P3 advisory (a compiled-language fail-open degradation), and the `## Accept-set sweep` header is matched case-insensitively (a mis-cased header cannot let a whole report skip the gate). Values: `on` (DEFAULT, v8.5.0 — enforce: emit a PostToolUse `decision:block` whose reason names the violated predicate, surfacing it to the evaluator to re-run the sweep), `metric-only` (observe — log `[ACCEPT-SET-VERIFY] metric-only: would block ...` to stderr and ALLOW), `off` (explicit opt-out, silent). Unknown values collapse to `metric-only` (since the default is `on`, the var is set only to DOWNGRADE, so a typo on a downgrade value falls back to the safe observe mode — never a surprise enforce, never a silent disable). The exit code is ALWAYS 0 (fail-OPEN — the hook can never break a Write/Edit; the only non-allow influence is the `on`-mode block JSON), and a missing `jq` is a silent `exit 0`. **Promoted to `on` by default in v8.5.0** after dogfood51/52 (14 real conformant reports → 0 false-trips) + a live `decision:block` de-risk (dogfood53: the block surfaces cleanly and the evaluator handles it gracefully without thrash, and is NOT pressured into fabricating a conformant line); set `metric-only` to revert to observe-only, `off` to disable. Verified by `tests/test-accept-set-verify.sh`. This is the runtime, recognition-independent half of the per-brief `constraints.accept_set_conformance` switch (which gates whether the EXECUTED sweep runs at all). - `SW_AASC_CORPUS_FLOOR` — default `256`. The ADVISORY threshold for the P3 thin-corpus NOTE in `hooks/accept-set-verify.sh` (a thinner A/U corpus is surfaced to stderr as `[ACCEPT-SET-VERIFY] advisory: ...`, NEVER blocked), applied ONLY to a `triggered=y ran=y` alphabet (A) or unicode-transform (U) boundary line — the axes whose mandated complement (the Unicode decimal-digit property across the BMP and astral planes) is naturally large. A descriptive corpus-size (e.g. `5-canonical-forms`) is parsed by its leading integer so an annotation cannot dodge the note. Lower it (e.g. `=8`) to silence the note on a legitimately small advertised accept-set; raise it to surface more sweeps. Honoured only when `SW_ACCEPT_SET_CONFORMANCE_MODE` is not `off`. +- `SW_PARALLEL_TICKETS_MODE` — default `off`. The run kill switch for the run-scoped parallel ticket-execution path. Controls whether `/autopilot` (and a `/brief chain=on` chained run) routes the cross-ticket `/scout`→`/impl`→`/ship` pipeline through one `ticket-executor` subagent per ready ticket / topological wave, instead of the inline serial loop. Values: `off` (DEFAULT = the **prior-version behaviour**: every ticket runs inline in the main loop, strictly serial — no `ticket-executor` is spawned and every hook fires exactly as before, byte-identical to a no-directive run), `on` (force the executor-routed path even when no `parallel=` argument was supplied), `metric-only` (log the resolved wave plan + the `[PARALLEL-MODE]` line but execute the inline serial path). The per-run `parallel=` argument on `/autopilot` / `/brief` selects the same tri-state; this environment knob is the global override / kill switch and resolves **safe to `off`** on an unknown value (the same fail-safe direction as `uc=`), so a parallel path that misbehaves can be disabled from the shell without editing any invocation. The deterministic env-vs-argument precedence is applied by the parallel-mode resolver helper that the wave-aware hooks consult. This is a **(B) harness-own** substrate per `## Product/Language/Domain Agnosticism` — it governs the plugin's own orchestration engine (the `/autopilot` execution path), not the user's product, language, or domain. ## Language diff --git a/agents/ticket-executor.md b/agents/ticket-executor.md new file mode 100644 index 0000000..eda91ec --- /dev/null +++ b/agents/ticket-executor.md @@ -0,0 +1,66 @@ +--- +name: ticket-executor +description: "Execute one autopilot ticket's complete per-ticket pipeline (Policy guard -> /scout -> /impl -> /ship -> artifact-presence gate) as a subagent and return a single structured [TICKET-EXECUTOR-RESULT] envelope. Spawned once per ready ticket by the /autopilot main loop when parallel_mode != off. Never writes autopilot-state.yaml (the main loop is the single writer)." +maxTurns: 250 +--- + +You are the **ticket-executor**. The `/autopilot` main loop spawns you once per ready ticket (only when `parallel_mode != off`) to run that ONE ticket's complete per-ticket pipeline and return a structured result envelope. You execute the same per-ticket logic the serial main loop otherwise runs inline (`skills/autopilot/SKILL.md` "Per-ticket pipeline"), scoped to the single ticket named in your spawn prompt. + +Your `tools:` field is intentionally omitted: you inherit the full parent tool inventory, **including the Agent tool and the Skill tool**. This is required because `/scout` / `/impl` / `/ship` spawn their own subagents (`researcher`, `planner`, `implementer`, `ac-evaluator`, ...), so you must be able to invoke those pipeline skills via the Skill tool and let them spawn at depth+1. You are the one agent for which invoking pipeline skills is the contract, not a violation. + +## Single-writer contract (load-bearing) + +**You MUST NOT write `autopilot-state.yaml`.** The main loop is the single writer of the brief-level `autopilot-state.yaml`: it writes `status: in_progress` before spawning you and writes the terminal `steps` / `status` after it receives your envelope. You own only the per-ticket `phase-state.yaml` writes that `/scout` / `/impl` / `/ship` perform internally (a disjoint per-ticket inode). Writing `autopilot-state.yaml` from inside an executor would create a lost-update race once concurrency > 1 (Phase 2), so the prohibition holds even at concurrency 1 where you are the only executor in flight. + +## Inputs (from the spawn prompt, verbatim) + +- `logical_id` — the ticket's logical id (e.g. `{parent-slug}-part-N`). +- `parent_slug` — the parent slug. +- `ticket_dir` — the ticket dir path; the pipeline starts in `product_backlog/{parent-slug}/{NNN}-{slug}` and `/scout` moves it to `active/`. +- `target_branch` — the branch `/ship` targets (the repo default branch). +- `uc` — the run-scoped orchestration mode to forward to `/impl`, present ONLY when the main loop resolved `UC_ORCH != off`. When absent, OMIT `uc=` from the `/impl` call so it is byte-identical to a default run. +- `## Bound capabilities (per AC)` — the verbatim capability-binding block, present when the ticket carries a `### Capabilities` section. Pass it through to the pipeline skills unchanged; do NOT re-derive capability relevance yourself. + +## Pipeline (one ticket) + +Run these steps in order. On any step failure, STOP this ticket's pipeline (do not proceed to later steps), set `status`/`failure_reason`, and emit the envelope. The main loop handles dependents. + +1. **Pre-scout Policy guard** — confirm `autopilot-policy.yaml` exists in the ticket dir (`product_backlog/{ticket-dir}/`, copied by `/create-ticket`). Missing → `status = failed`, `failure_reason = policy_missing_scout`, emit envelope. + +2. **scout** — invoke `/scout .simple-workflow/backlog/product_backlog/{parent-slug}/{NNN}-{slug}` via the Skill tool. Required artifacts: `investigation.md` + `plan.md`. Missing → `steps.scout = failed`, `status = failed`, `failure_reason = scout_artifact_missing`, emit envelope. Else `steps.scout = completed`. + +3. **impl** — re-confirm the policy guard, then invoke `/impl .simple-workflow/backlog/active/{parent-slug}/{NNN}-{slug}/plan.md` via the Skill tool. **Forward the run-scoped orchestration mode**: append `uc={uc}` ONLY when the spawn prompt provided a `uc` value other than `off`; OMIT `uc=` entirely otherwise (byte-identical to a default run). Required artifacts: ≥1 `eval-round-*.md` (on PASS also ≥1 `audit-round-*.md` AND `quality-round-*.md`; skipped when all AC rounds FAILED). Missing → `steps.impl = failed`, `status = failed`, `failure_reason = impl_artifact_missing`, emit envelope. Else `steps.impl = completed`. + +4. **ship** — re-confirm the policy guard, then invoke `/ship {target_branch} ticket-dir={ticket-dir}` via the Skill tool (no `merge=true`). `/ship` atomically commits + moves the ticket + runs `/tune` + opens a PR (or, with no git remote, commits locally and skips push + PR — a local-only ship still reaches `steps.ship: completed`; an absent remote is NOT a failure). Required: the ticket dir moved to `.simple-workflow/backlog/done/{parent-slug}/{NNN}-{slug}/`. Missing → `steps.ship = failed`, `status = failed`, `failure_reason = ship_artifact_missing`, emit envelope. Else `steps.ship = completed`. + +5. **Artifact-presence gate** — the 7-pattern gate (`done/` first, else `active/`): `ticket.md`, `investigation.md`, `plan.md`, `eval-round-*.md`, `audit-round-*.md`, `quality-round-*.md`, `security-scan-*.md`. Exception: a last `eval-round-*.md` that is FAIL / FAIL-CRITICAL (all AC rounds failed) skips the last 3 patterns. Missing → `status = failed`, `failure_reason = artifact_gate:{patterns}`. All present → `status = completed`. + +## Mandatory Skill invocations (no substitutes) + +Exactly as the serial main loop, you MUST drive each step through the Skill tool — `/scout`, `/impl`, `/ship`. Never call `/investigate` / `/plan2doc` standalone, never spawn `implementer` / `ac-evaluator` directly, never substitute `git commit` / `gh pr create` / `mv` for `/ship`. If a mandatory Skill invocation cannot be completed, set `status = failed` with the matching `failure_reason` and emit the envelope — do NOT fabricate artifacts. + +## Return value — the `[TICKET-EXECUTOR-RESULT]` envelope (fixed format) + +Your FINAL message MUST be exactly this envelope. The main loop reads it to write `autopilot-state.yaml` as the single writer, so the format is load-bearing: + +``` +[TICKET-EXECUTOR-RESULT] +logical_id: {logical_id} +status: {completed|failed|skipped} +steps.scout: {pending|completed|failed} +steps.impl: {pending|completed|failed} +steps.ship: {pending|completed|failed} +pr_url: {url or null} +failure_reason: {null or a short snake_case reason} +``` + +- `status` — the terminal ticket status: `completed` when all steps completed and the artifact gate passed; `failed` when any step failed or the gate missed. +- `steps.{scout,impl,ship}` — the per-step terminal values you reached; a step you never started stays `pending`. +- `pr_url` — the PR URL `/ship` reported, or `null` when there is no remote (local-only ship) or no PR was opened. A `null` `pr_url` is NOT a failure. +- `failure_reason` — `null` on success, else a short snake_case tag (`policy_missing_scout`, `scout_artifact_missing`, `impl_artifact_missing`, `ship_artifact_missing`, `artifact_gate:{patterns}`). + +The `branch` and `head_sha` envelope fields are added by T-008 (worktree isolation); at concurrency 1 (no worktree, main checkout) they are not yet part of the envelope. + +## Language + +All prose you write to tracked files is ENGLISH. The pipeline skills enforce their own artifact contracts; this note covers any executor-authored text. diff --git a/skills/autopilot/SKILL.md b/skills/autopilot/SKILL.md index 202880e..60b750d 100644 --- a/skills/autopilot/SKILL.md +++ b/skills/autopilot/SKILL.md @@ -72,7 +72,9 @@ Target parent-slug: $ARGUMENTS Before detecting `{parent-slug}`, extract the optional `uc=` token from `$ARGUMENTS` using the SAME case-insensitive `key=value` convention the pipeline already uses for other key=value args (mirrors `/impl` `rounds=N` at its Step 1a and `/brief` `chain=`): scan for a `uc=` token (key matched case-insensitively), accept `` ∈ `on` | `off` | `metric-only`, and **strip that token from `$ARGUMENTS`** so the remaining-text/parent-slug detection below is unaffected. Resolve `UC_ORCH` from the stripped value; **when the `uc=` token is absent, `UC_ORCH = off`** (default). Emit the resolution line — the SHARED CONTRACT marker `[UC-ORCH-MODE] mode={on|off|metric-only} active={y|n} reason=invocation` to stderr (`active=y` only when `mode=on`, else `active=n`) — using `reason=invocation` here in Argument Parsing. `UC_ORCH` is carried through Phase 2 to each per-ticket `/impl` invocation (Step 3c) and recorded in the run-scoped state file (Phase 2 State file initialization). This is additive: with no `uc=` token the resolution defaults to `off` and the bare-`{parent-slug}` parse below behaves exactly as before. -Parse the (uc-stripped) `$ARGUMENTS`: extract `{parent-slug}` (first arg). `{parent-slug}` is the dir basename under `.simple-workflow/backlog/product_backlog/` (or brief slug under `briefs/active/`); legacy `{slug}` is interchangeable. Empty → see `## Error Handling`. +After resolving `UC_ORCH`, similarly extract the optional `parallel=` token from `$ARGUMENTS` using the SAME case-insensitive `key=value` convention: scan for a `parallel=` token (key matched case-insensitively), accept `` ∈ `on` | `off` | `metric-only`, and **strip that token from `$ARGUMENTS`** so the parent-slug detection below is unaffected. Resolve `PARALLEL_MODE` from the stripped value; **when the `parallel=` token is absent, `PARALLEL_MODE = off`** (default). An unrecognised value resolves **safe to `off`** (the same fail-safe direction as `uc=`). **Emit the resolution line ONLY when `PARALLEL_MODE != off`** — the SHARED CONTRACT marker `[PARALLEL-MODE] mode={on|metric-only} active={y|n} reason=invocation` to stderr (`active=y` only when `mode=on`, else `active=n`), using `reason=invocation` here in Argument Parsing. **When `PARALLEL_MODE == off` (the default or absent token, or an unknown value coerced to off), emit NO `[PARALLEL-MODE]` line at all** — a non-parallel run is byte-identical to prior versions with zero new stderr noise. (This is a deliberate stricter-than-`uc=` posture: `[UC-ORCH-MODE]` is emitted even for `uc=off`, but the `parallel=off` lane is the load-bearing byte-identical opt-out / rollback path for the parallel feature, so it stays completely silent.) `PARALLEL_MODE` is **orthogonal** to `UC_ORCH` (composable: `/autopilot {slug} uc=on parallel=on`); it is carried through Phase 2 to select the per-ticket execution path (current inline serial branch vs one `ticket-executor` subagent per ticket) and recorded in the run-scoped state file (Phase 2 State file initialization). This is additive: with no `parallel=` token the resolution defaults to `off`, Phase 2 takes the current inline serial path, and behaviour is byte-identical to prior versions. A `SW_PARALLEL_TICKETS_MODE` environment knob (documented in `CLAUDE.md`) is the run kill switch that forces serial; its deterministic env-vs-argument precedence is applied by the parallel-mode resolver helper introduced alongside the wave-cursor schema. + +Parse the (uc- and parallel-stripped) `$ARGUMENTS`: extract `{parent-slug}` (first arg). `{parent-slug}` is the dir basename under `.simple-workflow/backlog/product_backlog/` (or brief slug under `briefs/active/`); legacy `{slug}` is interchangeable. Empty → see `## Error Handling`. ## Non-interactive orchestrator contract (3-tier, risk_tolerance-aware) @@ -138,22 +140,24 @@ re-emit the same block. 4. **Human override detection**: compare each gate in `autopilot-policy.yaml` to defaults for `risk_tolerance`. `conservative` defaults + `moderate` defaults: in [references/state-file.md](references/state-file.md). `aggressive` defaults: moderate + `aggressive ship_ci_pending.timeout_minutes: 60`, `aggressive constraints.max_total_rounds: 12`, `aggressive constraints.allow_breaking_changes: true`. Gate differs + `# kb-suggested` → `kb_override` else `human_override`. Render to `## Human Overrides` / `## KB Overrides`; `## Decisions Made` distinguishes `human_override` from `kb_override`. **Exclude `kb_override`** from `## Human Overrides`. No diff → "No human overrides detected." -5. **State recovery**: absent `autopilot-state.yaml` → `resume_mode = false`. Else `resume_mode = true`; emit `[RESUME] ...` summary (resume msg, execution mode, progress N/total, per-ticket status). **When `resume_mode == true`, READ the top-level `ultracode_mode:` scalar from the recovered `autopilot-state.yaml`** (use the same top-level YAML scalar read the rest of the pipeline relies on; `hooks/lib/parse-state-file.sh::parse_yaml_scalar ultracode_mode` is the canonical reader) and **reconstruct `UC_ORCH`** from it for the remainder of the run: a value of `on` / `off` / `metric-only` resolves directly, while a missing / `null` / empty value defaults `UC_ORCH = off`. Emit the SHARED CONTRACT resolution line `[UC-ORCH-MODE] mode={on|off|metric-only} active={y|n} reason=resume` to stderr (`active=y` only when `mode=on`), and add the reconstructed mode to the `[RESUME] ...` summary so the run-scoped orchestration mode is visible on resume. This re-read is what carries `uc` across auto-compact / resume without any hook change. If `started` is older than 7 days, emit `[RESUME] WARNING` to delete `autopilot-state.yaml` and re-run. Carry `ticket_mapping`. Per-ticket: `completed` → skip (`[RESUME] Skipping {logical_id}: already completed`); `failed`/`skipped` → retry first non-completed; `in_progress` → re-run; `pending` → normal. If state recovery cannot continue (e.g. unparseable `autopilot-state.yaml`, hostile working tree detected during this step, or any other Phase 1 precondition that newly fails here), emit `[AUTOPILOT-POLICY] gate=unexpected_error action=stop reason=state_recovery_hard_stop` and write `## Stop Reason` with `tag: policy_gate_stop` plus a resume hint of the form `Resume after fixing X with: /autopilot {parent-slug}`; never escalate to `AskUserQuestion`. Any existing verbatim `ERROR:` / `[RESUME] WARNING` literal continues to be emitted alongside the new `policy_gate_stop` exit path. +5. **State recovery**: absent `autopilot-state.yaml` → `resume_mode = false`. Else `resume_mode = true`; emit `[RESUME] ...` summary (resume msg, execution mode, progress N/total, per-ticket status). **When `resume_mode == true`, READ the top-level `ultracode_mode:` scalar from the recovered `autopilot-state.yaml`** (use the same top-level YAML scalar read the rest of the pipeline relies on; `hooks/lib/parse-state-file.sh::parse_yaml_scalar ultracode_mode` is the canonical reader) and **reconstruct `UC_ORCH`** from it for the remainder of the run: a value of `on` / `off` / `metric-only` resolves directly, while a missing / `null` / empty value defaults `UC_ORCH = off`. Emit the SHARED CONTRACT resolution line `[UC-ORCH-MODE] mode={on|off|metric-only} active={y|n} reason=resume` to stderr (`active=y` only when `mode=on`), and add the reconstructed mode to the `[RESUME] ...` summary so the run-scoped orchestration mode is visible on resume. This re-read is what carries `uc` across auto-compact / resume without any hook change. **Likewise READ the top-level `parallel_mode:` scalar** from the recovered state (`hooks/lib/parse-state-file.sh::parse_yaml_scalar parallel_mode`) and **reconstruct `PARALLEL_MODE`** for the remainder of the run: `on` / `off` / `metric-only` resolves directly, while a missing / `null` / empty value defaults `PARALLEL_MODE = off`. **Only when `PARALLEL_MODE != off`**, emit the SHARED CONTRACT line `[PARALLEL-MODE] mode={on|metric-only} active={y|n} reason=resume` to stderr (`active=y` only when `mode=on`) and add the reconstructed parallel mode to the `[RESUME] ...` summary; **when `PARALLEL_MODE == off`, emit no `[PARALLEL-MODE]` line and add nothing to the `[RESUME] ...` summary** (a resumed non-parallel run is byte-identical to prior versions). This re-read carries the parallel execution mode across auto-compact / resume exactly as `ultracode_mode` carries `uc`. If `started` is older than 7 days, emit `[RESUME] WARNING` to delete `autopilot-state.yaml` and re-run. Carry `ticket_mapping`. Per-ticket: `completed` → skip (`[RESUME] Skipping {logical_id}: already completed`); `failed`/`skipped` → retry first non-completed; `in_progress` → re-run; `pending` → normal. If state recovery cannot continue (e.g. unparseable `autopilot-state.yaml`, hostile working tree detected during this step, or any other Phase 1 precondition that newly fails here), emit `[AUTOPILOT-POLICY] gate=unexpected_error action=stop reason=state_recovery_hard_stop` and write `## Stop Reason` with `tag: policy_gate_stop` plus a resume hint of the form `Resume after fixing X with: /autopilot {parent-slug}`; never escalate to `AskUserQuestion`. Any existing verbatim `ERROR:` / `[RESUME] WARNING` literal continues to be emitted alongside the new `policy_gate_stop` exit path. ## Phase 2: Pipeline Execution ### State file initialization -Skip if `resume_mode = true`. Brief-level `autopilot-state.yaml` ≠ per-ticket `phase-state.yaml`. Write at `briefs/active/{parent-slug}/` (else `product_backlog/{parent-slug}/`); hooks also accept `briefs/done/{parent-slug}/`. Fields: `ticket_mapping`, per-ticket `ticket_dir:` + `status` + `steps` + `invocation_method` ∈ `skill`/`manual-bash`/`unknown`, append-only `runtime_metrics: []` (`hooks/autopilot-continue.sh` + `hooks/pre-compact-save.sh` only; skills MUST NOT write). **Also write the top-level field `ultracode_mode: {on|off|metric-only}` from the `UC_ORCH` resolved in Argument Parsing** — a run-scoped orchestration mode (sibling of `version` / `parent_slug` / `started` / `execution_mode` / `total_tickets` / `ticket_mapping` / `tickets`). It records the run-scoped continuity value so Phase 1 Step 5 can re-read it on resume; the default `UC_ORCH = off` writes `ultracode_mode: off` (round-trips to `off` on resume). This is run-state, NOT a permanent policy flag — it lives only in `autopilot-state.yaml` (moved to `briefs/done/` on completion), never in `autopilot-policy.yaml`. Schema documented in [references/state-file.md](references/state-file.md). **MUST emit `tickets:` as a YAML list** of dash-prefixed `- logical_id: …` mappings — NOT a map keyed by `logical_id`. The map form silently bypasses the hook-layer skip-transition guard (`parse_proposed_tickets`) and the Stop-hook loop-guard counters (`parse_ticket_statuses`); field evidence `test_simple_workflow28`. Hook tolerance was added in WI-4 as a safety net only; SKILL prose remains the enforcement. Loop-guard emits `[AUTOPILOT-STALL] ...`. Schema invariants (including `tickets:` list-vs-map) + precedence + counters + kill switch + `boundary`/`stop_reason` domains in [references/state-file.md](references/state-file.md) + [references/stop-reason-taxonomy.md](references/stop-reason-taxonomy.md). +Skip if `resume_mode = true`. Brief-level `autopilot-state.yaml` ≠ per-ticket `phase-state.yaml`. Write at `briefs/active/{parent-slug}/` (else `product_backlog/{parent-slug}/`); hooks also accept `briefs/done/{parent-slug}/`. Fields: `ticket_mapping`, per-ticket `ticket_dir:` + `status` + `steps` + `invocation_method` ∈ `skill`/`manual-bash`/`unknown`, append-only `runtime_metrics: []` (`hooks/autopilot-continue.sh` + `hooks/pre-compact-save.sh` only; skills MUST NOT write). **Also write the top-level field `ultracode_mode: {on|off|metric-only}` from the `UC_ORCH` resolved in Argument Parsing** — a run-scoped orchestration mode (sibling of `version` / `parent_slug` / `started` / `execution_mode` / `total_tickets` / `ticket_mapping` / `tickets`). It records the run-scoped continuity value so Phase 1 Step 5 can re-read it on resume; the default `UC_ORCH = off` writes `ultracode_mode: off` (round-trips to `off` on resume). This is run-state, NOT a permanent policy flag — it lives only in `autopilot-state.yaml` (moved to `briefs/done/` on completion), never in `autopilot-policy.yaml`. **Also write the top-level field `parallel_mode: {on|metric-only}` from the `PARALLEL_MODE` resolved in Argument Parsing, but ONLY when `PARALLEL_MODE != off`** — a sibling run-scoped field with the identical lifecycle (init-written, resume-replayed at Phase 1 Step 5, moved to `briefs/done/` on completion, never a policy flag). **When `PARALLEL_MODE == off` (the default), OMIT the `parallel_mode:` field entirely** so the state file is byte-identical to prior versions (which had no `parallel_mode` field); Phase 1 Step 5 reconstructs `off` from an absent / `null` field. This deliberately differs from `ultracode_mode:` (which is written even for `off`): the `parallel=off` lane carries the byte-identical-**state** guarantee of AC-1, so it must not add a field. Schema documented in [references/state-file.md](references/state-file.md). **MUST emit `tickets:` as a YAML list** of dash-prefixed `- logical_id: …` mappings — NOT a map keyed by `logical_id`. The map form silently bypasses the hook-layer skip-transition guard (`parse_proposed_tickets`) and the Stop-hook loop-guard counters (`parse_ticket_statuses`); field evidence `test_simple_workflow28`. Hook tolerance was added in WI-4 as a safety net only; SKILL prose remains the enforcement. Loop-guard emits `[AUTOPILOT-STALL] ...`. Schema invariants (including `tickets:` list-vs-map) + precedence + counters + kill switch + `boundary`/`stop_reason` domains in [references/state-file.md](references/state-file.md) + [references/stop-reason-taxonomy.md](references/stop-reason-taxonomy.md). ### Split Execution Flow -Parse `SPLIT_PLAN` frontmatter + tickets, build dependency graph, run topological sort (lex tiebreak), emit `Processing order: {NNN-slug}` per ticket. Parsing/algorithm in [references/split-plan-parsing.md](references/split-plan-parsing.md). Edge-case ERROR literals (zero entries, cyclic `depends_on`) in `## Error Handling`. Single-ticket plans flow through the same path. +Parse `SPLIT_PLAN` frontmatter + tickets, build dependency graph, run topological sort (lex tiebreak), emit `Processing order: {NNN-slug}` per ticket. Parsing/algorithm in [references/split-plan-parsing.md](references/split-plan-parsing.md). Edge-case ERROR literals (zero entries, cyclic `depends_on`) in `## Error Handling`. Single-ticket plans flow through the same path. **When `PARALLEL_MODE != off`, additionally emit the wave layering** — one `Wave {k}: {NNN-slug}, ...` line per topological level (the level-synchronous Kahn variant in [references/split-plan-parsing.md](references/split-plan-parsing.md)); `Processing order:` is unchanged (the waves concatenated in order, each read lex). At concurrency 1 (Phase 1) the wave lines are emit/test-only and do NOT change execution order. #### Per-ticket pipeline > **Non-interactive orchestrator contract**: see `## Non-interactive orchestrator contract (3-tier, risk_tolerance-aware)` above. Per-ticket pipeline inherits the same 3-tier matrix; the only mid-pipeline `end_turn` is the auto-compact exception in step e. +**Execution-path routing (`PARALLEL_MODE`)**: when `PARALLEL_MODE == off` (the default), execute the inline serial per-ticket steps below exactly as in prior versions — byte-identical, and every hook behaves as before. When `PARALLEL_MODE != off`, execute the **executor-routed** variant in `##### Executor-routed per-ticket pipeline` (below the inline loop) INSTEAD of the inline steps: the dependency check, terminal statuses, and artifact set are equivalent, but each ticket's `/scout`→`/impl`→`/ship` runs inside a `ticket-executor` subagent and the main loop is the single writer of `autopilot-state.yaml`. In Phase 1 the executor path runs at **concurrency 1** (one executor at a time, in `PROCESSING_ORDER` order, no worktree = main checkout), so its terminal result matches the inline path. + For each ticket in `PROCESSING_ORDER` (`i` = 0-based): 1. **Resume skip check** (`resume_mode = true` only): `completed` → skip with `[RESUME] Skipping ticket {logical_id}: already completed`; `skipped` → re-evaluate dependencies; `failed`/`in_progress` → resume from first non-completed step. @@ -199,6 +203,22 @@ For each ticket in `PROCESSING_ORDER` (`i` = 0-based): 4. **Per-ticket error handling**: any step failure → ticket `failed`, log error, next ticket (do NOT stop pipeline). Dependents skipped (step 2). Independent tickets still run. +##### Executor-routed per-ticket pipeline (`PARALLEL_MODE != off`) + +Taken INSTEAD of the inline loop above when `PARALLEL_MODE != off`. The dependency graph, `PROCESSING_ORDER`, resume-skip semantics, and per-ticket terminal statuses are unchanged; the difference is that each ticket's pipeline runs inside a `ticket-executor` subagent (`agents/ticket-executor.md`) and the **main loop is the single writer** of `autopilot-state.yaml`. + +For each ticket in `PROCESSING_ORDER` (`i` = 0-based), at **concurrency 1** in Phase 1 (one executor in flight at a time): + +1. **Resume skip check** + **Dependency check** — identical to inline steps 1-2 above (`completed` → skip; any dep `failed`/`skipped` → this ticket `skipped` with reason `dependency_{dep-slug}_{status}`). These run in the main loop, NOT in the executor. + +2. **Write `status: in_progress`** for this ticket to `autopilot-state.yaml` (main loop, single writer), then **spawn one `simple-workflow:ticket-executor`** via the Agent tool. The spawn prompt passes verbatim: `logical_id`, `parent_slug`, `ticket_dir`, `target_branch` (the default branch from Pre-computed Context), `uc={UC_ORCH}` **only when `UC_ORCH != off`** (omit otherwise, so the executor's `/impl` call is byte-identical to a default run), and the `## Bound capabilities (per AC)` block when the ticket carries a `### Capabilities` section. Do NOT invoke `/scout`/`/impl`/`/ship` directly here — the executor owns the pipeline. + +3. **Receive the `[TICKET-EXECUTOR-RESULT]` envelope** and, as the single writer, transcribe its `steps.{scout,impl,ship}` / `status` / PR URL into this ticket's `autopilot-state.yaml` entry (canonical FLAT schema — `steps.` is a string on its own line). A `failed` envelope marks the ticket `failed`; dependents are skipped by the step-1 dependency check on later iterations. The artifact-presence gate already ran inside the executor; the main loop trusts the envelope `status` and MAY re-confirm `done/` presence opportunistically. + +4. **Loop-tail** — re-enter for `i+1`. The auto-compact exception and the MUST-NOT-`end_turn` rule of inline step e still apply at the main-loop level; the executor is a foreground subagent that blocks the main loop while it runs, so a premature main-loop `end_turn` cannot occur mid-ticket. **Phase 1 known limitation**: because `/scout`/`/impl`/`/ship` run inside the executor and off the main transcript, the main-transcript-scanning Stop / checkpoint / auto-compact hooks do not observe them; this is reworked when the hooks become wave-aware. `PARALLEL_MODE == off` (the default) keeps every hook byte-identical. + +**Concurrency in Phase 1 is fixed at 1** (serial-equivalent): the main loop spawns the next executor only AFTER the previous envelope is received and state is written. Real per-wave parallel spawning and a `parallel_max=` cap are added in Phase 2; the wave layering is computed and emitted now (Split Execution Flow) but does not yet drive concurrent spawns. + ### Split Autopilot Log Write overall `autopilot-log.md` at `briefs/active/{parent-slug}/` (or `briefs/done/` post-move; no brief dir → `product_backlog/{parent-slug}/`) AND per-ticket logs in each ticket dir (`done/...` if `/ship` Step 5 reached, else `active/...`). Per-ticket logs required. Frontmatter + per-ticket subsection + six common sections (`## Pipeline Execution`, `## Warnings`, `## Human Overrides`, `## KB Overrides`, `## Decisions Made`, `## Unreached Gates`) + Manual Bash Fallback rendering (`manual_bash_fallbacks[]` SSoT in `autopilot-state.yaml`; per-step `invocation_method == manual-bash` derived) live in [references/autopilot-log.md](references/autopilot-log.md). diff --git a/skills/autopilot/references/split-plan-parsing.md b/skills/autopilot/references/split-plan-parsing.md index 907584a..22797c4 100644 --- a/skills/autopilot/references/split-plan-parsing.md +++ b/skills/autopilot/references/split-plan-parsing.md @@ -90,6 +90,53 @@ Emit one `Processing order: {NNN-slug}` line per ticket at the top of Phase 2 (`{NNN-slug}` is the basename of `ticket_dir`, e.g. `005-add-user-auth`). +## Wave layering (level-synchronous Kahn) — `PARALLEL_MODE != off` only + +When `PARALLEL_MODE != off`, `/autopilot` ALSO groups the tickets into +**topological waves** so non-blocked tickets can be executed together +(Phase 2 parallelism; emit/test-only at Phase 1 concurrency 1). A wave is +one level of the dependency DAG: wave 0 is every root, wave k+1 is every +ticket whose `depends_on` are all in waves ≤ k. + +Level-synchronous Kahn — the same in-degree + lexicographic-tiebreak +machinery as the linear sort above, but peel a whole in-degree-0 LEVEL +per round instead of one node: + +1. Compute in-degree per node. +2. **Wave 0** = all nodes with in-degree 0 (roots), listed in ascending + lexicographic `ticket_dir` order. +3. Remove the current wave's nodes; decrement each dependent's in-degree. +4. **Wave k+1** = all nodes whose in-degree has now reached 0 and were + not placed in any earlier wave, again in lexicographic order. +5. Repeat until every node is placed. If some node never reaches + in-degree 0 the graph had a cycle — use the same ERROR path as the + cycle edge (already detected by the linear sort above, which runs + first). + +Emit one line per wave, AFTER the existing `Processing order:` block: + +``` +Wave 0: {NNN-slug}, {NNN-slug} +Wave 1: {NNN-slug} +``` + +`{NNN-slug}` is the basename of `ticket_dir`; entries within a wave are +listed lexicographically. + +**Relationship to `Processing order:`.** The linear `Processing order:` +(per-node Kahn, authoritative for serial / concurrency-1 execution) is +**unchanged** — wave layering is purely additive, and both honour every +`depends_on` edge. For the common layout where a dependent ticket is +numbered (hence sorts) after the tickets it depends on, reading the waves +in order and each wave lexicographically reproduces `Processing order:` +exactly. The two diverge only when a dependent sorts lexicographically +*before* an unrelated independent ticket; in that case `Processing +order:` stays authoritative for serial execution and the wave grouping is +authoritative for Phase 2 parallel execution (a ticket is spawned only +once all its `depends_on` sit in completed waves). At Phase 1 concurrency +1, execution follows `Processing order:` and the wave lines are +emit/test-only. + ## Mapping table If `resume_mode`, use `ticket_mapping` from `autopilot-state.yaml`. diff --git a/skills/autopilot/references/state-file.md b/skills/autopilot/references/state-file.md index 35e21bc..6cd326a 100644 --- a/skills/autopilot/references/state-file.md +++ b/skills/autopilot/references/state-file.md @@ -11,8 +11,8 @@ The brief-level / parent-level `autopilot-state.yaml` is distinct from each ticket's `phase-state.yaml` (owned by `/scout`, `/impl`, `/ship`). Skip writing it if `resume_mode = true` (state already exists). -The file has 7 top-level fields plus an OPTIONAL run-scoped -`ultracode_mode:` field and an append-only metrics list: +The file has 7 top-level fields plus two OPTIONAL run-scoped mode fields +(`ultracode_mode:` and `parallel_mode:`) and an append-only metrics list: ```yaml version: 1 @@ -21,6 +21,7 @@ started: {ISO-8601 via `date -u +%Y-%m-%dT%H:%M:%SZ`} execution_mode: split total_tickets: {N} ultracode_mode: off # OPTIONAL run-scoped orchestration mode: on | off | metric-only (default off) +parallel_mode: on # OPTIONAL run-scoped parallel exec mode: on | metric-only — WRITTEN ONLY when != off; ABSENT (-> off) on a default serial run ticket_mapping: {} tickets: - logical_id: {parent-slug}-part-{N} # one entry per split-plan ticket, in topological order @@ -67,6 +68,25 @@ Field summary (the 7 top-level fields plus `runtime_metrics:`): it survives auto-compact and resume (Phase 1 Step 5 re-reads it via the same top-level scalar path as the other fields, e.g. `parse_yaml_scalar ultracode_mode`). +- `parallel_mode` — OPTIONAL. Run-scoped parallel execution mode; value + domain `on` | `off` | `metric-only` (default `off` when absent). A near- + complete mirror of `ultracode_mode` with the same lifecycle: set once at + Phase 2 state-file initialization from the `parallel=` invocation argument + resolved in Argument Parsing **but written ONLY when `!= off`**, re-read on + resume at Phase 1 Step 5 (`parse_yaml_scalar parallel_mode`) to + reconstruct the run's execution path, and moved to `briefs/done/` with the + rest of the file on completion. It is **run-scoped run-state, NOT a + permanent policy flag** — a fresh run with no `parallel=` argument (or + `parallel=off`) **OMITS the field entirely**, keeping the state file + byte-identical to a pre-parallel version, and resume reconstructs `off` + from the absent field; it never lives in `autopilot-policy.yaml`. The one + deliberate difference from `ultracode_mode` (which is written even for + `off`) is exactly this omit-on-`off`, required by the `parallel=off` + byte-identical-state guarantee. At Phase 2 it selects whether each ticket + runs through the current inline serial branch (`off` / absent) or a + `ticket-executor` subagent (`on` / `metric-only`); it is **orthogonal to + `ultracode_mode`** (the two compose). The `SW_PARALLEL_TICKETS_MODE` + environment knob can force it to `off` as a run kill switch. - `runtime_metrics:` — append-only metrics list (see schema below). The `steps:` / `invocation_method:` maps no longer contain a `create-ticket` @@ -185,6 +205,46 @@ the map form (`pomodoro-timer-web-app-part-1: {...}`) and broke the pre-WI-4 LIST-only parsers in both `parse_ticket_statuses` and `parse_proposed_tickets` silently. +## `[TICKET-EXECUTOR-RESULT]` envelope + single-writer contract (`PARALLEL_MODE != off`) + +When `PARALLEL_MODE != off`, each ticket's `/scout`→`/impl`→`/ship` +pipeline runs inside a `ticket-executor` subagent +(`agents/ticket-executor.md`) rather than inline in the main loop. Two +contracts govern the state file on that path: + +**Single writer.** The `ticket-executor` MUST NOT write +`autopilot-state.yaml`. The main loop is the sole writer: it writes +`status: in_progress` for a ticket *before* spawning its executor, and +transcribes the terminal `steps` / `status` / PR URL *after* receiving the +executor's envelope. The executor owns only the per-ticket +`phase-state.yaml` writes that `/scout` / `/impl` / `/ship` perform +internally (a disjoint per-ticket inode). This keeps the brief-level state +file free of concurrent writers, so no lost-update is possible once +concurrency > 1 (Phase 2). At Phase 1 concurrency 1 the per-ticket boundary +coincides with the wave boundary, so the single-writer rule holds cleanly +under serial execution too. + +**Envelope.** The executor's FINAL message is a fixed-format envelope the +main loop parses to perform its single write: + +``` +[TICKET-EXECUTOR-RESULT] +logical_id: {parent-slug}-part-N +status: {completed|failed|skipped} +steps.scout: {pending|completed|failed} +steps.impl: {pending|completed|failed} +steps.ship: {pending|completed|failed} +pr_url: {url or null} +failure_reason: {null or a short snake_case reason} +``` + +The main loop maps the envelope onto the canonical FLAT `steps:` schema +(each `steps.` a string on its own line) and the ticket's `status`. +A `null` `pr_url` is NOT a failure (a local-only ship with no remote still +reports `steps.ship: completed`). The `branch` / `head_sha` fields are +added with worktree isolation (Phase 2); they are absent at Phase 1 +concurrency 1 (no worktree, main checkout). + ## `autopilot-state.yaml` location precedence `/autopilot` chooses **one** location based on what is already on disk. diff --git a/skills/brief/SKILL.md b/skills/brief/SKILL.md index 31916b3..a26439e 100644 --- a/skills/brief/SKILL.md +++ b/skills/brief/SKILL.md @@ -24,7 +24,7 @@ allowed-tools: - "Bash(mv:*)" - "Bash(ls:*)" - "Bash(date:*)" -argument-hint: " [chain=on|off] [uc=on|off|metric-only] (legacy: mode=auto|manual)" +argument-hint: " [chain=on|off] [uc=on|off|metric-only] [parallel=on|off|metric-only] (legacy: mode=auto|manual)" --- ## Pre-computed Context @@ -64,7 +64,13 @@ Parse `$ARGUMENTS`: - When `chain` resolves to `off` (≡ legacy `mode=manual`) AND the parsed `uc=` value is `on` or `metric-only` → emit to stderr the single line `WARNING: uc=on ignored when chain=off (no chained /autopilot to receive it)` (verbatim literal, including the leading `WARNING:`) and set `resolved_uc = off`. This is a **WARNING, not an error**: there is no chained `/autopilot` in `chain=off` mode to receive the value, so `uc` is ignored; processing continues normally (the brief is still written, Step 3 manual guidance still runs). Do NOT stop, do NOT exit non-zero, do NOT suppress any artifact. - When `chain` resolves to `off` AND the parsed `uc=` value is `off` (or `uc=` is omitted) → `resolved_uc = off` silently (no warning — nothing to ignore). - Any value other than `on`/`off`/`metric-only` → treat as `off` (this argument is an observe-only opt-in; an unrecognized value never blocks brief creation). `resolved_uc` is carried forward to Finalization Step 2. -- Remove the parsed `chain=` AND `mode=` AND `uc=` tokens from the description; remaining text is ``. +- **parallel execution — `parallel=` (additive, run-scoped opt-in)**: extract `parallel=` if present, using the SAME case-insensitive `key=value` convention as `uc=` above (token `parallel=` matched case-insensitively; trim whitespace and lowercase the value, so `parallel=ON`, `parallel=Off`, `parallel= metric-only ` normalize to `on`/`off`/`metric-only`). Accepted values: `on`, `off`, `metric-only`. **Default when `parallel=` is omitted: `off`.** Resolve the effective `parallel` value (`resolved_parallel`) AFTER the `chain` value has been resolved above, exactly mirroring `resolved_uc`: + - When `parallel=` is omitted → `resolved_parallel = off`. + - When `chain` resolves to `on` → `resolved_parallel` = the parsed `parallel=` value (`on`, `off`, or `metric-only`). The value is carried into the Finalization Step 2 chained handoff (forwarded to the chained `/autopilot`, which records it as run-scoped state and selects each ticket's execution path; `/brief` itself does not act on `parallel` beyond forwarding it). + - When `chain` resolves to `off` AND the parsed `parallel=` value is `on` or `metric-only` → emit to stderr the single line `WARNING: parallel=on ignored when chain=off (no chained /autopilot to receive it)` (verbatim literal, including the leading `WARNING:`) and set `resolved_parallel = off`. This is a **WARNING, not an error**: there is no chained `/autopilot` in `chain=off` mode to receive the value; processing continues normally (the brief is still written). Do NOT stop, do NOT exit non-zero, do NOT suppress any artifact. + - When `chain` resolves to `off` AND the parsed `parallel=` value is `off` (or `parallel=` is omitted) → `resolved_parallel = off` silently (no warning — nothing to ignore). + - Any value other than `on`/`off`/`metric-only` → treat as `off` (fail-safe; an unrecognized value never blocks brief creation). `resolved_parallel` is carried forward to Finalization Step 2. +- Remove the parsed `chain=` AND `mode=` AND `uc=` AND `parallel=` tokens from the description; remaining text is ``. - If `` is empty, print `Usage: /brief [chain=on|off]` (legacy alias accepted: `mode=auto|manual`) and stop. - Generate `{slug}` from `` using kebab-case (e.g., "Add User Auth" -> `add-user-auth`). The brief `{slug}` also serves as the `{parent-slug}` downstream. @@ -210,7 +216,7 @@ f. Otherwise (`/create-ticket` succeeded), proceed to the final invocation: > **CHECKPOINT — RE-ANCHOR BEFORE CONTINUING**: `auto-kick.yaml` is present at `.simple-workflow/backlog/briefs/active/{slug}/auto-kick.yaml`. Invoke `/autopilot {slug}` via the Skill tool now. Do NOT end your turn or summarize. - **MUST invoke `/autopilot` via the Skill tool** with argument `{slug}` (the brief's slug, which is also the `parent-slug` that `/create-ticket` wrote under `.simple-workflow/backlog/product_backlog/{slug}/`). **ultracode forwarding (additive)**: if `resolved_uc` (from Argument Parsing) is not `off`, append the resolved `uc` token to this invocation — i.e. invoke `/autopilot {slug} uc={resolved_uc}` (where `{resolved_uc}` is `on` or `metric-only`). When `resolved_uc` is `off` (the default, or downgraded by the `chain=off` warning above), the invocation stays exactly `/autopilot {slug}` with no `uc` token — so the default chained run is byte-identical to prior behavior. The chained `/autopilot` records `uc` as run-scoped state and propagates it to each per-ticket `/impl`; do NOT add `uc` to the `/create-ticket` invocation in sub-step (d) (`/create-ticket` does not start autopilot, so it is out of scope for `uc`). + **MUST invoke `/autopilot` via the Skill tool** with argument `{slug}` (the brief's slug, which is also the `parent-slug` that `/create-ticket` wrote under `.simple-workflow/backlog/product_backlog/{slug}/`). **ultracode forwarding (additive)**: if `resolved_uc` (from Argument Parsing) is not `off`, append the resolved `uc` token to this invocation — i.e. invoke `/autopilot {slug} uc={resolved_uc}` (where `{resolved_uc}` is `on` or `metric-only`). When `resolved_uc` is `off` (the default, or downgraded by the `chain=off` warning above), the invocation stays exactly `/autopilot {slug}` with no `uc` token — so the default chained run is byte-identical to prior behavior. The chained `/autopilot` records `uc` as run-scoped state and propagates it to each per-ticket `/impl`; do NOT add `uc` to the `/create-ticket` invocation in sub-step (d) (`/create-ticket` does not start autopilot, so it is out of scope for `uc`). **parallel forwarding (additive, composes with `uc`)**: independently, if `resolved_parallel` (from Argument Parsing) is not `off`, append `parallel={resolved_parallel}` to the SAME invocation — so a run with both set invokes `/autopilot {slug} uc={resolved_uc} parallel={resolved_parallel}`. When `resolved_parallel` is `off` (the default, or downgraded by the `chain=off` warning), no `parallel=` token is appended, so the default chained run stays byte-identical. Exactly like `uc`, `parallel` is NOT added to the `/create-ticket` invocation in sub-step (d) (`/create-ticket` does not start autopilot). ### Step 3 — `chain=off` (≡ legacy `mode=manual`; no chained handoff) diff --git a/tests/test-skill-contracts.sh b/tests/test-skill-contracts.sh index df3fad2..26fc0f8 100644 --- a/tests/test-skill-contracts.sh +++ b/tests/test-skill-contracts.sh @@ -10576,6 +10576,120 @@ assert_true \ echo "" +# ============================================================================= +# Cat PARALLEL: parallel ticket-execution opt-in (parallel= arg surface, +# parallel_mode run-scoped state, wave layering emit, ticket-executor contract, +# execution-path routing, SW_PARALLEL_TICKETS_MODE kill switch). +# Diff: NEW category (Phase 1 / T-001). The parallel= path is ADDITIVE — the +# no-parallel / parallel=off default is byte-identical (the inline serial +# loop) — so these CTs pin the NET-NEW surface only: the arg grammar + +# [PARALLEL-MODE] marker, the chain=off ignore WARNING + brief->autopilot +# forward, the parallel_mode write/re-read, the Wave layering contract, +# the ticket-executor agent contract (tools-omitted / no-state-write / +# envelope), the Execution-path routing branch, and the env kill switch. +# Mirrors Cat UC-ORCH (the uc= peer this surface is uniform with). +# ============================================================================= +echo "--- Cat PARALLEL: parallel ticket-execution opt-in (T-001) ---" + +PAR_AUTOPILOT="$REPO_DIR/skills/autopilot/SKILL.md" +PAR_BRIEF="$REPO_DIR/skills/brief/SKILL.md" +PAR_STATEFILE="$REPO_DIR/skills/autopilot/references/state-file.md" +PAR_SPLITPARSE="$REPO_DIR/skills/autopilot/references/split-plan-parsing.md" +PAR_EXECUTOR="$REPO_DIR/agents/ticket-executor.md" +PAR_CLAUDEMD="$REPO_DIR/CLAUDE.md" + +# CT-PARALLEL-1 (parallel= argument surface + [PARALLEL-MODE] marker + brief forward). +# The arg grammar (token parallel=, values on|off|metric-only) is documented on the two +# spawner surfaces that START autopilot (/autopilot + /brief; /impl does NOT parse parallel=, +# that asymmetry is correct — parallel lives at the autopilot layer); the [PARALLEL-MODE] +# resolution marker appears in /autopilot; /brief carries the chain=off ignore WARNING and +# forwards parallel={resolved_parallel} to the chained /autopilot. +par1_autopilot_arg=$(grep -cF 'parallel=' "$PAR_AUTOPILOT" || true) +par1_autopilot_marker=$(grep -cF '[PARALLEL-MODE]' "$PAR_AUTOPILOT" || true) +par1_brief_arg=$(grep -cF 'parallel=on|off|metric-only' "$PAR_BRIEF" || true) +par1_brief_warn=$(grep -cF 'parallel=on ignored when chain=off' "$PAR_BRIEF" || true) +par1_brief_fwd=$(grep -cF 'parallel={resolved_parallel}' "$PAR_BRIEF" || true) +par1_result="false" +if [ "$par1_autopilot_arg" -ge 1 ] && [ "$par1_autopilot_marker" -ge 1 ] && [ "$par1_brief_arg" -ge 1 ] \ + && [ "$par1_brief_warn" -ge 1 ] && [ "$par1_brief_fwd" -ge 1 ]; then par1_result="true"; fi +assert_true \ + "CT-PARALLEL-1 (parallel= arg surface + propagation): autopilot arg ($par1_autopilot_arg>=1) [PARALLEL-MODE] marker ($par1_autopilot_marker>=1); brief arg ($par1_brief_arg>=1) chain=off warning ($par1_brief_warn>=1) brief->autopilot forward ($par1_brief_fwd>=1)" \ + "$par1_result" + +# CT-PARALLEL-2 (run-scoped continuity: parallel_mode in autopilot-state.yaml). The +# state-file schema reference DOCUMENTS the top-level parallel_mode: field; /autopilot +# WRITES it at state init and RE-READS it on resume (Phase 1 Step 5) via parse_yaml_scalar. +# Mirror of ultracode_mode (kind-2 run-scoped state, NOT a permanent policy flag). +par2_statefile_doc=$(grep -cF 'parallel_mode' "$PAR_STATEFILE" || true) +par2_autopilot_field=$(grep -cF 'parallel_mode' "$PAR_AUTOPILOT" || true) +par2_autopilot_init=$(grep -cF 'top-level field `parallel_mode: {on|metric-only}`' "$PAR_AUTOPILOT" || true) +par2_autopilot_resume=$(grep -cF 'parse_yaml_scalar parallel_mode' "$PAR_AUTOPILOT" || true) +par2_result="false" +if [ "$par2_statefile_doc" -ge 1 ] && [ "$par2_autopilot_field" -ge 1 ] \ + && [ "$par2_autopilot_init" -ge 1 ] && [ "$par2_autopilot_resume" -ge 1 ]; then par2_result="true"; fi +assert_true \ + "CT-PARALLEL-2 (run-scoped continuity parallel_mode): state-file documents field ($par2_statefile_doc>=1); autopilot field ($par2_autopilot_field>=1) state-init write ($par2_autopilot_init>=1) re-read on resume ($par2_autopilot_resume>=1)" \ + "$par2_result" + +# CT-PARALLEL-3 (wave layering emit contract). split-plan-parsing.md documents the +# level-synchronous Kahn wave layering and the `Wave k:` emit format; /autopilot calls it +# (emits the wave lines when PARALLEL_MODE != off; emit-only at concurrency 1). +par3_wave_section=$(grep -cF 'Wave layering' "$PAR_SPLITPARSE" || true) +par3_level_sync=$(grep -cF 'level-synchronous Kahn' "$PAR_SPLITPARSE" || true) +par3_wave_emit=$(grep -cF 'Wave 0:' "$PAR_SPLITPARSE" || true) +par3_autopilot_wave=$(grep -cF 'Wave {k}:' "$PAR_AUTOPILOT" || true) +par3_result="false" +if [ "$par3_wave_section" -ge 1 ] && [ "$par3_level_sync" -ge 1 ] \ + && [ "$par3_wave_emit" -ge 1 ] && [ "$par3_autopilot_wave" -ge 1 ]; then par3_result="true"; fi +assert_true \ + "CT-PARALLEL-3 (wave layering emit): split-parse section ($par3_wave_section>=1) level-synchronous ($par3_level_sync>=1) Wave-emit format ($par3_wave_emit>=1); autopilot emits waves ($par3_autopilot_wave>=1)" \ + "$par3_result" + +# CT-PARALLEL-4 (ticket-executor agent contract). agents/ticket-executor.md EXISTS and +# pins the load-bearing contract strings: tools OMITTED (full inherit incl Agent), MUST NOT +# write autopilot-state.yaml (single-writer), and the fixed [TICKET-EXECUTOR-RESULT] envelope. +par4_exists=0; if [ -f "$PAR_EXECUTOR" ]; then par4_exists=1; fi +par4_tools_omit=$(grep -cF 'tools:` field is intentionally omitted' "$PAR_EXECUTOR" 2>/dev/null || true) +par4_no_write=$(grep -cF 'MUST NOT write `autopilot-state.yaml`' "$PAR_EXECUTOR" 2>/dev/null || true) +par4_envelope=$(grep -cF '[TICKET-EXECUTOR-RESULT]' "$PAR_EXECUTOR" 2>/dev/null || true) +par4_fields=$(grep -cF 'failure_reason' "$PAR_EXECUTOR" 2>/dev/null || true) +par4_result="false" +if [ "$par4_exists" -eq 1 ] && [ "$par4_tools_omit" -ge 1 ] && [ "$par4_no_write" -ge 1 ] \ + && [ "$par4_envelope" -ge 1 ] && [ "$par4_fields" -ge 1 ]; then par4_result="true"; fi +assert_true \ + "CT-PARALLEL-4 (ticket-executor contract): exists ($par4_exists=1) tools-omitted ($par4_tools_omit>=1) no-state-write ($par4_no_write>=1) envelope ($par4_envelope>=1) fields ($par4_fields>=1)" \ + "$par4_result" + +# CT-PARALLEL-5 (autopilot execution-path routing). /autopilot Phase 2 carries the +# Execution-path routing branch (PARALLEL_MODE == off -> inline serial byte-identical; +# != off -> executor-routed), spawns simple-workflow:ticket-executor as the single writer, +# and pins concurrency 1 for Phase 1. +par5_routing=$(grep -cF 'Execution-path routing' "$PAR_AUTOPILOT" || true) +par5_executor_subsec=$(grep -cF 'Executor-routed per-ticket pipeline' "$PAR_AUTOPILOT" || true) +par5_spawn=$(grep -cF 'simple-workflow:ticket-executor' "$PAR_AUTOPILOT" || true) +par5_single_writer=$(grep -cF 'single writer' "$PAR_AUTOPILOT" || true) +par5_conc1=$(grep -cF 'concurrency 1' "$PAR_AUTOPILOT" || true) +par5_result="false" +if [ "$par5_routing" -ge 1 ] && [ "$par5_executor_subsec" -ge 1 ] && [ "$par5_spawn" -ge 1 ] \ + && [ "$par5_single_writer" -ge 1 ] && [ "$par5_conc1" -ge 1 ]; then par5_result="true"; fi +assert_true \ + "CT-PARALLEL-5 (execution-path routing): routing branch ($par5_routing>=1) executor subsection ($par5_executor_subsec>=1) spawn ticket-executor ($par5_spawn>=1) single-writer ($par5_single_writer>=1) concurrency-1 ($par5_conc1>=1)" \ + "$par5_result" + +# CT-PARALLEL-6 (env kill switch + (B) harness-own). CLAUDE.md documents +# SW_PARALLEL_TICKETS_MODE (default off = prior serial behaviour) as the run kill switch and +# marks it (B) harness-own substrate. +par6_knob=$(grep -cF 'SW_PARALLEL_TICKETS_MODE' "$PAR_CLAUDEMD" || true) +par6_killswitch=$(grep -cF 'run kill switch for the run-scoped parallel ticket-execution' "$PAR_CLAUDEMD" || true) +par6_bsubstrate=$(grep -cF '(B) harness-own' "$PAR_CLAUDEMD" || true) +par6_result="false" +if [ "$par6_knob" -ge 1 ] && [ "$par6_killswitch" -ge 1 ] && [ "$par6_bsubstrate" -ge 1 ]; then par6_result="true"; fi +assert_true \ + "CT-PARALLEL-6 (env kill switch): SW_PARALLEL_TICKETS_MODE documented ($par6_knob>=1) kill-switch prose ($par6_killswitch>=1) (B) harness-own ($par6_bsubstrate>=1)" \ + "$par6_result" + +echo "" + # ============================================================================= # Category EV-MODEL: M5 criticality scalar + evaluator-model allocation (v8.3.0) # Diff: New category (M5). Drift-guards the criticality scalar From 94c57de4644208ac77f57d7dc3ed0ae5549e89ed Mon Sep 17 00:00:00 2001 From: aimsise Date: Thu, 25 Jun 2026 03:42:50 +0900 Subject: [PATCH 02/10] feat(uc): flip ultracode orchestration to on by default (prose only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T-002 of the parallel-autopilot feature (Wave 0). Flips the `uc=` (ultracode orchestration) absent-token default from `off` to `on` across all four resolution sites + the state-file doc + README. Pure prose / argument-parsing change — zero hook coupling (no hook reads `uc` / `ultracode_mode` / `UC_ORCH`; AC-6 verified). - skills/autopilot/SKILL.md: Argument Parsing absent-token default -> on (+ reason=default for the on-by-default path); state-init writes ultracode_mode: on by default; the Step-5 resume missing-field fallback stays off (legacy-state fidelity — rationale added). - skills/impl/SKILL.md: Step 1a-uc + Step 3a UC_ORCH resolution default -> on; the explicit-`off` "byte-identical to v8.5.0" literal preserved verbatim (R-c1/R3 drift guard); Step 15 dispatch unchanged. - skills/brief/SKILL.md: omitted uc under chain=on -> on; chain=off + omitted stays off SILENTLY (no false WARNING; AC-2); explicit uc=on under chain=off still warns. - state-file.md: ultracode_mode default doc off -> on. - README.md: /brief signature default uc=on; ultracode-on-by-default narrative + uc=off as the revert; cost note promoted to by-default. - tests/test-skill-contracts.sh: CT-UC-ORCH-5 (four-site flip + the explicit-off byte-identity literal survives). The byte-identity guarantee moves to the EXPLICIT opt-out: `uc=off` is byte-identical to v8.5.0 (Agent path), exactly as the parallel=off lane in T-001. No version bump / CHANGELOG (single v9.0.0 release at T-009, which also documents this as a breaking bare-invocation cost change + migration). Verification: test-skill-contracts 872/872, test-path-consistency 145/145, test-accept-set-verify 32/32, ShellCheck clean, no hooks/ touched. Adversarially verified (2 read-only lenses): flip-completeness PASS (4/4 sites, leftover hunt clean — one self-caught state-init leftover fixed pre-commit), preserve-literal + AC-2/3/4 + README PASS. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Mw2bH4wbEPeebXsvSG6rWe --- README.md | 6 +++--- skills/autopilot/SKILL.md | 6 +++--- skills/autopilot/references/state-file.md | 11 ++++++----- skills/brief/SKILL.md | 5 ++--- skills/impl/SKILL.md | 4 ++-- tests/test-skill-contracts.sh | 18 ++++++++++++++++++ 6 files changed, 34 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 75644ba..db0d675 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ claude plugin install simple-workflow@aimsise-simple-workflow --scope project Inside an active Claude Code session, type `/brief ` and the plugin handles the rest end-to-end: codebase investigation, requirements interview, ticket creation, implementation, multi-agent review, and pull request. -Full argument signature: `/brief [chain=on|off] [uc=on|off]` (default `chain=on`, `uc=off`). The `chain=on|off` form is canonical; `mode=auto|manual` is a deprecated legacy alias (`chain=on` ≡ `mode=auto`, `chain=off` ≡ `mode=manual`). +Full argument signature: `/brief [chain=on|off] [uc=on|off]` (default `chain=on`, `uc=on`). The `chain=on|off` form is canonical; `mode=auto|manual` is a deprecated legacy alias (`chain=on` ≡ `mode=auto`, `chain=off` ≡ `mode=manual`). -Optional **`uc=on`** opts into **ultracode orchestration**: non-trivial (M+) tickets run their AC evaluation as a parallel multi-verifier panel via Claude Code's Workflow tool (forwarded `/brief` → `/autopilot` → each `/impl` and preserved across auto-`/compact`/resume; tier-appropriate model — Sonnet at `thorough`, Opus at `exhaustive`). Default `uc=off` is byte-identical to prior behaviour; `uc=on` requires `chain=on`. Also accepted on `/autopilot uc=on` and `/impl … uc=on`. Details: `skills/impl/SKILL.md`. +**ultracode orchestration** is **on by default**: non-trivial (M+) tickets run their AC evaluation as a parallel multi-verifier panel via Claude Code's Workflow tool (forwarded `/brief` → `/autopilot` → each `/impl` and preserved across auto-`/compact`/resume; tier-appropriate model — Sonnet at `thorough`, Opus at `exhaustive`). Pass **`uc=off`** to revert to the byte-identical single-evaluator Agent path. Default-on applies under `chain=on` (the `/brief` default); under `chain=off` there is no chained `/autopilot`, so `uc` resolves `off`. Also accepted (as `uc=on|off|metric-only`) on `/autopilot ` and `/impl …`. Details: `skills/impl/SKILL.md`. | Mode | Command | Result | |------|---------|--------| @@ -194,7 +194,7 @@ When injection cannot fire, the hook surfaces a one-line diagnostic via `inject_ - Designed for use with Claude Code CLI. IDE extensions (VS Code, JetBrains) may have limited support for hooks and plugin features. - Pull-request creation requires GitHub CLI (`gh`) with authentication. Other Git hosting services are not supported. - Ticket management uses the local filesystem (`.simple-workflow/backlog/`). There is no sync with external issue trackers (Jira, Linear, etc.). -- Sub-agents consume API tokens independently. The Generator (implementer) always runs on Opus, and the evaluator escalates to Opus for critical/exhaustive work, so larger or higher-risk tickets may incur higher API costs. With `uc=on` (ultracode orchestration), M+ tickets additionally run a 3-lens parallel evaluator panel (Sonnet at `thorough`, Opus at `exhaustive`), further increasing token cost. +- Sub-agents consume API tokens independently. The Generator (implementer) always runs on Opus, and the evaluator escalates to Opus for critical/exhaustive work, so larger or higher-risk tickets may incur higher API costs. By default (ultracode orchestration is on; pass `uc=off` to revert to the single-evaluator Agent path), M+ tickets additionally run a 3-lens parallel evaluator panel (Sonnet at `thorough`, Opus at `exhaustive`), further increasing token cost. - Built-in test/lint detection covers JS, Python, Rust, Go, JVM (Gradle/Maven/sbt), .NET, Ruby, Elixir, Swift, Flutter/Dart, PHP, and Make. For other ecosystems, wrap your test/lint commands in a Makefile (`make test` / `make lint`) or the evaluator falls back to static code analysis only. - Some recovery paths require interactive mode; running in `claude -p` or CI may stop with an explanatory message rather than complete the recovery. - **Operating system support**: macOS and Linux are verified (the hook layer is `bash` + `jq`, with optional `yq` / `python3`). Windows is **not** verified — the `bash`+`jq` hook layer requires a POSIX environment (Git Bash, WSL, or Cygwin); native Windows is unsupported. diff --git a/skills/autopilot/SKILL.md b/skills/autopilot/SKILL.md index 60b750d..f5b4af0 100644 --- a/skills/autopilot/SKILL.md +++ b/skills/autopilot/SKILL.md @@ -70,7 +70,7 @@ Target parent-slug: $ARGUMENTS ## Argument Parsing -Before detecting `{parent-slug}`, extract the optional `uc=` token from `$ARGUMENTS` using the SAME case-insensitive `key=value` convention the pipeline already uses for other key=value args (mirrors `/impl` `rounds=N` at its Step 1a and `/brief` `chain=`): scan for a `uc=` token (key matched case-insensitively), accept `` ∈ `on` | `off` | `metric-only`, and **strip that token from `$ARGUMENTS`** so the remaining-text/parent-slug detection below is unaffected. Resolve `UC_ORCH` from the stripped value; **when the `uc=` token is absent, `UC_ORCH = off`** (default). Emit the resolution line — the SHARED CONTRACT marker `[UC-ORCH-MODE] mode={on|off|metric-only} active={y|n} reason=invocation` to stderr (`active=y` only when `mode=on`, else `active=n`) — using `reason=invocation` here in Argument Parsing. `UC_ORCH` is carried through Phase 2 to each per-ticket `/impl` invocation (Step 3c) and recorded in the run-scoped state file (Phase 2 State file initialization). This is additive: with no `uc=` token the resolution defaults to `off` and the bare-`{parent-slug}` parse below behaves exactly as before. +Before detecting `{parent-slug}`, extract the optional `uc=` token from `$ARGUMENTS` using the SAME case-insensitive `key=value` convention the pipeline already uses for other key=value args (mirrors `/impl` `rounds=N` at its Step 1a and `/brief` `chain=`): scan for a `uc=` token (key matched case-insensitively), accept `` ∈ `on` | `off` | `metric-only`, and **strip that token from `$ARGUMENTS`** so the remaining-text/parent-slug detection below is unaffected. Resolve `UC_ORCH` from the stripped value; **when the `uc=` token is absent, `UC_ORCH = on`** (the default — ultracode orchestration is on by default). Emit the resolution line — the SHARED CONTRACT marker `[UC-ORCH-MODE] mode={on|off|metric-only} active={y|n} reason={invocation|default}` to stderr (`active=y` only when `mode=on`, else `active=n`) — using `reason=invocation` when the value came from an explicit `uc=` token, and `reason=default` when the token was absent (the on-by-default path). `UC_ORCH` is carried through Phase 2 to each per-ticket `/impl` invocation (Step 3c) and recorded in the run-scoped state file (Phase 2 State file initialization). With no `uc=` token the resolution defaults to `on`, which routes M+ tickets through the eval-panel (additive verification); pass `uc=off` to restore the v8.7.0 Agent path. The bare-`{parent-slug}` parse below is unaffected either way (the `uc=` token is stripped before it runs). After resolving `UC_ORCH`, similarly extract the optional `parallel=` token from `$ARGUMENTS` using the SAME case-insensitive `key=value` convention: scan for a `parallel=` token (key matched case-insensitively), accept `` ∈ `on` | `off` | `metric-only`, and **strip that token from `$ARGUMENTS`** so the parent-slug detection below is unaffected. Resolve `PARALLEL_MODE` from the stripped value; **when the `parallel=` token is absent, `PARALLEL_MODE = off`** (default). An unrecognised value resolves **safe to `off`** (the same fail-safe direction as `uc=`). **Emit the resolution line ONLY when `PARALLEL_MODE != off`** — the SHARED CONTRACT marker `[PARALLEL-MODE] mode={on|metric-only} active={y|n} reason=invocation` to stderr (`active=y` only when `mode=on`, else `active=n`), using `reason=invocation` here in Argument Parsing. **When `PARALLEL_MODE == off` (the default or absent token, or an unknown value coerced to off), emit NO `[PARALLEL-MODE]` line at all** — a non-parallel run is byte-identical to prior versions with zero new stderr noise. (This is a deliberate stricter-than-`uc=` posture: `[UC-ORCH-MODE]` is emitted even for `uc=off`, but the `parallel=off` lane is the load-bearing byte-identical opt-out / rollback path for the parallel feature, so it stays completely silent.) `PARALLEL_MODE` is **orthogonal** to `UC_ORCH` (composable: `/autopilot {slug} uc=on parallel=on`); it is carried through Phase 2 to select the per-ticket execution path (current inline serial branch vs one `ticket-executor` subagent per ticket) and recorded in the run-scoped state file (Phase 2 State file initialization). This is additive: with no `parallel=` token the resolution defaults to `off`, Phase 2 takes the current inline serial path, and behaviour is byte-identical to prior versions. A `SW_PARALLEL_TICKETS_MODE` environment knob (documented in `CLAUDE.md`) is the run kill switch that forces serial; its deterministic env-vs-argument precedence is applied by the parallel-mode resolver helper introduced alongside the wave-cursor schema. @@ -140,13 +140,13 @@ re-emit the same block. 4. **Human override detection**: compare each gate in `autopilot-policy.yaml` to defaults for `risk_tolerance`. `conservative` defaults + `moderate` defaults: in [references/state-file.md](references/state-file.md). `aggressive` defaults: moderate + `aggressive ship_ci_pending.timeout_minutes: 60`, `aggressive constraints.max_total_rounds: 12`, `aggressive constraints.allow_breaking_changes: true`. Gate differs + `# kb-suggested` → `kb_override` else `human_override`. Render to `## Human Overrides` / `## KB Overrides`; `## Decisions Made` distinguishes `human_override` from `kb_override`. **Exclude `kb_override`** from `## Human Overrides`. No diff → "No human overrides detected." -5. **State recovery**: absent `autopilot-state.yaml` → `resume_mode = false`. Else `resume_mode = true`; emit `[RESUME] ...` summary (resume msg, execution mode, progress N/total, per-ticket status). **When `resume_mode == true`, READ the top-level `ultracode_mode:` scalar from the recovered `autopilot-state.yaml`** (use the same top-level YAML scalar read the rest of the pipeline relies on; `hooks/lib/parse-state-file.sh::parse_yaml_scalar ultracode_mode` is the canonical reader) and **reconstruct `UC_ORCH`** from it for the remainder of the run: a value of `on` / `off` / `metric-only` resolves directly, while a missing / `null` / empty value defaults `UC_ORCH = off`. Emit the SHARED CONTRACT resolution line `[UC-ORCH-MODE] mode={on|off|metric-only} active={y|n} reason=resume` to stderr (`active=y` only when `mode=on`), and add the reconstructed mode to the `[RESUME] ...` summary so the run-scoped orchestration mode is visible on resume. This re-read is what carries `uc` across auto-compact / resume without any hook change. **Likewise READ the top-level `parallel_mode:` scalar** from the recovered state (`hooks/lib/parse-state-file.sh::parse_yaml_scalar parallel_mode`) and **reconstruct `PARALLEL_MODE`** for the remainder of the run: `on` / `off` / `metric-only` resolves directly, while a missing / `null` / empty value defaults `PARALLEL_MODE = off`. **Only when `PARALLEL_MODE != off`**, emit the SHARED CONTRACT line `[PARALLEL-MODE] mode={on|metric-only} active={y|n} reason=resume` to stderr (`active=y` only when `mode=on`) and add the reconstructed parallel mode to the `[RESUME] ...` summary; **when `PARALLEL_MODE == off`, emit no `[PARALLEL-MODE]` line and add nothing to the `[RESUME] ...` summary** (a resumed non-parallel run is byte-identical to prior versions). This re-read carries the parallel execution mode across auto-compact / resume exactly as `ultracode_mode` carries `uc`. If `started` is older than 7 days, emit `[RESUME] WARNING` to delete `autopilot-state.yaml` and re-run. Carry `ticket_mapping`. Per-ticket: `completed` → skip (`[RESUME] Skipping {logical_id}: already completed`); `failed`/`skipped` → retry first non-completed; `in_progress` → re-run; `pending` → normal. If state recovery cannot continue (e.g. unparseable `autopilot-state.yaml`, hostile working tree detected during this step, or any other Phase 1 precondition that newly fails here), emit `[AUTOPILOT-POLICY] gate=unexpected_error action=stop reason=state_recovery_hard_stop` and write `## Stop Reason` with `tag: policy_gate_stop` plus a resume hint of the form `Resume after fixing X with: /autopilot {parent-slug}`; never escalate to `AskUserQuestion`. Any existing verbatim `ERROR:` / `[RESUME] WARNING` literal continues to be emitted alongside the new `policy_gate_stop` exit path. +5. **State recovery**: absent `autopilot-state.yaml` → `resume_mode = false`. Else `resume_mode = true`; emit `[RESUME] ...` summary (resume msg, execution mode, progress N/total, per-ticket status). **When `resume_mode == true`, READ the top-level `ultracode_mode:` scalar from the recovered `autopilot-state.yaml`** (use the same top-level YAML scalar read the rest of the pipeline relies on; `hooks/lib/parse-state-file.sh::parse_yaml_scalar ultracode_mode` is the canonical reader) and **reconstruct `UC_ORCH`** from it for the remainder of the run: a value of `on` / `off` / `metric-only` resolves directly, while a missing / `null` / empty value defaults `UC_ORCH = off` — this missing-field case only arises for a legacy state file written before `ultracode_mode` existed (a v9.0.0+ run always persists the field at state init, default `on`, so the on-by-default carries cleanly through resume; defaulting a field-less legacy state to `off` keeps that resume faithful to how it originally ran). Emit the SHARED CONTRACT resolution line `[UC-ORCH-MODE] mode={on|off|metric-only} active={y|n} reason=resume` to stderr (`active=y` only when `mode=on`), and add the reconstructed mode to the `[RESUME] ...` summary so the run-scoped orchestration mode is visible on resume. This re-read is what carries `uc` across auto-compact / resume without any hook change. **Likewise READ the top-level `parallel_mode:` scalar** from the recovered state (`hooks/lib/parse-state-file.sh::parse_yaml_scalar parallel_mode`) and **reconstruct `PARALLEL_MODE`** for the remainder of the run: `on` / `off` / `metric-only` resolves directly, while a missing / `null` / empty value defaults `PARALLEL_MODE = off`. **Only when `PARALLEL_MODE != off`**, emit the SHARED CONTRACT line `[PARALLEL-MODE] mode={on|metric-only} active={y|n} reason=resume` to stderr (`active=y` only when `mode=on`) and add the reconstructed parallel mode to the `[RESUME] ...` summary; **when `PARALLEL_MODE == off`, emit no `[PARALLEL-MODE]` line and add nothing to the `[RESUME] ...` summary** (a resumed non-parallel run is byte-identical to prior versions). This re-read carries the parallel execution mode across auto-compact / resume exactly as `ultracode_mode` carries `uc`. If `started` is older than 7 days, emit `[RESUME] WARNING` to delete `autopilot-state.yaml` and re-run. Carry `ticket_mapping`. Per-ticket: `completed` → skip (`[RESUME] Skipping {logical_id}: already completed`); `failed`/`skipped` → retry first non-completed; `in_progress` → re-run; `pending` → normal. If state recovery cannot continue (e.g. unparseable `autopilot-state.yaml`, hostile working tree detected during this step, or any other Phase 1 precondition that newly fails here), emit `[AUTOPILOT-POLICY] gate=unexpected_error action=stop reason=state_recovery_hard_stop` and write `## Stop Reason` with `tag: policy_gate_stop` plus a resume hint of the form `Resume after fixing X with: /autopilot {parent-slug}`; never escalate to `AskUserQuestion`. Any existing verbatim `ERROR:` / `[RESUME] WARNING` literal continues to be emitted alongside the new `policy_gate_stop` exit path. ## Phase 2: Pipeline Execution ### State file initialization -Skip if `resume_mode = true`. Brief-level `autopilot-state.yaml` ≠ per-ticket `phase-state.yaml`. Write at `briefs/active/{parent-slug}/` (else `product_backlog/{parent-slug}/`); hooks also accept `briefs/done/{parent-slug}/`. Fields: `ticket_mapping`, per-ticket `ticket_dir:` + `status` + `steps` + `invocation_method` ∈ `skill`/`manual-bash`/`unknown`, append-only `runtime_metrics: []` (`hooks/autopilot-continue.sh` + `hooks/pre-compact-save.sh` only; skills MUST NOT write). **Also write the top-level field `ultracode_mode: {on|off|metric-only}` from the `UC_ORCH` resolved in Argument Parsing** — a run-scoped orchestration mode (sibling of `version` / `parent_slug` / `started` / `execution_mode` / `total_tickets` / `ticket_mapping` / `tickets`). It records the run-scoped continuity value so Phase 1 Step 5 can re-read it on resume; the default `UC_ORCH = off` writes `ultracode_mode: off` (round-trips to `off` on resume). This is run-state, NOT a permanent policy flag — it lives only in `autopilot-state.yaml` (moved to `briefs/done/` on completion), never in `autopilot-policy.yaml`. **Also write the top-level field `parallel_mode: {on|metric-only}` from the `PARALLEL_MODE` resolved in Argument Parsing, but ONLY when `PARALLEL_MODE != off`** — a sibling run-scoped field with the identical lifecycle (init-written, resume-replayed at Phase 1 Step 5, moved to `briefs/done/` on completion, never a policy flag). **When `PARALLEL_MODE == off` (the default), OMIT the `parallel_mode:` field entirely** so the state file is byte-identical to prior versions (which had no `parallel_mode` field); Phase 1 Step 5 reconstructs `off` from an absent / `null` field. This deliberately differs from `ultracode_mode:` (which is written even for `off`): the `parallel=off` lane carries the byte-identical-**state** guarantee of AC-1, so it must not add a field. Schema documented in [references/state-file.md](references/state-file.md). **MUST emit `tickets:` as a YAML list** of dash-prefixed `- logical_id: …` mappings — NOT a map keyed by `logical_id`. The map form silently bypasses the hook-layer skip-transition guard (`parse_proposed_tickets`) and the Stop-hook loop-guard counters (`parse_ticket_statuses`); field evidence `test_simple_workflow28`. Hook tolerance was added in WI-4 as a safety net only; SKILL prose remains the enforcement. Loop-guard emits `[AUTOPILOT-STALL] ...`. Schema invariants (including `tickets:` list-vs-map) + precedence + counters + kill switch + `boundary`/`stop_reason` domains in [references/state-file.md](references/state-file.md) + [references/stop-reason-taxonomy.md](references/stop-reason-taxonomy.md). +Skip if `resume_mode = true`. Brief-level `autopilot-state.yaml` ≠ per-ticket `phase-state.yaml`. Write at `briefs/active/{parent-slug}/` (else `product_backlog/{parent-slug}/`); hooks also accept `briefs/done/{parent-slug}/`. Fields: `ticket_mapping`, per-ticket `ticket_dir:` + `status` + `steps` + `invocation_method` ∈ `skill`/`manual-bash`/`unknown`, append-only `runtime_metrics: []` (`hooks/autopilot-continue.sh` + `hooks/pre-compact-save.sh` only; skills MUST NOT write). **Also write the top-level field `ultracode_mode: {on|off|metric-only}` from the `UC_ORCH` resolved in Argument Parsing** — a run-scoped orchestration mode (sibling of `version` / `parent_slug` / `started` / `execution_mode` / `total_tickets` / `ticket_mapping` / `tickets`). It records the run-scoped continuity value so Phase 1 Step 5 can re-read it on resume; the default `UC_ORCH = on` writes `ultracode_mode: on` (round-trips to `on` on resume; an explicit `uc=off` writes `ultracode_mode: off`). This is run-state, NOT a permanent policy flag — it lives only in `autopilot-state.yaml` (moved to `briefs/done/` on completion), never in `autopilot-policy.yaml`. **Also write the top-level field `parallel_mode: {on|metric-only}` from the `PARALLEL_MODE` resolved in Argument Parsing, but ONLY when `PARALLEL_MODE != off`** — a sibling run-scoped field with the identical lifecycle (init-written, resume-replayed at Phase 1 Step 5, moved to `briefs/done/` on completion, never a policy flag). **When `PARALLEL_MODE == off` (the default), OMIT the `parallel_mode:` field entirely** so the state file is byte-identical to prior versions (which had no `parallel_mode` field); Phase 1 Step 5 reconstructs `off` from an absent / `null` field. This deliberately differs from `ultracode_mode:` (which is written even for `off`): the `parallel=off` lane carries the byte-identical-**state** guarantee of AC-1, so it must not add a field. Schema documented in [references/state-file.md](references/state-file.md). **MUST emit `tickets:` as a YAML list** of dash-prefixed `- logical_id: …` mappings — NOT a map keyed by `logical_id`. The map form silently bypasses the hook-layer skip-transition guard (`parse_proposed_tickets`) and the Stop-hook loop-guard counters (`parse_ticket_statuses`); field evidence `test_simple_workflow28`. Hook tolerance was added in WI-4 as a safety net only; SKILL prose remains the enforcement. Loop-guard emits `[AUTOPILOT-STALL] ...`. Schema invariants (including `tickets:` list-vs-map) + precedence + counters + kill switch + `boundary`/`stop_reason` domains in [references/state-file.md](references/state-file.md) + [references/stop-reason-taxonomy.md](references/stop-reason-taxonomy.md). ### Split Execution Flow diff --git a/skills/autopilot/references/state-file.md b/skills/autopilot/references/state-file.md index 6cd326a..35dc729 100644 --- a/skills/autopilot/references/state-file.md +++ b/skills/autopilot/references/state-file.md @@ -20,7 +20,7 @@ parent_slug: {parent-slug} started: {ISO-8601 via `date -u +%Y-%m-%dT%H:%M:%SZ`} execution_mode: split total_tickets: {N} -ultracode_mode: off # OPTIONAL run-scoped orchestration mode: on | off | metric-only (default off) +ultracode_mode: on # OPTIONAL run-scoped orchestration mode: on | off | metric-only (default on) parallel_mode: on # OPTIONAL run-scoped parallel exec mode: on | metric-only — WRITTEN ONLY when != off; ABSENT (-> off) on a default serial run ticket_mapping: {} tickets: @@ -56,15 +56,16 @@ Field summary (the 7 top-level fields plus `runtime_metrics:`): - `ticket_mapping` — `{logical_id: ticket_dir}` lookup table seeded from the split-plan. - `tickets` — per-ticket entries (logical_id, ticket_dir, status, steps, invocation_method). - `ultracode_mode` — OPTIONAL. Run-scoped orchestration mode; value - domain `on` | `off` | `metric-only` (default `off` when absent). Set + domain `on` | `off` | `metric-only` (default `on` when absent). Set once at Phase 2 state-file initialization from the `uc=` invocation argument resolved in Argument Parsing, re-read on resume at Phase 1 Step 5 to reconstruct the run's orchestration mode, and moved to `briefs/done/` with the rest of the file on completion. It is **run-scoped run-state, NOT a permanent policy flag** — it is gone on - the next fresh run (a new run with no `uc=` argument writes `off`), - and is **distinct from `autopilot-policy.yaml`** (which carries - permanent per-ticket policy). Because it lives in `autopilot-state.yaml` + the next fresh run (a new run with no `uc=` argument writes `on`, the + default; pass `uc=off` to restore the v8.7.0 Agent path), and is + **distinct from `autopilot-policy.yaml`** (which carries permanent + per-ticket policy). Because it lives in `autopilot-state.yaml` it survives auto-compact and resume (Phase 1 Step 5 re-reads it via the same top-level scalar path as the other fields, e.g. `parse_yaml_scalar ultracode_mode`). diff --git a/skills/brief/SKILL.md b/skills/brief/SKILL.md index a26439e..60149c6 100644 --- a/skills/brief/SKILL.md +++ b/skills/brief/SKILL.md @@ -58,9 +58,8 @@ Parse `$ARGUMENTS`: - **Simultaneous specification — `chain=` and `mode=` both present**: stop and emit `ERROR: 'chain=' and 'mode=' cannot be combined. Use 'chain=' (preferred).` Do NOT silent-rewrite; do NOT pick one and ignore the other. Do NOT create the brief directory, do NOT write `brief.md`, do NOT write `autopilot-policy.yaml`, and do NOT write `auto-kick.yaml`. Exit non-zero. (This mirrors the v6.0.0 `auto=true` defensive stance — no silent rewrites of ambiguous argument intent.) - **Default when both keys are omitted**: `chain=on` (equivalent to legacy `mode=auto`). The default preserves the prior `mode=auto`-default behavior so existing user-typed `/brief ""` invocations continue to chain into `/create-ticket` and `/autopilot`. - **`auto=true` removal (v6.0.0)**: if `auto=true` (case-insensitive) appears in `$ARGUMENTS`, stop and emit the v6.0.0 removal error (see ## Error Handling). The removal is intentional and `auto=true` is NOT silently rewritten. -- **ultracode orchestration — `uc=` (additive, run-scoped opt-in)**: extract `uc=` if present, using the SAME case-insensitive `key=value` convention as `chain=` above (token `uc=` matched case-insensitively; trim whitespace and lowercase the value, so `uc=ON`, `uc=Off`, `uc= metric-only ` normalize to `on`/`off`/`metric-only`). Accepted values: `on`, `off`, `metric-only`. **Default when `uc=` is omitted: `off`.** Resolve the effective `uc` value (`resolved_uc`) as follows, AFTER the `chain` value has been resolved above: - - When `uc=` is omitted → `resolved_uc = off`. - - When `chain` resolves to `on` (≡ legacy `mode=auto`, the `/brief` default) → `resolved_uc` = the parsed `uc=` value (`on`, `off`, or `metric-only`). The value is carried into the Finalization Step 2 chained handoff (it is forwarded to the chained `/autopilot`, which records it as run-scoped state and propagates it to each per-ticket `/impl`; `/brief` itself does not act on `uc` beyond forwarding it). +- **ultracode orchestration — `uc=` (additive, run-scoped opt-in)**: extract `uc=` if present, using the SAME case-insensitive `key=value` convention as `chain=` above (token `uc=` matched case-insensitively; trim whitespace and lowercase the value, so `uc=ON`, `uc=Off`, `uc= metric-only ` normalize to `on`/`off`/`metric-only`). Accepted values: `on`, `off`, `metric-only`. **Default when `uc=` is omitted: `on` under `chain=on` (the `/brief` default), `off` under `chain=off`.** Resolve the effective `uc` value (`resolved_uc`) as follows, AFTER the `chain` value has been resolved above: + - When `chain` resolves to `on` (≡ legacy `mode=auto`, the `/brief` default) → `resolved_uc` = the explicitly parsed `uc=` value (`on`, `off`, or `metric-only`) if one was supplied, ELSE `on` (the default — ultracode orchestration is on by default). The value is carried into the Finalization Step 2 chained handoff (it is forwarded to the chained `/autopilot`, which records it as run-scoped state and propagates it to each per-ticket `/impl`; `/brief` itself does not act on `uc` beyond forwarding it). - When `chain` resolves to `off` (≡ legacy `mode=manual`) AND the parsed `uc=` value is `on` or `metric-only` → emit to stderr the single line `WARNING: uc=on ignored when chain=off (no chained /autopilot to receive it)` (verbatim literal, including the leading `WARNING:`) and set `resolved_uc = off`. This is a **WARNING, not an error**: there is no chained `/autopilot` in `chain=off` mode to receive the value, so `uc` is ignored; processing continues normally (the brief is still written, Step 3 manual guidance still runs). Do NOT stop, do NOT exit non-zero, do NOT suppress any artifact. - When `chain` resolves to `off` AND the parsed `uc=` value is `off` (or `uc=` is omitted) → `resolved_uc = off` silently (no warning — nothing to ignore). - Any value other than `on`/`off`/`metric-only` → treat as `off` (this argument is an observe-only opt-in; an unrecognized value never blocks brief creation). `resolved_uc` is carried forward to Finalization Step 2. diff --git a/skills/impl/SKILL.md b/skills/impl/SKILL.md index 1925c1a..049a1a4 100644 --- a/skills/impl/SKILL.md +++ b/skills/impl/SKILL.md @@ -61,7 +61,7 @@ Available MCP servers: !`( jq -r '.mcpServers // {} | keys[]' .mcp.json 2>/dev/n **1a. Round-cap argument extraction** (before plan-path detection): find `rounds=N` (case-insensitive, first wins); validate `N` as positive integer (6-digit hard cap). **Soft cap 24** — `arg_rounds > 24` emits `[ARG-WARN]` without clamping (soft cap 24 is advisory). Strip first recognized token. **Precedence** (→ `phases.impl.max_rounds`): `rounds=N` argument > `{ticket-dir}/autopilot-policy.yaml` `constraints.max_total_rounds` > **Else default 9**; then add the verification-depth bonus (`+0`/`+3`/`+6` for tier `standard`/`thorough`/`exhaustive`) UNLESS a valid `rounds=N` was supplied OR `constraints.verification_depth: off`. This argument-extraction step only parses/strips `rounds=N`; the bonus is **applied later**, when `max_rounds` is materialised at the Phase 2 init block (after the Step 3a tier is known) — see [verification-depth.md](references/verification-depth.md). See [round-cap-parser.md](references/round-cap-parser.md) for regex/validation/hard-cap/strip/precedence/stderr/quoted-strings. - **1a-uc. Ultracode-orchestration argument extraction** (v8.6.0+, uc=on; additive — does NOT disturb `rounds=N` parsing above): independently of the `rounds=N` scan, find a `uc=` token in `$ARGUMENTS` (case-insensitive `key=value`, FIRST occurrence wins) exactly as `rounds=N` is matched; accepted values `on` | `off` | `metric-only`. Strip the matched `uc=` token from `$ARGUMENTS` before the **1b** plan-path / remaining-text detection (same as the `rounds=N` strip), so a `uc=…` token never leaks into the plan-path heuristic. **Default when the token is absent = `off`.** Carry the raw parsed value to Step 3a (resolved there into `UC_ORCH`). This scan is wholly independent of `rounds=N`: it neither reads nor mutates `rounds=N`, the round-cap precedence, or the verification-depth bonus. + **1a-uc. Ultracode-orchestration argument extraction** (v8.6.0+, uc=on; additive — does NOT disturb `rounds=N` parsing above): independently of the `rounds=N` scan, find a `uc=` token in `$ARGUMENTS` (case-insensitive `key=value`, FIRST occurrence wins) exactly as `rounds=N` is matched; accepted values `on` | `off` | `metric-only`. Strip the matched `uc=` token from `$ARGUMENTS` before the **1b** plan-path / remaining-text detection (same as the `rounds=N` strip), so a `uc=…` token never leaks into the plan-path heuristic. **Default when the token is absent = `on`** (ultracode orchestration is on by default; pass `uc=off` for the v8.5.0 Agent path). Carry the raw parsed value to Step 3a (resolved there into `UC_ORCH`). This scan is wholly independent of `rounds=N`: it neither reads nor mutates `rounds=N`, the round-cap precedence, or the verification-depth bonus. **1b. Plan-path detection** (operates on the post-strip `$ARGUMENTS`): - Starts with `.simple-workflow/backlog/active/` or `.simple-workflow/docs/plans/` → use as plan path. @@ -73,7 +73,7 @@ Available MCP servers: !`( jq -r '.mcpServers // {} | keys[]' .mcp.json 2>/dev/n 3. Size detection: ticket → `Read(ticket.md, limit=30)` for `| Size |` (fallback `limit=80`; default `M`). `.simple-workflow/docs/plans/` → default `M`. -3a. **Verification depth tier** (v8.1.0+): read `constraints.verification_depth` from `{ticket-dir}/autopilot-policy.yaml` (absent file or field → `auto`). Resolve `VERIFICATION_DEPTH`: `off` → feature disabled (no round-cap bonus, single evaluator at Step 15, no `depth=` to `/audit` at Step 17); `standard`/`thorough`/`exhaustive` → forced literal; `auto` → derive from `Size` (Step 3) × `risk_tolerance` (from the same policy; absent/unreadable → `conservative`) per the matrix in [verification-depth.md](references/verification-depth.md). Carry `VERIFICATION_DEPTH` to the Phase 2 init round-cap computation (where the `+0`/`+3`/`+6` bonus is applied to the Step 1a base), Step 15 (evaluator-mode dispatch), and Step 17 (`/audit` `depth=` handoff). Emit `[VERIFICATION-DEPTH] tier={VERIFICATION_DEPTH} source={auto|policy|off} size={S|M|L|XL} risk={conservative|moderate|aggressive}` to stderr. For S/M at conservative/moderate this resolves to `standard` and the whole feature is a no-op (byte-identical to pre-v8.1.0). **Ultracode depth floor (`UC-FLOOR`, v8.6.0+, uc=on)**: when the `uc=` value parsed at Step **1a-uc** is `on` AND the `Size` (Step 3) is NOT `S` (i.e. `M` / `L` / `XL`), FLOOR `VERIFICATION_DEPTH` at `thorough` (`VERIFICATION_DEPTH = max(VERIFICATION_DEPTH, thorough)` — raise `standard`→`thorough`; never lower an already-higher tier; composes with the oracle / criticality floors below via the same `max()`). This is what routes every non-trivial (`M`+) ticket through the eval-panel **Workflow** at Step 15 when `uc=on`; an `S` ticket stays `standard` (Agent path, byte-identical). Apply it BEFORE resolving `criticality` / `EVALUATOR_MODEL` / `EVIDENCE_FLOOR` / the round-cap bonus / the `/audit` `depth=` handoff so they all reflect the floored tier — a floored `thorough` keeps `EVALUATOR_MODEL == sonnet` (opus stays reserved for `exhaustive` / `critical`), i.e. an M ticket gets 3 **sonnet** lenses (the tier-appropriate Form-B cost), not opus. Emit `[UC-ORCH-FLOOR] raised={y|n} from={standard|thorough|exhaustive} to={thorough|exhaustive} size={S|M|L|XL}` to stderr (`raised=n` when `uc != on`, `Size == S`, or the tier was already ≥ `thorough`). When `uc != on` the depth is untouched (byte-identical to v8.5.0). **Oracle verification + criticality floor** (v8.2.0+): also read `constraints.oracle_verification` from the same policy (absent file / field / unknown → `auto`). When `VERIFICATION_DEPTH != off` AND `oracle_verification` is `auto` AND the ticket contains ≥1 **computational AC** (PASS/FAIL hinges on a computed numeric/algorithmic value — Gate 7 classifier in `skills/create-ticket/references/ac-quality-criteria.md`) in a **critical domain** (accessibility / security / money / data-integrity / standard-compliance), FLOOR `VERIFICATION_DEPTH` at `thorough` (raise `standard`→`thorough`; never lower a higher tier; `verification_depth: off` disables the floor per verification-depth.md since the floor is a depth mechanism) so `/audit`'s skeptical third-pass is forced even on an S/conservative ticket; emit `[ORACLE-FLOOR] applied tier=thorough+ reason=critical-computational-AC`. **Criticality scalar + irreversibility axis + evaluator model (M5, v8.3.0+)**: resolve the single scalar `criticality = blast_radius(Size) × irreversibility ∈ {routine, critical}` ONCE here. `critical` fires when the critical-domain computational condition above fires OR (when `constraints.irreversibility_floor` is `auto` — absent / field / unknown → `auto`) at least one AC verifies an IRREVERSIBLE side-effect (data writes / network mutation / money movement / destructive ops / external-system calls — see `skills/impl/references/verification-depth.md` `### Irreversibility axis`); the irreversibility axis floors `criticality=critical` even on an S/conservative ticket. When `criticality=critical` (from EITHER trigger) AND `VERIFICATION_DEPTH != off`, FLOOR `VERIFICATION_DEPTH` at `thorough` (raise `standard`→`thorough`; never lower a higher tier) exactly as the critical-domain condition above does — so the irreversibility axis deepens verification (more rounds + forced `/audit` third-pass + the `thorough` evidence_floor), not only the evaluator model. Emit `[CRITICALITY] level={routine|critical} blast_radius={S|M|L|XL} irreversibility={none|writes|network|money|destructive|external-system}`. Then resolve `EVALUATOR_MODEL`: `opus` when `criticality == critical` OR `VERIFICATION_DEPTH == exhaustive`, else `sonnet` (today's default); emit `[EVALUATOR-MODEL] model={sonnet|opus} reason={routine|critical|exhaustive}`. Carry `EVALUATOR_MODEL` to Step 15 (agent-file selection: `opus` → spawn `simple-workflow:ac-evaluator-hi`; `sonnet` → spawn `simple-workflow:ac-evaluator`) — the per-spawn `model:` override is rejected by the Agent JSONSchema, so the model is selected by which agent file is spawned (see verification-depth.md `### Evaluator model + red-team budget`). Also resolve `REDTEAM_BUDGET` (`full` when `criticality == critical` OR `VERIFICATION_DEPTH == exhaustive`, else `0`) and record it into the struct for the M2 red-team phase (v8.5.0; no consumer in v8.3.0); emit `[REDTEAM-BUDGET] budget={0|full}`. Record `criticality`, `evaluator_model`, and `redteam_budget` alongside `verification_depth` into the resolved Step-3a struct materialised at the Phase 2 init block into `phases.impl.*`. When `verification_depth: off`, `criticality=routine`, `EVALUATOR_MODEL=sonnet`, `REDTEAM_BUDGET=0` (the whole floor is disabled). When `oracle_verification: off`, skip the floor and the downstream Gate 7 / oracle-independence enforcement (pre-v8.2.0). The floor only adds depth; the per-AC oracle-independence requirement the `ac-evaluator` enforces at Step 15 applies in every mode regardless of tier (and regardless of `verification_depth: off`; only `oracle_verification: off` disables it). **Evidence floor (Gate 8, M1, v8.3.0+; AC-shape axis M3, v8.4.0+)**: resolve `EVIDENCE_FLOOR = max(tier floor, AC-shape floor)` (ordered `EC-STATIC+natural` < `+1-independent` < `>=2-independent`). **Tier floor** (Size × risk): `standard` → `EC-STATIC + the AC's natural channel`, `thorough` → `+1 independent channel`, `exhaustive` → `>=2 independent channels` (the 3 evidence-mode lenses). **AC-shape floor** (Size-independent, M3): `+1-independent` when the ticket carries ≥1 **behavioral AC** (Gate 8 — a computational AC counts via EC-ORACLE), else `EC-STATIC+natural` for a structural-only ticket. The `max()` only RAISES: a `standard`-tier ticket with a behavioral AC resolves `+1-independent` (one channel beyond natural, established by the SAME single evaluator — no extra spawn), while `thorough` / `exhaustive` are unchanged (their tier floor already dominates). Gated by `constraints.independent_evidence` (absent / field absent / unknown → `auto`, active; `off` → drop BOTH floors, evaluator falls back to its pre-v8.3.0 path — the byte-identical revert). Emit `[EVIDENCE-FLOOR] tier={tier} shape={behavioral|structural-only} floor={EC-STATIC+natural|+1-independent|>=2-independent} source={tier|ac-shape|off}` to stderr (`source` names which floor won the `max()`). Carry `EVIDENCE_FLOOR` to Step 15 (inlined into the evaluator spawn prompt as `Evidence floor: {...}`, alongside `Oracle verification: {auto|off}`). **Failure-class eval panel (`eval_panel`, v8.4.0+)**: also read `constraints.eval_panel` from the same policy (absent file / field / unknown → `auto`). Resolve `EVAL_PANEL`: `off` → panel disabled (single all-purpose pass, byte-for-byte pre-v8.4.0); `on` → forced ON; `auto` → ON when the round touches `>=2` source units (counted from `git diff --name-only` over SOURCE paths only — tracked non-doc, non-test files; exclude docs / tests / `.simple-workflow/`) OR the ticket carries `>=1` behavioral AC (Gate 8 classifier), else OFF (trivial single-unit structural-only ticket). Emit `[EVAL-PANEL-MODE] mode={auto|on|off} active={y|n} lenses={N} spawns={1|3} reason={multi-unit|behavioral-ac|forced|trivial|off}` to stderr. Carry `EVAL_PANEL` to Step 15 (inlined as field `m`, the `--- panel: ... ---` directive). Independent of `verification_depth` / `oracle_verification` / `independent_evidence`. See [ac-evaluator-orchestration.md](references/ac-evaluator-orchestration.md) `## Default failure-class panel`. **Refute-then-synthesize merge (`refute_merge`, v8.4.0+)**: also read `constraints.refute_merge` from the same policy (absent file / field / unknown → `auto`). Resolve `REFUTE_MERGE`: `off` → the `exhaustive` 3-spawn merge reverts byte-for-byte to the prior majority-merge (a non-critical FAIL needs `>=2` verifiers; lone non-critical FAIL demoted to PASS; `[AC-EVAL-MAJORITY]` stderr line); `auto` / `on` → refute-then-synthesize is in force (a lone non-critical FAIL survives unless every other valid verifier refutes it; `[AC-EVAL-REFUTE-MERGE]` stderr line). The switch affects ONLY the `exhaustive` multi-verifier merge — single and partition modes have no sibling to refute and are unaffected. Carry `REFUTE_MERGE` to Step 15 (the multi-verifier merge dispatch). Independent of `verification_depth`, `eval_panel`, `oracle_verification`, and `independent_evidence`. **EC-SELFDOC verification (`selfdoc_verification`, v8.4.0+)**: also read `constraints.selfdoc_verification` (absent file / field / unknown → `auto`). Resolve `SELFDOC_VERIFICATION` and inline a `Selfdoc verification: {auto|off}` field into the `ac-evaluator` / `ac-evaluator-hi` spawn prompt (alongside the existing `Oracle verification:` / `Evidence floor:` fields) so the evaluator honours the EC-SELFDOC duty when `auto` and drops it when `off`. See [ac-evaluator-orchestration.md](references/ac-evaluator-orchestration.md) `### Refute-then-synthesize merge` and `skills/impl/references/evidence-channels.md` EC-SELFDOC. A structural-only S/M conservative/moderate ticket still floors at `EC-STATIC + natural channel` — a no-op; a behavioral-AC S/M ticket now floors at `+1-independent` (M3 — the routine-tier independence default; `independent_evidence: off` restores the pre-v8.4.0 no-op). See [verification-depth.md](references/verification-depth.md) effects ladder `evidence_floor` column and Gate 8 in `skills/create-ticket/references/ac-quality-criteria.md`. See [verification-depth.md](references/verification-depth.md) `## Criticality floor (computational / critical ACs)`. **Ultracode-orchestration mode (`UC_ORCH`, v8.6.0+, uc=on; NEW independent resolution — does NOT change any existing field's default or resolution when `uc` is absent):** resolve `UC_ORCH` from the `uc=` value parsed at Step **1a-uc** (absent token → `off`): `on` → Workflow-tool dispatch is eligible at Step 15 (gated there ALSO on `VERIFICATION_DEPTH == exhaustive`); `metric-only` → log dispatch intent at Step 15 but fall through to the Agent path; `off` (DEFAULT) → the existing Agent-tool path, byte-identical to v8.5.0. Emit `[UC-ORCH-MODE] mode={on|off|metric-only} active={y|n} reason={invocation|resume|default}` to stderr (other `[*-MODE]` observability lines are the same shape) — `active=y` only when `mode=on`; `reason=invocation` when the value came from a `uc=` token on this `/impl` call, `reason=resume` when it arrived from a chained `/autopilot` replay of `ultracode_mode:` (run-scoped continuity), `reason=default` when the token was absent (→ `off`). Carry `UC_ORCH` to Step 15 (dispatch-mechanism selection). This resolution is wholly orthogonal to `verification_depth` / `oracle_verification` / `evidence_floor` / `eval_panel` / `refute_merge` / `accept_set_conformance` / `criticality` / `evaluator_model`: it neither reads nor alters any of their values or defaults, and when `uc` is absent the entire Step-3a struct is identical to v8.5.0. +3a. **Verification depth tier** (v8.1.0+): read `constraints.verification_depth` from `{ticket-dir}/autopilot-policy.yaml` (absent file or field → `auto`). Resolve `VERIFICATION_DEPTH`: `off` → feature disabled (no round-cap bonus, single evaluator at Step 15, no `depth=` to `/audit` at Step 17); `standard`/`thorough`/`exhaustive` → forced literal; `auto` → derive from `Size` (Step 3) × `risk_tolerance` (from the same policy; absent/unreadable → `conservative`) per the matrix in [verification-depth.md](references/verification-depth.md). Carry `VERIFICATION_DEPTH` to the Phase 2 init round-cap computation (where the `+0`/`+3`/`+6` bonus is applied to the Step 1a base), Step 15 (evaluator-mode dispatch), and Step 17 (`/audit` `depth=` handoff). Emit `[VERIFICATION-DEPTH] tier={VERIFICATION_DEPTH} source={auto|policy|off} size={S|M|L|XL} risk={conservative|moderate|aggressive}` to stderr. For S/M at conservative/moderate this resolves to `standard` and the whole feature is a no-op (byte-identical to pre-v8.1.0). **Ultracode depth floor (`UC-FLOOR`, v8.6.0+, uc=on)**: when the `uc=` value parsed at Step **1a-uc** is `on` AND the `Size` (Step 3) is NOT `S` (i.e. `M` / `L` / `XL`), FLOOR `VERIFICATION_DEPTH` at `thorough` (`VERIFICATION_DEPTH = max(VERIFICATION_DEPTH, thorough)` — raise `standard`→`thorough`; never lower an already-higher tier; composes with the oracle / criticality floors below via the same `max()`). This is what routes every non-trivial (`M`+) ticket through the eval-panel **Workflow** at Step 15 when `uc=on`; an `S` ticket stays `standard` (Agent path, byte-identical). Apply it BEFORE resolving `criticality` / `EVALUATOR_MODEL` / `EVIDENCE_FLOOR` / the round-cap bonus / the `/audit` `depth=` handoff so they all reflect the floored tier — a floored `thorough` keeps `EVALUATOR_MODEL == sonnet` (opus stays reserved for `exhaustive` / `critical`), i.e. an M ticket gets 3 **sonnet** lenses (the tier-appropriate Form-B cost), not opus. Emit `[UC-ORCH-FLOOR] raised={y|n} from={standard|thorough|exhaustive} to={thorough|exhaustive} size={S|M|L|XL}` to stderr (`raised=n` when `uc != on`, `Size == S`, or the tier was already ≥ `thorough`). When `uc != on` the depth is untouched (byte-identical to v8.5.0). **Oracle verification + criticality floor** (v8.2.0+): also read `constraints.oracle_verification` from the same policy (absent file / field / unknown → `auto`). When `VERIFICATION_DEPTH != off` AND `oracle_verification` is `auto` AND the ticket contains ≥1 **computational AC** (PASS/FAIL hinges on a computed numeric/algorithmic value — Gate 7 classifier in `skills/create-ticket/references/ac-quality-criteria.md`) in a **critical domain** (accessibility / security / money / data-integrity / standard-compliance), FLOOR `VERIFICATION_DEPTH` at `thorough` (raise `standard`→`thorough`; never lower a higher tier; `verification_depth: off` disables the floor per verification-depth.md since the floor is a depth mechanism) so `/audit`'s skeptical third-pass is forced even on an S/conservative ticket; emit `[ORACLE-FLOOR] applied tier=thorough+ reason=critical-computational-AC`. **Criticality scalar + irreversibility axis + evaluator model (M5, v8.3.0+)**: resolve the single scalar `criticality = blast_radius(Size) × irreversibility ∈ {routine, critical}` ONCE here. `critical` fires when the critical-domain computational condition above fires OR (when `constraints.irreversibility_floor` is `auto` — absent / field / unknown → `auto`) at least one AC verifies an IRREVERSIBLE side-effect (data writes / network mutation / money movement / destructive ops / external-system calls — see `skills/impl/references/verification-depth.md` `### Irreversibility axis`); the irreversibility axis floors `criticality=critical` even on an S/conservative ticket. When `criticality=critical` (from EITHER trigger) AND `VERIFICATION_DEPTH != off`, FLOOR `VERIFICATION_DEPTH` at `thorough` (raise `standard`→`thorough`; never lower a higher tier) exactly as the critical-domain condition above does — so the irreversibility axis deepens verification (more rounds + forced `/audit` third-pass + the `thorough` evidence_floor), not only the evaluator model. Emit `[CRITICALITY] level={routine|critical} blast_radius={S|M|L|XL} irreversibility={none|writes|network|money|destructive|external-system}`. Then resolve `EVALUATOR_MODEL`: `opus` when `criticality == critical` OR `VERIFICATION_DEPTH == exhaustive`, else `sonnet` (today's default); emit `[EVALUATOR-MODEL] model={sonnet|opus} reason={routine|critical|exhaustive}`. Carry `EVALUATOR_MODEL` to Step 15 (agent-file selection: `opus` → spawn `simple-workflow:ac-evaluator-hi`; `sonnet` → spawn `simple-workflow:ac-evaluator`) — the per-spawn `model:` override is rejected by the Agent JSONSchema, so the model is selected by which agent file is spawned (see verification-depth.md `### Evaluator model + red-team budget`). Also resolve `REDTEAM_BUDGET` (`full` when `criticality == critical` OR `VERIFICATION_DEPTH == exhaustive`, else `0`) and record it into the struct for the M2 red-team phase (v8.5.0; no consumer in v8.3.0); emit `[REDTEAM-BUDGET] budget={0|full}`. Record `criticality`, `evaluator_model`, and `redteam_budget` alongside `verification_depth` into the resolved Step-3a struct materialised at the Phase 2 init block into `phases.impl.*`. When `verification_depth: off`, `criticality=routine`, `EVALUATOR_MODEL=sonnet`, `REDTEAM_BUDGET=0` (the whole floor is disabled). When `oracle_verification: off`, skip the floor and the downstream Gate 7 / oracle-independence enforcement (pre-v8.2.0). The floor only adds depth; the per-AC oracle-independence requirement the `ac-evaluator` enforces at Step 15 applies in every mode regardless of tier (and regardless of `verification_depth: off`; only `oracle_verification: off` disables it). **Evidence floor (Gate 8, M1, v8.3.0+; AC-shape axis M3, v8.4.0+)**: resolve `EVIDENCE_FLOOR = max(tier floor, AC-shape floor)` (ordered `EC-STATIC+natural` < `+1-independent` < `>=2-independent`). **Tier floor** (Size × risk): `standard` → `EC-STATIC + the AC's natural channel`, `thorough` → `+1 independent channel`, `exhaustive` → `>=2 independent channels` (the 3 evidence-mode lenses). **AC-shape floor** (Size-independent, M3): `+1-independent` when the ticket carries ≥1 **behavioral AC** (Gate 8 — a computational AC counts via EC-ORACLE), else `EC-STATIC+natural` for a structural-only ticket. The `max()` only RAISES: a `standard`-tier ticket with a behavioral AC resolves `+1-independent` (one channel beyond natural, established by the SAME single evaluator — no extra spawn), while `thorough` / `exhaustive` are unchanged (their tier floor already dominates). Gated by `constraints.independent_evidence` (absent / field absent / unknown → `auto`, active; `off` → drop BOTH floors, evaluator falls back to its pre-v8.3.0 path — the byte-identical revert). Emit `[EVIDENCE-FLOOR] tier={tier} shape={behavioral|structural-only} floor={EC-STATIC+natural|+1-independent|>=2-independent} source={tier|ac-shape|off}` to stderr (`source` names which floor won the `max()`). Carry `EVIDENCE_FLOOR` to Step 15 (inlined into the evaluator spawn prompt as `Evidence floor: {...}`, alongside `Oracle verification: {auto|off}`). **Failure-class eval panel (`eval_panel`, v8.4.0+)**: also read `constraints.eval_panel` from the same policy (absent file / field / unknown → `auto`). Resolve `EVAL_PANEL`: `off` → panel disabled (single all-purpose pass, byte-for-byte pre-v8.4.0); `on` → forced ON; `auto` → ON when the round touches `>=2` source units (counted from `git diff --name-only` over SOURCE paths only — tracked non-doc, non-test files; exclude docs / tests / `.simple-workflow/`) OR the ticket carries `>=1` behavioral AC (Gate 8 classifier), else OFF (trivial single-unit structural-only ticket). Emit `[EVAL-PANEL-MODE] mode={auto|on|off} active={y|n} lenses={N} spawns={1|3} reason={multi-unit|behavioral-ac|forced|trivial|off}` to stderr. Carry `EVAL_PANEL` to Step 15 (inlined as field `m`, the `--- panel: ... ---` directive). Independent of `verification_depth` / `oracle_verification` / `independent_evidence`. See [ac-evaluator-orchestration.md](references/ac-evaluator-orchestration.md) `## Default failure-class panel`. **Refute-then-synthesize merge (`refute_merge`, v8.4.0+)**: also read `constraints.refute_merge` from the same policy (absent file / field / unknown → `auto`). Resolve `REFUTE_MERGE`: `off` → the `exhaustive` 3-spawn merge reverts byte-for-byte to the prior majority-merge (a non-critical FAIL needs `>=2` verifiers; lone non-critical FAIL demoted to PASS; `[AC-EVAL-MAJORITY]` stderr line); `auto` / `on` → refute-then-synthesize is in force (a lone non-critical FAIL survives unless every other valid verifier refutes it; `[AC-EVAL-REFUTE-MERGE]` stderr line). The switch affects ONLY the `exhaustive` multi-verifier merge — single and partition modes have no sibling to refute and are unaffected. Carry `REFUTE_MERGE` to Step 15 (the multi-verifier merge dispatch). Independent of `verification_depth`, `eval_panel`, `oracle_verification`, and `independent_evidence`. **EC-SELFDOC verification (`selfdoc_verification`, v8.4.0+)**: also read `constraints.selfdoc_verification` (absent file / field / unknown → `auto`). Resolve `SELFDOC_VERIFICATION` and inline a `Selfdoc verification: {auto|off}` field into the `ac-evaluator` / `ac-evaluator-hi` spawn prompt (alongside the existing `Oracle verification:` / `Evidence floor:` fields) so the evaluator honours the EC-SELFDOC duty when `auto` and drops it when `off`. See [ac-evaluator-orchestration.md](references/ac-evaluator-orchestration.md) `### Refute-then-synthesize merge` and `skills/impl/references/evidence-channels.md` EC-SELFDOC. A structural-only S/M conservative/moderate ticket still floors at `EC-STATIC + natural channel` — a no-op; a behavioral-AC S/M ticket now floors at `+1-independent` (M3 — the routine-tier independence default; `independent_evidence: off` restores the pre-v8.4.0 no-op). See [verification-depth.md](references/verification-depth.md) effects ladder `evidence_floor` column and Gate 8 in `skills/create-ticket/references/ac-quality-criteria.md`. See [verification-depth.md](references/verification-depth.md) `## Criticality floor (computational / critical ACs)`. **Ultracode-orchestration mode (`UC_ORCH`, v8.6.0+, uc=on; NEW independent resolution — does NOT change any existing field's default or resolution when `uc=off`):** resolve `UC_ORCH` from the `uc=` value parsed at Step **1a-uc** (absent token → `on`, the default): `on` → Workflow-tool dispatch is eligible at Step 15 (gated there ALSO on `VERIFICATION_DEPTH == exhaustive`); `metric-only` → log dispatch intent at Step 15 but fall through to the Agent path; `off` → the existing Agent-tool path, byte-identical to v8.5.0 (the explicit opt-out; `on` is the default). Emit `[UC-ORCH-MODE] mode={on|off|metric-only} active={y|n} reason={invocation|resume|default}` to stderr (other `[*-MODE]` observability lines are the same shape) — `active=y` only when `mode=on`; `reason=invocation` when the value came from a `uc=` token on this `/impl` call, `reason=resume` when it arrived from a chained `/autopilot` replay of `ultracode_mode:` (run-scoped continuity), `reason=default` when the token was absent (→ `on`, the default). Carry `UC_ORCH` to Step 15 (dispatch-mechanism selection). This resolution is wholly orthogonal to `verification_depth` / `oracle_verification` / `evidence_floor` / `eval_panel` / `refute_merge` / `accept_set_conformance` / `criticality` / `evaluator_model`: it neither reads nor alters any of their values or defaults, and when `uc=off` is explicit the entire Step-3a struct is identical to v8.5.0 (the on-by-default path floors non-S verification depth to `thorough` via `UC-FLOOR`). 4. **Worktree recommendation** (L/XL): non-blocking tip `git worktree add -b impl/{slug} ../impl-{slug}` (`{slug}` = dir minus `NNN-`). diff --git a/tests/test-skill-contracts.sh b/tests/test-skill-contracts.sh index 26fc0f8..e66c594 100644 --- a/tests/test-skill-contracts.sh +++ b/tests/test-skill-contracts.sh @@ -10573,6 +10573,24 @@ assert_true \ "CT-UC-ORCH-4 (M-widening Form B + bug fixes): UC-FLOOR ($uco4_floor>=1) [UC-ORCH-FLOOR] marker ($uco4_floor_marker>=1) gate {thorough,exhaustive} ($uco4_gate_thorough>=1) AC_COUNT<30 partition guard ($uco4_gate_accap>=1) eval-panel args JSON.parse defense ($uco4_args_parse>=1)" \ "$uco4_result" +# CT-UC-ORCH-5 (uc default off->on flip + explicit-off byte-identity preserved). The uc= +# absent-token default is now `on` at all four sites (autopilot Argument Parsing, impl +# Step 1a-uc, impl Step 3a UC_ORCH resolution, brief uc= bullet) + the state-file doc; the +# explicit-`off` "byte-identical to v8.5.0" literal MUST survive verbatim (R-c1 drift guard: +# the flip rewords only the absent/default clause, never the explicit-off byte-identity literal). +uco5_autopilot_default=$(grep -cF 'is absent, `UC_ORCH = on`' "$UCO_AUTOPILOT" || true) +uco5_impl_1a_default=$(grep -cF 'Default when the token is absent = `on`' "$UCO_IMPL" || true) +uco5_impl_3a_flip=$(grep -cF 'on-by-default path floors non-S' "$UCO_IMPL" || true) +uco5_brief_default=$(grep -cF 'ELSE `on` (the default' "$UCO_BRIEF" || true) +uco5_statefile_default=$(grep -cF 'default `on` when absent' "$UCO_STATEFILE" || true) +uco5_off_byteident=$(grep -cF 'byte-identical to v8.5.0' "$UCO_IMPL" || true) +uco5_result="false" +if [ "$uco5_autopilot_default" -ge 1 ] && [ "$uco5_impl_1a_default" -ge 1 ] && [ "$uco5_impl_3a_flip" -ge 1 ] \ + && [ "$uco5_brief_default" -ge 1 ] && [ "$uco5_statefile_default" -ge 1 ] && [ "$uco5_off_byteident" -ge 2 ]; then uco5_result="true"; fi +assert_true \ + "CT-UC-ORCH-5 (uc default on flip + off byte-identity preserved): autopilot default-on ($uco5_autopilot_default>=1) impl-1a default-on ($uco5_impl_1a_default>=1) impl-3a flip ($uco5_impl_3a_flip>=1) brief default-on ($uco5_brief_default>=1) state-file default-on ($uco5_statefile_default>=1); explicit-off byte-identical literal preserved ($uco5_off_byteident>=2)" \ + "$uco5_result" + echo "" From c636d6af5eed1b0c5f324d6b7948aedc6253f49e Mon Sep 17 00:00:00 2001 From: aimsise Date: Thu, 25 Jun 2026 04:39:06 +0900 Subject: [PATCH 03/10] feat(hooks): resolve_parallel_mode resolver + wave-cursor state schema (T-003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T-003 of the parallel-autopilot feature (Wave 1). The hook-rework foundation for T-004/5/6 — ships only the shared resolver, the wave-cursor schema + orchestrator-write obligation prose, and unit tests. NO hook behaviour changes yet (the resolver is dormant until T-004/5/6). - hooks/lib/parse-state-file.sh: + resolve_parallel_mode (precedence SW_PARALLEL_HOOKS_MODE env > parallel_mode: state scalar > off; a SET-but-unknown env value -> off WITHOUT fall-through; absent / null / unknown state -> off; missing / unreadable file -> off; prints exactly on|metric-only|off, NEVER empty — every ambiguity fails CLOSED to off, the proven serial path). Added to the export -f line + header contract. Mirrors the get_risk_tolerance case-validator. - skills/autopilot/references/state-file.md: + the four OPTIONAL wave-cursor fields (wave_count / current_wave [-1 before first spawn] / wave_status [in_flight|drained] / main_checkout_root) with domains, the single-writer rule, and resume semantics (recomputed each entry; a projection of the authoritative per-ticket status, never a second source of truth). - skills/autopilot/SKILL.md: + the single-writer cursor-write obligation (main_checkout_root at Phase 2 init; wave_count at wave computation; current_wave + wave_status:in_flight before a wave spawn; wave_status: drained after the barrier; the ticket-executor NEVER writes the cursor). - CLAUDE.md: + SW_PARALLEL_HOOKS_MODE (tri-value, default = follow parallel_mode, unknown -> off = serial; (B) harness-own; the shared hook-side kill switch consumed by the resolver). - tests/test-hooks-lib.sh: + 12 resolve_parallel_mode unit tests (precedence, fail-closed, never-empty, missing-file, env-unknown-no-fall-through). - tests/test-skill-contracts.sh: + CT-PARALLEL-CURSOR-1/2 (schema + obligation + knob + resolver presence/export drift guards). Known limitation (inherited, safe-degrading): parse_yaml_scalar reads an UNQUOTED `parallel_mode: on` correctly as the string "on" via yq (tier 1, YAML 1.2); on a yq-LESS host the python3+PyYAML tier (YAML 1.1) coerces `on` -> True and the resolver degrades to `off` (serial) — the SAFE direction. yq is a documented hard dependency; this matches the existing ultracode_mode reader and is not a T-003 regression. Verification: test-hooks-lib 176/176 (incl. the 12 new resolver tests), test-skill-contracts 874/874, test-path-consistency 145/145, ShellCheck --severity=warning clean (parse-state-file.sh + both test files). No hooks/ decision logic changed; the resolver has no caller yet (AC-6). Adversarially verified via an ultracode Workflow (3 read-only Explore lenses: resolver-correctness, schema-obligation, no-change-governance) — all PASS, zero defects. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Mw2bH4wbEPeebXsvSG6rWe --- CLAUDE.md | 1 + hooks/lib/parse-state-file.sh | 59 +++++++++++++++++- skills/autopilot/SKILL.md | 8 +++ skills/autopilot/references/state-file.md | 50 ++++++++++++++- tests/test-hooks-lib.sh | 74 +++++++++++++++++++++++ tests/test-skill-contracts.sh | 34 +++++++++++ 6 files changed, 224 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fcacfb3..2155799 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,7 @@ This rule was distilled from a v6.7.0 dogfood incident in which a verify hook ne - `SW_ACCEPT_SET_CONFORMANCE_MODE` — default `on` (v8.5.0). Controls `hooks/accept-set-verify.sh`, the PostToolUse(Write|Edit) gate that deterministically verifies the persisted `## Accept-set sweep` section of an `eval-round-{n}.md` report (the Advertised-Accept-Set Conformance observability line written by `agents/ac-evaluator.md`'s Persistence-First terminal rewrite). The hook reads the EMITTED line and applies the lens's OWN self-incrimination rule with zero model recall — BLOCKING on a triggered boundary not run (P1), an alphabet/unicode (A/U-axis) sweep that skipped the astral complement (P2), or an authoritative divergence not driven to FAIL (P4); plus a non-blocking ADVISORY note when an A/U-axis corpus falls below `SW_AASC_CORPUS_FLOOR` (P3 — corpus-size is a weak depth proxy, so a thin corpus is surfaced, NEVER blocked; the dogfood51 confirmation run showed flooring it false-trips a legitimately-thin-but-conformant sweep, so P3 was demoted to advisory). The keyed (K) / canonical-writer (W) axes are exempt from the astral/corpus checks (a reflection-derived key corpus is legitimately small), `caveat=no-runnable-artifact` exempts P1/P2 and the P3 advisory (a compiled-language fail-open degradation), and the `## Accept-set sweep` header is matched case-insensitively (a mis-cased header cannot let a whole report skip the gate). Values: `on` (DEFAULT, v8.5.0 — enforce: emit a PostToolUse `decision:block` whose reason names the violated predicate, surfacing it to the evaluator to re-run the sweep), `metric-only` (observe — log `[ACCEPT-SET-VERIFY] metric-only: would block ...` to stderr and ALLOW), `off` (explicit opt-out, silent). Unknown values collapse to `metric-only` (since the default is `on`, the var is set only to DOWNGRADE, so a typo on a downgrade value falls back to the safe observe mode — never a surprise enforce, never a silent disable). The exit code is ALWAYS 0 (fail-OPEN — the hook can never break a Write/Edit; the only non-allow influence is the `on`-mode block JSON), and a missing `jq` is a silent `exit 0`. **Promoted to `on` by default in v8.5.0** after dogfood51/52 (14 real conformant reports → 0 false-trips) + a live `decision:block` de-risk (dogfood53: the block surfaces cleanly and the evaluator handles it gracefully without thrash, and is NOT pressured into fabricating a conformant line); set `metric-only` to revert to observe-only, `off` to disable. Verified by `tests/test-accept-set-verify.sh`. This is the runtime, recognition-independent half of the per-brief `constraints.accept_set_conformance` switch (which gates whether the EXECUTED sweep runs at all). - `SW_AASC_CORPUS_FLOOR` — default `256`. The ADVISORY threshold for the P3 thin-corpus NOTE in `hooks/accept-set-verify.sh` (a thinner A/U corpus is surfaced to stderr as `[ACCEPT-SET-VERIFY] advisory: ...`, NEVER blocked), applied ONLY to a `triggered=y ran=y` alphabet (A) or unicode-transform (U) boundary line — the axes whose mandated complement (the Unicode decimal-digit property across the BMP and astral planes) is naturally large. A descriptive corpus-size (e.g. `5-canonical-forms`) is parsed by its leading integer so an annotation cannot dodge the note. Lower it (e.g. `=8`) to silence the note on a legitimately small advertised accept-set; raise it to surface more sweeps. Honoured only when `SW_ACCEPT_SET_CONFORMANCE_MODE` is not `off`. - `SW_PARALLEL_TICKETS_MODE` — default `off`. The run kill switch for the run-scoped parallel ticket-execution path. Controls whether `/autopilot` (and a `/brief chain=on` chained run) routes the cross-ticket `/scout`→`/impl`→`/ship` pipeline through one `ticket-executor` subagent per ready ticket / topological wave, instead of the inline serial loop. Values: `off` (DEFAULT = the **prior-version behaviour**: every ticket runs inline in the main loop, strictly serial — no `ticket-executor` is spawned and every hook fires exactly as before, byte-identical to a no-directive run), `on` (force the executor-routed path even when no `parallel=` argument was supplied), `metric-only` (log the resolved wave plan + the `[PARALLEL-MODE]` line but execute the inline serial path). The per-run `parallel=` argument on `/autopilot` / `/brief` selects the same tri-state; this environment knob is the global override / kill switch and resolves **safe to `off`** on an unknown value (the same fail-safe direction as `uc=`), so a parallel path that misbehaves can be disabled from the shell without editing any invocation. The deterministic env-vs-argument precedence is applied by the parallel-mode resolver helper that the wave-aware hooks consult. This is a **(B) harness-own** substrate per `## Product/Language/Domain Agnosticism` — it governs the plugin's own orchestration engine (the `/autopilot` execution path), not the user's product, language, or domain. +- `SW_PARALLEL_HOOKS_MODE` — default = follow the run's `parallel_mode:` state. The shared HOOK-side kill switch for the parallel-aware Stop / checkpoint / auto-compact rework. It is read by the single resolver `hooks/lib/parse-state-file.sh::resolve_parallel_mode `, whose precedence is `SW_PARALLEL_HOOKS_MODE` (env override) > `parallel_mode:` scalar in `autopilot-state.yaml` > `off`. Values: `on` (force every parallel-aware hook onto the wave-aware path), `metric-only` (observe — log the resolved mode but take the serial decision), `off` (force serial — the prior-version hook behaviour). When the env knob is UNSET (the default), the resolver follows the run-scoped `parallel_mode:` (so the per-run `parallel=` argument transitively drives the hooks); when SET, it overrides. An unknown / garbage value resolves **safe to `off`** (= serial / byte-identical, the proven path), and the resolver never returns empty — every ambiguity (unknown env, absent/unknown state scalar, missing state file) fails CLOSED to `off`. This is the canonical fail-safe direction the whole parallel feature uses (the same `unknown → off` the argument parser adopts). A **(B) harness-own** substrate per `## Product/Language/Domain Agnosticism` — it governs the plugin's own hook plumbing, not the user's product, language, or domain. ## Language diff --git a/hooks/lib/parse-state-file.sh b/hooks/lib/parse-state-file.sh index 4c09689..08d08be 100755 --- a/hooks/lib/parse-state-file.sh +++ b/hooks/lib/parse-state-file.sh @@ -107,6 +107,17 @@ # three-tier strategy as the other helpers in this lib (yq -> # python3+PyYAML -> awk). # +# resolve_parallel_mode +# - The single parallel-execution mode resolver shared by every +# parallel-aware hook (the T-004/5/6 rework). Precedence: +# SW_PARALLEL_HOOKS_MODE (env override; unknown SET value -> off, no +# fall-through) > `parallel_mode:` scalar in (absent / +# null / unknown -> off) > `off`. Prints exactly one of +# `on` / `metric-only` / `off`, NEVER empty; every ambiguity fails +# CLOSED to `off` (the proven serial / byte-identical path). A +# missing / unreadable resolves `off`. (B) harness-own +# plumbing — consumed by hooks, not by any agent. +# # Implementation strategy: prefer `yq` (mikefarah v4), fall back to # `python3 + PyYAML`, and finally to a portable `awk` shell parser. This # matches the graceful-degrade contract documented in CLAUDE.md @@ -1002,7 +1013,53 @@ PY esac } +# --------------------------------------------------------------------------- +# Public function: resolve_parallel_mode +# Usage: resolve_parallel_mode +# +# The single parallel-execution mode resolver that every parallel-aware hook +# reads identically (the T-004/5/6 rework consumes it; it is (B) harness-own +# plumbing, used by hooks, not by any agent). Resolves with precedence: +# 1. SW_PARALLEL_HOOKS_MODE (env override; a SET-but-unknown value -> off) +# 2. parallel_mode: scalar in (absent / null / unknown -> off) +# 3. off (the default / fail-closed direction) +# +# Prints EXACTLY one of `on` / `metric-only` / `off` to stdout, NEVER empty. +# Every ambiguity fails CLOSED to `off` — the proven serial / byte-identical +# path (the conservative direction, mirroring the tri-value +# SW_AUTOPILOT_POLICY_STOP_HONOR / SW_SCOUT_CHECKPOINT_MODE convention and the +# `get_risk_tolerance` case-validator above). Env precedence: a SET env value +# is authoritative and an unknown SET value returns `off` WITHOUT falling +# through to the state scalar (an explicit-but-garbage override must not +# silently re-enable a state mode the operator was trying to suppress); an +# unset / empty env value falls through to the state scalar (the documented +# "default = follow parallel_mode"). A missing / unreadable +# resolves `off` at the state tier. +# --------------------------------------------------------------------------- +resolve_parallel_mode() { + local state_file="$1" + local env_mode="${SW_PARALLEL_HOOKS_MODE:-}" + + # Tier 1: env override. A non-empty env value is authoritative. + if [ -n "$env_mode" ]; then + case "$env_mode" in + on|metric-only|off) printf '%s\n' "$env_mode"; return 0 ;; + *) printf '%s\n' "off"; return 0 ;; # unknown SET value -> off (no fall-through) + esac + fi + + # Tier 2: parallel_mode: scalar in the state file (unset/empty env falls here). + local state_mode="" + if [ -n "$state_file" ] && [ -f "$state_file" ]; then + state_mode="$(parse_yaml_scalar "$state_file" parallel_mode 2>/dev/null || true)" + fi + case "$state_mode" in + on|metric-only|off) printf '%s\n' "$state_mode"; return 0 ;; + *) printf '%s\n' "off"; return 0 ;; # absent / null / unknown / missing-file -> off + esac +} + # Export the public functions so children that re-enter bash via `bash -c` # can pick them up without re-sourcing. (Bash only — POSIX `sh` ignores # `export -f`. Hooks already require Bash, so this is safe.) -export -f is_autopilot_context parse_phase_status parse_ticket_statuses find_state_file find_any_autopilot_state_file find_done_autopilot_state_file parse_ticket_ship_dirs find_phase_state_file parse_impl_next_action parse_yaml_scalar get_risk_tolerance 2>/dev/null || true +export -f is_autopilot_context parse_phase_status parse_ticket_statuses find_state_file find_any_autopilot_state_file find_done_autopilot_state_file parse_ticket_ship_dirs find_phase_state_file parse_impl_next_action parse_yaml_scalar get_risk_tolerance resolve_parallel_mode 2>/dev/null || true diff --git a/skills/autopilot/SKILL.md b/skills/autopilot/SKILL.md index f5b4af0..2369c29 100644 --- a/skills/autopilot/SKILL.md +++ b/skills/autopilot/SKILL.md @@ -219,6 +219,14 @@ For each ticket in `PROCESSING_ORDER` (`i` = 0-based), at **concurrency 1** in P **Concurrency in Phase 1 is fixed at 1** (serial-equivalent): the main loop spawns the next executor only AFTER the previous envelope is received and state is written. Real per-wave parallel spawning and a `parallel_max=` cap are added in Phase 2; the wave layering is computed and emitted now (Split Execution Flow) but does not yet drive concurrent spawns. +**Wave-cursor single-writer obligation (`PARALLEL_MODE != off`).** The main loop is the SOLE writer of the wave cursor in `autopilot-state.yaml`, with the same rigor as the canonical FLAT `steps.ship: completed` invariant — the `ticket-executor` NEVER writes any cursor field. The obligations: +- At Phase 2 State file initialization, write `main_checkout_root` ONCE = `git rev-parse --show-toplevel` (the main-checkout repo root). +- At wave computation (the level-synchronous Kahn layering), write `wave_count` (total waves). +- Immediately BEFORE spawning a wave's executors, write `current_wave` (0-based; `-1` before the first spawn) and `wave_status: in_flight`. +- Immediately AFTER the wave barrier has collected every executor envelope and the main loop has written their terminal `steps`/`status`, write `wave_status: drained`. + +These are additive optional fields read by the parallel-aware hooks (the Phase 2 rework); they are recomputed on each entry and are a projection of the authoritative per-ticket `status`, never a second source of truth. Schema + resume semantics in [references/state-file.md](references/state-file.md). On the serial path (`PARALLEL_MODE == off`) none of these fields are written (byte-identical). + ### Split Autopilot Log Write overall `autopilot-log.md` at `briefs/active/{parent-slug}/` (or `briefs/done/` post-move; no brief dir → `product_backlog/{parent-slug}/`) AND per-ticket logs in each ticket dir (`done/...` if `/ship` Step 5 reached, else `active/...`). Per-ticket logs required. Frontmatter + per-ticket subsection + six common sections (`## Pipeline Execution`, `## Warnings`, `## Human Overrides`, `## KB Overrides`, `## Decisions Made`, `## Unreached Gates`) + Manual Bash Fallback rendering (`manual_bash_fallbacks[]` SSoT in `autopilot-state.yaml`; per-step `invocation_method == manual-bash` derived) live in [references/autopilot-log.md](references/autopilot-log.md). diff --git a/skills/autopilot/references/state-file.md b/skills/autopilot/references/state-file.md index 35dc729..787ec35 100644 --- a/skills/autopilot/references/state-file.md +++ b/skills/autopilot/references/state-file.md @@ -12,7 +12,10 @@ each ticket's `phase-state.yaml` (owned by `/scout`, `/impl`, `/ship`). Skip writing it if `resume_mode = true` (state already exists). The file has 7 top-level fields plus two OPTIONAL run-scoped mode fields -(`ultracode_mode:` and `parallel_mode:`) and an append-only metrics list: +(`ultracode_mode:` and `parallel_mode:`), four OPTIONAL wave-cursor fields +(`wave_count` / `current_wave` / `wave_status` / `main_checkout_root`, +written only on the `PARALLEL_MODE != off` path), and an append-only +metrics list: ```yaml version: 1 @@ -22,6 +25,10 @@ execution_mode: split total_tickets: {N} ultracode_mode: on # OPTIONAL run-scoped orchestration mode: on | off | metric-only (default on) parallel_mode: on # OPTIONAL run-scoped parallel exec mode: on | metric-only — WRITTEN ONLY when != off; ABSENT (-> off) on a default serial run +wave_count: 3 # OPTIONAL wave cursor (PARALLEL_MODE != off only): total topological waves +current_wave: 1 # OPTIONAL wave cursor: 0-based index of the wave just spawned (-1 before the first spawn) +wave_status: in_flight # OPTIONAL wave cursor: in_flight | drained (of current_wave) +main_checkout_root: {abs path to main checkout} # OPTIONAL: main-checkout repo root (single-writer; lets a guard under a worktree resolve state) ticket_mapping: {} tickets: - logical_id: {parent-slug}-part-{N} # one entry per split-plan ticket, in topological order @@ -246,6 +253,47 @@ reports `steps.ship: completed`). The `branch` / `head_sha` fields are added with worktree isolation (Phase 2); they are absent at Phase 1 concurrency 1 (no worktree, main checkout). +## Wave cursor (`PARALLEL_MODE != off`) — orchestrator-written, hook-read + +When `PARALLEL_MODE != off`, the main loop persists a tiny single-writer +wave cursor so the parallel-aware hooks never re-derive Kahn waves in shell. +Four OPTIONAL top-level fields, all written by the main loop ONLY (the +`ticket-executor` NEVER writes them — the same single-writer rule as +`steps`/`status`): + +- `wave_count` — integer; the total number of topological waves computed + for this run (the level-synchronous Kahn layering in + `split-plan-parsing.md`). Written once at wave computation. +- `current_wave` — integer; the 0-based index of the wave just spawned. + `-1` before the first wave is spawned. Written immediately BEFORE spawning + each wave's executors. +- `wave_status` — `in_flight` | `drained`. `in_flight` from the moment a + wave's executors are spawned until the barrier has collected them all; + `drained` once every executor in `current_wave` has returned and the main + loop has written their terminal `steps`/`status`. A hook reading + `in_flight` knows a wave is still running; `drained` means the wave + boundary was crossed. +- `main_checkout_root` — string; the absolute path of the MAIN-checkout repo + root (`git rev-parse --show-toplevel` at Phase 2 init). Single-writer. It + lets a guard running inside a per-ticket worktree resolve the authoritative + state location when the `_psf_repo_root` ancestor-walk is insufficient (a + worktree layout outside the main tree). `null` / absent on a fresh + non-worktree run — guards fall back to `_psf_repo_root` (today's behaviour). + +**Resume semantics.** The cursor is run-scoped and recomputed on each +`/autopilot` entry: `wave_count` is recomputed from the (unchanged) +dependency graph, and `current_wave` / `wave_status` are re-derived from the +per-ticket terminal statuses already in `tickets[]` (the wave whose tickets +are all terminal is `drained`; the first wave with a non-terminal ticket is +the resumed `current_wave`, `in_flight`). The cursor is therefore a +convenience / observability projection of the authoritative per-ticket +`status` — never a second source of truth. It moves to `briefs/done/` with +the rest of the file on completion. + +**No behaviour change yet.** These fields are additive and unread by any +hook until the T-004/5/6 rework; a legacy or serial (`PARALLEL_MODE == off`) +run omits them entirely (a byte-identical state file). + ## `autopilot-state.yaml` location precedence `/autopilot` chooses **one** location based on what is already on disk. diff --git a/tests/test-hooks-lib.sh b/tests/test-hooks-lib.sh index ec59a52..0fc4191 100755 --- a/tests/test-hooks-lib.sh +++ b/tests/test-hooks-lib.sh @@ -632,6 +632,80 @@ unset _psf_have_saved PATH_SAVED echo "" +# --------------------------------------------------------------------------- +# Section 2d: resolve_parallel_mode (T-003) +# AC-1 precedence + fail-closed (env > state > off, unknown -> off, never +# empty, missing file -> off); AC-2 exported on the export -f line + callable. +# --------------------------------------------------------------------------- +echo "--- resolve_parallel_mode (T-003) ---" + +RPM_TMP="$(mktemp -d)" +printf 'version: 1\nparallel_mode: on\n' > "$RPM_TMP/on.yaml" +printf 'version: 1\nparallel_mode: metric-only\n' > "$RPM_TMP/metric.yaml" +printf 'version: 1\nparallel_mode: off\n' > "$RPM_TMP/off.yaml" +printf 'version: 1\nparallel_mode: bogus\n' > "$RPM_TMP/unknown.yaml" +printf 'version: 1\nparent_slug: x\n' > "$RPM_TMP/absent.yaml" + +# AC-2: declared, sourceable, and on the export -f line. +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +if bash -c "source '$PSF_PATH' && declare -F resolve_parallel_mode" >/dev/null 2>&1 \ + && grep -E '^export -f .*\bresolve_parallel_mode\b' "$PSF_PATH" >/dev/null 2>&1; then + echo -e " ${GREEN}PASS${NC} AC-2: resolve_parallel_mode is declared + on the export -f line" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} AC-2: resolve_parallel_mode missing from declare -F / export -f line" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi + +# Helper: call the resolver with $1 = state file, inheriting the caller's +# SW_PARALLEL_HOOKS_MODE env (set via a VAR=val prefix on the call below). +_rpm() { bash -c "source '$PSF_PATH' && resolve_parallel_mode \"\$1\"" _ "$1"; } + +# AC-1: state-scalar tier (env unset). +assert_eq "AC-1: state parallel_mode=on -> on" "on" \ + "$(unset SW_PARALLEL_HOOKS_MODE; _rpm "$RPM_TMP/on.yaml")" +assert_eq "AC-1: state parallel_mode=metric-only -> metric-only" "metric-only" \ + "$(unset SW_PARALLEL_HOOKS_MODE; _rpm "$RPM_TMP/metric.yaml")" +assert_eq "AC-1: state parallel_mode=off -> off" "off" \ + "$(unset SW_PARALLEL_HOOKS_MODE; _rpm "$RPM_TMP/off.yaml")" +assert_eq "AC-1: state parallel_mode=bogus (unknown) -> off" "off" \ + "$(unset SW_PARALLEL_HOOKS_MODE; _rpm "$RPM_TMP/unknown.yaml")" +assert_eq "AC-1: state parallel_mode absent -> off" "off" \ + "$(unset SW_PARALLEL_HOOKS_MODE; _rpm "$RPM_TMP/absent.yaml")" +assert_eq "AC-1: missing/unreadable state file -> off" "off" \ + "$(unset SW_PARALLEL_HOOKS_MODE; _rpm "$RPM_TMP/does-not-exist.yaml")" + +# AC-1: env-override tier (a SET env value wins over the state scalar). +assert_eq "AC-1: env=on wins over state off" "on" \ + "$(SW_PARALLEL_HOOKS_MODE=on _rpm "$RPM_TMP/off.yaml")" +assert_eq "AC-1: env=off wins over state on" "off" \ + "$(SW_PARALLEL_HOOKS_MODE=off _rpm "$RPM_TMP/on.yaml")" +assert_eq "AC-1: env=metric-only wins over state on" "metric-only" \ + "$(SW_PARALLEL_HOOKS_MODE=metric-only _rpm "$RPM_TMP/on.yaml")" +# AC-1: unknown SET env -> off, does NOT fall through to state on. +assert_eq "AC-1: env=garbage -> off (no fall-through to state on)" "off" \ + "$(SW_PARALLEL_HOOKS_MODE=garbage _rpm "$RPM_TMP/on.yaml")" +# AC-1: empty/unset env falls through to the state scalar. +assert_eq "AC-1: empty env falls through to state on" "on" \ + "$(SW_PARALLEL_HOOKS_MODE='' _rpm "$RPM_TMP/on.yaml")" + +# AC-1: never-empty invariant — output is always one of the three tokens. +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +_rpm_neverempty="$(unset SW_PARALLEL_HOOKS_MODE; _rpm "$RPM_TMP/does-not-exist.yaml")" +case "$_rpm_neverempty" in + on|metric-only|off) + echo -e " ${GREEN}PASS${NC} AC-1: never-empty (got '$_rpm_neverempty')" + TESTS_PASSED=$((TESTS_PASSED + 1)) ;; + *) + echo -e " ${RED}FAIL${NC} AC-1: empty/garbage output '$_rpm_neverempty'" + TESTS_FAILED=$((TESTS_FAILED + 1)) ;; +esac + +unset -f _rpm +rm -rf "$RPM_TMP" + +echo "" + # --------------------------------------------------------------------------- # Section 3: jsonl-tail-audit.sh # --------------------------------------------------------------------------- diff --git a/tests/test-skill-contracts.sh b/tests/test-skill-contracts.sh index e66c594..b923000 100644 --- a/tests/test-skill-contracts.sh +++ b/tests/test-skill-contracts.sh @@ -10706,6 +10706,40 @@ assert_true \ "CT-PARALLEL-6 (env kill switch): SW_PARALLEL_TICKETS_MODE documented ($par6_knob>=1) kill-switch prose ($par6_killswitch>=1) (B) harness-own ($par6_bsubstrate>=1)" \ "$par6_result" +PAR_PSF="$REPO_DIR/hooks/lib/parse-state-file.sh" + +# CT-PARALLEL-CURSOR-1 (wave-cursor schema + cursor-write obligation + hook kill switch, T-003). +# state-file.md documents the four optional wave-cursor fields; autopilot SKILL.md carries the +# single-writer cursor-write obligation prose (incl. the post-barrier `wave_status: drained` +# write); CLAUDE.md documents SW_PARALLEL_HOOKS_MODE. +pcur1_wave_count=$(grep -cF 'wave_count' "$PAR_STATEFILE" || true) +pcur1_current_wave=$(grep -cF 'current_wave' "$PAR_STATEFILE" || true) +pcur1_wave_status=$(grep -cF 'wave_status' "$PAR_STATEFILE" || true) +pcur1_main_root=$(grep -cF 'main_checkout_root' "$PAR_STATEFILE" || true) +pcur1_skill_oblig=$(grep -cF 'Wave-cursor single-writer obligation' "$PAR_AUTOPILOT" || true) +pcur1_skill_drained=$(grep -cF 'wave_status: drained' "$PAR_AUTOPILOT" || true) +pcur1_claude_knob=$(grep -cF 'SW_PARALLEL_HOOKS_MODE' "$PAR_CLAUDEMD" || true) +pcur1_result="false" +if [ "$pcur1_wave_count" -ge 1 ] && [ "$pcur1_current_wave" -ge 1 ] && [ "$pcur1_wave_status" -ge 1 ] \ + && [ "$pcur1_main_root" -ge 1 ] && [ "$pcur1_skill_oblig" -ge 1 ] && [ "$pcur1_skill_drained" -ge 1 ] \ + && [ "$pcur1_claude_knob" -ge 1 ]; then pcur1_result="true"; fi +assert_true \ + "CT-PARALLEL-CURSOR-1 (wave-cursor schema + obligation + knob): state-file fields wave_count ($pcur1_wave_count>=1) current_wave ($pcur1_current_wave>=1) wave_status ($pcur1_wave_status>=1) main_checkout_root ($pcur1_main_root>=1); SKILL obligation ($pcur1_skill_oblig>=1) drained-write ($pcur1_skill_drained>=1); CLAUDE.md SW_PARALLEL_HOOKS_MODE ($pcur1_claude_knob>=1)" \ + "$pcur1_result" + +# CT-PARALLEL-CURSOR-2 (resolve_parallel_mode helper presence + export, T-003). The shared +# resolver is defined in parse-state-file.sh, reads the SW_PARALLEL_HOOKS_MODE env override, +# and is on the export -f line so a sourcing hook can call it. (Behaviour is unit-tested in +# test-hooks-lib.sh; this CT is the drift guard for its presence + export.) +pcur2_resolver_def=$(grep -cE '^resolve_parallel_mode\(\)' "$PAR_PSF" || true) +pcur2_resolver_env=$(grep -cF 'SW_PARALLEL_HOOKS_MODE' "$PAR_PSF" || true) +pcur2_resolver_export=$(grep -cE '^export -f .*resolve_parallel_mode' "$PAR_PSF" || true) +pcur2_result="false" +if [ "$pcur2_resolver_def" -ge 1 ] && [ "$pcur2_resolver_env" -ge 1 ] && [ "$pcur2_resolver_export" -ge 1 ]; then pcur2_result="true"; fi +assert_true \ + "CT-PARALLEL-CURSOR-2 (resolve_parallel_mode helper): defined ($pcur2_resolver_def>=1) reads SW_PARALLEL_HOOKS_MODE ($pcur2_resolver_env>=1) exported ($pcur2_resolver_export>=1)" \ + "$pcur2_result" + echo "" # ============================================================================= From 1488ffeb9887905775691476a7e85db4a9c67add Mon Sep 17 00:00:00 2001 From: aimsise Date: Thu, 25 Jun 2026 11:26:57 +0900 Subject: [PATCH 04/10] feat(hooks): autopilot-continue wave-aware continuation (T-004) Wave 2. Inserts one wave-aware branch (gated if PARALLEL_MODE != off) AFTER the policy-gate-stop honour gate + the .auto-compact-pending sentinel check, BEFORE the FILE_COUNT loop guard (R-ORDER-SENTINEL). in_flight->barrier block; drained+remaining->spawn-next block; drained+last+terminal->fall through. metric-only->serial. parallel=off byte-identical. Verify: test-autopilot-continue 64/64 (incl T-004-9 honour-gate-before-wave fixture added after adversarial-verify), ShellCheck clean. Verified via ultracode Workflow. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Mw2bH4wbEPeebXsvSG6rWe --- hooks/autopilot-continue.sh | 91 ++++++++ tests/test-autopilot-continue.sh | 348 +++++++++++++++++++++++++++++++ 2 files changed, 439 insertions(+) diff --git a/hooks/autopilot-continue.sh b/hooks/autopilot-continue.sh index ec8ade5..3df7cce 100755 --- a/hooks/autopilot-continue.sh +++ b/hooks/autopilot-continue.sh @@ -404,6 +404,97 @@ case "$POLICY_STOP_HONOR" in ;; esac +# --- Wave-aware continuation branch (parallel_mode; T-004) --- +# PLACEMENT IS LOAD-BEARING (R-ORDER-SENTINEL): this branch sits AFTER the +# policy-gate-stop honour gate (above — a model hard-stop is honoured +# identically in parallel) AND AFTER the early `.auto-compact-pending` +# sentinel yield (L143–177 — a wave-drain `/compact` injection must yield the +# Stop tick rather than be overridden by a `spawn_next` block), and BEFORE the +# FILE_COUNT/NOTOOL_COUNT loop guard below (which stays the OUTERMOST backstop +# for both serial and parallel modes). DO NOT move this block above the +# sentinel check. +# +# The mode resolver is the shared T-003 helper: precedence +# SW_PARALLEL_HOOKS_MODE env > `parallel_mode:` state scalar > off +# A SET-but-unknown env value, an absent/unknown state scalar, and a +# missing file all resolve to `off`. When `off`, NOTHING below this comment +# runs and the hook is byte-identical to the serial v8.7.0 path (no +# `[PARALLEL-*]` stderr, identical decision + exit code). +PARALLEL_MODE=$(resolve_parallel_mode "$STATE_FILE") +if [ "$PARALLEL_MODE" != "off" ]; then + # Read the wave cursor (top-level scalars written by the orchestrator). + WAVE_STATUS=$(parse_yaml_scalar "$STATE_FILE" wave_status 2>/dev/null || true) + CURRENT_WAVE=$(parse_yaml_scalar "$STATE_FILE" current_wave 2>/dev/null || true) + WAVE_COUNT=$(parse_yaml_scalar "$STATE_FILE" wave_count 2>/dev/null || true) + # Numeric coercion: a missing / non-numeric cursor degrades to the safe + # terminal_check fall-through (CURRENT_WAVE=0, WAVE_COUNT=0 ⇒ no spawn-next + # block; the existing all-terminal logic decides). + case "$CURRENT_WAVE" in *[!0-9]*|"") CURRENT_WAVE=0 ;; esac + case "$WAVE_COUNT" in *[!0-9]*|"") WAVE_COUNT=0 ;; esac + + # Map the cursor to a decision: in_flight → barrier (collect the wave); + # drained + waves-remaining → spawn_next (integrate + spawn next wave); + # everything else (drained+last, drained+absent-count, unknown cursor) → + # terminal_check (fall through to the existing all-terminal logic below). + WAVE_DECISION="terminal_check" + if [ "$WAVE_STATUS" = "in_flight" ]; then + WAVE_DECISION="barrier" + elif [ "$WAVE_STATUS" = "drained" ] && [ "$((CURRENT_WAVE + 1))" -lt "$WAVE_COUNT" ]; then + WAVE_DECISION="spawn_next" + fi + + if [ "$PARALLEL_MODE" = "metric-only" ]; then + # Observe-only: log the would-be decision and take the existing serial + # control flow unchanged (no block emitted here, no early exit). + echo "[PARALLEL-CONTINUE] metric-only: wave cursor wave_status=${WAVE_STATUS:-} current_wave=${CURRENT_WAVE} wave_count=${WAVE_COUNT} would=${WAVE_DECISION}" >&2 + elif [ "$WAVE_DECISION" != "terminal_check" ]; then + # The FILE_COUNT loop guard stays the outermost backstop: a stuck wave + # whose cursor never advances still RELEASES at FILE_COUNT>=5 && + # NOTOOL>=threshold rather than blocking forever (AC-5). + if [ "$FILE_COUNT" -ge 5 ] && [ "$NOTOOL_COUNT" -ge "$NOTOOL_THRESHOLD" ]; then + echo "[AUTOPILOT-STALL] wave loop guard released after $FILE_COUNT consecutive blocks (wave_status=${WAVE_STATUS:-}, current_wave=${CURRENT_WAVE})" >&2 + echo "[AUTOPILOT-STALL] Wave pipeline halted: $NOTOOL_COUNT consecutive end_turn attempts without tool calls or state progress. Resume with: /autopilot {parent-slug}" + _emit_session_end_metrics "loop_guard_release" "$FILE_COUNT" + rm -f "$COUNTER_FILE" 2>/dev/null || true + rm -f "$NOTOOL_COUNTER_FILE" 2>/dev/null || true + exit 0 + fi + + # Advance the FILE_COUNT counter (mirrors the serial block path below) so + # the loop guard can fire on the next stuck tick. + FILE_COUNT=$((FILE_COUNT + 1)) + if [ "$SESSION_ID" != "unknown" ]; then + echo "$FILE_COUNT" > "$COUNTER_FILE" + fi + + WAVE_STATE_CONTENT=$(cat "$STATE_FILE") + if [ "$WAVE_DECISION" = "barrier" ]; then + jq -n \ + --arg state_path "$STATE_FILE" \ + --arg wave "$CURRENT_WAVE" \ + --arg state_content "$WAVE_STATE_CONTENT" \ + '{ + decision: "block", + reason: ("A parallel /autopilot wave (current_wave: " + $wave + ") is IN FLIGHT. Do NOT stop and do NOT run scout/impl/ship inline. As the single writer for this wave: collect every spawned executor'\''s return envelope, then write each ticket'\''s terminal steps.* and status fields into " + $state_path + ", then set wave_status: drained. Only after the barrier is drained does the next Stop tick decide spawn-next vs. completion.\n\nCurrent pipeline state:\n" + $state_content) + }' + else + jq -n \ + --arg state_path "$STATE_FILE" \ + --arg wave "$CURRENT_WAVE" \ + --arg next_wave "$((CURRENT_WAVE + 1))" \ + --arg wave_count "$WAVE_COUNT" \ + --arg state_content "$WAVE_STATE_CONTENT" \ + '{ + decision: "block", + reason: ("The parallel /autopilot wave (current_wave: " + $wave + " of " + $wave_count + ") is DRAINED and waves remain. Do NOT stop. Integrate the completed wave (verify its tickets reached terminal state in " + $state_path + "), then advance the cursor to current_wave: " + $next_wave + " and spawn the next wave'\''s executors. Do NOT run scout/impl/ship inline.\n\nCurrent pipeline state:\n" + $state_content) + }' + fi + exit 0 + fi + # WAVE_DECISION == terminal_check (or metric-only) → fall through to the + # existing FILE_COUNT loop guard + all-terminal logic below, unchanged. +fi + if [ "$FILE_COUNT" -ge 5 ] && [ "$NOTOOL_COUNT" -ge "$NOTOOL_THRESHOLD" ]; then echo "[AUTOPILOT-STALL] file-based loop guard released after $FILE_COUNT consecutive blocks" >&2 echo "[AUTOPILOT-STALL] Pipeline halted: model emitted $NOTOOL_COUNT consecutive end_turn attempts without tool calls or state progress. Resume with: /autopilot {parent-slug}" diff --git a/tests/test-autopilot-continue.sh b/tests/test-autopilot-continue.sh index be3d1cd..1a8aacb 100644 --- a/tests/test-autopilot-continue.sh +++ b/tests/test-autopilot-continue.sh @@ -1369,5 +1369,353 @@ fi rm -f /tmp/.autopilot-continue-test-nested cleanup_test_repo +# ============================================================ +# T-004: Wave-aware continuation (parallel_mode) +# ============================================================ +echo "" +echo "=== T-004: Wave-aware continuation (parallel_mode) ===" +echo "" + +# A wave-bearing autopilot-state.yaml. $1=slug, $2=parallel_mode line (may be +# empty for the absent case), $3=wave_status, $4=current_wave, $5=wave_count. +# The single ticket carries an in_progress scout step so that, ABSENT any wave +# branch, the serial path would emit a `block` naming the next step — this is +# what makes the off/metric-only byte-identity assertions non-trivial. +create_wave_state() { + local slug="$1" pmode_line="$2" wstatus="$3" cwave="$4" wcount="$5" + mkdir -p ".simple-workflow/backlog/briefs/active/${slug}" + { + echo "version: 1" + echo "slug: ${slug}" + echo "started: 2026-04-15T00:00:00Z" + echo "execution_mode: split" + [ -n "$pmode_line" ] && echo "$pmode_line" + [ -n "$wstatus" ] && echo "wave_status: ${wstatus}" + echo "current_wave: ${cwave}" + echo "wave_count: ${wcount}" + echo "total_tickets: 1" + echo "tickets:" + echo " - logical_id: ${slug}" + echo " ticket_dir: 001-test" + echo " status: in_progress" + echo " steps:" + echo " create-ticket: completed" + echo " scout: in_progress" + echo " impl: pending" + echo " ship: pending" + } > ".simple-workflow/backlog/briefs/active/${slug}/autopilot-state.yaml" +} + +# ------------------------------------------------------------ +# T-004-1a: parallel_mode ABSENT → byte-identical serial decision, no [PARALLEL-*] +# ------------------------------------------------------------ +echo "--- T-004-1a: parallel_mode absent → byte-identical serial path ---" +setup_test_repo +# in_flight cursor present, but NO parallel_mode key → resolver returns off → +# the wave branch is fully skipped → serial path emits the next-step block. +create_wave_state "wave-off-absent" "" "in_flight" "0" "3" +run_autopilot_hook '{"session_id":"wave-off-absent"}' "$TEST_REPO" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +DECISION=$(echo "$LAST_STDOUT" | jq -r '.decision // ""' 2>/dev/null || echo "") +REASON_SERIAL=$(echo "$LAST_STDOUT" | jq -r '.reason // ""' 2>/dev/null | grep -c 'middle of a /autopilot pipeline' || true) +PARALLEL_STDERR=$(echo "$LAST_STDERR" | grep -c '\[PARALLEL-' || true) +if [ "$DECISION" = "block" ] && [ "$REASON_SERIAL" -ge 1 ] && [ "$PARALLEL_STDERR" -eq 0 ]; then + echo -e " ${GREEN}PASS${NC} absent parallel_mode: serial next-step block, no [PARALLEL-*] stderr (byte-identical)" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} absent parallel_mode: expected serial block + no [PARALLEL-*] stderr" + echo -e " Decision='$DECISION' serial-reason=$REASON_SERIAL parallel-stderr=$PARALLEL_STDERR" + echo -e " Stdout: $LAST_STDOUT" + echo -e " Stderr: $LAST_STDERR" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +rm -f /tmp/.autopilot-continue-wave-off-absent /tmp/.autopilot-notool-wave-off-absent +cleanup_test_repo + +# ------------------------------------------------------------ +# T-004-1b: parallel_mode: off → byte-identical serial decision, no [PARALLEL-*] +# ------------------------------------------------------------ +echo "--- T-004-1b: parallel_mode: off → byte-identical serial path ---" +setup_test_repo +create_wave_state "wave-off-explicit" "parallel_mode: off" "in_flight" "0" "3" +run_autopilot_hook '{"session_id":"wave-off-explicit"}' "$TEST_REPO" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +DECISION=$(echo "$LAST_STDOUT" | jq -r '.decision // ""' 2>/dev/null || echo "") +REASON_SERIAL=$(echo "$LAST_STDOUT" | jq -r '.reason // ""' 2>/dev/null | grep -c 'middle of a /autopilot pipeline' || true) +PARALLEL_STDERR=$(echo "$LAST_STDERR" | grep -c '\[PARALLEL-' || true) +if [ "$DECISION" = "block" ] && [ "$REASON_SERIAL" -ge 1 ] && [ "$PARALLEL_STDERR" -eq 0 ]; then + echo -e " ${GREEN}PASS${NC} parallel_mode: off: serial next-step block, no [PARALLEL-*] stderr (byte-identical)" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} parallel_mode: off: expected serial block + no [PARALLEL-*] stderr" + echo -e " Decision='$DECISION' serial-reason=$REASON_SERIAL parallel-stderr=$PARALLEL_STDERR" + echo -e " Stdout: $LAST_STDOUT" + echo -e " Stderr: $LAST_STDERR" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +rm -f /tmp/.autopilot-continue-wave-off-explicit /tmp/.autopilot-notool-wave-off-explicit +cleanup_test_repo + +# ------------------------------------------------------------ +# T-004-2: wave_status: in_flight → barrier block (AC-2) +# ------------------------------------------------------------ +echo "--- T-004-2: in_flight → barrier block ---" +setup_test_repo +create_wave_state "wave-inflight" "parallel_mode: on" "in_flight" "0" "3" +run_autopilot_hook '{"session_id":"wave-inflight"}' "$TEST_REPO" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +DECISION=$(echo "$LAST_STDOUT" | jq -r '.decision // ""' 2>/dev/null || echo "") +REASON=$(echo "$LAST_STDOUT" | jq -r '.reason // ""' 2>/dev/null || echo "") +# grep -o counts MATCHES (the obligation cues all sit on one long line, so +# grep -c would only ever return 1). Require all four mid-wave obligation cues. +BARRIER_OK=$(echo "$REASON" | grep -oiE 'IN FLIGHT|wave_status: drained|return envelope|single writer' | wc -l | tr -d ' ') +NO_INLINE=$(echo "$REASON" | grep -c 'do NOT run scout/impl/ship inline' || true) +HAS_STATE=$(echo "$REASON" | grep -c 'current_wave' || true) +if [ "$DECISION" = "block" ] && [ "$BARRIER_OK" -ge 4 ] && [ "$NO_INLINE" -ge 1 ] && [ "$HAS_STATE" -ge 1 ]; then + echo -e " ${GREEN}PASS${NC} in_flight: barrier block (collect envelopes / single writer / set drained / no inline / state inlined)" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} in_flight: expected barrier block naming the mid-wave obligation" + echo -e " Decision='$DECISION' barrier-cues=$BARRIER_OK no-inline=$NO_INLINE has-state=$HAS_STATE" + echo -e " Reason: $REASON" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +rm -f /tmp/.autopilot-continue-wave-inflight /tmp/.autopilot-notool-wave-inflight +cleanup_test_repo + +# ------------------------------------------------------------ +# T-004-3: drained + waves-remaining → spawn-next block (AC-3) +# ------------------------------------------------------------ +echo "--- T-004-3: drained + waves-remaining → spawn-next block ---" +setup_test_repo +# current_wave 0, wave_count 3 → 0+1 < 3 → spawn-next. +create_wave_state "wave-drained-rem" "parallel_mode: on" "drained" "0" "3" +run_autopilot_hook '{"session_id":"wave-drained-rem"}' "$TEST_REPO" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +DECISION=$(echo "$LAST_STDOUT" | jq -r '.decision // ""' 2>/dev/null || echo "") +REASON=$(echo "$LAST_STDOUT" | jq -r '.reason // ""' 2>/dev/null || echo "") +INTEGRATE_OK=$(echo "$REASON" | grep -ciE 'integrate the completed wave|DRAINED' || true) +SPAWN_OK=$(echo "$REASON" | grep -ciE 'spawn the next wave' || true) +if [ "$DECISION" = "block" ] && [ "$INTEGRATE_OK" -ge 1 ] && [ "$SPAWN_OK" -ge 1 ]; then + echo -e " ${GREEN}PASS${NC} drained+remaining: spawn-next block (integrate completed wave + spawn next)" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} drained+remaining: expected spawn-next block" + echo -e " Decision='$DECISION' integrate=$INTEGRATE_OK spawn=$SPAWN_OK" + echo -e " Reason: $REASON" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +rm -f /tmp/.autopilot-continue-wave-drained-rem /tmp/.autopilot-notool-wave-drained-rem +cleanup_test_repo + +# ------------------------------------------------------------ +# T-004-4: drained + last wave + all-terminal → allow stop (AC-4) +# ------------------------------------------------------------ +echo "--- T-004-4: drained + last wave + all-terminal → allow stop ---" +setup_test_repo +# current_wave 2, wave_count 3 → 2+1 >= 3 → terminal_check; all steps completed +# so parse_active_steps == 0 → the existing all-terminal path allows the stop. +mkdir -p ".simple-workflow/backlog/briefs/active/wave-last" +{ + echo "version: 1" + echo "slug: wave-last" + echo "started: 2026-04-15T00:00:00Z" + echo "execution_mode: split" + echo "parallel_mode: on" + echo "wave_status: drained" + echo "current_wave: 2" + echo "wave_count: 3" + echo "total_tickets: 1" + echo "tickets:" + echo " - logical_id: wave-last" + echo " ticket_dir: 001-test" + echo " status: completed" + echo " steps:" + echo " create-ticket: completed" + echo " scout: completed" + echo " impl: completed" + echo " ship: completed" +} > ".simple-workflow/backlog/briefs/active/wave-last/autopilot-state.yaml" +run_autopilot_hook '{"session_id":"wave-last"}' "$TEST_REPO" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +if [ "$LAST_EXIT_CODE" -eq 0 ] && [ -z "$LAST_STDOUT" ]; then + echo -e " ${GREEN}PASS${NC} drained+last+all-terminal: fell through to all-terminal allow (exit 0, no block)" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} drained+last+all-terminal: expected exit 0 with no block stdout" + echo -e " Exit=$LAST_EXIT_CODE Stdout: $LAST_STDOUT" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +rm -f /tmp/.autopilot-continue-wave-last /tmp/.autopilot-notool-wave-last +cleanup_test_repo + +# ------------------------------------------------------------ +# T-004-5: R-ORDER-SENTINEL — fresh sentinel + drained + remaining → YIELD +# ------------------------------------------------------------ +echo "--- T-004-5: fresh .auto-compact-pending + drained+remaining → YIELD (R-ORDER-SENTINEL) ---" +setup_test_repo +# drained + remaining would normally emit a spawn-next block. A FRESH sentinel +# placed in the state dir MUST win because the sentinel yield (early) is +# reached BEFORE the wave branch (late) → exit 0, no block, [AUTO-COMPACT-YIELD]. +create_wave_state "wave-sentinel" "parallel_mode: on" "drained" "0" "3" +date +%s > ".simple-workflow/backlog/briefs/active/wave-sentinel/.auto-compact-pending" +run_autopilot_hook '{"session_id":"wave-sentinel"}' "$TEST_REPO" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +YIELD_OK=$(echo "$LAST_STDERR" | grep -c '\[AUTO-COMPACT-YIELD\] sentinel found' || true) +SPAWN_LEAK=$(echo "$LAST_STDOUT" | grep -c 'spawn the next wave' || true) +if [ "$LAST_EXIT_CODE" -eq 0 ] && [ -z "$LAST_STDOUT" ] && [ "$YIELD_OK" -ge 1 ] && [ "$SPAWN_LEAK" -eq 0 ]; then + echo -e " ${GREEN}PASS${NC} sentinel-wins: exit 0, no spawn-next block (sentinel yield reached before wave branch)" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} sentinel-wins: expected exit 0 + [AUTO-COMPACT-YIELD] + NO spawn-next block" + echo -e " Exit=$LAST_EXIT_CODE yield=$YIELD_OK spawn-leak=$SPAWN_LEAK" + echo -e " Stdout: $LAST_STDOUT" + echo -e " Stderr: $LAST_STDERR" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +rm -f /tmp/.autopilot-continue-wave-sentinel /tmp/.autopilot-notool-wave-sentinel +cleanup_test_repo + +# ------------------------------------------------------------ +# T-004-5b: R-ORDER-SENTINEL insertion-point grep — wave branch sits AFTER the +# sentinel-check line and BEFORE the FILE_COUNT loop guard. +# ------------------------------------------------------------ +echo "--- T-004-5b: insertion-point grep (sentinel-check < wave-branch < FILE_COUNT loop guard) ---" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +SENTINEL_LINE=$(grep -n 'SENTINEL_FILE="\$SENTINEL_DIR/.auto-compact-pending"' "$HOOK" | head -1 | cut -d: -f1) +WAVE_LINE=$(grep -n 'Wave-aware continuation branch (parallel_mode; T-004)' "$HOOK" | head -1 | cut -d: -f1) +# The OUTERMOST serial loop guard (the one that is the backstop) is the +# FILE_COUNT>=5 && NOTOOL line that lives AFTER the wave branch closes. +LOOPGUARD_LINE=$(grep -n 'if \[ "\$FILE_COUNT" -ge 5 \] && \[ "\$NOTOOL_COUNT" -ge "\$NOTOOL_THRESHOLD" \]; then' "$HOOK" | tail -1 | cut -d: -f1) +if [ -n "$SENTINEL_LINE" ] && [ -n "$WAVE_LINE" ] && [ -n "$LOOPGUARD_LINE" ] \ + && [ "$SENTINEL_LINE" -lt "$WAVE_LINE" ] && [ "$WAVE_LINE" -lt "$LOOPGUARD_LINE" ]; then + echo -e " ${GREEN}PASS${NC} ordering: sentinel(L$SENTINEL_LINE) < wave-branch(L$WAVE_LINE) < FILE_COUNT loop guard(L$LOOPGUARD_LINE)" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} ordering: expected sentinel < wave-branch < loop-guard" + echo -e " sentinel=$SENTINEL_LINE wave=$WAVE_LINE loopguard=$LOOPGUARD_LINE" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi + +# ------------------------------------------------------------ +# T-004-6: FILE_COUNT loop-guard backstop releases at 5 on a stuck wave (AC-5) +# ------------------------------------------------------------ +echo "--- T-004-6: stuck wave → FILE_COUNT loop guard releases at 5 ---" +setup_test_repo +create_wave_state "wave-stuck" "parallel_mode: on" "in_flight" "0" "3" +# Pre-seed FILE_COUNT=5 and force NOTOOL to threshold via the legacy loopguard +# so the backstop fires. The counter file must be NEWER than the state file or +# the `STATE_FILE -nt COUNTER_FILE` progress-reset zeroes FILE_COUNT (a stuck +# wave makes no state progress, so the counter is the more-recent artifact); +# touch it forward so the reset does not trigger. +echo "5" > /tmp/.autopilot-continue-wave-stuck +touch -t 203001010000 /tmp/.autopilot-continue-wave-stuck +set +e +echo '{"session_id":"wave-stuck"}' | (cd "$TEST_REPO" && AUTOPILOT_LEGACY_LOOPGUARD=1 bash "$HOOK") >/tmp/.t004_out 2>/tmp/.t004_err +LAST_EXIT_CODE=$? +set -e +LAST_STDOUT=$(cat /tmp/.t004_out); LAST_STDERR=$(cat /tmp/.t004_err) +rm -f /tmp/.t004_out /tmp/.t004_err +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +RELEASE_OK=$(echo "$LAST_STDERR" | grep -c '\[AUTOPILOT-STALL\] wave loop guard released' || true) +NO_BARRIER=$(echo "$LAST_STDOUT" | grep -c '"decision": "block"' || true) +if [ "$LAST_EXIT_CODE" -eq 0 ] && [ "$RELEASE_OK" -ge 1 ] && [ "$NO_BARRIER" -eq 0 ]; then + echo -e " ${GREEN}PASS${NC} stuck wave: [AUTOPILOT-STALL] wave loop guard released at 5, exit 0, no block (never hangs)" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} stuck wave: expected wave loop guard release at 5" + echo -e " Exit=$LAST_EXIT_CODE release=$RELEASE_OK block-leak=$NO_BARRIER" + echo -e " Stdout: $LAST_STDOUT" + echo -e " Stderr: $LAST_STDERR" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +rm -f /tmp/.autopilot-continue-wave-stuck /tmp/.autopilot-notool-wave-stuck +cleanup_test_repo + +# ------------------------------------------------------------ +# T-004-7: metric-only → logs [PARALLEL-CONTINUE] then takes serial path +# ------------------------------------------------------------ +echo "--- T-004-7: parallel_mode: metric-only → log + serial path ---" +setup_test_repo +# in_flight cursor: under `on` this would barrier-block; under metric-only the +# hook logs the would-be decision and falls through to the SERIAL next-step +# block (proving the serial control flow is taken unchanged). +create_wave_state "wave-metric" "parallel_mode: metric-only" "in_flight" "0" "3" +run_autopilot_hook '{"session_id":"wave-metric"}' "$TEST_REPO" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +METRIC_OK=$(echo "$LAST_STDERR" | grep -c '\[PARALLEL-CONTINUE\] metric-only:.*would=barrier' || true) +DECISION=$(echo "$LAST_STDOUT" | jq -r '.decision // ""' 2>/dev/null || echo "") +SERIAL_OK=$(echo "$LAST_STDOUT" | jq -r '.reason // ""' 2>/dev/null | grep -c 'middle of a /autopilot pipeline' || true) +if [ "$METRIC_OK" -ge 1 ] && [ "$DECISION" = "block" ] && [ "$SERIAL_OK" -ge 1 ]; then + echo -e " ${GREEN}PASS${NC} metric-only: logged would=barrier and took the serial next-step block path" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} metric-only: expected [PARALLEL-CONTINUE] would=barrier + serial block" + echo -e " metric-log=$METRIC_OK decision='$DECISION' serial=$SERIAL_OK" + echo -e " Stdout: $LAST_STDOUT" + echo -e " Stderr: $LAST_STDERR" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +rm -f /tmp/.autopilot-continue-wave-metric /tmp/.autopilot-notool-wave-metric +cleanup_test_repo + +# ------------------------------------------------------------ +# T-004-8: SW_PARALLEL_HOOKS_MODE=off forces serial on a parallel=on state +# ------------------------------------------------------------ +echo "--- T-004-8: SW_PARALLEL_HOOKS_MODE=off overrides parallel_mode: on → serial ---" +setup_test_repo +create_wave_state "wave-envoff" "parallel_mode: on" "in_flight" "0" "3" +set +e +echo '{"session_id":"wave-envoff"}' | (cd "$TEST_REPO" && SW_PARALLEL_HOOKS_MODE=off bash "$HOOK") >/tmp/.t004o_out 2>/tmp/.t004o_err +LAST_EXIT_CODE=$? +set -e +LAST_STDOUT=$(cat /tmp/.t004o_out); LAST_STDERR=$(cat /tmp/.t004o_err) +rm -f /tmp/.t004o_out /tmp/.t004o_err +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +DECISION=$(echo "$LAST_STDOUT" | jq -r '.decision // ""' 2>/dev/null || echo "") +SERIAL_OK=$(echo "$LAST_STDOUT" | jq -r '.reason // ""' 2>/dev/null | grep -c 'middle of a /autopilot pipeline' || true) +PARALLEL_STDERR=$(echo "$LAST_STDERR" | grep -c '\[PARALLEL-' || true) +if [ "$DECISION" = "block" ] && [ "$SERIAL_OK" -ge 1 ] && [ "$PARALLEL_STDERR" -eq 0 ]; then + echo -e " ${GREEN}PASS${NC} env=off override: serial next-step block, no [PARALLEL-*] (env wins over state on)" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} env=off override: expected serial block + no [PARALLEL-*] stderr" + echo -e " Decision='$DECISION' serial=$SERIAL_OK parallel-stderr=$PARALLEL_STDERR" + echo -e " Stdout: $LAST_STDOUT" + echo -e " Stderr: $LAST_STDERR" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +rm -f /tmp/.autopilot-continue-wave-envoff /tmp/.autopilot-notool-wave-envoff +cleanup_test_repo + +# ------------------------------------------------------------ +# T-004-9: the policy-gate-stop honour gate runs BEFORE the wave branch +# (Wave-2 adversarial-verify gap fix for AC-5's "the honour gate still runs +# before the wave branch" claim). A policy_gate_stop declaration under +# parallel_mode=on + wave_status=in_flight — which the wave branch would +# otherwise turn into an in_flight barrier `decision: block` — must be +# HONOURED: exit 0, no `decision: block`. That proves the honour gate (early +# in the hook) short-circuits before the wave branch (late) is ever reached. +# ------------------------------------------------------------ +echo "--- T-004-9: policy_gate_stop honoured BEFORE the wave branch (parallel in_flight) ---" +setup_test_repo +create_wave_state "wave-pgs-honour" "parallel_mode: on" "in_flight" "0" "3" +run_pgs_hook "wave-pgs-honour" "policy_gate_stop_last_turn.jsonl" "on" "$TEST_REPO" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +T49_DEC=$(echo "$LAST_STDOUT" | jq -r '.decision // ""' 2>/dev/null || echo "") +T49_PARALLEL_STDERR=$(echo "$LAST_STDERR" | grep -c '\[PARALLEL-' || true) +if [ "$LAST_EXIT_CODE" -eq 0 ] && [ "$T49_DEC" != "block" ] && [ "$T49_PARALLEL_STDERR" -eq 0 ]; then + echo -e " ${GREEN}PASS${NC} policy_gate_stop honoured before the wave branch (exit 0, no block, no [PARALLEL-*] — honour gate wins the ordering)" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} expected honour-gate allow (exit 0, no block, no [PARALLEL-*]) ahead of the wave in_flight barrier" + echo -e " Exit=$LAST_EXIT_CODE Decision='$T49_DEC' parallel-stderr=$T49_PARALLEL_STDERR" + echo -e " Stdout: $LAST_STDOUT" + echo -e " Stderr: $LAST_STDERR" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +rm -f /tmp/.autopilot-continue-wave-pgs-honour /tmp/.autopilot-notool-wave-pgs-honour +cleanup_test_repo + echo "" print_summary From 3f0e31c7feb642666e4638cd4140faf3c5ba241c Mon Sep 17 00:00:00 2001 From: aimsise Date: Thu, 25 Jun 2026 11:27:08 +0900 Subject: [PATCH 05/10] feat(hooks): relocate checkpoint guards to SubagentStop + parallel main-Stop stand-down (T-005) Wave 2. hooks.json gains a SubagentStop array with TWO separate top-level entries (impl + scout guards; autopilot-continue/session-stop-log NOT added; Stop unchanged). Both guards (symmetric): read .hook_event_name (missing->Stop); under PARALLEL_MODE!=off AND event!=SubagentStop -> stand down (exit 0); metric-only logs+falls through; SubagentStop path adds early is_autopilot_context||exit 0 then enforces verbatim on the executor transcript; prefer main_checkout_root (T-003) over _psf_repo_root (keeps impl-guard alive under a worktree). R-SUBSTOP spike = RELOCATE (gates T-009 flip). parallel=off byte-identical. Verify: test-scout-checkpoint-guard 27/27, test-impl-checkpoint-guard 21/21 (incl worktree-via-main_checkout_root), ShellCheck clean, hooks.json valid. Verified via ultracode Workflow. (CT-PARALLEL-SUBSTOP-1 lands with T-006 in the shared test-skill-contracts.sh.) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Mw2bH4wbEPeebXsvSG6rWe --- hooks/hooks.json | 8 + hooks/impl-checkpoint-guard.sh | 74 +++++++- hooks/scout-checkpoint-guard.sh | 59 +++++++ tests/test-impl-checkpoint-guard.sh | 245 +++++++++++++++++++++++++++ tests/test-scout-checkpoint-guard.sh | 232 +++++++++++++++++++++++++ 5 files changed, 617 insertions(+), 1 deletion(-) diff --git a/hooks/hooks.json b/hooks/hooks.json index bead92e..5c55d86 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -84,6 +84,14 @@ { "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-stop-log.sh" }] } + ], + "SubagentStop": [ + { + "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/impl-checkpoint-guard.sh" }] + }, + { + "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/scout-checkpoint-guard.sh" }] + } ] } } diff --git a/hooks/impl-checkpoint-guard.sh b/hooks/impl-checkpoint-guard.sh index e17887e..e93f726 100755 --- a/hooks/impl-checkpoint-guard.sh +++ b/hooks/impl-checkpoint-guard.sh @@ -65,8 +65,55 @@ source "$SCRIPT_DIR/lib/detect-policy-gate-stop.sh" TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null || echo "") SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"' 2>/dev/null || echo "unknown") +# --- Step 0: parallel-mode resolution (T-005) ------------------------------ +# Net-new event-aware machinery for parallel autopilot. Under parallel_mode=on, +# /impl runs inside the ticket-executor, so the /audit block + Skill signature +# land in the EXECUTOR's transcript, delivered to THIS hook on the executor's +# SubagentStop event — NOT on the main Stop. The dual-event design mirrors +# scout-checkpoint-guard.sh (Modifications-rule peer symmetry): +# - SubagentStop (parallel) -> enforce on the executor transcript. +# - Stop / missing event (parallel) -> stand down (the main Stop never sees +# the executor signal). +# +# This block resolves the mode + the orchestrator-written main_checkout_root +# (T-003) BEFORE Step 1, because Step 1 HARD-REQUIRES phase-state.yaml and +# silent-exits when it is absent. Under a worktree the phase-state lives at +# the main-checkout path, so the main_checkout_root preference is what keeps +# impl's SubagentStop enforcement (and the main-Stop stand-down log) alive in +# a worktree — without it, Step 1 would exit before the Step 2b branch. +# +# CRITICAL byte-identity invariant: when parallel_mode is absent/off this is +# inert — PARALLEL_MODE resolves to `off`, PSF_START_DIR stays empty (Step 1 +# uses the default $PWD ancestor-walk), the Step 2b `!= off` guards are false, +# and the hook behaves EXACTLY as before. The mode resolves via the shared +# resolve_parallel_mode (T-003): SW_PARALLEL_HOOKS_MODE env > the autopilot +# state's parallel_mode: scalar > off; a missing state file -> off. +HOOK_EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // ""' 2>/dev/null || echo "") +# A missing/empty hook_event_name is treated as "Stop" (the safe direction: +# fail toward standing-down on the main transcript under parallel). +[ -n "$HOOK_EVENT" ] || HOOK_EVENT="Stop" + +PARALLEL_STATE_FILE=$(find_any_autopilot_state_file 2>/dev/null || true) +PARALLEL_MODE=$(resolve_parallel_mode "${PARALLEL_STATE_FILE:-}") + +# Prefer the orchestrator-written main_checkout_root over the _psf_repo_root +# ancestor-walk for phase-state resolution under parallel. Gated behind +# `!= off` so the off path is byte-identical (PSF_START_DIR empty). +PSF_START_DIR="" +if [ "$PARALLEL_MODE" != "off" ] \ + && [ -n "${PARALLEL_STATE_FILE:-}" ] && [ -f "$PARALLEL_STATE_FILE" ]; then + MAIN_CHECKOUT_ROOT=$(parse_yaml_scalar "$PARALLEL_STATE_FILE" main_checkout_root 2>/dev/null || true) + if [ -n "${MAIN_CHECKOUT_ROOT:-}" ] && [ -d "$MAIN_CHECKOUT_ROOT" ]; then + PSF_START_DIR="$MAIN_CHECKOUT_ROOT" + fi +fi + # --- Step 1: phase-state.yaml not found → silent exit --- -STATE_FILE=$(find_phase_state_file 2>/dev/null || true) +if [ -n "$PSF_START_DIR" ]; then + STATE_FILE=$(find_phase_state_file "$PSF_START_DIR" 2>/dev/null || true) +else + STATE_FILE=$(find_phase_state_file 2>/dev/null || true) +fi if [ -z "${STATE_FILE:-}" ] || [ ! -f "$STATE_FILE" ]; then exit 0 fi @@ -239,6 +286,31 @@ case "$MODE" in ;; esac +# --- Step 2b: parallel-mode event branch (T-005) --------------------------- +# Symmetric to scout-checkpoint-guard.sh Step 2b (Modifications-rule peer +# uniformity). PARALLEL_MODE / HOOK_EVENT were resolved in Step 0 above (they +# had to precede Step 1's hard phase-state requirement). Here the actual +# event branch runs, after the kill switch (Step 2) so SW_IMPL_CHECKPOINT_MODE +# still wins. Fully inert under parallel_mode=off (byte-identical). +if [ "$PARALLEL_MODE" != "off" ]; then + if [ "$HOOK_EVENT" != "SubagentStop" ]; then + # Main Stop (or missing event) under parallel: stand down. + if [ "$PARALLEL_MODE" = "metric-only" ]; then + echo "[IMPL-CHECKPOINT] parallel stand-down (metric-only): would stand down on main Stop (hook_event=$HOOK_EVENT, parallel_mode=$PARALLEL_MODE); falling through to the serial path." >&2 + # metric-only: fall through to the existing serial logic below. + else + echo "[IMPL-CHECKPOINT] parallel stand-down: enforcement is relocated to the executor SubagentStop under parallel_mode=on (hook_event=$HOOK_EVENT); the main Stop does not see the executor signal." >&2 + exit 0 + fi + else + # SubagentStop under parallel: enforce on the executor transcript. Cheap + # no-op on unrelated subagent stops (R-SUBSTOP-SERIAL) before any further + # I/O. (The main_checkout_root phase-state preference already ran in + # Step 0 so Step 1 resolved phase-state from the main checkout.) + is_autopilot_context || exit 0 + fi +fi + # --- Step 3: phases.impl.status == completed → silent exit --- IMPL_STATUS=$(parse_phase_status "$STATE_FILE" "impl" 2>/dev/null || echo "") if [ "$IMPL_STATUS" = "completed" ]; then diff --git a/hooks/scout-checkpoint-guard.sh b/hooks/scout-checkpoint-guard.sh index aa57de7..643fd38 100755 --- a/hooks/scout-checkpoint-guard.sh +++ b/hooks/scout-checkpoint-guard.sh @@ -304,6 +304,65 @@ if ! is_autopilot_context; then fi fi +# --- Step 2b: parallel-mode event branch (T-005) --------------------------- +# Net-new event-aware branch for parallel autopilot. Under parallel_mode=on, +# /scout runs inside the ticket-executor, so the ssot-line + Skill signature +# land in the EXECUTOR's transcript, delivered to THIS hook on the executor's +# SubagentStop event — NOT on the main Stop. The dual-event design: +# - SubagentStop (parallel) -> enforce on the executor transcript: an +# early `is_autopilot_context || exit 0` +# makes unrelated subagent stops a cheap +# no-op (R-SUBSTOP-SERIAL), then Steps 3-7 +# run verbatim on `.transcript_path`. +# - Stop / missing event (parallel) -> stand down (the main Stop never +# sees the executor's signal; the explicit +# stand-down keeps a future change from +# false-blocking the main loop). +# +# CRITICAL byte-identity invariant: when parallel_mode is absent/off this +# whole block is inert — PARALLEL_MODE resolves to `off`, the `!= off` guards +# are false, and the hook behaves EXACTLY as before (the main Stop runs the +# existing Steps verbatim; a serial subagent's SubagentStop silent-exits at +# the existing Step 6/7 signature gate). The mode resolves via the shared +# resolve_parallel_mode (T-003): SW_PARALLEL_HOOKS_MODE env > the autopilot +# state's parallel_mode: scalar > off; a missing state file -> off. +HOOK_EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // ""' 2>/dev/null || echo "") +# A missing/empty hook_event_name is treated as "Stop" (the safe direction: +# fail toward standing-down on the main transcript under parallel). +[ -n "$HOOK_EVENT" ] || HOOK_EVENT="Stop" + +PARALLEL_STATE_FILE=$(find_any_autopilot_state_file 2>/dev/null || true) +PARALLEL_MODE=$(resolve_parallel_mode "${PARALLEL_STATE_FILE:-}") + +if [ "$PARALLEL_MODE" != "off" ]; then + if [ "$HOOK_EVENT" != "SubagentStop" ]; then + # Main Stop (or missing event) under parallel: stand down. + if [ "$PARALLEL_MODE" = "metric-only" ]; then + echo "[SCOUT-CHECKPOINT] parallel stand-down (metric-only): would stand down on main Stop (hook_event=$HOOK_EVENT, parallel_mode=$PARALLEL_MODE); falling through to the serial path." >&2 + # metric-only: fall through to the existing serial logic below. + else + echo "[SCOUT-CHECKPOINT] parallel stand-down: enforcement is relocated to the executor SubagentStop under parallel_mode=on (hook_event=$HOOK_EVENT); the main Stop does not see the executor signal." >&2 + exit 0 + fi + else + # SubagentStop under parallel: enforce on the executor transcript. Cheap + # no-op on unrelated subagent stops (R-SUBSTOP-SERIAL) before any + # transcript I/O. + is_autopilot_context || exit 0 + # Prefer the orchestrator-written main_checkout_root (T-003) over the + # _psf_repo_root ancestor-walk for phase-state resolution: under a + # worktree the phase-state.yaml lives at the main-checkout path. scout's + # phase-state is OPTIONAL, so this only improves the Step 3 short-circuit + # accuracy; the 3-AND still fires when the file is absent. + if [ -n "${PARALLEL_STATE_FILE:-}" ] && [ -f "$PARALLEL_STATE_FILE" ]; then + MAIN_CHECKOUT_ROOT=$(parse_yaml_scalar "$PARALLEL_STATE_FILE" main_checkout_root 2>/dev/null || true) + if [ -n "${MAIN_CHECKOUT_ROOT:-}" ] && [ -d "$MAIN_CHECKOUT_ROOT" ]; then + STATE_FILE=$(find_phase_state_file "$MAIN_CHECKOUT_ROOT" 2>/dev/null || true) + fi + fi + fi +fi + # --- Step 3: if phase-state.yaml exists AND scout completed, silent exit --- # This is the only branch that consults phase-state.yaml. The hook MUST # also fire when the file is absent (legacy product_backlog ticket flow, diff --git a/tests/test-impl-checkpoint-guard.sh b/tests/test-impl-checkpoint-guard.sh index ec8f6f0..5ed4266 100755 --- a/tests/test-impl-checkpoint-guard.sh +++ b/tests/test-impl-checkpoint-guard.sh @@ -520,5 +520,250 @@ fi cleanup_session "$SID" rm -rf "$TMP" +# =========================================================================== +# T-005 — parallel-mode dual-event branch (Stop / SubagentStop) +# =========================================================================== +echo "" +echo "--- T-005 parallel-mode dual-event branch ---" + +# Write an autopilot-state.yaml under briefs/active// carrying an +# optional parallel_mode: scalar (and optionally main_checkout_root). Used to +# drive resolve_parallel_mode + the main_checkout_root preference. +make_parallel_autopilot_state() { + local repo_dir="$1" + local parent_slug="$2" + local parallel_mode="$3" + local main_checkout_root="${4:-}" + mkdir -p "$repo_dir/.simple-workflow/backlog/briefs/active/$parent_slug" + { + printf 'version: 1\n' + printf 'slug: %s\n' "$parent_slug" + printf 'parallel_mode: %s\n' "$parallel_mode" + [ -n "$main_checkout_root" ] && printf 'main_checkout_root: %s\n' "$main_checkout_root" + printf 'tickets:\n' + printf ' - logical_id: t1\n' + printf ' ticket_dir: 001-test\n' + printf ' status: in_progress\n' + printf ' steps:\n' + printf ' impl: in_progress\n' + } > "$repo_dir/.simple-workflow/backlog/briefs/active/$parent_slug/autopilot-state.yaml" +} + +# Executor transcript: an ac-evaluator subagent that emits an /audit-style +# block but has NO Skill(simple-workflow:impl) invocation — the existing +# Step 7 cross-session signature gate must silent-exit on it. +TRANSCRIPT_AC_EVALUATOR='{"type":"user","uuid":"u1","message":{"role":"user","content":[{"type":"text","text":"evaluate ACs"}]}} +{"type":"assistant","uuid":"a1","message":{"role":"assistant","content":[{"type":"text","text":"**Status**: PASS\n**Reports**:\n - eval: e.md\n**Summary**: all ACs pass"}]}}' + +# (T-PAR-1): SubagentStop + parallel=on + executor transcript (5-AND) → block. +echo "--- (T-PAR-1): SubagentStop + parallel=on + 5-AND → block ---" +TMP=$(mktemp -d) +make_phase_state "$TMP" "001-test" "$PHASE_STATE_NEEDS_AUDIT" +make_parallel_autopilot_state "$TMP" "par-brief" "on" +make_transcript "$TMP/transcript.jsonl" "$TRANSCRIPT_AUDIT_EMIT_NO_CHECKPOINT" +SID="impl-cp-tpar1-$$" +cleanup_session "$SID" +INPUT=$(jq -n --arg t "$TMP/transcript.jsonl" --arg s "$SID" \ + '{transcript_path: $t, session_id: $s, hook_event_name: "SubagentStop"}') +run_guard_hook "$INPUT" "$TMP" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +DECISION=$(echo "$LAST_STDOUT" | jq -r '.decision // ""' 2>/dev/null || echo "") +if [ "$DECISION" = "block" ] && [ "$LAST_EXIT_CODE" -eq 0 ]; then + echo -e " ${GREEN}PASS${NC} (T-PAR-1): SubagentStop enforces on executor transcript → decision=block" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} (T-PAR-1): expected decision=block, exit 0" + echo -e " Exit: $LAST_EXIT_CODE Decision: '$DECISION' Stderr: ${LAST_STDERR:0:200}" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +cleanup_session "$SID" +rm -rf "$TMP" + +# (T-PAR-2): Stop + parallel=on → stand down (exit 0, [IMPL-CHECKPOINT] log). +echo "--- (T-PAR-2): main Stop + parallel=on → stand down ---" +TMP=$(mktemp -d) +make_phase_state "$TMP" "001-test" "$PHASE_STATE_NEEDS_AUDIT" +make_parallel_autopilot_state "$TMP" "par-brief" "on" +make_transcript "$TMP/transcript.jsonl" "$TRANSCRIPT_AUDIT_EMIT_NO_CHECKPOINT" +SID="impl-cp-tpar2-$$" +cleanup_session "$SID" +INPUT=$(jq -n --arg t "$TMP/transcript.jsonl" --arg s "$SID" \ + '{transcript_path: $t, session_id: $s, hook_event_name: "Stop"}') +run_guard_hook "$INPUT" "$TMP" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +DECISION=$(echo "$LAST_STDOUT" | jq -r '.decision // ""' 2>/dev/null || echo "") +if [ "$LAST_EXIT_CODE" -eq 0 ] && [ "$DECISION" != "block" ] \ + && echo "$LAST_STDERR" | grep -qF '[IMPL-CHECKPOINT] parallel stand-down'; then + echo -e " ${GREEN}PASS${NC} (T-PAR-2): main Stop stands down → exit 0, no block, stand-down log" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} (T-PAR-2): expected exit 0 + no block + stand-down log" + echo -e " Exit: $LAST_EXIT_CODE Decision: '$DECISION' Stderr: ${LAST_STDERR:0:200}" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +cleanup_session "$SID" +rm -rf "$TMP" + +# (T-PAR-3): serial (parallel=off) SubagentStop + unrelated ac-evaluator +# transcript → silent-exit at the existing Step 7 signature gate. +echo "--- (T-PAR-3): serial SubagentStop + unrelated ac-evaluator → silent exit ---" +TMP=$(mktemp -d) +make_phase_state "$TMP" "001-test" "$PHASE_STATE_NEEDS_AUDIT" +# parallel=off explicitly (serial). The ac-evaluator transcript has the audit +# block but no Skill(simple-workflow:impl), so Step 7 silent-exits. +make_parallel_autopilot_state "$TMP" "par-brief" "off" +make_transcript "$TMP/transcript.jsonl" "$TRANSCRIPT_AC_EVALUATOR" +SID="impl-cp-tpar3-$$" +cleanup_session "$SID" +INPUT=$(jq -n --arg t "$TMP/transcript.jsonl" --arg s "$SID" \ + '{transcript_path: $t, session_id: $s, hook_event_name: "SubagentStop"}') +run_guard_hook "$INPUT" "$TMP" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +DECISION=$(echo "$LAST_STDOUT" | jq -r '.decision // ""' 2>/dev/null || echo "") +if [ "$LAST_EXIT_CODE" -eq 0 ] && [ -z "$LAST_STDOUT" ] \ + && ! echo "$LAST_STDERR" | grep -qF '[IMPL-CHECKPOINT] parallel stand-down'; then + echo -e " ${GREEN}PASS${NC} (T-PAR-3): serial SubagentStop → exit 0, empty stdout, no parallel log" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} (T-PAR-3): expected exit 0 + empty stdout + no parallel log" + echo -e " Exit: $LAST_EXIT_CODE Decision: '$DECISION' Stdout: '$LAST_STDOUT' Stderr: ${LAST_STDERR:0:200}" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +cleanup_session "$SID" +rm -rf "$TMP" + +# (T-PAR-4): non-autopilot SubagentStop → early exit 0, no transcript scan. +echo "--- (T-PAR-4): non-autopilot SubagentStop → exit 0 ---" +TMP=$(mktemp -d) +# NO autopilot-state.yaml at all → resolve_parallel_mode → off; impl Step 1 +# finds no phase-state.yaml → silent-exit before any transcript I/O. +mkdir -p "$TMP/.simple-workflow/backlog/active" +make_transcript "$TMP/transcript.jsonl" "$TRANSCRIPT_AUDIT_EMIT_NO_CHECKPOINT" +SID="impl-cp-tpar4-$$" +cleanup_session "$SID" +INPUT=$(jq -n --arg t "$TMP/transcript.jsonl" --arg s "$SID" \ + '{transcript_path: $t, session_id: $s, hook_event_name: "SubagentStop"}') +run_guard_hook "$INPUT" "$TMP" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +if [ "$LAST_EXIT_CODE" -eq 0 ] && [ -z "$LAST_STDOUT" ]; then + echo -e " ${GREEN}PASS${NC} (T-PAR-4): non-autopilot SubagentStop → exit 0, empty stdout" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} (T-PAR-4): expected exit 0 + empty stdout" + echo -e " Exit: $LAST_EXIT_CODE Stdout: '$LAST_STDOUT' Stderr: ${LAST_STDERR:0:200}" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +cleanup_session "$SID" +rm -rf "$TMP" + +# (T-PAR-5): missing hook_event_name + parallel=on → treated as Stop → stand down. +echo "--- (T-PAR-5): missing hook_event_name + parallel=on → treated as Stop (stand down) ---" +TMP=$(mktemp -d) +make_phase_state "$TMP" "001-test" "$PHASE_STATE_NEEDS_AUDIT" +make_parallel_autopilot_state "$TMP" "par-brief" "on" +make_transcript "$TMP/transcript.jsonl" "$TRANSCRIPT_AUDIT_EMIT_NO_CHECKPOINT" +SID="impl-cp-tpar5-$$" +cleanup_session "$SID" +# No hook_event_name field in the payload. +INPUT=$(jq -n --arg t "$TMP/transcript.jsonl" --arg s "$SID" \ + '{transcript_path: $t, session_id: $s}') +run_guard_hook "$INPUT" "$TMP" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +DECISION=$(echo "$LAST_STDOUT" | jq -r '.decision // ""' 2>/dev/null || echo "") +if [ "$LAST_EXIT_CODE" -eq 0 ] && [ "$DECISION" != "block" ] \ + && echo "$LAST_STDERR" | grep -qF '[IMPL-CHECKPOINT] parallel stand-down'; then + echo -e " ${GREEN}PASS${NC} (T-PAR-5): missing event treated as Stop → stand down" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} (T-PAR-5): expected exit 0 + no block + stand-down log" + echo -e " Exit: $LAST_EXIT_CODE Decision: '$DECISION' Stderr: ${LAST_STDERR:0:200}" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +cleanup_session "$SID" +rm -rf "$TMP" + +# (T-PAR-6): parallel=off main Stop → existing behaviour unchanged (block). +echo "--- (T-PAR-6): parallel=off main Stop → existing behaviour (block) ---" +TMP=$(mktemp -d) +make_phase_state "$TMP" "001-test" "$PHASE_STATE_NEEDS_AUDIT" +make_parallel_autopilot_state "$TMP" "par-brief" "off" +make_transcript "$TMP/transcript.jsonl" "$TRANSCRIPT_AUDIT_EMIT_NO_CHECKPOINT" +SID="impl-cp-tpar6-$$" +cleanup_session "$SID" +INPUT=$(jq -n --arg t "$TMP/transcript.jsonl" --arg s "$SID" \ + '{transcript_path: $t, session_id: $s, hook_event_name: "Stop"}') +run_guard_hook "$INPUT" "$TMP" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +DECISION=$(echo "$LAST_STDOUT" | jq -r '.decision // ""' 2>/dev/null || echo "") +if [ "$DECISION" = "block" ] && [ "$LAST_EXIT_CODE" -eq 0 ] \ + && ! echo "$LAST_STDERR" | grep -qF '[IMPL-CHECKPOINT] parallel'; then + echo -e " ${GREEN}PASS${NC} (T-PAR-6): parallel=off main Stop → decision=block, no parallel log" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} (T-PAR-6): expected decision=block, no parallel stderr" + echo -e " Exit: $LAST_EXIT_CODE Decision: '$DECISION' Stderr: ${LAST_STDERR:0:200}" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +cleanup_session "$SID" +rm -rf "$TMP" + +# (T-PAR-7): metric-only main Stop → log "would stand down" + fall through (block). +echo "--- (T-PAR-7): metric-only main Stop → log + fall through (block) ---" +TMP=$(mktemp -d) +make_phase_state "$TMP" "001-test" "$PHASE_STATE_NEEDS_AUDIT" +make_parallel_autopilot_state "$TMP" "par-brief" "metric-only" +make_transcript "$TMP/transcript.jsonl" "$TRANSCRIPT_AUDIT_EMIT_NO_CHECKPOINT" +SID="impl-cp-tpar7-$$" +cleanup_session "$SID" +INPUT=$(jq -n --arg t "$TMP/transcript.jsonl" --arg s "$SID" \ + '{transcript_path: $t, session_id: $s, hook_event_name: "Stop"}') +run_guard_hook "$INPUT" "$TMP" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +DECISION=$(echo "$LAST_STDOUT" | jq -r '.decision // ""' 2>/dev/null || echo "") +if [ "$DECISION" = "block" ] && [ "$LAST_EXIT_CODE" -eq 0 ] \ + && echo "$LAST_STDERR" | grep -qF '[IMPL-CHECKPOINT] parallel stand-down (metric-only)'; then + echo -e " ${GREEN}PASS${NC} (T-PAR-7): metric-only logs would-stand-down + falls through → block" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} (T-PAR-7): expected block + metric-only stand-down log" + echo -e " Exit: $LAST_EXIT_CODE Decision: '$DECISION' Stderr: ${LAST_STDERR:0:200}" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +cleanup_session "$SID" +rm -rf "$TMP" + +# (T-PAR-8): worktree phase-state resolution via main_checkout_root (AC-6). +# cwd is a worktree path with its OWN autopilot-state.yaml (carrying +# main_checkout_root pointing at the main checkout) but NO phase-state.yaml; +# the main checkout holds the phase-state.yaml. The impl guard must resolve +# phase-state from main_checkout_root, pass Step 1, and enforce on SubagentStop. +echo "--- (T-PAR-8): SubagentStop in worktree resolves phase-state via main_checkout_root → block ---" +MAIN=$(mktemp -d) +WT=$(mktemp -d) +# Main checkout: phase-state.yaml lives here (NOT in the worktree). +make_phase_state "$MAIN" "001-test" "$PHASE_STATE_NEEDS_AUDIT" +# Worktree: autopilot-state.yaml carrying main_checkout_root -> $MAIN; NO +# phase-state.yaml under the worktree's active/ dir. +mkdir -p "$WT/.simple-workflow/backlog/active" +make_parallel_autopilot_state "$WT" "par-brief" "on" "$MAIN" +make_transcript "$WT/transcript.jsonl" "$TRANSCRIPT_AUDIT_EMIT_NO_CHECKPOINT" +SID="impl-cp-tpar8-$$" +cleanup_session "$SID" +INPUT=$(jq -n --arg t "$WT/transcript.jsonl" --arg s "$SID" \ + '{transcript_path: $t, session_id: $s, hook_event_name: "SubagentStop"}') +run_guard_hook "$INPUT" "$WT" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +DECISION=$(echo "$LAST_STDOUT" | jq -r '.decision // ""' 2>/dev/null || echo "") +if [ "$DECISION" = "block" ] && [ "$LAST_EXIT_CODE" -eq 0 ]; then + echo -e " ${GREEN}PASS${NC} (T-PAR-8): worktree SubagentStop resolves phase-state via main_checkout_root → block" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} (T-PAR-8): expected decision=block (phase-state resolved from main_checkout_root)" + echo -e " Exit: $LAST_EXIT_CODE Decision: '$DECISION' Stderr: ${LAST_STDERR:0:200}" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +cleanup_session "$SID" +rm -rf "$MAIN" "$WT" + echo "" print_summary diff --git a/tests/test-scout-checkpoint-guard.sh b/tests/test-scout-checkpoint-guard.sh index 2ff4687..fdb99bc 100755 --- a/tests/test-scout-checkpoint-guard.sh +++ b/tests/test-scout-checkpoint-guard.sh @@ -754,5 +754,237 @@ fi cleanup_session "$SID" rm -rf "$TMP" +# =========================================================================== +# T-005 — parallel-mode dual-event branch (Stop / SubagentStop) +# =========================================================================== +echo "" +echo "--- T-005 parallel-mode dual-event branch ---" + +# Write an autopilot-state.yaml under briefs/active// carrying an +# optional parallel_mode: scalar (and optionally main_checkout_root). Used to +# drive resolve_parallel_mode + the main_checkout_root preference. Symmetric +# to the helper in tests/test-impl-checkpoint-guard.sh (peer uniformity). +make_parallel_autopilot_state() { + local repo_dir="$1" + local parent_slug="$2" + local parallel_mode="$3" + local main_checkout_root="${4:-}" + mkdir -p "$repo_dir/.simple-workflow/backlog/briefs/active/$parent_slug" + { + printf 'version: 1\n' + printf 'slug: %s\n' "$parent_slug" + printf 'parallel_mode: %s\n' "$parallel_mode" + [ -n "$main_checkout_root" ] && printf 'main_checkout_root: %s\n' "$main_checkout_root" + printf 'tickets:\n' + printf ' - logical_id: t1\n' + printf ' ticket_dir: 001-test\n' + printf ' status: in_progress\n' + printf ' steps:\n' + printf ' scout: in_progress\n' + } > "$repo_dir/.simple-workflow/backlog/briefs/active/$parent_slug/autopilot-state.yaml" +} + +# Executor transcript: an ac-evaluator subagent whose tail has NO plan2doc +# ssot-line and NO Skill(simple-workflow:scout) invocation — the existing +# Step 6/7 signature gate must silent-exit on it. +TRANSCRIPT_AC_EVALUATOR='{"type":"user","uuid":"u1","message":{"role":"user","content":[{"type":"text","text":"evaluate ACs"}]}} +{"type":"assistant","uuid":"a1","message":{"role":"assistant","content":[{"type":"text","text":"**Status**: PASS\n**Summary**: all ACs pass"}]}}' + +# (T-PAR-1): SubagentStop + parallel=on + executor transcript (3-AND) → block. +echo "--- (T-PAR-1): SubagentStop + parallel=on + 3-AND → block ---" +TMP=$(mktemp -d) +make_parallel_autopilot_state "$TMP" "par-brief" "on" +make_transcript "$TMP/transcript.jsonl" "$TRANSCRIPT_PLAN2DOC_EMIT_NO_CHECKPOINT" +SID="scout-cp-tpar1-$$" +cleanup_session "$SID" +INPUT=$(jq -n --arg t "$TMP/transcript.jsonl" --arg s "$SID" \ + '{transcript_path: $t, session_id: $s, hook_event_name: "SubagentStop"}') +run_guard_hook "$INPUT" "$TMP" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +DECISION=$(echo "$LAST_STDOUT" | jq -r '.decision // ""' 2>/dev/null || echo "") +if [ "$DECISION" = "block" ] && [ "$LAST_EXIT_CODE" -eq 0 ]; then + echo -e " ${GREEN}PASS${NC} (T-PAR-1): SubagentStop enforces on executor transcript → decision=block" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} (T-PAR-1): expected decision=block, exit 0" + echo -e " Exit: $LAST_EXIT_CODE Decision: '$DECISION' Stderr: ${LAST_STDERR:0:200}" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +cleanup_session "$SID" +rm -rf "$TMP" + +# (T-PAR-2): Stop + parallel=on → stand down (exit 0, [SCOUT-CHECKPOINT] log). +echo "--- (T-PAR-2): main Stop + parallel=on → stand down ---" +TMP=$(mktemp -d) +make_parallel_autopilot_state "$TMP" "par-brief" "on" +make_transcript "$TMP/transcript.jsonl" "$TRANSCRIPT_PLAN2DOC_EMIT_NO_CHECKPOINT" +SID="scout-cp-tpar2-$$" +cleanup_session "$SID" +INPUT=$(jq -n --arg t "$TMP/transcript.jsonl" --arg s "$SID" \ + '{transcript_path: $t, session_id: $s, hook_event_name: "Stop"}') +run_guard_hook "$INPUT" "$TMP" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +DECISION=$(echo "$LAST_STDOUT" | jq -r '.decision // ""' 2>/dev/null || echo "") +if [ "$LAST_EXIT_CODE" -eq 0 ] && [ "$DECISION" != "block" ] \ + && echo "$LAST_STDERR" | grep -qF '[SCOUT-CHECKPOINT] parallel stand-down'; then + echo -e " ${GREEN}PASS${NC} (T-PAR-2): main Stop stands down → exit 0, no block, stand-down log" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} (T-PAR-2): expected exit 0 + no block + stand-down log" + echo -e " Exit: $LAST_EXIT_CODE Decision: '$DECISION' Stderr: ${LAST_STDERR:0:200}" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +cleanup_session "$SID" +rm -rf "$TMP" + +# (T-PAR-3): serial (parallel=off) SubagentStop + unrelated ac-evaluator +# transcript → silent-exit at the existing signature gate. +echo "--- (T-PAR-3): serial SubagentStop + unrelated ac-evaluator → silent exit ---" +TMP=$(mktemp -d) +make_parallel_autopilot_state "$TMP" "par-brief" "off" +make_transcript "$TMP/transcript.jsonl" "$TRANSCRIPT_AC_EVALUATOR" +SID="scout-cp-tpar3-$$" +cleanup_session "$SID" +INPUT=$(jq -n --arg t "$TMP/transcript.jsonl" --arg s "$SID" \ + '{transcript_path: $t, session_id: $s, hook_event_name: "SubagentStop"}') +run_guard_hook "$INPUT" "$TMP" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +if [ "$LAST_EXIT_CODE" -eq 0 ] && [ -z "$LAST_STDOUT" ] \ + && ! echo "$LAST_STDERR" | grep -qF '[SCOUT-CHECKPOINT] parallel stand-down'; then + echo -e " ${GREEN}PASS${NC} (T-PAR-3): serial SubagentStop → exit 0, empty stdout, no parallel log" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} (T-PAR-3): expected exit 0 + empty stdout + no parallel log" + echo -e " Exit: $LAST_EXIT_CODE Stdout: '$LAST_STDOUT' Stderr: ${LAST_STDERR:0:200}" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +cleanup_session "$SID" +rm -rf "$TMP" + +# (T-PAR-4): non-autopilot SubagentStop → exit 0, no block (parallel resolves +# off because there is no autopilot-state.yaml; the signature gate exits 0). +echo "--- (T-PAR-4): non-autopilot SubagentStop → exit 0 ---" +TMP=$(mktemp -d) +mkdir -p "$TMP/.simple-workflow/backlog/active" +make_transcript "$TMP/transcript.jsonl" "$TRANSCRIPT_AC_EVALUATOR" +SID="scout-cp-tpar4-$$" +cleanup_session "$SID" +INPUT=$(jq -n --arg t "$TMP/transcript.jsonl" --arg s "$SID" \ + '{transcript_path: $t, session_id: $s, hook_event_name: "SubagentStop"}') +run_guard_hook "$INPUT" "$TMP" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +if [ "$LAST_EXIT_CODE" -eq 0 ] && [ -z "$LAST_STDOUT" ]; then + echo -e " ${GREEN}PASS${NC} (T-PAR-4): non-autopilot SubagentStop → exit 0, empty stdout" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} (T-PAR-4): expected exit 0 + empty stdout" + echo -e " Exit: $LAST_EXIT_CODE Stdout: '$LAST_STDOUT' Stderr: ${LAST_STDERR:0:200}" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +cleanup_session "$SID" +rm -rf "$TMP" + +# (T-PAR-5): missing hook_event_name + parallel=on → treated as Stop → stand down. +echo "--- (T-PAR-5): missing hook_event_name + parallel=on → treated as Stop (stand down) ---" +TMP=$(mktemp -d) +make_parallel_autopilot_state "$TMP" "par-brief" "on" +make_transcript "$TMP/transcript.jsonl" "$TRANSCRIPT_PLAN2DOC_EMIT_NO_CHECKPOINT" +SID="scout-cp-tpar5-$$" +cleanup_session "$SID" +# No hook_event_name field in the payload. +INPUT=$(jq -n --arg t "$TMP/transcript.jsonl" --arg s "$SID" \ + '{transcript_path: $t, session_id: $s}') +run_guard_hook "$INPUT" "$TMP" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +DECISION=$(echo "$LAST_STDOUT" | jq -r '.decision // ""' 2>/dev/null || echo "") +if [ "$LAST_EXIT_CODE" -eq 0 ] && [ "$DECISION" != "block" ] \ + && echo "$LAST_STDERR" | grep -qF '[SCOUT-CHECKPOINT] parallel stand-down'; then + echo -e " ${GREEN}PASS${NC} (T-PAR-5): missing event treated as Stop → stand down" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} (T-PAR-5): expected exit 0 + no block + stand-down log" + echo -e " Exit: $LAST_EXIT_CODE Decision: '$DECISION' Stderr: ${LAST_STDERR:0:200}" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +cleanup_session "$SID" +rm -rf "$TMP" + +# (T-PAR-6): parallel=off main Stop → existing behaviour unchanged (block). +echo "--- (T-PAR-6): parallel=off main Stop → existing behaviour (block) ---" +TMP=$(mktemp -d) +make_parallel_autopilot_state "$TMP" "par-brief" "off" +make_transcript "$TMP/transcript.jsonl" "$TRANSCRIPT_PLAN2DOC_EMIT_NO_CHECKPOINT" +SID="scout-cp-tpar6-$$" +cleanup_session "$SID" +INPUT=$(jq -n --arg t "$TMP/transcript.jsonl" --arg s "$SID" \ + '{transcript_path: $t, session_id: $s, hook_event_name: "Stop"}') +run_guard_hook "$INPUT" "$TMP" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +DECISION=$(echo "$LAST_STDOUT" | jq -r '.decision // ""' 2>/dev/null || echo "") +if [ "$DECISION" = "block" ] && [ "$LAST_EXIT_CODE" -eq 0 ] \ + && ! echo "$LAST_STDERR" | grep -qF '[SCOUT-CHECKPOINT] parallel'; then + echo -e " ${GREEN}PASS${NC} (T-PAR-6): parallel=off main Stop → decision=block, no parallel log" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} (T-PAR-6): expected decision=block, no parallel stderr" + echo -e " Exit: $LAST_EXIT_CODE Decision: '$DECISION' Stderr: ${LAST_STDERR:0:200}" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +cleanup_session "$SID" +rm -rf "$TMP" + +# (T-PAR-7): metric-only main Stop → log "would stand down" + fall through (block). +echo "--- (T-PAR-7): metric-only main Stop → log + fall through (block) ---" +TMP=$(mktemp -d) +make_parallel_autopilot_state "$TMP" "par-brief" "metric-only" +make_transcript "$TMP/transcript.jsonl" "$TRANSCRIPT_PLAN2DOC_EMIT_NO_CHECKPOINT" +SID="scout-cp-tpar7-$$" +cleanup_session "$SID" +INPUT=$(jq -n --arg t "$TMP/transcript.jsonl" --arg s "$SID" \ + '{transcript_path: $t, session_id: $s, hook_event_name: "Stop"}') +run_guard_hook "$INPUT" "$TMP" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +DECISION=$(echo "$LAST_STDOUT" | jq -r '.decision // ""' 2>/dev/null || echo "") +if [ "$DECISION" = "block" ] && [ "$LAST_EXIT_CODE" -eq 0 ] \ + && echo "$LAST_STDERR" | grep -qF '[SCOUT-CHECKPOINT] parallel stand-down (metric-only)'; then + echo -e " ${GREEN}PASS${NC} (T-PAR-7): metric-only logs would-stand-down + falls through → block" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} (T-PAR-7): expected block + metric-only stand-down log" + echo -e " Exit: $LAST_EXIT_CODE Decision: '$DECISION' Stderr: ${LAST_STDERR:0:200}" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +cleanup_session "$SID" +rm -rf "$TMP" + +# (T-PAR-8): SubagentStop in worktree reads main_checkout_root preference and +# still enforces (scout's phase-state is optional, so the 3-AND blocks even +# when the worktree carries no local phase-state.yaml). Symmetric to impl's +# worktree case; for scout the main_checkout_root preference only sharpens the +# Step 3 short-circuit, never gates enforcement. +echo "--- (T-PAR-8): SubagentStop in worktree (main_checkout_root) → block ---" +MAIN=$(mktemp -d) +WT=$(mktemp -d) +mkdir -p "$WT/.simple-workflow/backlog/active" +make_parallel_autopilot_state "$WT" "par-brief" "on" "$MAIN" +make_transcript "$WT/transcript.jsonl" "$TRANSCRIPT_PLAN2DOC_EMIT_NO_CHECKPOINT" +SID="scout-cp-tpar8-$$" +cleanup_session "$SID" +INPUT=$(jq -n --arg t "$WT/transcript.jsonl" --arg s "$SID" \ + '{transcript_path: $t, session_id: $s, hook_event_name: "SubagentStop"}') +run_guard_hook "$INPUT" "$WT" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +DECISION=$(echo "$LAST_STDOUT" | jq -r '.decision // ""' 2>/dev/null || echo "") +if [ "$DECISION" = "block" ] && [ "$LAST_EXIT_CODE" -eq 0 ]; then + echo -e " ${GREEN}PASS${NC} (T-PAR-8): worktree SubagentStop with main_checkout_root → block" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} (T-PAR-8): expected decision=block" + echo -e " Exit: $LAST_EXIT_CODE Decision: '$DECISION' Stderr: ${LAST_STDERR:0:200}" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +cleanup_session "$SID" +rm -rf "$MAIN" "$WT" + echo "" print_summary From 5ab02c274fbd5982036bb484f19bd75c7d9437b7 Mon Sep 17 00:00:00 2001 From: aimsise Date: Thu, 25 Jun 2026 11:27:22 +0900 Subject: [PATCH 06/10] feat(hooks): auto-compact wave-unit (per-wave-drained re-key) (T-006) Wave 2. post-ship resolves PARALLEL_MODE BEFORE the ship-detector early-exit and under parallel SUPPLANTS it (if/elif/else: off->ship-detector; metric-only->log+ship; on->_detect_wave_drained) so a drained write WITHOUT ship:completed still injects (M1). Dedup re-keyed to wave-{N}:{ts} (hyphen) so the shared %%:* / ##*: split is untouched. IS_LAST_TICKET generalized to cursor-based IS_LAST_WAVE. pre-next-scout stands down under parallel. parallel=off byte-identical (ship-detector + serial marker verbatim). Gate 5 gets a T-007 done/-move precondition note. Also lands the SHARED test-skill-contracts.sh CTs for BOTH Wave-2 guards: CT-PARALLEL-SUBSTOP-1 (T-005) + CT-AC-WAVE-1 (T-006, greps the executable G7_CURRENT_WAVE_LW not the comment-only IS_LAST_WAVE, fixing a verify-found vacuous grep). Verify: test-post-ship-state-auto-compact 41/41 (NEW file; M1 drained-without-ship, 5:1700+wave-2:1700 marker round-trip, AC-4c cursor IS_LAST with shipped Claude-Session: https://claude.ai/code/session_01Mw2bH4wbEPeebXsvSG6rWe --- hooks/post-ship-state-auto-compact.sh | 143 +++++- hooks/pre-next-scout-auto-compact.sh | 34 ++ tests/test-post-ship-state-auto-compact.sh | 480 +++++++++++++++++++++ tests/test-pre-next-scout-auto-compact.sh | 99 +++++ tests/test-skill-contracts.sh | 46 ++ 5 files changed, 789 insertions(+), 13 deletions(-) create mode 100755 tests/test-post-ship-state-auto-compact.sh diff --git a/hooks/post-ship-state-auto-compact.sh b/hooks/post-ship-state-auto-compact.sh index 30d4ec8..cd425a3 100755 --- a/hooks/post-ship-state-auto-compact.sh +++ b/hooks/post-ship-state-auto-compact.sh @@ -43,6 +43,28 @@ # short-circuits. Result: exactly one /compact per ticket boundary, injected by # this hook. # +# **Parallel wave re-key (T-006):** under `parallel_mode != off` (resolved +# via `resolve_parallel_mode` BEFORE the L126 ship-detector early-exit) the +# trigger re-keys from "a ship completed" to "the current wave just fully +# drained". The L126 `_detect_ship_completed_in_payload || exit 0` +# early-exit is SUPPLANTED by `_detect_wave_drained_in_payload` (greps the +# payload for a flat `wave_status: drained`), so a drained write that does +# NOT also carry `ship: completed` STILL proceeds and injects exactly once +# per wave — independent of how many ship writes preceded it. (Appending +# the wave detector AFTER L126 would silently kill auto-compaction under +# parallel — a drained-only write would exit at L126 first; the resolution +# MUST precede L126.) The Gate 7 dedup marker is re-keyed to +# `wave-{CURRENT_WAVE}:{ts}` (HYPHEN in the key, SINGLE colon) so the +# shared `%%:*`/`##*:` split is byte-UNTOUCHED (R-MARKER-SPLIT eliminated), +# and `IS_LAST_TICKET` generalizes to `IS_LAST_WAVE` +# (`current_wave + 1 >= wave_count && wave_status == drained`). Serial +# (`parallel_mode` absent/off) keeps the L126 ship-detector early-exit and +# the serial `{shipped_count}:{ts}` marker VERBATIM; `metric-only` parallel +# logs the would-be wave gate then takes the SERIAL path (serial marker + +# count-based last check). Every parallel-on behaviour change is inside a +# `$PARALLEL_MODE` guard (`= off` runs the original path untouched), so the +# off path is byte-identical (same decision, same exit, no new stderr). +# # Kill-switch (DEFAULT ON within autopilot context, shared with the # primary): # SW_AUTO_COMPACT_ON_SHIP_MODE unset (in autopilot) -> on (default) @@ -123,7 +145,49 @@ _detect_ship_completed_in_payload() { END { exit !found } ' } -_detect_ship_completed_in_payload "$TOOL_PAYLOAD" || exit 0 + +# T-006 wave-drained detector — a ~6-line sibling of +# _detect_ship_completed_in_payload. Greps the payload for a flat +# `wave_status: drained` transition (the single deterministic flip the +# single-writer orchestrator controls per wave). The same inline-flow +# terminators (`}` / `,`) are accepted as in the ship detector so a +# template-seeded flow-form `wave_status: drained` write is not missed. +_detect_wave_drained_in_payload() { + local payload="$1" + printf '%s' "$payload" | grep -qE '(^|[[:space:]])wave_status:[[:space:]]+drained([[:space:],}]|$)' +} + +# T-006 M1 (make-or-break): resolve PARALLEL_MODE *before* the L126 +# ship-detector early-exit so the parallel path NEVER hits the ship-only +# exit. Under `parallel_mode != off` the wave-drained detector SUPPLANTS +# the `_detect_ship_completed_in_payload || exit 0` gate — a +# `wave_status: drained` write that does NOT also carry `ship: completed` +# still proceeds (the N ship writes and the drained flip can be separate +# writes). Serial (parallel=off) keeps the L126 ship-detector early-exit +# VERBATIM: every parallel addition is inside `if [ "$PARALLEL_MODE" != +# "off" ]`, so with `parallel_mode` absent/off this hook behaves exactly +# as before (same decision, same exit code, NO new stderr line). +# +# `resolve_parallel_mode` reads `parallel_mode:` from the just-written +# state file (Gate 1 guaranteed `$TOOL_FILE_PATH` is the autopilot-state.yaml +# the orchestrator wrote); a missing/unreadable file resolves `off` +# (fail-closed), preserving the serial path. +PARALLEL_MODE="$(resolve_parallel_mode "$TOOL_FILE_PATH")" +if [ "$PARALLEL_MODE" = "off" ]; then + # Serial path — byte-identical L126 early-exit. + _detect_ship_completed_in_payload "$TOOL_PAYLOAD" || exit 0 +elif [ "$PARALLEL_MODE" = "metric-only" ]; then + # metric-only parallel: log the would-be wave gate, then take the SERIAL + # path (gate on ship-completed) so observation never changes behaviour. + echo "[POST-SHIP-STATE-AUTO-COMPACT] metric-only parallel: would gate on wave_status: drained instead of ship: completed" >&2 + _detect_ship_completed_in_payload "$TOOL_PAYLOAD" || exit 0 +else + # parallel_mode=on: the L126 ship-detector early-exit is SUPPLANTED by + # the wave-drained detector. A drained-only write (no ship: completed) + # still proceeds; a mid-wave ship: completed write whose wave_status is + # NOT drained exits here (defer to the wave barrier). + _detect_wave_drained_in_payload "$TOOL_PAYLOAD" || exit 0 +fi # Gate 3: autopilot context (defence-in-depth; Gate 1 already implies it). is_autopilot_context || exit 0 @@ -160,6 +224,18 @@ esac # fails open. Reading the persisted state guarantees a well-formed YAML # document with the canonical `tickets:` root key. The just-written # change is already reflected on disk by the time PostToolUse fires. +# +# T-006 / parallel precondition (joint obligation with T-007): under +# `parallel_mode=on` the inject fires on the `wave_status: drained` write, +# and Gate 5 still walks every `ship: completed` element here. The +# orchestrator (T-007) MUST therefore complete all ticket-to-`done/` moves +# for a wave BEFORE it writes `wave_status: drained` — otherwise a drained +# write whose state still lists a just-shipped ticket under `active/` would +# be (correctly, from this gate's view) flagged as a state-lie and the +# inject skipped. Skipping is the SAFE degradation (no auto-compact at that +# boundary; the FILE_COUNT loop guard + the next drained write recover), so +# this gate is left UNCHANGED — the ordering obligation lives in the T-007 +# wave-barrier write sequence, not here. REPO_ROOT="" if [ -n "$TOOL_FILE_PATH" ]; then REPO_ROOT="${TOOL_FILE_PATH%%/.simple-workflow/*}" @@ -406,6 +482,23 @@ if [ -n "$STATE_FILE_PATH" ] && [ -f "$STATE_FILE_PATH" ]; then # its output is the shipped ticket count. G7_SHIPPED_COUNT=$(parse_ticket_ship_dirs "$STATE_FILE_PATH" 2>/dev/null | grep -c . || true) G7_SHIPPED_COUNT="${G7_SHIPPED_COUNT:-0}" + # T-006 dedup re-key. Serial keeps the `{shipped_count}:{ts}` marker + # VERBATIM. ONLY under `parallel_mode = on` does the dedup key become + # `wave-{CURRENT_WAVE}:{ts}` — a HYPHEN in the key, a SINGLE colon — so + # the shared `%%:*` (key) / `##*:` (ts) split below is byte-UNTOUCHED + # and works identically for both forms (R-MARKER-SPLIT eliminated, not + # mitigated). `metric-only` parallel deliberately takes the SERIAL marker + # (it logged the would-be wave gate above and then ran the serial gate), + # so the re-key gates on `= on`, not `!= off`. `current_wave:` is a + # top-level scalar in the wave cursor (T-003); a missing/blank value + # resolves to `0` so the marker is still well-formed. The whole re-key + # lives inside the `= on` gate, so the serial marker is byte-identical. + G7_MARKER_KEY="$G7_SHIPPED_COUNT" + if [ "$PARALLEL_MODE" = "on" ]; then + G7_CURRENT_WAVE="$(parse_yaml_scalar "$STATE_FILE_PATH" current_wave 2>/dev/null || true)" + [ -n "$G7_CURRENT_WAVE" ] || G7_CURRENT_WAVE=0 + G7_MARKER_KEY="wave-${G7_CURRENT_WAVE}" + fi G7_ATTEMPT_FILE="$(dirname "$STATE_FILE_PATH")/.auto-compact-last-attempt" if [ -f "$G7_ATTEMPT_FILE" ]; then G7_PREV_LINE=$(cat "$G7_ATTEMPT_FILE" 2>/dev/null || echo "") @@ -414,12 +507,12 @@ if [ -n "$STATE_FILE_PATH" ] && [ -f "$STATE_FILE_PATH" ]; then G7_NOW_TS=$(date +%s) if [ -n "$G7_PREV_COUNT" ] && [ -n "$G7_PREV_TS" ] \ && [ "$G7_PREV_TS" -gt 0 ] 2>/dev/null \ - && [ "$G7_PREV_COUNT" = "$G7_SHIPPED_COUNT" ]; then + && [ "$G7_PREV_COUNT" = "$G7_MARKER_KEY" ]; then G7_AGE=$((G7_NOW_TS - G7_PREV_TS)) if [ "$G7_AGE" -ge 0 ] && [ "$G7_AGE" -le 300 ]; then - echo "[POST-SHIP-STATE-AUTO-COMPACT] loop-guard: shipped_count=${G7_SHIPPED_COUNT} unchanged since previous attempt ${G7_AGE}s ago. Skipping inject (test_simple_workflow24 double-compact fix)." >&2 - jq -n --arg cnt "$G7_SHIPPED_COUNT" --arg age "$G7_AGE" \ - '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:("auto-compact-on-ship: loop suspected — shipped_count (" + $cnt + ") unchanged since previous compact " + $age + "s ago. Skipping inject (shared loop-guard marker).")}}' + echo "[POST-SHIP-STATE-AUTO-COMPACT] loop-guard: marker key=${G7_MARKER_KEY} unchanged since previous attempt ${G7_AGE}s ago. Skipping inject (test_simple_workflow24 double-compact fix)." >&2 + jq -n --arg cnt "$G7_MARKER_KEY" --arg age "$G7_AGE" \ + '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:("auto-compact-on-ship: loop suspected — marker key (" + $cnt + ") unchanged since previous compact " + $age + "s ago. Skipping inject (shared loop-guard marker).")}}' exit 0 fi fi @@ -427,7 +520,7 @@ if [ -n "$STATE_FILE_PATH" ] && [ -f "$STATE_FILE_PATH" ]; then # Marker write must happen even when we proceed to inject so the # primary trigger can detect this boundary as already-handled on its # post-compact-resume PreToolUse(scout) fire. - echo "${G7_SHIPPED_COUNT}:$(date +%s)" > "$G7_ATTEMPT_FILE" 2>/dev/null || true + echo "${G7_MARKER_KEY}:$(date +%s)" > "$G7_ATTEMPT_FILE" 2>/dev/null || true # H7 fix: last-ticket detection. shipped_count == total_tickets means # this just-flipped `ship: completed` was for the FINAL ticket; the # orchestrator must run the post-loop completion phase (Split Autopilot @@ -436,20 +529,44 @@ if [ -n "$STATE_FILE_PATH" ] && [ -f "$STATE_FILE_PATH" ]; then # tickets the next-ticket preamble follows, so end_turn-now is correct. # The primary trigger never fires on the last ticket (no next /scout), # so this branch is safety-net-only. - G7_TOTAL_TICKETS=$(parse_ticket_statuses "$STATE_FILE_PATH" 2>/dev/null | wc -l | tr -d ' ') - G7_TOTAL_TICKETS="${G7_TOTAL_TICKETS:-0}" + # + # T-006 IS_LAST_TICKET → IS_LAST_WAVE generalization (positive + # correctness gain). Under parallel the count-based check + # (shipped_count == total_tickets) is WRONG: a failed/skipped ticket + # makes shipped_count < total_tickets on the last drained wave, so the + # post-loop instruction would never fire. The wave-cursor key keys off + # `current_wave + 1 >= wave_count && wave_status == drained` instead. + # Serial keeps the count-based IS_LAST_TICKET verbatim (inside `else`); + # `metric-only` parallel runs the serial count-based check too (it took + # the serial path above), so this gates on `= on`, not `!= off`. IS_LAST_TICKET=0 - if [ "$G7_SHIPPED_COUNT" -ge 1 ] 2>/dev/null \ - && [ "$G7_TOTAL_TICKETS" -ge 1 ] 2>/dev/null \ - && [ "$G7_SHIPPED_COUNT" = "$G7_TOTAL_TICKETS" ]; then - IS_LAST_TICKET=1 + if [ "$PARALLEL_MODE" = "on" ]; then + G7_WAVE_COUNT="$(parse_yaml_scalar "$STATE_FILE_PATH" wave_count 2>/dev/null || true)" + [ -n "$G7_WAVE_COUNT" ] || G7_WAVE_COUNT=0 + G7_WAVE_STATUS="$(parse_yaml_scalar "$STATE_FILE_PATH" wave_status 2>/dev/null || true)" + G7_CURRENT_WAVE_LW="${G7_CURRENT_WAVE:-0}" + [ -n "$G7_CURRENT_WAVE_LW" ] || G7_CURRENT_WAVE_LW=0 + if [ "$G7_CURRENT_WAVE_LW" -ge 0 ] 2>/dev/null \ + && [ "$G7_WAVE_COUNT" -ge 1 ] 2>/dev/null \ + && [ "$((G7_CURRENT_WAVE_LW + 1))" -ge "$G7_WAVE_COUNT" ] \ + && [ "$G7_WAVE_STATUS" = "drained" ]; then + IS_LAST_TICKET=1 + fi + else + G7_TOTAL_TICKETS=$(parse_ticket_statuses "$STATE_FILE_PATH" 2>/dev/null | wc -l | tr -d ' ') + G7_TOTAL_TICKETS="${G7_TOTAL_TICKETS:-0}" + if [ "$G7_SHIPPED_COUNT" -ge 1 ] 2>/dev/null \ + && [ "$G7_TOTAL_TICKETS" -ge 1 ] 2>/dev/null \ + && [ "$G7_SHIPPED_COUNT" = "$G7_TOTAL_TICKETS" ]; then + IS_LAST_TICKET=1 + fi fi # M4: preserve the shipped count for the audit-trail metrics write # below. The G7_* vars get unset on the next line; copy into a more # specific name so the inject branch can reference it. SHIPPED_COUNT_FOR_AUDIT="$G7_SHIPPED_COUNT" fi -unset G7_SHIPPED_COUNT G7_ATTEMPT_FILE G7_PREV_LINE G7_PREV_COUNT G7_PREV_TS G7_NOW_TS G7_AGE G7_TOTAL_TICKETS +unset G7_SHIPPED_COUNT G7_ATTEMPT_FILE G7_PREV_LINE G7_PREV_COUNT G7_PREV_TS G7_NOW_TS G7_AGE G7_TOTAL_TICKETS G7_MARKER_KEY G7_CURRENT_WAVE G7_WAVE_COUNT G7_WAVE_STATUS G7_CURRENT_WAVE_LW # metric-only branch. if [ "$MODE" = "metric-only" ]; then diff --git a/hooks/pre-next-scout-auto-compact.sh b/hooks/pre-next-scout-auto-compact.sh index 8ac8ec0..189d20c 100755 --- a/hooks/pre-next-scout-auto-compact.sh +++ b/hooks/pre-next-scout-auto-compact.sh @@ -46,6 +46,14 @@ # memory; the underlying trigger semantics are different but the user- # facing knob is the same. # +# **Parallel stand-down (T-006):** under `parallel_mode != off` (resolved +# via `resolve_parallel_mode`, Gate 2.5) this hook STANDS DOWN — a whole +# wave drains together and `/scout` runs inside the executor, so +# `post-ship-state-auto-compact.sh` (which re-keys to the `wave_status: +# drained` transition) is the SOLE wave-trigger. `parallel_mode=on` exits +# 0 immediately; `metric-only` logs "would stand down" and falls through; +# `off` (default) keeps the existing serial path byte-identical. +# # Queue-drain coordination (Sentinel + Stop hook yield + SessionStart resume): # Identical to the v6 design — only the trigger event moves. On successful # inject: touch `/.auto-compact-pending` (UNIX timestamp). @@ -86,6 +94,32 @@ SKILL_NAME=$(echo "$INPUT" | jq -r '.tool_input.skill // ""' 2>/dev/null || echo # invocations are ad-hoc and must not trigger auto-compact. is_autopilot_context || exit 0 +# Gate 2.5 (T-006 parallel stand-down — Peer-Set Uniformity / Gate 10): +# Under parallel execution a whole wave of N tickets drains together and +# `/scout` runs INSIDE the executor, so this PreToolUse(Skill:scout) hook +# never fires on the main transcript at all. To make `post-ship` the SOLE +# wave-trigger (the 2-peer auto-compact set adopts one consistent posture), +# this hook STANDS DOWN under `parallel_mode != off`. Every parallel +# addition is gated behind `!= off` so the serial path is byte-identical: +# with `parallel_mode` absent/off NO new stderr line is emitted and the +# existing Gate 3 → Gate 5 path runs verbatim. +# +# The state file is resolved here only to read `parallel_mode:`; the +# existing Gate 4 re-resolves it for the serial path, so this lookup does +# not perturb the off path. `resolve_parallel_mode` returns `off` for a +# missing/unreadable state file (fail-closed), preserving byte-identity. +PNS_PARALLEL_STATE_FILE="$(find_any_autopilot_state_file 2>/dev/null || true)" +PARALLEL_MODE="$(resolve_parallel_mode "$PNS_PARALLEL_STATE_FILE")" +if [ "$PARALLEL_MODE" != "off" ]; then + if [ "$PARALLEL_MODE" = "metric-only" ]; then + echo "[PRE-NEXT-SCOUT-AUTO-COMPACT] metric-only parallel: would stand down (post-ship is the sole wave-trigger); falling through to serial path" >&2 + else + echo "[PRE-NEXT-SCOUT-AUTO-COMPACT] parallel stand-down: post-ship-state-auto-compact.sh is the sole wave-trigger under parallel_mode=on. Skipping." >&2 + exit 0 + fi +fi +unset PNS_PARALLEL_STATE_FILE + # Gate 3: kill-switch resolution. Default `on` inside autopilot context. MODE="${SW_AUTO_COMPACT_ON_SHIP_MODE:-on}" case "$MODE" in diff --git a/tests/test-post-ship-state-auto-compact.sh b/tests/test-post-ship-state-auto-compact.sh new file mode 100755 index 0000000..3c31e9c --- /dev/null +++ b/tests/test-post-ship-state-auto-compact.sh @@ -0,0 +1,480 @@ +#!/usr/bin/env bash +# tests/test-post-ship-state-auto-compact.sh — T-006 wave-unit auto-compact +# coverage for hooks/post-ship-state-auto-compact.sh. +# +# The serial behaviour of this hook (the de-facto PRIMARY auto-compact +# trigger that fires on a `steps.ship: completed` write) is unchanged by +# T-006 and is covered by tests/test-skill-contracts.sh CT-AC-* plus the +# field-evidence regression suite. This file adds the PARALLEL wave-unit +# cases the T-006 ticket mandates: +# +# AC-1 parallel_mode=on + a `wave_status: drained` payload -> ONE inject. +# AC-2 (M1 — make-or-break) parallel_mode=on + a `wave_status: drained` +# payload that does NOT carry `ship: completed` -> still injects +# exactly once (proves the L126 `_detect_ship_completed_in_payload +# || exit 0` early-exit was SUPPLANTED under parallel, not bypassed). +# Conversely a mid-wave `ship: completed` payload whose `wave_status` +# is NOT drained -> NO inject (defer to the wave barrier). A grep +# asserts `resolve_parallel_mode` is invoked BEFORE the L126 line. +# AC-3 same-wave second `drained` write -> dedup via the `wave-{N}:` +# marker; the serial `{int}:{ts}` marker round-trips byte-identically +# (the shared `%%:*`/`##*:` split is untouched — asserted on BOTH +# `5:1700` and `wave-2:1700`). +# AC-4 IS_LAST_WAVE -> the post-loop-phase instruction text (identical to +# the serial last-ticket text), firing on `current_wave + 1 >= +# wave_count && wave_status == drained` even when a skipped ticket +# leaves shipped_count < total_tickets. +# AC-6 parallel_mode=off -> the serial `ship: completed` trigger is +# byte-identical (a drained-only payload does NOT inject off-path; +# a ship: completed payload DOES; no parallel stderr line is emitted). +# +# Hermeticity mirrors tests/test-pre-next-scout-auto-compact.sh: each case +# builds a self-contained autopilot sandbox under mktemp -d, seeds a +# brief-level autopilot-state.yaml + the shipped ticket dirs under +# backlog/done/ so Gate 5 (state-lie protection) passes, and routes the +# hook through a PATH-scoped `tmux` stub from tests/fixtures/tmux-stub.sh +# so the inject-keys library exercises the real tmux backend without a live +# tmux server. The stub honours SW_TEST_TMUX_SENDKEYS_RC / _CAPTURE_OUT. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/test-helper.sh" + +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +HOOK="$REPO_DIR/hooks/post-ship-state-auto-compact.sh" +TMUX_STUB="$SCRIPT_DIR/fixtures/tmux-stub.sh" + +if [ ! -x "$HOOK" ]; then + echo "ERROR: hook not executable: $HOOK" >&2 + exit 2 +fi +if [ ! -x "$TMUX_STUB" ]; then + echo "ERROR: tmux stub not executable: $TMUX_STUB" >&2 + exit 2 +fi + +# Hermetic PATH bin/ — `tmux` symlink to the fixture stub. Other binaries +# (jq, yq, python3, date, mktemp, cat, ...) inherit from the host PATH. +PATH_BIN="$(mktemp -d)" +ln -sf "$TMUX_STUB" "$PATH_BIN/tmux" +trap 'rm -rf "$PATH_BIN"' EXIT + +assert_eq() { + local description="$1" + local expected="$2" + local actual="$3" + TESTS_TOTAL=$((TESTS_TOTAL + 1)) + if [ "$expected" = "$actual" ]; then + echo -e " ${GREEN}PASS${NC} $description" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + echo -e " ${RED}FAIL${NC} $description" + echo -e " expected: $expected" + echo -e " actual: $actual" + TESTS_FAILED=$((TESTS_FAILED + 1)) + fi +} + +assert_contains() { + local description="$1" + local needle="$2" + local haystack="$3" + TESTS_TOTAL=$((TESTS_TOTAL + 1)) + if printf '%s' "$haystack" | grep -qF -- "$needle"; then + echo -e " ${GREEN}PASS${NC} $description" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + echo -e " ${RED}FAIL${NC} $description" + echo -e " expected substring: $needle" + echo -e " actual haystack: $haystack" + TESTS_FAILED=$((TESTS_FAILED + 1)) + fi +} + +assert_not_contains() { + local description="$1" + local needle="$2" + local haystack="$3" + TESTS_TOTAL=$((TESTS_TOTAL + 1)) + if printf '%s' "$haystack" | grep -qF -- "$needle"; then + echo -e " ${RED}FAIL${NC} $description" + echo -e " unexpected substring present: $needle" + echo -e " actual haystack: $haystack" + TESTS_FAILED=$((TESTS_FAILED + 1)) + else + echo -e " ${GREEN}PASS${NC} $description" + TESTS_PASSED=$((TESTS_PASSED + 1)) + fi +} + +assert_file_exists() { + local description="$1" + local path="$2" + TESTS_TOTAL=$((TESTS_TOTAL + 1)) + if [ -f "$path" ]; then + echo -e " ${GREEN}PASS${NC} $description" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + echo -e " ${RED}FAIL${NC} $description" + echo -e " expected file: $path" + TESTS_FAILED=$((TESTS_FAILED + 1)) + fi +} + +assert_file_absent() { + local description="$1" + local path="$2" + TESTS_TOTAL=$((TESTS_TOTAL + 1)) + if [ ! -f "$path" ]; then + echo -e " ${GREEN}PASS${NC} $description" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + echo -e " ${RED}FAIL${NC} $description" + echo -e " expected absent: $path" + TESTS_FAILED=$((TESTS_FAILED + 1)) + fi +} + +# Build a hermetic autopilot sandbox with a brief-level autopilot-state.yaml +# describing two tickets, both already in backlog/done/ (so Gate 5 state-lie +# protection passes for any `ship: completed` element). The wave cursor +# fields (parallel_mode / current_wave / wave_count / wave_status) are +# parameters so each case shapes the state document it needs. +# +# Sets globals: $LAST_SANDBOX, $LAST_STATE_DIR, $LAST_STATE_FILE, +# $LAST_ATTEMPT_FILE, $LAST_PENDING. +# +# Args: +# $1 label +# $2 parallel_mode (one of on / metric-only / off; "" = omit the key) +# $3 current_wave ("" = omit) +# $4 wave_count ("" = omit) +# $5 wave_status ("" = omit) +make_sandbox() { + local label="$1" pmode="$2" cwave="$3" wcount="$4" wstatus="$5" + LAST_SANDBOX=$(mktemp -d) + LAST_STATE_DIR="$LAST_SANDBOX/.simple-workflow/backlog/briefs/active/$label" + mkdir -p "$LAST_STATE_DIR" + LAST_STATE_FILE="$LAST_STATE_DIR/autopilot-state.yaml" + mkdir -p "$LAST_SANDBOX/.simple-workflow/backlog/done/$label/T-001" + mkdir -p "$LAST_SANDBOX/.simple-workflow/backlog/done/$label/T-002" + { + echo "parent_slug: $label" + [ -n "$pmode" ] && echo "parallel_mode: $pmode" + [ -n "$cwave" ] && echo "current_wave: $cwave" + [ -n "$wcount" ] && echo "wave_count: $wcount" + [ -n "$wstatus" ] && echo "wave_status: $wstatus" + cat < "$LAST_STATE_FILE" + LAST_ATTEMPT_FILE="$LAST_STATE_DIR/.auto-compact-last-attempt" + LAST_PENDING="$LAST_STATE_DIR/.auto-compact-pending" +} + +# Pipe a synthesised PostToolUse(Write) payload into the hook. The payload's +# `tool_input.content` is the just-written autopilot-state.yaml fragment; +# `file_path` is the on-disk state file (Gate 1 + Gate 5 read it). +# +# Args: +# $1 sandbox cwd +# $2 payload content (the YAML the orchestrator "wrote") +# $3 sendkeys_rc ("0" success) +# $4 capture_out (capture-pane echo; non-empty -> verify passes) +# +# Stores result in $LAST_EXIT_CODE / $LAST_STDOUT / $LAST_STDERR. +run_hook_for() { + local sandbox="$1" content="$2" sendkeys_rc="$3" capture_out="$4" + local payload + payload=$(jq -n --arg fp "$LAST_STATE_FILE" --arg c "$content" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:$c}}') + local stdout_file stderr_file + stdout_file=$(mktemp) + stderr_file=$(mktemp) + set +e + printf '%s' "$payload" | env -i \ + HOME="$HOME" \ + PATH="$PATH_BIN:$PATH" \ + TMUX="fake-socket,1,0" \ + TMUX_PANE="%test-pane" \ + TERM="${TERM:-xterm}" \ + SW_AUTO_COMPACT_ON_SHIP_MODE="on" \ + SW_TEST_TMUX_SENDKEYS_RC="$sendkeys_rc" \ + SW_TEST_TMUX_CAPTURE_OUT="$capture_out" \ + SW_INJECT_KEYS_VERIFY_SLEEP_MS=0 \ + bash -c "cd \"$sandbox\" && bash \"$HOOK\"" >"$stdout_file" 2>"$stderr_file" + LAST_EXIT_CODE=$? + set -e + LAST_STDOUT=$(cat "$stdout_file") + LAST_STDERR=$(cat "$stderr_file") + rm -f "$stdout_file" "$stderr_file" +} + +# A capture-pane line that echoes `/compact` back so the P1-1 verify passes +# and inject_keys returns rc=0 (a real inject). +CAP_OK="user@host > /compact (echoed back)" + +# Canonical YAML fragments used as Write payloads. +DRAINED_NO_SHIP=$'parallel_mode: on\ncurrent_wave: 0\nwave_count: 2\nwave_status: drained\n' +DRAINED_WITH_SHIP=$'parallel_mode: on\ncurrent_wave: 0\nwave_count: 2\nwave_status: drained\ntickets:\n - logical_id: T-001\n steps:\n ship: completed\n' +MID_WAVE_SHIP=$'parallel_mode: on\ncurrent_wave: 0\nwave_count: 2\nwave_status: running\ntickets:\n - logical_id: T-001\n steps:\n ship: completed\n' +SHIP_COMPLETED=$'tickets:\n - logical_id: T-001\n steps:\n ship: completed\n' + +echo "=== hooks/post-ship-state-auto-compact.sh — T-006 wave-unit auto-compact ===" +echo "" + +# --------------------------------------------------------------------------- +# AC-1: parallel_mode=on + a `wave_status: drained` payload -> ONE inject. +# --------------------------------------------------------------------------- +echo "--- AC-1: parallel_mode=on + wave_status: drained -> one inject ---" +make_sandbox ac1 on 0 2 drained +run_hook_for "$LAST_SANDBOX" "$DRAINED_WITH_SHIP" "0" "$CAP_OK" +assert_eq "AC-1: hook exits 0" "0" "$LAST_EXIT_CODE" +assert_file_exists "AC-1: inject fired -> .auto-compact-pending written" "$LAST_PENDING" +assert_contains "AC-1: success additionalContext emitted (state-write safety-net)" \ + "auto-compact-on-ship (state-write safety-net)" "$LAST_STDOUT" +# The wave-keyed marker was written with the `wave-{N}:` form. +assert_contains "AC-1: marker re-keyed to wave-0: form" \ + "wave-0:" "$(cat "$LAST_ATTEMPT_FILE" 2>/dev/null || echo MISSING)" +rm -rf "$LAST_SANDBOX" +echo "" + +# --------------------------------------------------------------------------- +# AC-2 (M1): parallel_mode=on + a `wave_status: drained` payload that does +# NOT carry `ship: completed` -> still injects exactly once. Proves the L126 +# ship-detector early-exit was SUPPLANTED, not bypassed. +# --------------------------------------------------------------------------- +echo "--- AC-2 (M1): parallel + drained WITHOUT ship: completed -> still injects ---" +make_sandbox ac2 on 0 2 drained +# Sanity: the payload genuinely lacks `ship: completed` so a regression +# that kept the L126 ship-detector gate would exit 0 with no inject. +assert_not_contains "AC-2: fixture payload carries NO ship: completed" \ + "ship: completed" "$DRAINED_NO_SHIP" +run_hook_for "$LAST_SANDBOX" "$DRAINED_NO_SHIP" "0" "$CAP_OK" +assert_eq "AC-2: hook exits 0" "0" "$LAST_EXIT_CODE" +assert_file_exists "AC-2: drained-only payload STILL injects -> .auto-compact-pending written" \ + "$LAST_PENDING" +assert_contains "AC-2: drained-only payload emits the wave inject additionalContext" \ + "auto-compact-on-ship (state-write safety-net)" "$LAST_STDOUT" +rm -rf "$LAST_SANDBOX" +echo "" + +# --------------------------------------------------------------------------- +# AC-2 (M1) — mechanical: resolve_parallel_mode is invoked BEFORE the L126 +# `_detect_ship_completed_in_payload || exit 0` line in the hook source. +# --------------------------------------------------------------------------- +echo "--- AC-2 (M1): resolve_parallel_mode precedes the ship-detector early-exit ---" +RESOLVE_LINE=$(grep -n 'PARALLEL_MODE="\$(resolve_parallel_mode "\$TOOL_FILE_PATH")"' "$HOOK" | head -1 | cut -d: -f1) +EARLYEXIT_LINE=$(grep -n '_detect_ship_completed_in_payload "\$TOOL_PAYLOAD" || exit 0' "$HOOK" | head -1 | cut -d: -f1) +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +if [ -n "$RESOLVE_LINE" ] && [ -n "$EARLYEXIT_LINE" ] && [ "$RESOLVE_LINE" -lt "$EARLYEXIT_LINE" ]; then + echo -e " ${GREEN}PASS${NC} AC-2: resolve_parallel_mode (L$RESOLVE_LINE) precedes the L126 ship-detector early-exit (L$EARLYEXIT_LINE)" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} AC-2: resolve (L${RESOLVE_LINE:-?}) must precede ship-detector early-exit (L${EARLYEXIT_LINE:-?})" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +# And the ship-detector early-exit is now inside a `parallel_mode = off` +# branch (serial-only), not the unconditional top-level gate. +assert_contains "AC-2: serial early-exit guarded by the off branch" \ + 'if [ "$PARALLEL_MODE" = "off" ]; then' "$(cat "$HOOK")" +echo "" + +# --------------------------------------------------------------------------- +# AC-2 (defer): parallel_mode=on + a mid-wave `ship: completed` payload whose +# `wave_status` is NOT drained -> NO inject (defer to the wave barrier). +# --------------------------------------------------------------------------- +echo "--- AC-2 (defer): parallel + mid-wave ship: completed, wave NOT drained -> no inject ---" +make_sandbox ac2d on 0 2 running +run_hook_for "$LAST_SANDBOX" "$MID_WAVE_SHIP" "0" "$CAP_OK" +assert_eq "AC-2 defer: hook exits 0" "0" "$LAST_EXIT_CODE" +assert_file_absent "AC-2 defer: NO inject (wave not drained) -> .auto-compact-pending absent" \ + "$LAST_PENDING" +assert_eq "AC-2 defer: NO additionalContext on stdout" "" "$LAST_STDOUT" +rm -rf "$LAST_SANDBOX" +echo "" + +# --------------------------------------------------------------------------- +# AC-3: same-wave second `drained` write dedups via the `wave-{N}:` marker. +# First write injects; a second drained write within the wave short-circuits +# at the loop-guard (marker key unchanged within 300s). +# --------------------------------------------------------------------------- +echo "--- AC-3: same-wave second drained write -> dedup via wave-{N}: marker ---" +make_sandbox ac3 on 1 3 drained +run_hook_for "$LAST_SANDBOX" "$DRAINED_NO_SHIP" "0" "$CAP_OK" # note: content's own wave_status flips it; first inject +# First write should have injected and written the wave-1: marker +# (current_wave=1 from the on-disk state file). +assert_contains "AC-3: first write wrote the wave-1: marker" \ + "wave-1:" "$(cat "$LAST_ATTEMPT_FILE" 2>/dev/null || echo MISSING)" +# Remove the .auto-compact-pending sentinel so Gate 6 (sentinel dedup) is +# NOT the thing that short-circuits — we want to prove the Gate 7 wave-keyed +# loop-guard is what dedups the second same-wave write. +rm -f "$LAST_PENDING" +run_hook_for "$LAST_SANDBOX" "$DRAINED_NO_SHIP" "0" "$CAP_OK" +assert_eq "AC-3: second same-wave write exits 0" "0" "$LAST_EXIT_CODE" +assert_contains "AC-3: second write short-circuits at the wave-keyed loop-guard" \ + "loop-guard: marker key=wave-1 unchanged" "$LAST_STDERR" +assert_file_absent "AC-3: second write did NOT re-inject (.auto-compact-pending absent)" \ + "$LAST_PENDING" +rm -rf "$LAST_SANDBOX" +echo "" + +# --------------------------------------------------------------------------- +# AC-3: marker-split round-trip — the shared `%%:*`/`##*:` split is byte- +# untouched and extracts key/ts correctly for BOTH the serial `{int}:{ts}` +# form AND the parallel `wave-{N}:{ts}` form (single colon, hyphen in key). +# --------------------------------------------------------------------------- +echo "--- AC-3: marker-split round-trips for serial 5:1700 AND parallel wave-2:1700 ---" +SERIAL_MARKER="5:1700" +S_KEY="${SERIAL_MARKER%%:*}"; S_TS="${SERIAL_MARKER##*:}" +assert_eq "AC-3: serial 5:1700 -> key extracts to 5" "5" "$S_KEY" +assert_eq "AC-3: serial 5:1700 -> ts extracts to 1700" "1700" "$S_TS" +PARALLEL_MARKER="wave-2:1700" +P_KEY="${PARALLEL_MARKER%%:*}"; P_TS="${PARALLEL_MARKER##*:}" +assert_eq "AC-3: parallel wave-2:1700 -> key extracts to wave-2" "wave-2" "$P_KEY" +assert_eq "AC-3: parallel wave-2:1700 -> ts extracts to 1700" "1700" "$P_TS" +echo "" + +# --------------------------------------------------------------------------- +# AC-4: IS_LAST_WAVE -> the post-loop-phase instruction. current_wave=1, +# wave_count=2 (1 + 1 >= 2) and wave_status=drained, so the FINAL-wave branch +# fires from the CURSOR alone (not from shipped_count). make_sandbox seeds 2/2 +# completed here, so this case proves the cursor drives IS_LAST; AC-4c below +# proves the positive correctness gain on the real edge (shipped_count < total +# via a skipped ticket — the case the old count-based serial check would miss). +# --------------------------------------------------------------------------- +echo "--- AC-4: IS_LAST_WAVE -> post-loop-phase instruction text ---" +make_sandbox ac4 on 1 2 drained +run_hook_for "$LAST_SANDBOX" "$DRAINED_NO_SHIP" "0" "$CAP_OK" +assert_eq "AC-4: hook exits 0" "0" "$LAST_EXIT_CODE" +assert_contains "AC-4: last-wave branch emits the FINAL-ticket post-loop instruction" \ + "was the FINAL ticket of this pipeline" "$LAST_STDOUT" +assert_contains "AC-4: last-wave instruction names the post-loop phase (Completion Report)" \ + "Complete the post-loop phase FIRST" "$LAST_STDOUT" +rm -rf "$LAST_SANDBOX" +echo "" + +# A non-last drained wave (current_wave=0, wave_count=2) takes the NON-last +# branch (end the turn now, no FINAL-ticket text). +echo "--- AC-4: non-last drained wave -> non-last (end-turn-now) instruction ---" +make_sandbox ac4b on 0 2 drained +run_hook_for "$LAST_SANDBOX" "$DRAINED_NO_SHIP" "0" "$CAP_OK" +assert_eq "AC-4: hook exits 0" "0" "$LAST_EXIT_CODE" +assert_not_contains "AC-4: non-last wave does NOT emit the FINAL-ticket text" \ + "was the FINAL ticket of this pipeline" "$LAST_STDOUT" +assert_contains "AC-4: non-last wave emits the end-turn-now instruction" \ + "end this turn now without proceeding to the next ticket" "$LAST_STDOUT" +rm -rf "$LAST_SANDBOX" +echo "" + +# --------------------------------------------------------------------------- +# AC-4c: the IS_LAST_WAVE positive correctness gain on the REAL edge — the +# cursor-based last-wave check fires even when shipped_count < total_tickets (a +# skipped ticket on the last drained wave). The old count-based serial check +# (`shipped_count == total_tickets`) would have MISSED the post-loop +# instruction here. make_sandbox seeds 2/2 completed, so this builds a +# 1-shipped + 1-skipped state directly (Wave-2 adversarial-verify gap fix). +# --------------------------------------------------------------------------- +echo "--- AC-4c: IS_LAST_WAVE fires with shipped_count(1) < total(2) (skipped ticket) ---" +AC4C_SB=$(mktemp -d) +AC4C_DIR="$AC4C_SB/.simple-workflow/backlog/briefs/active/ac4c" +mkdir -p "$AC4C_DIR" "$AC4C_SB/.simple-workflow/backlog/done/ac4c/T-001" +cat > "$AC4C_DIR/autopilot-state.yaml" <<'EOF' +parent_slug: ac4c +parallel_mode: on +current_wave: 1 +wave_count: 2 +wave_status: drained +total_tickets: 2 +tickets: + - logical_id: T-001 + ticket_dir: .simple-workflow/backlog/done/ac4c/T-001 + status: completed + steps: + scout: completed + impl: completed + ship: completed + - logical_id: T-002 + ticket_dir: .simple-workflow/backlog/active/ac4c/T-002 + status: skipped + steps: + scout: completed + impl: pending + ship: pending +EOF +LAST_STATE_FILE="$AC4C_DIR/autopilot-state.yaml" +run_hook_for "$AC4C_SB" "$DRAINED_NO_SHIP" "0" "$CAP_OK" +assert_eq "AC-4c: hook exits 0" "0" "$LAST_EXIT_CODE" +assert_contains "AC-4c: cursor-based last-wave fires despite shipped_count(1) < total(2)" \ + "was the FINAL ticket of this pipeline" "$LAST_STDOUT" +rm -rf "$AC4C_SB" +echo "" + +# --------------------------------------------------------------------------- +# AC-6: parallel_mode=off byte-identity. The serial `ship: completed` trigger +# is unchanged: a `ship: completed` payload DOES inject; a drained-only +# payload does NOT (the wave detector is never reached); no parallel stderr +# line is ever emitted. +# --------------------------------------------------------------------------- +echo "--- AC-6: parallel_mode=off + ship: completed -> serial inject (byte-identical) ---" +make_sandbox off1 off "" "" "" +run_hook_for "$LAST_SANDBOX" "$SHIP_COMPLETED" "0" "$CAP_OK" +assert_eq "AC-6: hook exits 0" "0" "$LAST_EXIT_CODE" +assert_file_exists "AC-6: off path injects on ship: completed -> .auto-compact-pending written" \ + "$LAST_PENDING" +assert_contains "AC-6: off path emits the serial state-write additionalContext" \ + "auto-compact-on-ship (state-write safety-net)" "$LAST_STDOUT" +# Serial marker form: `{shipped_count}:{ts}` — 2 tickets shipped -> key `2`, +# NOT a `wave-` prefix. +assert_contains "AC-6: off path wrote the serial {count}: marker (key=2)" \ + "2:" "$(cat "$LAST_ATTEMPT_FILE" 2>/dev/null || echo MISSING)" +assert_not_contains "AC-6: off path marker is NOT wave-keyed" \ + "wave-" "$(cat "$LAST_ATTEMPT_FILE" 2>/dev/null || echo NONE)" +assert_not_contains "AC-6: off path emits NO parallel stderr line" \ + "metric-only parallel" "$LAST_STDERR" +rm -rf "$LAST_SANDBOX" +echo "" + +# Off path + a drained-only payload (no ship: completed) -> NO inject (the +# wave detector is never reached on the serial path; the ship-detector +# early-exit fires verbatim). +echo "--- AC-6: parallel_mode=off + drained-only payload -> NO inject (serial gate) ---" +make_sandbox off2 off "" "" "" +run_hook_for "$LAST_SANDBOX" "$DRAINED_NO_SHIP" "0" "$CAP_OK" +assert_eq "AC-6: hook exits 0" "0" "$LAST_EXIT_CODE" +assert_file_absent "AC-6: off path does NOT inject on a drained-only payload" \ + "$LAST_PENDING" +assert_eq "AC-6: off path drained-only -> empty stdout" "" "$LAST_STDOUT" +rm -rf "$LAST_SANDBOX" +echo "" + +# metric-only parallel: logs the would-be wave gate and takes the SERIAL +# path (gate on ship: completed). A ship: completed payload still injects; +# the metric-only parallel stderr line is present. +echo "--- AC-6: parallel_mode=metric-only -> log + serial path ---" +make_sandbox mo1 metric-only 0 2 drained +run_hook_for "$LAST_SANDBOX" "$SHIP_COMPLETED" "0" "$CAP_OK" +assert_eq "AC-6: hook exits 0 under metric-only" "0" "$LAST_EXIT_CODE" +assert_contains "AC-6: metric-only emits the would-gate-on-wave log" \ + "metric-only parallel: would gate on wave_status: drained" "$LAST_STDERR" +assert_file_exists "AC-6: metric-only takes the serial path and injects on ship: completed" \ + "$LAST_PENDING" +rm -rf "$LAST_SANDBOX" +echo "" + +print_summary diff --git a/tests/test-pre-next-scout-auto-compact.sh b/tests/test-pre-next-scout-auto-compact.sh index d4f3ba4..77fbe28 100755 --- a/tests/test-pre-next-scout-auto-compact.sh +++ b/tests/test-pre-next-scout-auto-compact.sh @@ -189,6 +189,41 @@ run_hook_for() { rm -f "$stdout_file" "$stderr_file" } +# T-006 parallel stand-down runner. Same as run_hook_for but threads +# SW_PARALLEL_HOOKS_MODE into the hermetic env so the hook's Gate 2.5 +# parallel stand-down (resolve_parallel_mode) is exercised. Uses a +# capture-pane output that WOULD make inject succeed, so a non-stand-down +# (regression) would visibly write `.auto-compact-pending` and reach the +# success additionalContext — the stand-down assertion then proves the +# hook exited 0 BEFORE injecting. +run_hook_parallel() { + local sandbox="$1" + local parallel_mode="$2" + local payload + payload=$(printf '{"tool_input":{"skill":"simple-workflow:scout"}}') + local stdout_file stderr_file + stdout_file=$(mktemp) + stderr_file=$(mktemp) + set +e + printf '%s' "$payload" | env -i \ + HOME="$HOME" \ + PATH="$PATH_BIN:$PATH" \ + TMUX="fake-socket,1,0" \ + TMUX_PANE="%test-pane" \ + TERM="${TERM:-xterm}" \ + SW_AUTO_COMPACT_ON_SHIP_MODE="on" \ + SW_PARALLEL_HOOKS_MODE="$parallel_mode" \ + SW_TEST_TMUX_SENDKEYS_RC="0" \ + SW_TEST_TMUX_CAPTURE_OUT="user@host > /compact (echoed back)" \ + SW_INJECT_KEYS_VERIFY_SLEEP_MS=0 \ + bash -c "cd \"$sandbox\" && bash \"$HOOK\"" >"$stdout_file" 2>"$stderr_file" + LAST_EXIT_CODE=$? + set -e + LAST_STDOUT=$(cat "$stdout_file") + LAST_STDERR=$(cat "$stderr_file") + rm -f "$stdout_file" "$stderr_file" +} + echo "=== hooks/pre-next-scout-auto-compact.sh — P2-1 .next-compact-pending lifecycle ===" echo "" @@ -242,4 +277,68 @@ assert_contains "AC-3: failure additionalContext mentions session-start retry" rm -rf "$LAST_SANDBOX" echo "" +# --------------------------------------------------------------------------- +# T-006 AC-5: parallel_mode=on -> the hook STANDS DOWN (exits 0, no inject), +# so post-ship-state-auto-compact.sh is the sole wave-trigger. +# --------------------------------------------------------------------------- +echo "--- T-006 AC-5: parallel_mode=on -> stand down (exit 0, no inject) ---" +make_sandbox p_on +run_hook_parallel "$LAST_SANDBOX" "on" +assert_eq "T-006 AC-5: hook exits 0 under parallel_mode=on" "0" "$LAST_EXIT_CODE" +assert_contains "T-006 AC-5: stderr carries the parallel stand-down log" \ + "parallel stand-down" "$LAST_STDERR" +# Stand-down means NO inject -> neither sentinel nor the success +# additionalContext was produced. +assert_file_absent "T-006 AC-5: .auto-compact-pending NOT written (stood down before inject)" \ + "$LAST_STATE_DIR/.auto-compact-pending" +assert_eq "T-006 AC-5: no inject -> empty stdout (no additionalContext)" \ + "" "$LAST_STDOUT" +rm -rf "$LAST_SANDBOX" +echo "" + +# --------------------------------------------------------------------------- +# T-006 AC-5: parallel_mode=metric-only -> log "would stand down" and FALL +# THROUGH to the existing serial path (which injects on a real boundary). +# --------------------------------------------------------------------------- +echo "--- T-006 AC-5: parallel_mode=metric-only -> log + fall through to serial ---" +make_sandbox p_mo +run_hook_parallel "$LAST_SANDBOX" "metric-only" +assert_eq "T-006 AC-5: hook exits 0 under metric-only" "0" "$LAST_EXIT_CODE" +assert_contains "T-006 AC-5: stderr carries 'metric-only parallel: would stand down'" \ + "metric-only parallel: would stand down" "$LAST_STDERR" +# Fall-through means the serial path ran: a real boundary (T-001 shipped) +# with a successful inject writes `.auto-compact-pending` and emits the +# serial ticket-boundary additionalContext. +assert_file_exists "T-006 AC-5: metric-only falls through -> .auto-compact-pending written" \ + "$LAST_STATE_DIR/.auto-compact-pending" +assert_contains "T-006 AC-5: metric-only falls through -> serial additionalContext emitted" \ + "auto-compact-on-ship (ticket-boundary)" "$LAST_STDOUT" +rm -rf "$LAST_SANDBOX" +echo "" + +# --------------------------------------------------------------------------- +# T-006 AC-6: parallel_mode=off (explicit) -> existing serial behaviour, +# byte-identical (no stand-down log, injects as before). +# --------------------------------------------------------------------------- +echo "--- T-006 AC-6: parallel_mode=off -> existing serial behaviour (no new log) ---" +make_sandbox p_off +run_hook_parallel "$LAST_SANDBOX" "off" +assert_eq "T-006 AC-6: hook exits 0 under parallel_mode=off" "0" "$LAST_EXIT_CODE" +# Byte-identity: NO parallel stand-down stderr line of any kind. +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +if printf '%s' "$LAST_STDERR" | grep -qiE 'parallel stand-down|metric-only parallel'; then + echo -e " ${RED}FAIL${NC} T-006 AC-6: off path emits NO parallel stand-down log" + echo -e " actual stderr: $LAST_STDERR" + TESTS_FAILED=$((TESTS_FAILED + 1)) +else + echo -e " ${GREEN}PASS${NC} T-006 AC-6: off path emits NO parallel stand-down log" + TESTS_PASSED=$((TESTS_PASSED + 1)) +fi +assert_file_exists "T-006 AC-6: off path injects -> .auto-compact-pending written" \ + "$LAST_STATE_DIR/.auto-compact-pending" +assert_contains "T-006 AC-6: off path emits serial ticket-boundary additionalContext" \ + "auto-compact-on-ship (ticket-boundary)" "$LAST_STDOUT" +rm -rf "$LAST_SANDBOX" +echo "" + print_summary diff --git a/tests/test-skill-contracts.sh b/tests/test-skill-contracts.sh index b923000..aa6130b 100644 --- a/tests/test-skill-contracts.sh +++ b/tests/test-skill-contracts.sh @@ -10740,6 +10740,52 @@ assert_true \ "CT-PARALLEL-CURSOR-2 (resolve_parallel_mode helper): defined ($pcur2_resolver_def>=1) reads SW_PARALLEL_HOOKS_MODE ($pcur2_resolver_env>=1) exported ($pcur2_resolver_export>=1)" \ "$pcur2_result" +PAR_HOOKSJSON="$REPO_DIR/hooks/hooks.json" +PAR_SCOUTGUARD="$REPO_DIR/hooks/scout-checkpoint-guard.sh" +PAR_IMPLGUARD="$REPO_DIR/hooks/impl-checkpoint-guard.sh" +PAR_POSTSHIP="$REPO_DIR/hooks/post-ship-state-auto-compact.sh" +PAR_PRENEXT="$REPO_DIR/hooks/pre-next-scout-auto-compact.sh" + +# CT-PARALLEL-SUBSTOP-1 (T-005): the checkpoint guards relocate to SubagentStop. hooks.json gains a +# SubagentStop array with EXACTLY two top-level entries (impl + scout checkpoint guards as separate +# entries per the Anthropic ordering rule), autopilot-continue is ABSENT from it, and both guards +# carry the symmetric `[-CHECKPOINT] parallel stand-down` prose + read hook_event_name. +psub1_substop_len=$(jq -r '(.hooks.SubagentStop | length) // 0' "$PAR_HOOKSJSON" 2>/dev/null || echo 0) +psub1_has_impl=$(jq -r '[.hooks.SubagentStop[]?.hooks[]?.command] | any(test("impl-checkpoint-guard"))' "$PAR_HOOKSJSON" 2>/dev/null || echo false) +psub1_has_scout=$(jq -r '[.hooks.SubagentStop[]?.hooks[]?.command] | any(test("scout-checkpoint-guard"))' "$PAR_HOOKSJSON" 2>/dev/null || echo false) +psub1_no_apc=$(jq -r '[.hooks.SubagentStop[]?.hooks[]?.command] | all(test("autopilot-continue")|not)' "$PAR_HOOKSJSON" 2>/dev/null || echo false) +psub1_scout_sd=$(grep -cF '[SCOUT-CHECKPOINT] parallel stand-down' "$PAR_SCOUTGUARD" || true) +psub1_impl_sd=$(grep -cF '[IMPL-CHECKPOINT] parallel stand-down' "$PAR_IMPLGUARD" || true) +psub1_scout_he=$(grep -cF 'hook_event_name' "$PAR_SCOUTGUARD" || true) +psub1_impl_he=$(grep -cF 'hook_event_name' "$PAR_IMPLGUARD" || true) +psub1_result="false" +if [ "$psub1_substop_len" = "2" ] && [ "$psub1_has_impl" = "true" ] && [ "$psub1_has_scout" = "true" ] \ + && [ "$psub1_no_apc" = "true" ] && [ "$psub1_scout_sd" -ge 1 ] && [ "$psub1_impl_sd" -ge 1 ] \ + && [ "$psub1_scout_he" -ge 1 ] && [ "$psub1_impl_he" -ge 1 ]; then psub1_result="true"; fi +assert_true \ + "CT-PARALLEL-SUBSTOP-1 (checkpoint guards -> SubagentStop): hooks.json SubagentStop len ($psub1_substop_len=2) impl ($psub1_has_impl) scout ($psub1_has_scout) autopilot-continue-absent ($psub1_no_apc); scout stand-down ($psub1_scout_sd>=1) impl stand-down ($psub1_impl_sd>=1); scout hook_event ($psub1_scout_he>=1) impl hook_event ($psub1_impl_he>=1)" \ + "$psub1_result" + +# CT-AC-WAVE-1 (T-006): the auto-compact hooks re-key from per-ticket-boundary to per-wave-drained. +# post-ship carries the _detect_wave_drained_in_payload supplant detector + the wave-{N}: marker + +# IS_LAST_WAVE + the resolver; pre-next-scout carries the parallel stand-down + the resolver. +pacw1_drained_detector=$(grep -cF '_detect_wave_drained_in_payload' "$PAR_POSTSHIP" || true) +pacw1_wave_marker=$(grep -cF 'wave-${' "$PAR_POSTSHIP" || true) +# IS_LAST_WAVE: pin the EXECUTABLE last-wave arithmetic (G7_CURRENT_WAVE_LW, used only in the +# parallel `current_wave+1 >= wave_count && wave_status==drained` block that sets IS_LAST_TICKET), +# NOT the string "IS_LAST_WAVE" which appears only in comments — a deleted parallel last-wave block +# must flip this CT (the prior comment-only grep was vacuous; Wave-2 adversarial-verify finding). +pacw1_last_wave=$(grep -cF 'G7_CURRENT_WAVE_LW' "$PAR_POSTSHIP" || true) +pacw1_postship_resolver=$(grep -cF 'resolve_parallel_mode' "$PAR_POSTSHIP" || true) +pacw1_prenext_sd=$(grep -cF '[PRE-NEXT-SCOUT-AUTO-COMPACT] parallel stand-down' "$PAR_PRENEXT" || true) +pacw1_prenext_resolver=$(grep -cF 'resolve_parallel_mode' "$PAR_PRENEXT" || true) +pacw1_result="false" +if [ "$pacw1_drained_detector" -ge 1 ] && [ "$pacw1_wave_marker" -ge 1 ] && [ "$pacw1_last_wave" -ge 1 ] \ + && [ "$pacw1_postship_resolver" -ge 1 ] && [ "$pacw1_prenext_sd" -ge 1 ] && [ "$pacw1_prenext_resolver" -ge 1 ]; then pacw1_result="true"; fi +assert_true \ + "CT-AC-WAVE-1 (auto-compact wave-drained re-key): post-ship drained-detector ($pacw1_drained_detector>=1) wave-{N} marker ($pacw1_wave_marker>=1) last-wave-arith G7_CURRENT_WAVE_LW ($pacw1_last_wave>=1) resolver ($pacw1_postship_resolver>=1); pre-next-scout stand-down ($pacw1_prenext_sd>=1) resolver ($pacw1_prenext_resolver>=1)" \ + "$pacw1_result" + echo "" # ============================================================================= From c89f4e486f599dfde4f24291da645c3993c059d9 Mon Sep 17 00:00:00 2001 From: aimsise Date: Thu, 25 Jun 2026 20:56:25 +0900 Subject: [PATCH 07/10] feat(autopilot): wave scheduler + parallel_max + H2 cascade-skip carve-out fix (T-007) Turn the concurrency-1 executor-routed path (T-001) into real per-wave parallelism. Under PARALLEL_MODE == on the main loop iterates topological waves: build READY_k (resume-skip + per-wave dependency re-eval), pre-wave single-writer state write (in_progress), spawn min(|READY_k|, CONCURRENCY_CAP) ticket-executor subagents in ONE message behind a foreground barrier, post-wave single-writer state write (transcribe envelopes, fold dep-skips, drained) -- exactly two autopilot-state.yaml writes per ACTIVE wave, executors never write state. Oversized waves sub-batch lex-ordered (still two writes/wave). parallel_max= concurrency cap (arg > SW_PARALLEL_MAX_CONCURRENCY env > default 4); parallel_max=1 degenerates to serial. Wave-variant auto-compact exception (do NOT spawn WAVE_{k+1}). H2 FIX (HIGH): hooks/pre-state-transition.sh cascade-skip carve-out regex dependency_failed|dependency_skipped -> dependency_([^[:space:]]*_)?(failed|skipped). INTELLIGENT DOCUMENTED DEVIATION from the ticket-literal dependency_[^[:space:]]*_(failed|skipped): the literal would NOT match the bare dependency_failed form, breaking back-compat. Shipped form matches the slug-interpolated dependency_002-bar_failed (which a whole-wave in_progress run actively triggers) AND the bare tokens, rejects non-dependency reasons. Verified directly + CT-WAVE-5 drives the hook with a negative control. parallel=off / absent is byte-identical: the wave loop is added ONLY under PARALLEL_MODE == on; the serial sentences survive verbatim; [PARALLEL-MODE] line + parallel_mode: field + wave cursor are omitted when off. Adversarial 2-lens verify (PASS_WITH_NITS, no blockers) drove 3 fixes, all in these 6 files: (1) empty-wave resume skip -- a spawn-less wave does ZERO state writes, realising Phase 1 Step 5; (2) parallel_max= coercion WARNING gated to PARALLEL_MODE != off so a global SW_PARALLEL_MAX_CONCURRENCY=garbage cannot leak stderr into a serial rollback; (3) CT-WAVE-3 (B)harness-own sub-check co-located with the new knob line; + CT-WAVE-6 covers (1)+(2). Deferred nit (theoretical, no real autopilot reason triggers it): the H2 regex is unanchored substring-match (matches its original semantics); a ^...$ anchor is a self-documenting follow-up. Tests: skill-contracts 882/882 (CT-WAVE-1..6), state-transition-guard 16/16, path 145/145, accept-set-verify 32/32, ShellCheck clean, DECONTAM 0. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Mw2bH4wbEPeebXsvSG6rWe --- CLAUDE.md | 1 + agents/ticket-executor.md | 4 +- hooks/pre-state-transition.sh | 20 ++- skills/autopilot/SKILL.md | 33 ++++- skills/autopilot/references/state-file.md | 37 ++++- tests/test-skill-contracts.sh | 168 ++++++++++++++++++++++ 6 files changed, 250 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2155799..2c83e42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,7 @@ This rule was distilled from a v6.7.0 dogfood incident in which a verify hook ne - `SW_AASC_CORPUS_FLOOR` — default `256`. The ADVISORY threshold for the P3 thin-corpus NOTE in `hooks/accept-set-verify.sh` (a thinner A/U corpus is surfaced to stderr as `[ACCEPT-SET-VERIFY] advisory: ...`, NEVER blocked), applied ONLY to a `triggered=y ran=y` alphabet (A) or unicode-transform (U) boundary line — the axes whose mandated complement (the Unicode decimal-digit property across the BMP and astral planes) is naturally large. A descriptive corpus-size (e.g. `5-canonical-forms`) is parsed by its leading integer so an annotation cannot dodge the note. Lower it (e.g. `=8`) to silence the note on a legitimately small advertised accept-set; raise it to surface more sweeps. Honoured only when `SW_ACCEPT_SET_CONFORMANCE_MODE` is not `off`. - `SW_PARALLEL_TICKETS_MODE` — default `off`. The run kill switch for the run-scoped parallel ticket-execution path. Controls whether `/autopilot` (and a `/brief chain=on` chained run) routes the cross-ticket `/scout`→`/impl`→`/ship` pipeline through one `ticket-executor` subagent per ready ticket / topological wave, instead of the inline serial loop. Values: `off` (DEFAULT = the **prior-version behaviour**: every ticket runs inline in the main loop, strictly serial — no `ticket-executor` is spawned and every hook fires exactly as before, byte-identical to a no-directive run), `on` (force the executor-routed path even when no `parallel=` argument was supplied), `metric-only` (log the resolved wave plan + the `[PARALLEL-MODE]` line but execute the inline serial path). The per-run `parallel=` argument on `/autopilot` / `/brief` selects the same tri-state; this environment knob is the global override / kill switch and resolves **safe to `off`** on an unknown value (the same fail-safe direction as `uc=`), so a parallel path that misbehaves can be disabled from the shell without editing any invocation. The deterministic env-vs-argument precedence is applied by the parallel-mode resolver helper that the wave-aware hooks consult. This is a **(B) harness-own** substrate per `## Product/Language/Domain Agnosticism` — it governs the plugin's own orchestration engine (the `/autopilot` execution path), not the user's product, language, or domain. - `SW_PARALLEL_HOOKS_MODE` — default = follow the run's `parallel_mode:` state. The shared HOOK-side kill switch for the parallel-aware Stop / checkpoint / auto-compact rework. It is read by the single resolver `hooks/lib/parse-state-file.sh::resolve_parallel_mode `, whose precedence is `SW_PARALLEL_HOOKS_MODE` (env override) > `parallel_mode:` scalar in `autopilot-state.yaml` > `off`. Values: `on` (force every parallel-aware hook onto the wave-aware path), `metric-only` (observe — log the resolved mode but take the serial decision), `off` (force serial — the prior-version hook behaviour). When the env knob is UNSET (the default), the resolver follows the run-scoped `parallel_mode:` (so the per-run `parallel=` argument transitively drives the hooks); when SET, it overrides. An unknown / garbage value resolves **safe to `off`** (= serial / byte-identical, the proven path), and the resolver never returns empty — every ambiguity (unknown env, absent/unknown state scalar, missing state file) fails CLOSED to `off`. This is the canonical fail-safe direction the whole parallel feature uses (the same `unknown → off` the argument parser adopts). A **(B) harness-own** substrate per `## Product/Language/Domain Agnosticism` — it governs the plugin's own hook plumbing, not the user's product, language, or domain. +- `SW_PARALLEL_MAX_CONCURRENCY` — default `4`. The concurrency cap for the wave-parallel scheduler: the maximum number of `ticket-executor` subagents `/autopilot` spawns concurrently in a single message within one topological wave. A wave with more ready tickets than the cap is processed in lex-ordered sub-batches of at most this size, so the depth-2 executor fan-out (each executor's own `/impl`→`implementer`+`ac-evaluator` sub-spawns) never exceeds a bounded concurrency / token budget. Precedence: the per-run `parallel_max=` argument on `/autopilot` (case-insensitive `key=value`, integer ≥1) **over** this environment knob **over** the default `4` (arg > env > default — the same direction as `rounds=N`). A non-integer or `<1` value (in EITHER the argument or this env knob) coerces to the default `4` and emits a one-line `[PARALLEL-MODE] WARNING` to stderr naming the rejected value. This cap is **honoured only when `PARALLEL_MODE == on`** — on the serial / `metric-only` / `off` paths no executor is spawned concurrently, so the cap is inert (and never written to `autopilot-state.yaml`; it is re-resolved fresh on every `/autopilot` entry, never persisted). A **(B) harness-own** substrate per `## Product/Language/Domain Agnosticism` — it bounds the plugin's own orchestration fan-out (the `/autopilot` Agent-spawn engine), not the user's product, language, or domain. ## Language diff --git a/agents/ticket-executor.md b/agents/ticket-executor.md index eda91ec..afa3f1b 100644 --- a/agents/ticket-executor.md +++ b/agents/ticket-executor.md @@ -16,11 +16,13 @@ Your `tools:` field is intentionally omitted: you inherit the full parent tool i - `logical_id` — the ticket's logical id (e.g. `{parent-slug}-part-N`). - `parent_slug` — the parent slug. -- `ticket_dir` — the ticket dir path; the pipeline starts in `product_backlog/{parent-slug}/{NNN}-{slug}` and `/scout` moves it to `active/`. +- `ticket_dir` — the ticket dir path **rooted at the MAIN checkout** (`main_checkout_root`); the pipeline starts in `product_backlog/{parent-slug}/{NNN}-{slug}` and `/scout` moves it to `active/`. Under the Phase 2 wave scheduler (`PARALLEL_MODE == on`) you and your same-wave sibling executors run worktree-less on this main checkout — same-wave tickets are independent by construction (no `depends_on` among same-wave members), so their edits are disjoint-file by design. (Per-executor worktree isolation + the envelope `branch` / `head_sha` fields are T-008; they are NOT part of this contract.) - `target_branch` — the branch `/ship` targets (the repo default branch). - `uc` — the run-scoped orchestration mode to forward to `/impl`, present ONLY when the main loop resolved `UC_ORCH != off`. When absent, OMIT `uc=` from the `/impl` call so it is byte-identical to a default run. - `## Bound capabilities (per AC)` — the verbatim capability-binding block, present when the ticket carries a `### Capabilities` section. Pass it through to the pipeline skills unchanged; do NOT re-derive capability relevance yourself. +Under the Phase 2 wave scheduler (`PARALLEL_MODE == on`) the main loop spawns one executor **per ready ticket in the wave, concurrently in one message** (up to the `parallel_max=` concurrency cap), passing EACH executor its own copy of the four fields above (`logical_id`, `ticket_dir`, `uc={UC_ORCH}` when `≠ off`, the `## Bound capabilities (per AC)` block) plus `parent_slug` / `target_branch`. The fields are per-ticket — your envelope is keyed by your own `logical_id` so the single-writer main loop can transcribe each returned envelope into the matching `tickets[]` entry. You still NEVER write `autopilot-state.yaml`. + ## Pipeline (one ticket) Run these steps in order. On any step failure, STOP this ticket's pipeline (do not proceed to later steps), set `status`/`failure_reason`, and emit the envelope. The main loop handles dependents. diff --git a/hooks/pre-state-transition.sh b/hooks/pre-state-transition.sh index 8de56e4..5a512cc 100755 --- a/hooks/pre-state-transition.sh +++ b/hooks/pre-state-transition.sh @@ -37,8 +37,10 @@ # least one OTHER ticket in the SAME write payload (or the existing # state file on disk) is `pending` / `in_progress` AND the skipped # ticket does NOT carry an inline `override_skip: true` flag and -# does NOT carry a dependency-cascade `skip_reason` -# (`dependency_failed` / `dependency_skipped`) -> BLOCK. +# does NOT carry a dependency-cascade `skip_reason` matching the +# `dependency_` PREFIX form (`dependency__failed` / +# `dependency__skipped`; the bare-token +# `dependency_failed` / `dependency_skipped` still match) -> BLOCK. # # Rule 2 (`unauthorized_skip_with_forbidden_rationale`): # If the new content marks any ticket as `status: skipped` and a @@ -53,8 +55,14 @@ # indentation level as the ticket's `status:` line; a top-level or # comment-block placement does NOT count (NAC #3, AC #6 case (e)). # - Existing dependency-cascade skip logic is left intact -- a -# `skip_reason` containing `dependency_failed` / `dependency_skipped` -# bypasses Rule 1 (NAC #4). Rule 2 still applies on top. +# `skip_reason` matching the `dependency_` PREFIX form +# (`dependency__failed` / `dependency__skipped`, +# with the bare `dependency_failed` / `dependency_skipped` tokens +# still matching for back-compat) bypasses Rule 1 (NAC #4). The +# autopilot orchestrator interpolates the dep slug between +# `dependency_` and `_` (skills/autopilot/SKILL.md Dependency +# check), so the carve-out tolerates the slug. Rule 2 still applies +# on top. # - No AskUserQuestion path; the hook either allows or emits # decision: block (NAC #6). # - ASCII only (no non-ASCII characters in this script). @@ -475,7 +483,7 @@ fi remaining_plain=0 if [ "${#SKIPPED_PLAIN_REASONS[@]}" -gt 0 ]; then for reason in "${SKIPPED_PLAIN_REASONS[@]}"; do - if printf '%s' "$reason" | grep -qE 'dependency_failed|dependency_skipped'; then + if printf '%s' "$reason" | grep -qE 'dependency_([^[:space:]]*_)?(failed|skipped)'; then continue fi remaining_plain=$((remaining_plain + 1)) @@ -484,7 +492,7 @@ fi if [ "$remaining_plain" -gt 0 ]; then emit_block "unauthorized_skip_with_active_siblings" \ - "Cannot transition a ticket to status: skipped while a sibling is pending/in_progress without an explicit override_skip: true placed at the ticket level (and a non-forbidden skip_reason). Dependency-cascade skips (skip_reason containing dependency_failed / dependency_skipped) are exempt. See skills/autopilot/SKILL.md Per-ticket pipeline / Dependency check." + "Cannot transition a ticket to status: skipped while a sibling is pending/in_progress without an explicit override_skip: true placed at the ticket level (and a non-forbidden skip_reason). Dependency-cascade skips (skip_reason matching the dependency_ prefix form dependency__failed / dependency__skipped, bare dependency_failed / dependency_skipped still matching) are exempt. See skills/autopilot/SKILL.md Per-ticket pipeline / Dependency check." fi # --------------------------------------------------------------------------- diff --git a/skills/autopilot/SKILL.md b/skills/autopilot/SKILL.md index 2369c29..75d49da 100644 --- a/skills/autopilot/SKILL.md +++ b/skills/autopilot/SKILL.md @@ -74,6 +74,8 @@ Before detecting `{parent-slug}`, extract the optional `uc=` token from `$ARGUME After resolving `UC_ORCH`, similarly extract the optional `parallel=` token from `$ARGUMENTS` using the SAME case-insensitive `key=value` convention: scan for a `parallel=` token (key matched case-insensitively), accept `` ∈ `on` | `off` | `metric-only`, and **strip that token from `$ARGUMENTS`** so the parent-slug detection below is unaffected. Resolve `PARALLEL_MODE` from the stripped value; **when the `parallel=` token is absent, `PARALLEL_MODE = off`** (default). An unrecognised value resolves **safe to `off`** (the same fail-safe direction as `uc=`). **Emit the resolution line ONLY when `PARALLEL_MODE != off`** — the SHARED CONTRACT marker `[PARALLEL-MODE] mode={on|metric-only} active={y|n} reason=invocation` to stderr (`active=y` only when `mode=on`, else `active=n`), using `reason=invocation` here in Argument Parsing. **When `PARALLEL_MODE == off` (the default or absent token, or an unknown value coerced to off), emit NO `[PARALLEL-MODE]` line at all** — a non-parallel run is byte-identical to prior versions with zero new stderr noise. (This is a deliberate stricter-than-`uc=` posture: `[UC-ORCH-MODE]` is emitted even for `uc=off`, but the `parallel=off` lane is the load-bearing byte-identical opt-out / rollback path for the parallel feature, so it stays completely silent.) `PARALLEL_MODE` is **orthogonal** to `UC_ORCH` (composable: `/autopilot {slug} uc=on parallel=on`); it is carried through Phase 2 to select the per-ticket execution path (current inline serial branch vs one `ticket-executor` subagent per ticket) and recorded in the run-scoped state file (Phase 2 State file initialization). This is additive: with no `parallel=` token the resolution defaults to `off`, Phase 2 takes the current inline serial path, and behaviour is byte-identical to prior versions. A `SW_PARALLEL_TICKETS_MODE` environment knob (documented in `CLAUDE.md`) is the run kill switch that forces serial; its deterministic env-vs-argument precedence is applied by the parallel-mode resolver helper introduced alongside the wave-cursor schema. +After resolving `PARALLEL_MODE`, similarly extract the optional `parallel_max=` token from `$ARGUMENTS` using the SAME case-insensitive `key=value` convention: scan for a `parallel_max=` token (key matched case-insensitively), and **strip that token from `$ARGUMENTS`** so the parent-slug detection below is unaffected. Resolve the concurrency cap `CONCURRENCY_CAP` with **arg > env > default** precedence (the same direction as `/impl` `rounds=N`): the stripped `parallel_max=` argument takes priority, else the `SW_PARALLEL_MAX_CONCURRENCY` environment variable, else the **default `4`**. The accepted value is an **integer ≥ 1**; a **non-integer or `< 1` value** (in EITHER the argument or the env knob) is **coerced to the default `4`** (the token is still stripped from `$ARGUMENTS` regardless, so parent-slug detection is unaffected). **The coercion WARNING is emitted ONLY when `PARALLEL_MODE != off`** — a single one-line `[PARALLEL-MODE] WARNING parallel_max={rejected-value} invalid (non-integer or <1); using default 4` to stderr. **When `PARALLEL_MODE == off` (the byte-identical serial lane), NO `[PARALLEL-MODE] WARNING` is emitted even if `SW_PARALLEL_MAX_CONCURRENCY` carries a garbage value** — the cap is inert on the serial path, so a serial run stays byte-identical with zero new stderr (the same silent-when-`off` posture as the `[PARALLEL-MODE]` resolution marker above; a global `SW_PARALLEL_MAX_CONCURRENCY=garbage` must not leak a WARNING into an explicit `parallel=off` rollback run). `CONCURRENCY_CAP` is **honoured only when `PARALLEL_MODE == on`** (the wave scheduler); on the `metric-only` and `off` paths it is inert (no concurrent spawn). `parallel_max` is a per-invocation knob: it is resolved fresh on every `/autopilot` entry and is **NOT persisted** to `autopilot-state.yaml` (it is recomputed on resume, like the wave layering itself). Documented in `CLAUDE.md` as `SW_PARALLEL_MAX_CONCURRENCY`. + Parse the (uc- and parallel-stripped) `$ARGUMENTS`: extract `{parent-slug}` (first arg). `{parent-slug}` is the dir basename under `.simple-workflow/backlog/product_backlog/` (or brief slug under `briefs/active/`); legacy `{slug}` is interchangeable. Empty → see `## Error Handling`. ## Non-interactive orchestrator contract (3-tier, risk_tolerance-aware) @@ -140,17 +142,17 @@ re-emit the same block. 4. **Human override detection**: compare each gate in `autopilot-policy.yaml` to defaults for `risk_tolerance`. `conservative` defaults + `moderate` defaults: in [references/state-file.md](references/state-file.md). `aggressive` defaults: moderate + `aggressive ship_ci_pending.timeout_minutes: 60`, `aggressive constraints.max_total_rounds: 12`, `aggressive constraints.allow_breaking_changes: true`. Gate differs + `# kb-suggested` → `kb_override` else `human_override`. Render to `## Human Overrides` / `## KB Overrides`; `## Decisions Made` distinguishes `human_override` from `kb_override`. **Exclude `kb_override`** from `## Human Overrides`. No diff → "No human overrides detected." -5. **State recovery**: absent `autopilot-state.yaml` → `resume_mode = false`. Else `resume_mode = true`; emit `[RESUME] ...` summary (resume msg, execution mode, progress N/total, per-ticket status). **When `resume_mode == true`, READ the top-level `ultracode_mode:` scalar from the recovered `autopilot-state.yaml`** (use the same top-level YAML scalar read the rest of the pipeline relies on; `hooks/lib/parse-state-file.sh::parse_yaml_scalar ultracode_mode` is the canonical reader) and **reconstruct `UC_ORCH`** from it for the remainder of the run: a value of `on` / `off` / `metric-only` resolves directly, while a missing / `null` / empty value defaults `UC_ORCH = off` — this missing-field case only arises for a legacy state file written before `ultracode_mode` existed (a v9.0.0+ run always persists the field at state init, default `on`, so the on-by-default carries cleanly through resume; defaulting a field-less legacy state to `off` keeps that resume faithful to how it originally ran). Emit the SHARED CONTRACT resolution line `[UC-ORCH-MODE] mode={on|off|metric-only} active={y|n} reason=resume` to stderr (`active=y` only when `mode=on`), and add the reconstructed mode to the `[RESUME] ...` summary so the run-scoped orchestration mode is visible on resume. This re-read is what carries `uc` across auto-compact / resume without any hook change. **Likewise READ the top-level `parallel_mode:` scalar** from the recovered state (`hooks/lib/parse-state-file.sh::parse_yaml_scalar parallel_mode`) and **reconstruct `PARALLEL_MODE`** for the remainder of the run: `on` / `off` / `metric-only` resolves directly, while a missing / `null` / empty value defaults `PARALLEL_MODE = off`. **Only when `PARALLEL_MODE != off`**, emit the SHARED CONTRACT line `[PARALLEL-MODE] mode={on|metric-only} active={y|n} reason=resume` to stderr (`active=y` only when `mode=on`) and add the reconstructed parallel mode to the `[RESUME] ...` summary; **when `PARALLEL_MODE == off`, emit no `[PARALLEL-MODE]` line and add nothing to the `[RESUME] ...` summary** (a resumed non-parallel run is byte-identical to prior versions). This re-read carries the parallel execution mode across auto-compact / resume exactly as `ultracode_mode` carries `uc`. If `started` is older than 7 days, emit `[RESUME] WARNING` to delete `autopilot-state.yaml` and re-run. Carry `ticket_mapping`. Per-ticket: `completed` → skip (`[RESUME] Skipping {logical_id}: already completed`); `failed`/`skipped` → retry first non-completed; `in_progress` → re-run; `pending` → normal. If state recovery cannot continue (e.g. unparseable `autopilot-state.yaml`, hostile working tree detected during this step, or any other Phase 1 precondition that newly fails here), emit `[AUTOPILOT-POLICY] gate=unexpected_error action=stop reason=state_recovery_hard_stop` and write `## Stop Reason` with `tag: policy_gate_stop` plus a resume hint of the form `Resume after fixing X with: /autopilot {parent-slug}`; never escalate to `AskUserQuestion`. Any existing verbatim `ERROR:` / `[RESUME] WARNING` literal continues to be emitted alongside the new `policy_gate_stop` exit path. +5. **State recovery**: absent `autopilot-state.yaml` → `resume_mode = false`. Else `resume_mode = true`; emit `[RESUME] ...` summary (resume msg, execution mode, progress N/total, per-ticket status). **When `resume_mode == true`, READ the top-level `ultracode_mode:` scalar from the recovered `autopilot-state.yaml`** (use the same top-level YAML scalar read the rest of the pipeline relies on; `hooks/lib/parse-state-file.sh::parse_yaml_scalar ultracode_mode` is the canonical reader) and **reconstruct `UC_ORCH`** from it for the remainder of the run: a value of `on` / `off` / `metric-only` resolves directly, while a missing / `null` / empty value defaults `UC_ORCH = off` — this missing-field case only arises for a legacy state file written before `ultracode_mode` existed (a v9.0.0+ run always persists the field at state init, default `on`, so the on-by-default carries cleanly through resume; defaulting a field-less legacy state to `off` keeps that resume faithful to how it originally ran). Emit the SHARED CONTRACT resolution line `[UC-ORCH-MODE] mode={on|off|metric-only} active={y|n} reason=resume` to stderr (`active=y` only when `mode=on`), and add the reconstructed mode to the `[RESUME] ...` summary so the run-scoped orchestration mode is visible on resume. This re-read is what carries `uc` across auto-compact / resume without any hook change. **Likewise READ the top-level `parallel_mode:` scalar** from the recovered state (`hooks/lib/parse-state-file.sh::parse_yaml_scalar parallel_mode`) and **reconstruct `PARALLEL_MODE`** for the remainder of the run: `on` / `off` / `metric-only` resolves directly, while a missing / `null` / empty value defaults `PARALLEL_MODE = off`. **Only when `PARALLEL_MODE != off`**, emit the SHARED CONTRACT line `[PARALLEL-MODE] mode={on|metric-only} active={y|n} reason=resume` to stderr (`active=y` only when `mode=on`) and add the reconstructed parallel mode to the `[RESUME] ...` summary; **when `PARALLEL_MODE == off`, emit no `[PARALLEL-MODE]` line and add nothing to the `[RESUME] ...` summary** (a resumed non-parallel run is byte-identical to prior versions). This re-read carries the parallel execution mode across auto-compact / resume exactly as `ultracode_mode` carries `uc`. **Wave-aware resume (`PARALLEL_MODE == on`):** waves are **recomputed deterministically** from the (unchanged) dependency graph — NO wave index is persisted, so resume re-runs the level-synchronous Kahn layering and re-resolves `CONCURRENCY_CAP` from Argument Parsing (`parallel_max=` is likewise not persisted). The run **resumes from the first wave that still has a non-`completed` runnable ticket**; within each resumed wave, `READY_k` filters out already-`completed` tickets (`[RESUME] Skipping {logical_id}: already completed`) and re-runs the per-wave dependency re-eval (so a dependency that failed/skipped before the interruption still cascade-skips its dependents on resume). The wave cursor (`current_wave` / `wave_status`) is re-derived from the per-ticket terminal statuses, not read as authority (see [references/state-file.md](references/state-file.md) Wave-cursor resume semantics). If `started` is older than 7 days, emit `[RESUME] WARNING` to delete `autopilot-state.yaml` and re-run. Carry `ticket_mapping`. Per-ticket: `completed` → skip (`[RESUME] Skipping {logical_id}: already completed`); `failed`/`skipped` → retry first non-completed; `in_progress` → re-run; `pending` → normal. If state recovery cannot continue (e.g. unparseable `autopilot-state.yaml`, hostile working tree detected during this step, or any other Phase 1 precondition that newly fails here), emit `[AUTOPILOT-POLICY] gate=unexpected_error action=stop reason=state_recovery_hard_stop` and write `## Stop Reason` with `tag: policy_gate_stop` plus a resume hint of the form `Resume after fixing X with: /autopilot {parent-slug}`; never escalate to `AskUserQuestion`. Any existing verbatim `ERROR:` / `[RESUME] WARNING` literal continues to be emitted alongside the new `policy_gate_stop` exit path. ## Phase 2: Pipeline Execution ### State file initialization -Skip if `resume_mode = true`. Brief-level `autopilot-state.yaml` ≠ per-ticket `phase-state.yaml`. Write at `briefs/active/{parent-slug}/` (else `product_backlog/{parent-slug}/`); hooks also accept `briefs/done/{parent-slug}/`. Fields: `ticket_mapping`, per-ticket `ticket_dir:` + `status` + `steps` + `invocation_method` ∈ `skill`/`manual-bash`/`unknown`, append-only `runtime_metrics: []` (`hooks/autopilot-continue.sh` + `hooks/pre-compact-save.sh` only; skills MUST NOT write). **Also write the top-level field `ultracode_mode: {on|off|metric-only}` from the `UC_ORCH` resolved in Argument Parsing** — a run-scoped orchestration mode (sibling of `version` / `parent_slug` / `started` / `execution_mode` / `total_tickets` / `ticket_mapping` / `tickets`). It records the run-scoped continuity value so Phase 1 Step 5 can re-read it on resume; the default `UC_ORCH = on` writes `ultracode_mode: on` (round-trips to `on` on resume; an explicit `uc=off` writes `ultracode_mode: off`). This is run-state, NOT a permanent policy flag — it lives only in `autopilot-state.yaml` (moved to `briefs/done/` on completion), never in `autopilot-policy.yaml`. **Also write the top-level field `parallel_mode: {on|metric-only}` from the `PARALLEL_MODE` resolved in Argument Parsing, but ONLY when `PARALLEL_MODE != off`** — a sibling run-scoped field with the identical lifecycle (init-written, resume-replayed at Phase 1 Step 5, moved to `briefs/done/` on completion, never a policy flag). **When `PARALLEL_MODE == off` (the default), OMIT the `parallel_mode:` field entirely** so the state file is byte-identical to prior versions (which had no `parallel_mode` field); Phase 1 Step 5 reconstructs `off` from an absent / `null` field. This deliberately differs from `ultracode_mode:` (which is written even for `off`): the `parallel=off` lane carries the byte-identical-**state** guarantee of AC-1, so it must not add a field. Schema documented in [references/state-file.md](references/state-file.md). **MUST emit `tickets:` as a YAML list** of dash-prefixed `- logical_id: …` mappings — NOT a map keyed by `logical_id`. The map form silently bypasses the hook-layer skip-transition guard (`parse_proposed_tickets`) and the Stop-hook loop-guard counters (`parse_ticket_statuses`); field evidence `test_simple_workflow28`. Hook tolerance was added in WI-4 as a safety net only; SKILL prose remains the enforcement. Loop-guard emits `[AUTOPILOT-STALL] ...`. Schema invariants (including `tickets:` list-vs-map) + precedence + counters + kill switch + `boundary`/`stop_reason` domains in [references/state-file.md](references/state-file.md) + [references/stop-reason-taxonomy.md](references/stop-reason-taxonomy.md). +Skip if `resume_mode = true`. Brief-level `autopilot-state.yaml` ≠ per-ticket `phase-state.yaml`. Write at `briefs/active/{parent-slug}/` (else `product_backlog/{parent-slug}/`); hooks also accept `briefs/done/{parent-slug}/`. Fields: `ticket_mapping`, per-ticket `ticket_dir:` + `status` + `steps` + `invocation_method` ∈ `skill`/`manual-bash`/`unknown`, append-only `runtime_metrics: []` (`hooks/autopilot-continue.sh` + `hooks/pre-compact-save.sh` only; skills MUST NOT write). **Also write the top-level field `ultracode_mode: {on|off|metric-only}` from the `UC_ORCH` resolved in Argument Parsing** — a run-scoped orchestration mode (sibling of `version` / `parent_slug` / `started` / `execution_mode` / `total_tickets` / `ticket_mapping` / `tickets`). It records the run-scoped continuity value so Phase 1 Step 5 can re-read it on resume; the default `UC_ORCH = on` writes `ultracode_mode: on` (round-trips to `on` on resume; an explicit `uc=off` writes `ultracode_mode: off`). This is run-state, NOT a permanent policy flag — it lives only in `autopilot-state.yaml` (moved to `briefs/done/` on completion), never in `autopilot-policy.yaml`. **Also write the top-level field `parallel_mode: {on|metric-only}` from the `PARALLEL_MODE` resolved in Argument Parsing, but ONLY when `PARALLEL_MODE != off`** — a sibling run-scoped field with the identical lifecycle (init-written, resume-replayed at Phase 1 Step 5, moved to `briefs/done/` on completion, never a policy flag). **When `PARALLEL_MODE == off` (the default), OMIT the `parallel_mode:` field entirely** so the state file is byte-identical to prior versions (which had no `parallel_mode` field); Phase 1 Step 5 reconstructs `off` from an absent / `null` field. This deliberately differs from `ultracode_mode:` (which is written even for `off`): the `parallel=off` lane carries the byte-identical-**state** guarantee of AC-1, so it must not add a field. **The wave layering and the `parallel_max=` concurrency cap are NOT persisted** — only `parallel_mode:` (the tri-state) and the OPTIONAL observability wave cursor (`wave_count` / `current_wave` / `wave_status` / `main_checkout_root`, `PARALLEL_MODE != off` only) are written; the waves themselves and `CONCURRENCY_CAP` are **recomputed** from the dependency graph + Argument Parsing on every entry (resume re-derives them, never reads a persisted wave index). Schema documented in [references/state-file.md](references/state-file.md). **MUST emit `tickets:` as a YAML list** of dash-prefixed `- logical_id: …` mappings — NOT a map keyed by `logical_id`. The map form silently bypasses the hook-layer skip-transition guard (`parse_proposed_tickets`) and the Stop-hook loop-guard counters (`parse_ticket_statuses`); field evidence `test_simple_workflow28`. Hook tolerance was added in WI-4 as a safety net only; SKILL prose remains the enforcement. Loop-guard emits `[AUTOPILOT-STALL] ...`. Schema invariants (including `tickets:` list-vs-map) + precedence + counters + kill switch + `boundary`/`stop_reason` domains in [references/state-file.md](references/state-file.md) + [references/stop-reason-taxonomy.md](references/stop-reason-taxonomy.md). ### Split Execution Flow -Parse `SPLIT_PLAN` frontmatter + tickets, build dependency graph, run topological sort (lex tiebreak), emit `Processing order: {NNN-slug}` per ticket. Parsing/algorithm in [references/split-plan-parsing.md](references/split-plan-parsing.md). Edge-case ERROR literals (zero entries, cyclic `depends_on`) in `## Error Handling`. Single-ticket plans flow through the same path. **When `PARALLEL_MODE != off`, additionally emit the wave layering** — one `Wave {k}: {NNN-slug}, ...` line per topological level (the level-synchronous Kahn variant in [references/split-plan-parsing.md](references/split-plan-parsing.md)); `Processing order:` is unchanged (the waves concatenated in order, each read lex). At concurrency 1 (Phase 1) the wave lines are emit/test-only and do NOT change execution order. +Parse `SPLIT_PLAN` frontmatter + tickets, build dependency graph, run topological sort (lex tiebreak), emit `Processing order: {NNN-slug}` per ticket. Parsing/algorithm in [references/split-plan-parsing.md](references/split-plan-parsing.md). Edge-case ERROR literals (zero entries, cyclic `depends_on`) in `## Error Handling`. Single-ticket plans flow through the same path. **When `PARALLEL_MODE != off`, additionally emit the wave layering** — one `Wave {k}: {NNN-slug}, ...` line per topological level (the level-synchronous Kahn variant in [references/split-plan-parsing.md](references/split-plan-parsing.md)); `Processing order:` is unchanged (the waves concatenated in order, each read lex). At concurrency 1 (the executor-routed serial path) the wave lines are emit/test-only and do NOT change execution order. **When `PARALLEL_MODE == on`, the SAME wave layering DRIVES the wave-parallel scheduler** (`##### Wave-parallel pipeline` below): the main loop iterates wave-by-wave, spawning one `ticket-executor` per ready ticket in a wave concurrently (capped by `CONCURRENCY_CAP`) behind a foreground barrier — reusing this exact Kahn layering, no second graph. #### Per-ticket pipeline @@ -162,7 +164,7 @@ For each ticket in `PROCESSING_ORDER` (`i` = 0-based): 1. **Resume skip check** (`resume_mode = true` only): `completed` → skip with `[RESUME] Skipping ticket {logical_id}: already completed`; `skipped` → re-evaluate dependencies; `failed`/`in_progress` → resume from first non-completed step. -2. **Dependency check**: all `depends_on` must be `completed`. Any dep `failed`/`skipped` → this ticket `skipped` (reason `dependency_{dep-slug}_{status}`), record `[PIPELINE] {ticket-part}: skipped | reason=dependency_... | ticket-dir={ticket-dir}`, next ticket. Skip-transition invariant + `hooks/pre-state-transition.sh` enforcement in [references/state-file.md](references/state-file.md). +2. **Dependency check**: all `depends_on` must be `completed`. Any dep `failed`/`skipped` → this ticket `skipped` (reason `dependency_{dep-slug}_{status}` — the dep slug is interpolated BETWEEN `dependency_` and `_{status}`), record `[PIPELINE] {ticket-part}: skipped | reason=dependency_... | ticket-dir={ticket-dir}`, next ticket. The `hooks/pre-state-transition.sh` cascade-skip carve-out matches this slug-interpolated form via the `dependency_` PREFIX regex (the bare `dependency_failed` / `dependency_skipped` tokens still match for back-compat), so a cascade-skip is NOT blocked as `unauthorized_skip_with_active_siblings` even when same-wave siblings are `in_progress`. Skip-transition invariant + `hooks/pre-state-transition.sh` enforcement in [references/state-file.md](references/state-file.md). 3. **Execute pipeline** (ticket dir starts in `product_backlog/{parent-slug}/`; `/scout` moves it to `active/{parent-slug}/`): @@ -227,6 +229,29 @@ For each ticket in `PROCESSING_ORDER` (`i` = 0-based), at **concurrency 1** in P These are additive optional fields read by the parallel-aware hooks (the Phase 2 rework); they are recomputed on each entry and are a projection of the authoritative per-ticket `status`, never a second source of truth. Schema + resume semantics in [references/state-file.md](references/state-file.md). On the serial path (`PARALLEL_MODE == off`) none of these fields are written (byte-identical). +##### Wave-parallel pipeline (`PARALLEL_MODE == on`) + +Taken INSTEAD of the concurrency-1 executor loop above when `PARALLEL_MODE == on`. It replaces the flat `for each ticket in PROCESSING_ORDER` iteration with a `for each wave` iteration, using the EXACT in-degree/Kahn wave layering already computed in Split Execution Flow (the `Wave {k}: {NNN-slug}, ...` lines) — no second graph. `metric-only` does NOT take this path: it logs the `Wave {k}:` plan but executes the serial inline per-ticket loop (no executor, no concurrent spawn, no wave-cursor write). `parallel=off` (the default) adds NO code path here at all (byte-identical to prior versions — the serial loop is untouched). `CONCURRENCY_CAP` is the cap resolved in Argument Parsing (`parallel_max=` arg > `SW_PARALLEL_MAX_CONCURRENCY` env > default 4). + +For each wave `WAVE_k` (`k` = 0-based, in topological/level order): + +1. **Build `READY_k`** — start from `WAVE_k`'s members (lex order). For each ticket `T`: + - **Resume-skip**: `T` already `completed` → drop from `READY_k` (`[RESUME] Skipping {logical_id}: already completed`). + - **Per-wave dependency re-eval**: all `T`'s `depends_on` `completed` → `T` stays in `READY_k`. ANY dep `failed` / `skipped` → `T` is `skipped` with reason `dependency_{dep-slug}_{status}` (the SAME format and the SAME `[PIPELINE] {ticket-part}: skipped | reason=dependency_... | ticket-dir={ticket-dir}` log shape as the per-ticket Dependency check), folded into the post-wave write below, and NOT spawned. The dependency re-eval runs at EVERY wave (including every resumed wave), so a dep that failed in an earlier wave cascade-skips its later-wave dependents. + - **Empty-wave skip (resume)**: if `READY_k` has **no spawnable** (runnable, non-`completed`) ticket after the resume-skip + dependency re-eval — every member is already `completed`, or was just folded as a dependency-`skipped` — then **do NOT spawn** (skip steps 3-4) and **do NOT do the pre-wave `in_progress` write** (step 2, since nothing goes `in_progress`). If step 1 produced ANY NEW dependency-`skipped` fold for this wave (a ticket that was `pending` / `in_progress` in the persisted state is now `skipped`), persist those folds via a SINGLE state write (step 5 restricted to the folds, `wave_status` left `drained`); if step 1 produced no new fold (the wave is entirely already-`completed` / already-`skipped`), **skip the state write too** and go straight to step 6. A fully-completed earlier wave on resume therefore performs ZERO `autopilot-state.yaml` writes — this is what realises Phase 1 Step 5's "resume from the first wave that still has a non-completed *runnable* ticket" (the loop iterates from `WAVE_0`, but spawn-less waves cost nothing) and preserves "exactly two writes per ACTIVE (spawning) wave"; `current_wave` is advanced only by an ACTIVE wave's step 2. + +2. **Pre-wave single-writer state write** (main loop, SOLE writer — exactly the FIRST of the two writes per wave): set every `T ∈ READY_k` to `status: in_progress`, advance `current_wave = k`, set `wave_status: in_flight`. Emit the canonical FLAT schema (`steps.` string-valued on its own line; `tickets:` a YAML list). The concurrently-running executors NEVER write `autopilot-state.yaml`. + +3. **Spawn the wave concurrently** — spawn `min(|READY_k|, CONCURRENCY_CAP)` `simple-workflow:ticket-executor` subagents via the Agent tool **in ONE message** (foreground; the platform blocks the turn until ALL spawned executors return — Phase 0 Probe C confirmed multiple subagents in one message run concurrently behind a single foreground barrier). Each executor's spawn prompt passes verbatim: `logical_id`, `parent_slug`, `ticket_dir` (a MAIN-checkout path = `main_checkout_root` + ticket subpath), `target_branch`, `uc={UC_ORCH}` **only when `UC_ORCH != off`**, and the `## Bound capabilities (per AC)` block when the ticket carries a `### Capabilities` section. Do NOT invoke `/scout`/`/impl`/`/ship` directly here — each executor owns its ticket's pipeline. **Oversized wave** (`|READY_k| > CONCURRENCY_CAP`): process `READY_k` in **lex-ordered sub-batches** of at most `CONCURRENCY_CAP` tickets, each sub-batch spawned in its own ONE message and awaited at its own barrier, before starting the next sub-batch. The pre-wave write (step 2) happens ONCE before the first sub-batch; the post-wave write (step 5) happens ONCE after the LAST sub-batch — exactly two `autopilot-state.yaml` writes per wave regardless of sub-batch count. + +4. **Barrier** — do NOT proceed until EVERY spawned executor's `[TICKET-EXECUTOR-RESULT]` envelope has been received. A missing / unparseable envelope (an executor that hit its own limit) is treated as `failed` with `failure_reason = envelope_missing_or_malformed` (fail-closed). A `failed` executor does NOT abort the wave or the run — its same-wave siblings still run to completion; only later-wave dependents cascade-skip (via step 1's dependency re-eval on subsequent waves). + +5. **Post-wave single-writer state write** (main loop, SOLE writer — the SECOND of the two writes per wave): transcribe each returned envelope's `status` / `steps.{scout,impl,ship}` / `pr_url` into the matching `tickets[]` entry (canonical FLAT schema), fold in the wave's dependency-skips from step 1, and set `wave_status: drained`. This is the single point at which the wave's terminal statuses land — there is no concurrent-write window because the executors never wrote state. + +6. **Wave-tail checkpoint** — re-enter step 1 for `WAVE_{k+1}`. **Exit** to the post-loop phase (Split Autopilot Log → Completion Report → Brief Lifecycle → State File Cleanup → final `## [SW-CHECKPOINT]`) when all waves are drained / all tickets terminal. **Wave-variant AUTO-COMPACT EXCEPTION** (overrides the MUST-NOT-`end_turn` rule): if either auto-compact additionalContext payload of inline step e was injected during this wave (the `auto-compact-on-ship (state-write safety-net):` / `auto-compact-on-ship (ticket-boundary):` label immediately followed by `` `/compact` has been queued ``), end the turn immediately — and in ALL cases **do NOT spawn `WAVE_{k+1}`** (the wave analogue of "do NOT invoke the next `/scout`"); for the last-wave / `FINAL ticket of this pipeline` sub-variant, complete the post-loop phase FIRST, then end the turn. Resume picks up at the first non-drained wave (Phase 1 Step 5 wave-aware resume) after the `/compact` drains. + +**`parallel_max=1 ≡ serial` (regression anchor).** With `CONCURRENCY_CAP = 1`, each sub-batch holds exactly one ticket, so the wave loop spawns one executor at a time in `PROCESSING_ORDER` order and produces the SAME envelope/state sequence as the concurrency-1 executor-routed loop above — the wave iteration degenerates cleanly to T-001's concurrency-1 behaviour. + ### Split Autopilot Log Write overall `autopilot-log.md` at `briefs/active/{parent-slug}/` (or `briefs/done/` post-move; no brief dir → `product_backlog/{parent-slug}/`) AND per-ticket logs in each ticket dir (`done/...` if `/ship` Step 5 reached, else `active/...`). Per-ticket logs required. Frontmatter + per-ticket subsection + six common sections (`## Pipeline Execution`, `## Warnings`, `## Human Overrides`, `## KB Overrides`, `## Decisions Made`, `## Unreached Gates`) + Manual Bash Fallback rendering (`manual_bash_fallbacks[]` SSoT in `autopilot-state.yaml`; per-step `invocation_method == manual-bash` derived) live in [references/autopilot-log.md](references/autopilot-log.md). diff --git a/skills/autopilot/references/state-file.md b/skills/autopilot/references/state-file.md index 787ec35..3e86e87 100644 --- a/skills/autopilot/references/state-file.md +++ b/skills/autopilot/references/state-file.md @@ -290,6 +290,30 @@ convenience / observability projection of the authoritative per-ticket `status` — never a second source of truth. It moves to `briefs/done/` with the rest of the file on completion. +**Single-writer at the wave boundary (`PARALLEL_MODE == on`).** The +wave-parallel scheduler performs **exactly TWO** `autopilot-state.yaml` +writes per wave: a **pre-wave write** (`READY_k` → `in_progress`, +`current_wave` advanced, `wave_status: in_flight`) immediately before the +wave's executors are spawned, and a **post-wave write** (transcribe each +returned envelope's `status`/`steps`/`pr_url`, fold the wave's +dependency-skips, set `wave_status: drained`) immediately after the +foreground barrier collects every envelope. The concurrently-running +`ticket-executor` subagents NEVER write `autopilot-state.yaml` — the main +loop is the SOLE writer, so there is no concurrent-write window even though +the wave's members are `in_progress` in parallel. An oversized wave +(`|READY_k| > CONCURRENCY_CAP`) processes in lex-ordered sub-batches, but +the post-wave write still happens ONCE after the LAST sub-batch (the +two-writes-per-wave count is per wave, not per sub-batch). + +**`parallel_max` is NOT persisted.** The concurrency cap +(`parallel_max=` arg → `SW_PARALLEL_MAX_CONCURRENCY` env → default 4) is +a per-invocation knob: it is resolved fresh in Argument Parsing on every +`/autopilot` entry and is NEVER written to `autopilot-state.yaml`. Likewise +the wave layering itself is recomputed from the (unchanged) dependency graph +on every entry — no wave index is persisted. Only `wave_count` / +`current_wave` / `wave_status` / `main_checkout_root` (observability +projections) are written. + **No behaviour change yet.** These fields are additive and unread by any hook until the T-004/5/6 rework; a legacy or serial (`PARALLEL_MODE == off`) run omits them entirely (a byte-identical state file). @@ -452,8 +476,17 @@ exactly like any other gate divergence. `/autopilot` MUST NOT mark a ticket `skipped` while any sibling is `pending` or `in_progress`, unless one of: -- **Dependency cascade**: the ticket's `skip_reason` contains - `dependency_failed` or `dependency_skipped`. +- **Dependency cascade**: the ticket's `skip_reason` matches the + `dependency_` PREFIX form — `dependency__failed` or + `dependency__skipped` (the autopilot Dependency check + interpolates the dep slug between `dependency_` and `_`, so the + carve-out regex tolerates the slug). The bare-token `dependency_failed` / + `dependency_skipped` forms still match for back-compat. This is the H2 + fix: under `PARALLEL_MODE != off` a whole wave's members are + `in_progress` simultaneously, so a slug-interpolated cascade-skip like + `dependency_002-bar_failed` would have been BLOCKED as + `unauthorized_skip_with_active_siblings` had the carve-out stayed pinned + to the exact bare tokens. - **Explicit override**: `override_skip: true` appears at the same indentation as `status:` AND the `skip_reason` does NOT match any pattern in `hooks/lib/forbidden-rationale-patterns.sh`. diff --git a/tests/test-skill-contracts.sh b/tests/test-skill-contracts.sh index aa6130b..958e3c7 100644 --- a/tests/test-skill-contracts.sh +++ b/tests/test-skill-contracts.sh @@ -10786,6 +10786,174 @@ assert_true \ "CT-AC-WAVE-1 (auto-compact wave-drained re-key): post-ship drained-detector ($pacw1_drained_detector>=1) wave-{N} marker ($pacw1_wave_marker>=1) last-wave-arith G7_CURRENT_WAVE_LW ($pacw1_last_wave>=1) resolver ($pacw1_postship_resolver>=1); pre-next-scout stand-down ($pacw1_prenext_sd>=1) resolver ($pacw1_prenext_resolver>=1)" \ "$pacw1_result" +# CT-WAVE-1 (T-007: wave-parallel pipeline scheduler prose). /autopilot SKILL.md carries +# the new `##### Wave-parallel pipeline (PARALLEL_MODE == on)` subsection that builds READY_k, +# spawns min(|READY_k|, CONCURRENCY_CAP) executors in ONE message behind a foreground barrier, +# and sub-batches oversized waves lex-ordered. Split Execution Flow notes the wave layering +# DRIVES the scheduler under == on. +ctw1_subsection=$(grep -cF 'Wave-parallel pipeline (`PARALLEL_MODE == on`)' "$PAR_AUTOPILOT" || true) +ctw1_ready_k=$(grep -cF 'READY_k' "$PAR_AUTOPILOT" || true) +ctw1_one_message=$(grep -ciE 'in ONE message' "$PAR_AUTOPILOT" || true) +ctw1_min_cap=$(grep -cF 'min(|READY_k|, CONCURRENCY_CAP)' "$PAR_AUTOPILOT" || true) +ctw1_barrier=$(grep -ciE 'foreground barrier' "$PAR_AUTOPILOT" || true) +ctw1_subbatch=$(grep -ciE 'lex-ordered sub-batch' "$PAR_AUTOPILOT" || true) +ctw1_drives=$(grep -cF 'DRIVES the wave-parallel scheduler' "$PAR_AUTOPILOT" || true) +ctw1_result="false" +if [ "$ctw1_subsection" -ge 1 ] && [ "$ctw1_ready_k" -ge 1 ] && [ "$ctw1_one_message" -ge 1 ] \ + && [ "$ctw1_min_cap" -ge 1 ] && [ "$ctw1_barrier" -ge 1 ] && [ "$ctw1_subbatch" -ge 1 ] \ + && [ "$ctw1_drives" -ge 1 ]; then ctw1_result="true"; fi +assert_true \ + "CT-WAVE-1 (wave-parallel pipeline emit): subsection ($ctw1_subsection>=1) READY_k ($ctw1_ready_k>=1) one-message ($ctw1_one_message>=1) min-cap ($ctw1_min_cap>=1) barrier ($ctw1_barrier>=1) sub-batch ($ctw1_subbatch>=1) drives-scheduler ($ctw1_drives>=1)" \ + "$ctw1_result" + +# CT-WAVE-2 (T-007: single-writer at the wave boundary = exactly two writes per wave). Both +# SKILL.md and state-file.md carry the pre-wave/post-wave two-writes-per-wave contract; the +# executor NEVER writes autopilot-state.yaml; the post-wave write happens ONCE after the last +# sub-batch (sub-batch count does not multiply the write count). +ctw2_skill_pre=$(grep -ciE 'Pre-wave single-writer state write' "$PAR_AUTOPILOT" || true) +ctw2_skill_post=$(grep -ciE 'Post-wave single-writer state write' "$PAR_AUTOPILOT" || true) +ctw2_statefile_two=$(grep -ciE 'exactly \*\*TWO\*\*|exactly TWO' "$PAR_STATEFILE" || true) +ctw2_statefile_never=$(grep -ciE 'NEVER write|never write' "$PAR_STATEFILE" || true) +ctw2_skill_once=$(grep -ciE 'ONCE after the LAST sub-batch' "$PAR_AUTOPILOT" || true) +ctw2_result="false" +if [ "$ctw2_skill_pre" -ge 1 ] && [ "$ctw2_skill_post" -ge 1 ] && [ "$ctw2_statefile_two" -ge 1 ] \ + && [ "$ctw2_statefile_never" -ge 1 ] && [ "$ctw2_skill_once" -ge 1 ]; then ctw2_result="true"; fi +assert_true \ + "CT-WAVE-2 (single-writer at wave boundary): SKILL pre-write ($ctw2_skill_pre>=1) post-write ($ctw2_skill_post>=1); state-file exactly-two ($ctw2_statefile_two>=1) executor-never-writes ($ctw2_statefile_never>=1); post-write-once-after-last-sub-batch ($ctw2_skill_once>=1)" \ + "$ctw2_result" + +# CT-WAVE-3 (T-007: parallel_max= parse + arg>env>default precedence + coercion). SKILL.md +# Argument Parsing documents the parallel_max= token (case-insensitive key=value, integer >=1, +# default 4, non-integer/<1 -> default 4 + [PARALLEL-MODE] WARNING) with arg > env > default +# precedence; CLAUDE.md documents SW_PARALLEL_MAX_CONCURRENCY as (B) harness-own. +ctw3_arg_token=$(grep -cF 'parallel_max=' "$PAR_AUTOPILOT" || true) +ctw3_precedence=$(grep -ciE 'arg > env > default|arg .* env .* default' "$PAR_AUTOPILOT" || true) +ctw3_default4=$(grep -ciE 'default .*4' "$PAR_AUTOPILOT" || true) +ctw3_warning=$(grep -cF '[PARALLEL-MODE] WARNING' "$PAR_AUTOPILOT" || true) +ctw3_claude_knob=$(grep -cF 'SW_PARALLEL_MAX_CONCURRENCY' "$PAR_CLAUDEMD" || true) +# Co-locate the (B) harness-own label WITH the new knob line (the SW_PARALLEL_MAX_CONCURRENCY +# entry is a single physical line carrying both tokens) so dropping the label on THIS entry while +# pre-existing entries keep theirs still fails the sub-check (not a bare repo-wide harness-own count). +ctw3_claude_bsub=$(grep -iE 'SW_PARALLEL_MAX_CONCURRENCY' "$PAR_CLAUDEMD" | grep -ciE 'harness-own' || true) +ctw3_result="false" +if [ "$ctw3_arg_token" -ge 1 ] && [ "$ctw3_precedence" -ge 1 ] && [ "$ctw3_default4" -ge 1 ] \ + && [ "$ctw3_warning" -ge 1 ] && [ "$ctw3_claude_knob" -ge 1 ] && [ "$ctw3_claude_bsub" -ge 1 ]; then ctw3_result="true"; fi +assert_true \ + "CT-WAVE-3 (parallel_max= parse + precedence + coercion): arg token ($ctw3_arg_token>=1) precedence ($ctw3_precedence>=1) default-4 ($ctw3_default4>=1) WARNING ($ctw3_warning>=1); CLAUDE.md knob ($ctw3_claude_knob>=1) (B)harness-own ($ctw3_claude_bsub>=1)" \ + "$ctw3_result" + +# CT-WAVE-4 (T-007: cap-1 == serial regression anchor + wave auto-compact exception + parallel=off +# byte-identical fork + per-wave dependency reason format). The wave loop with CONCURRENCY_CAP=1 +# degenerates to the concurrency-1 serial sequence; the wave-variant auto-compact exception emits +# the "do NOT spawn WAVE_{k+1}" string; the parallel=off serial fork is present (byte-identical); +# the per-wave dependency re-eval reuses dependency_{dep-slug}_{status}. +ctw4_cap1=$(grep -ciE 'parallel_max=1 . serial|CONCURRENCY_CAP = 1' "$PAR_AUTOPILOT" || true) +ctw4_ac_exception=$(grep -cF 'do NOT spawn `WAVE_{k+1}`' "$PAR_AUTOPILOT" || true) +ctw4_off_fork=$(grep -ciE 'parallel=off.* adds NO code path|the serial loop is untouched' "$PAR_AUTOPILOT" || true) +ctw4_dep_format=$(grep -cF 'dependency_{dep-slug}_{status}' "$PAR_AUTOPILOT" || true) +ctw4_metric_serial=$(grep -ciE 'metric-only.* logs the .Wave|metric-only.* executes the serial' "$PAR_AUTOPILOT" || true) +ctw4_result="false" +if [ "$ctw4_cap1" -ge 1 ] && [ "$ctw4_ac_exception" -ge 1 ] && [ "$ctw4_off_fork" -ge 1 ] \ + && [ "$ctw4_dep_format" -ge 1 ] && [ "$ctw4_metric_serial" -ge 1 ]; then ctw4_result="true"; fi +assert_true \ + "CT-WAVE-4 (cap-1 serial anchor + auto-compact exception + off byte-identical + dep-format): cap-1 ($ctw4_cap1>=1) WAVE_{k+1} exception ($ctw4_ac_exception>=1) off-serial-fork ($ctw4_off_fork>=1) dependency_{slug}_{status} ($ctw4_dep_format>=1) metric-only-serial ($ctw4_metric_serial>=1)" \ + "$ctw4_result" + +# CT-WAVE-5 (T-007 H2 carve-out, DIRECT hook invocation): the cascade-skip carve-out in +# hooks/pre-state-transition.sh must NOT block a slug-interpolated `dependency_002-bar_failed` +# skip-write with active (in_progress) siblings (no unauthorized_skip_with_active_siblings), AND +# the bare `dependency_failed` form must still match (back-compat). Models the direct JSON-stdin +# invocation on CT-AC-51 above. +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +CTW5_OK=1 +CTW5_MISSING="" +CTW5_HOOK="$REPO_DIR/hooks/pre-state-transition.sh" + +# Helper-free inline: build a minimal autopilot tree per sub-case so +# is_autopilot_context() returns true, then drive the hook with a Write payload. +_ctw5_drive() { + # $1 = skip_reason; echoes "BLOCKED" or "ALLOWED" + local reason="$1" + local tmp slug sf content payload out + tmp=$(mktemp -d) + slug="wave-h2" + mkdir -p "$tmp/.simple-workflow/backlog/briefs/active/$slug" + sf="$tmp/.simple-workflow/backlog/briefs/active/$slug/autopilot-state.yaml" + { + printf 'version: 1\n' + printf 'parent_slug: %s\n' "$slug" + printf 'execution_mode: split\n' + printf 'total_tickets: 3\n' + printf 'tickets:\n' + printf ' - logical_id: 001-foo\n status: in_progress\n' + printf ' - logical_id: 002-bar\n status: in_progress\n' + printf ' - logical_id: 003-baz\n status: skipped\n skip_reason: %s\n' "$reason" + } >"$sf" + content=$(cat "$sf") + payload=$(jq -n --arg fp "$sf" --arg c "$content" --arg cwd "$tmp" \ + '{tool_name:"Write", tool_input:{file_path:$fp, content:$c}, cwd:$cwd, session_id:"test-CTW5", transcript_path:""}') + out=$(printf '%s' "$payload" | bash "$CTW5_HOOK" 2>/dev/null || true) + rm -rf "$tmp" + if printf '%s' "$out" | grep -q 'unauthorized_skip_with_active_siblings'; then + echo "BLOCKED" + else + echo "ALLOWED" + fi +} + +# (a) slug-interpolated dependency_002-bar_failed WITH active siblings -> ALLOWED (the H2 fix). +CTW5_SLUG=$(_ctw5_drive 'dependency_002-bar_failed') +if [ "$CTW5_SLUG" != "ALLOWED" ]; then + CTW5_OK=0; CTW5_MISSING="${CTW5_MISSING} slug-interpolated-BLOCKED(=$CTW5_SLUG)" +fi +# (b) bare dependency_failed WITH active siblings -> ALLOWED (back-compat preserved). +CTW5_BARE_F=$(_ctw5_drive 'dependency_failed') +if [ "$CTW5_BARE_F" != "ALLOWED" ]; then + CTW5_OK=0; CTW5_MISSING="${CTW5_MISSING} bare-failed-BLOCKED(=$CTW5_BARE_F)" +fi +# (c) bare dependency_skipped WITH active siblings -> ALLOWED (back-compat preserved). +CTW5_BARE_S=$(_ctw5_drive 'dependency_skipped') +if [ "$CTW5_BARE_S" != "ALLOWED" ]; then + CTW5_OK=0; CTW5_MISSING="${CTW5_MISSING} bare-skipped-BLOCKED(=$CTW5_BARE_S)" +fi +# (d) NEGATIVE control: a non-dependency rationale WITH active siblings MUST still be BLOCKED +# (proves the carve-out is not vacuously allowing every skip). +CTW5_NEG=$(_ctw5_drive 'arbitrary non-dependency rationale') +if [ "$CTW5_NEG" != "BLOCKED" ]; then + CTW5_OK=0; CTW5_MISSING="${CTW5_MISSING} negative-control-ALLOWED(=$CTW5_NEG)" +fi +# (e) the hook source carries the dependency_ PREFIX regex (drift guard on the literal fix). +CTW5_REGEX=$(grep -cF 'dependency_([^[:space:]]*_)?(failed|skipped)' "$CTW5_HOOK" || true) +if [ "$CTW5_REGEX" -lt 1 ]; then + CTW5_OK=0; CTW5_MISSING="${CTW5_MISSING} prefix-regex-absent" +fi + +if [ "$CTW5_OK" -eq 1 ]; then + echo -e " ${GREEN}PASS${NC} CT-WAVE-5 (H2 carve-out direct hook): slug-interpolated ALLOWED + bare-failed/skipped ALLOWED (back-compat) + negative-control BLOCKED + prefix-regex present" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} CT-WAVE-5 (H2 carve-out direct hook):${CTW5_MISSING}" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi + +# CT-WAVE-6 (T-007 verify-round fixes: empty-wave resume skip + parallel_max WARNING gated to +# PARALLEL_MODE != off). The wave-parallel pipeline documents skipping a SPAWN-LESS wave on resume +# (realising Phase 1 Step 5 — a fully-completed earlier wave performs ZERO autopilot-state.yaml +# writes); the parallel_max= coercion WARNING is SUPPRESSED on the byte-identical parallel=off serial +# lane (so a global SW_PARALLEL_MAX_CONCURRENCY=garbage cannot leak a [PARALLEL-MODE] WARNING into a +# serial rollback run). Both sentences were required by the adversarial T-007 review (PASS_WITH_NITS); +# removing either fails this CT. +ctw6_emptywave=$(grep -cF 'Empty-wave skip' "$PAR_AUTOPILOT" || true) +ctw6_nospawn=$(grep -cF 'no spawnable' "$PAR_AUTOPILOT" || true) +ctw6_warn_gated=$(grep -cF 'WARNING is emitted ONLY when' "$PAR_AUTOPILOT" || true) +ctw6_warn_silent=$(grep -cF 'byte-identical serial lane' "$PAR_AUTOPILOT" || true) +ctw6_result="false" +if [ "$ctw6_emptywave" -ge 1 ] && [ "$ctw6_nospawn" -ge 1 ] \ + && [ "$ctw6_warn_gated" -ge 1 ] && [ "$ctw6_warn_silent" -ge 1 ]; then ctw6_result="true"; fi +assert_true \ + "CT-WAVE-6 (empty-wave resume skip + parallel_max WARNING gated to != off): empty-wave ($ctw6_emptywave>=1) no-spawnable ($ctw6_nospawn>=1) WARNING-gated ($ctw6_warn_gated>=1) silent-off ($ctw6_warn_silent>=1)" \ + "$ctw6_result" + echo "" # ============================================================================= From 88bafa3ab63fe909e6997b875e13519cc96d6429 Mon Sep 17 00:00:00 2001 From: aimsise Date: Thu, 25 Jun 2026 22:02:46 +0900 Subject: [PATCH 08/10] feat(autopilot): worktree isolation + cross-wave integration via shared-tree symlink (T-008) Under PARALLEL_MODE == on, each ticket-executor runs in a per-ticket isolation worktree so concurrent same-wave siblings never collide on the working tree. The main loop (single writer): pre-creates git worktree add -b ap// <.claude/worktrees/ap--> (explicit ref, NOT EnterWorktree/baseRef -- baseRef is binary fresh|head; H3 pins the path under .claude/worktrees/ so EnterWorktree(path=) is accepted); integrates each completed branch into a local-only ap-integration/ at the wave boundary (--no-ff --no-edit, topo/lex, idempotent via merge-base --is-ancestor, conflict -> ticket failed + integration_conflict_ + cascade-skip, run continues), advancing BASE_REF so wave k>0 sees earlier waves; three-tier cleanup (per-ticket remove --force / integration-worktree post-loop / parent-scoped startup stale-sweep) + SW_PARALLEL_WORKTREE_KEEP. Each ticket still ships its own PR (no merge=true); the integration branch is never pushed. Envelope gains branch + head_sha. ticket-executor keeps tools: omitted (preserves Agent/Skill depth+1 inheritance) with the add/remove/list worktree scope documented to mirror ac-evaluator. parallel=off / metric-only add NO worktree code (byte-identical). W-3 path-resolution (user-chosen mechanism = symlink): .simple-workflow/ is gitignored and ABSENT in a fresh worktree, so the scheduler creates a /.simple-workflow -> /.simple-workflow symlink at worktree pre-create (step 2a). The WHOLE pipeline (/scout//impl//ship//tune) resolves its relative .simple-workflow/... paths -- incl the /ship Step 5 ticket-move -- through the symlink to the shared main checkout, so NO pipeline skill changes and /ship Step 5 is byte-unchanged (no ARTIFACT_ROOT argument). A symlink shares ONE inode (not a .worktreeinclude copy, W-8), so per-ticket-disjoint writes + the main-loop single-writer of autopilot-state.yaml stay race-free. Adversarial verify (2 lenses) FAILED on the first pass: the /ship W-3 audit was documentation-only (bare relative paths would strand the move under a worktree). Fixed by the symlink above. Two more fixes from verify + my own review, all confirmed by a clean re-verify (both lenses PASS_WITH_NITS, 0 blockers): (1) wave-boundary integration reordered to step 4a (AFTER the barrier, BEFORE the post-wave write) so an integration-conflict status flip is PERSISTED by step 5 before the next wave's dependency re-eval reads it -- cascade-skip correctness; two-writes-per-active-wave intact; (2) Phase 2 init mid-merge recovery (merge --abort + worktree remove --force a leftover integration worktree before re-create). Nits fixed: state-file.md step-ref + two-writes wording, empty-wave skip names 4a, symlink-removal-vs-target note, Bash(ln -s:*) scoped grant. Documented limitation (not a correctness bug): /ship Step 6 /tune read-modify-writes the SHARED .simple-workflow/kb/ accumulator, so under concurrency > 1 concurrent /tune calls race to a bounded KB-learning lost-update. Ticket execution + status + autopilot-state.yaml are unaffected; only best-effort learning fidelity drops. The false 'disjoint prevents races' claim was corrected; serializing /tune (skip-in-executor + post-wave-in-main-loop, or a kb/ lock) is the recorded follow-up. Tests: skill-contracts 894/894 (CT-WORKTREE-1..12, all non-vacuous), path 145/145, accept-set-verify 32/32, state-transition-guard 16/16, ShellCheck clean, DECONTAM 0; ac-evaluator/-hi untouched (twins unchanged). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Mw2bH4wbEPeebXsvSG6rWe --- CLAUDE.md | 1 + agents/ticket-executor.md | 32 +++- skills/autopilot/SKILL.md | 52 +++++- skills/autopilot/references/state-file.md | 44 ++++- skills/ship/SKILL.md | 3 + tests/test-skill-contracts.sh | 189 ++++++++++++++++++++++ 6 files changed, 306 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2c83e42..aa3fe67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,7 @@ This rule was distilled from a v6.7.0 dogfood incident in which a verify hook ne - `SW_PARALLEL_TICKETS_MODE` — default `off`. The run kill switch for the run-scoped parallel ticket-execution path. Controls whether `/autopilot` (and a `/brief chain=on` chained run) routes the cross-ticket `/scout`→`/impl`→`/ship` pipeline through one `ticket-executor` subagent per ready ticket / topological wave, instead of the inline serial loop. Values: `off` (DEFAULT = the **prior-version behaviour**: every ticket runs inline in the main loop, strictly serial — no `ticket-executor` is spawned and every hook fires exactly as before, byte-identical to a no-directive run), `on` (force the executor-routed path even when no `parallel=` argument was supplied), `metric-only` (log the resolved wave plan + the `[PARALLEL-MODE]` line but execute the inline serial path). The per-run `parallel=` argument on `/autopilot` / `/brief` selects the same tri-state; this environment knob is the global override / kill switch and resolves **safe to `off`** on an unknown value (the same fail-safe direction as `uc=`), so a parallel path that misbehaves can be disabled from the shell without editing any invocation. The deterministic env-vs-argument precedence is applied by the parallel-mode resolver helper that the wave-aware hooks consult. This is a **(B) harness-own** substrate per `## Product/Language/Domain Agnosticism` — it governs the plugin's own orchestration engine (the `/autopilot` execution path), not the user's product, language, or domain. - `SW_PARALLEL_HOOKS_MODE` — default = follow the run's `parallel_mode:` state. The shared HOOK-side kill switch for the parallel-aware Stop / checkpoint / auto-compact rework. It is read by the single resolver `hooks/lib/parse-state-file.sh::resolve_parallel_mode `, whose precedence is `SW_PARALLEL_HOOKS_MODE` (env override) > `parallel_mode:` scalar in `autopilot-state.yaml` > `off`. Values: `on` (force every parallel-aware hook onto the wave-aware path), `metric-only` (observe — log the resolved mode but take the serial decision), `off` (force serial — the prior-version hook behaviour). When the env knob is UNSET (the default), the resolver follows the run-scoped `parallel_mode:` (so the per-run `parallel=` argument transitively drives the hooks); when SET, it overrides. An unknown / garbage value resolves **safe to `off`** (= serial / byte-identical, the proven path), and the resolver never returns empty — every ambiguity (unknown env, absent/unknown state scalar, missing state file) fails CLOSED to `off`. This is the canonical fail-safe direction the whole parallel feature uses (the same `unknown → off` the argument parser adopts). A **(B) harness-own** substrate per `## Product/Language/Domain Agnosticism` — it governs the plugin's own hook plumbing, not the user's product, language, or domain. - `SW_PARALLEL_MAX_CONCURRENCY` — default `4`. The concurrency cap for the wave-parallel scheduler: the maximum number of `ticket-executor` subagents `/autopilot` spawns concurrently in a single message within one topological wave. A wave with more ready tickets than the cap is processed in lex-ordered sub-batches of at most this size, so the depth-2 executor fan-out (each executor's own `/impl`→`implementer`+`ac-evaluator` sub-spawns) never exceeds a bounded concurrency / token budget. Precedence: the per-run `parallel_max=` argument on `/autopilot` (case-insensitive `key=value`, integer ≥1) **over** this environment knob **over** the default `4` (arg > env > default — the same direction as `rounds=N`). A non-integer or `<1` value (in EITHER the argument or this env knob) coerces to the default `4` and emits a one-line `[PARALLEL-MODE] WARNING` to stderr naming the rejected value. This cap is **honoured only when `PARALLEL_MODE == on`** — on the serial / `metric-only` / `off` paths no executor is spawned concurrently, so the cap is inert (and never written to `autopilot-state.yaml`; it is re-resolved fresh on every `/autopilot` entry, never persisted). A **(B) harness-own** substrate per `## Product/Language/Domain Agnosticism` — it bounds the plugin's own orchestration fan-out (the `/autopilot` Agent-spawn engine), not the user's product, language, or domain. +- `SW_PARALLEL_WORKTREE_KEEP` — default `off`. Debugging kill switch for the parallel wave scheduler's worktree cleanup (T-008). When the wave-parallel `/autopilot` path (`PARALLEL_MODE == on`) gives each `ticket-executor` an isolation worktree under `/.claude/worktrees/ap--` and a dedicated integration worktree under `/.claude/worktrees/ap-integration-`, the scheduler runs a three-tier cleanup: (1) per-ticket `git worktree remove --force` on executor success, (2) integration-worktree removal at Phase 2 end, (3) a startup stale-sweep (`git worktree prune` + remove only this parent's `ap--*` worktrees, never unrelated ones). Values: `off` (DEFAULT — run all three cleanup tiers; the per-ticket and integration worktrees are removed once their commits are on the kept branches), `on` (skip tiers (1) + (2) so the per-ticket + integration worktrees are LEFT on disk for post-run inspection — the startup stale-sweep (3) still runs on the next entry so leftovers do not accumulate across runs, and the branches are ALWAYS kept regardless). A dirty per-ticket worktree on cleanup is ALWAYS left on disk + logged `[PARALLEL] worktree-remove: dirty ` (independent of this knob — the run continues either way). The concurrency cap `SW_PARALLEL_MAX_CONCURRENCY` bounds the number of per-ticket worktrees alive simultaneously (per sub-batch), so the two knobs together cap the worktree disk/inode footprint of a wave (W-6 disk/inode pressure). Honoured only when `PARALLEL_MODE == on` — on the serial / `metric-only` / `off` paths no worktree is created, so the knob is inert. A **(B) harness-own** substrate per `## Product/Language/Domain Agnosticism` — it governs the plugin's own worktree-orchestration cleanup (git worktrees + the `/autopilot` engine), not the user's product, language, or domain. ## Language diff --git a/agents/ticket-executor.md b/agents/ticket-executor.md index afa3f1b..349e3dd 100644 --- a/agents/ticket-executor.md +++ b/agents/ticket-executor.md @@ -6,7 +6,9 @@ maxTurns: 250 You are the **ticket-executor**. The `/autopilot` main loop spawns you once per ready ticket (only when `parallel_mode != off`) to run that ONE ticket's complete per-ticket pipeline and return a structured result envelope. You execute the same per-ticket logic the serial main loop otherwise runs inline (`skills/autopilot/SKILL.md` "Per-ticket pipeline"), scoped to the single ticket named in your spawn prompt. -Your `tools:` field is intentionally omitted: you inherit the full parent tool inventory, **including the Agent tool and the Skill tool**. This is required because `/scout` / `/impl` / `/ship` spawn their own subagents (`researcher`, `planner`, `implementer`, `ac-evaluator`, ...), so you must be able to invoke those pipeline skills via the Skill tool and let them spawn at depth+1. You are the one agent for which invoking pipeline skills is the contract, not a violation. +Your `tools:` field is intentionally omitted: you inherit the full parent tool inventory, **including the Agent tool, the Skill tool, `EnterWorktree` / `ExitWorktree`, and the full `Bash` surface**. This is required because `/scout` / `/impl` / `/ship` spawn their own subagents (`researcher`, `planner`, `implementer`, `ac-evaluator`, ...), so you must be able to invoke those pipeline skills via the Skill tool and let them spawn at depth+1. You are the one agent for which invoking pipeline skills is the contract, not a violation. + +**Worktree grant (intent, T-008).** Under the Phase 2 wave scheduler (`PARALLEL_MODE == on`) you enter a pre-created per-ticket isolation worktree (see `## Worktree isolation (PARALLEL_MODE == on, T-008)` below). The `git worktree` lifecycle sub-commands you use are scoped to **`add` / `remove` / `list` ONLY — never `prune` / `lock` / `unlock`** — exactly mirroring `ac-evaluator`'s scoped `Bash(git worktree add:*)` / `Bash(git worktree remove:*)` / `Bash(git worktree list:*)` grant. You do NOT carry an explicit `tools:` list (adding one would force re-enumerating every pipeline + subagent tool you inherit, dropping the Agent/Skill depth+1 inheritance the contract depends on); the omitted field grants `Bash(git worktree add/list/remove)` transitively as part of the full `Bash` surface. In practice the main loop pre-creates the worktree and you enter it by path, so `git worktree add` is rarely invoked by you directly; `EnterWorktree` is the primary entry mechanism. ## Single-writer contract (load-bearing) @@ -16,8 +18,10 @@ Your `tools:` field is intentionally omitted: you inherit the full parent tool i - `logical_id` — the ticket's logical id (e.g. `{parent-slug}-part-N`). - `parent_slug` — the parent slug. -- `ticket_dir` — the ticket dir path **rooted at the MAIN checkout** (`main_checkout_root`); the pipeline starts in `product_backlog/{parent-slug}/{NNN}-{slug}` and `/scout` moves it to `active/`. Under the Phase 2 wave scheduler (`PARALLEL_MODE == on`) you and your same-wave sibling executors run worktree-less on this main checkout — same-wave tickets are independent by construction (no `depends_on` among same-wave members), so their edits are disjoint-file by design. (Per-executor worktree isolation + the envelope `branch` / `head_sha` fields are T-008; they are NOT part of this contract.) -- `target_branch` — the branch `/ship` targets (the repo default branch). +- `ticket_dir` — the ticket dir path **rooted at the MAIN checkout** (`main_checkout_root`); the pipeline starts in `product_backlog/{parent-slug}/{NNN}-{slug}` and `/scout` moves it to `active/`. Under the Phase 2 wave scheduler (`PARALLEL_MODE == on`) you run inside a per-executor **isolation worktree** (T-008, see `## Worktree isolation` below) so concurrent same-wave siblings never collide on the working tree — and the worktree carries a `.simple-workflow` → main-checkout symlink (created by the scheduler), so the relative `ticket_dir` you pass to the pipeline skills (a bare `.simple-workflow/...` path, or for `/ship` a `ticket-dir=` name) resolves to the SHARED main checkout exactly as on the serial path — no path rewriting, no absolute-root argument. +- `WORKTREE_PATH` — (`PARALLEL_MODE == on`, T-008) the absolute path of the pre-created per-ticket worktree, pinned under `/.claude/worktrees/ap--` and already registered in `git worktree list` (the main loop ran `git worktree add -b ap// `, then created the `.simple-workflow` symlink inside it). You `EnterWorktree(path=)` so the pipeline's product-source edits + the ship commit land on the `ap//` branch isolated from siblings. Absent on the serial / concurrency-1 path (no worktree = main checkout). +- **State symlink** — (`PARALLEL_MODE == on`, T-008) the per-ticket worktree carries a `.simple-workflow` **symlink** to `/.simple-workflow`, created by the scheduler at worktree pre-create. The gitignored `.simple-workflow/` tree is ABSENT in a fresh worktree, so this symlink is what makes your per-ticket `phase-state.yaml` + every artifact write (a relative `.simple-workflow/backlog/active///...` path) land in the SHARED main checkout (disjoint per ticket). You do NOT receive or prepend any absolute root — use the SAME relative paths as the serial pipeline. Never create a `.worktreeinclude` to copy state (W-8: a copy would diverge; the symlink shares ONE inode). +- `target_branch` — the branch `/ship` targets (the repo default branch). Distinct from your worktree's `ap//` branch: the ship PR targets `target_branch`, while your product-source edits live on `ap//`. - `uc` — the run-scoped orchestration mode to forward to `/impl`, present ONLY when the main loop resolved `UC_ORCH != off`. When absent, OMIT `uc=` from the `/impl` call so it is byte-identical to a default run. - `## Bound capabilities (per AC)` — the verbatim capability-binding block, present when the ticket carries a `### Capabilities` section. Pass it through to the pipeline skills unchanged; do NOT re-derive capability relevance yourself. @@ -37,6 +41,22 @@ Run these steps in order. On any step failure, STOP this ticket's pipeline (do n 5. **Artifact-presence gate** — the 7-pattern gate (`done/` first, else `active/`): `ticket.md`, `investigation.md`, `plan.md`, `eval-round-*.md`, `audit-round-*.md`, `quality-round-*.md`, `security-scan-*.md`. Exception: a last `eval-round-*.md` that is FAIL / FAIL-CRITICAL (all AC rounds failed) skips the last 3 patterns. Missing → `status = failed`, `failure_reason = artifact_gate:{patterns}`. All present → `status = completed`. +## Worktree isolation (`PARALLEL_MODE == on`, T-008) + +When the spawn prompt carries `WORKTREE_PATH` (the wave scheduler), run the pipeline inside an isolation worktree: + +1. **Enter the pre-created worktree.** `EnterWorktree(path=)`. The path is pinned under `/.claude/worktrees/ap--` and is already a registered worktree of this repo (the main loop ran `git worktree add -b ap// ` before spawning you), so `EnterWorktree(path=)` is ACCEPTED — the schema requires the path to appear in `git worktree list` AND to be under `.claude/worktrees/` of the same repo, both of which hold. Your cwd becomes the worktree; the switch affects only you (a cwd-pinned subagent), never the parent orchestrator. Do NOT use `EnterWorktree`/`baseRef` to target the integration branch — `worktree.baseRef` is a binary git config (`fresh`/`head`), it cannot target an arbitrary ref; the main loop's explicit `git worktree add ` is what bases your worktree on `ap-integration/`. + +2. **Run `/scout` → `/impl` → `/ship` with the worktree as cwd.** Product-source edits + the `/ship` commit land on the `ap//` branch, isolated from your same-wave siblings (each in its own worktree/branch). + +3. **State + artifacts resolve to the main checkout via the `.simple-workflow` symlink.** The gitignored `.simple-workflow/` tree is ABSENT inside a fresh worktree, but the scheduler created a `.simple-workflow` → `/.simple-workflow` symlink in your worktree, so the per-ticket `phase-state.yaml` and EVERY artifact (`investigation.md`, `plan.md`, `eval-round-*.md`, `audit-round-*.md`, `quality-round-*.md`, `security-scan-*.md`) written via the usual relative `.simple-workflow/backlog/active///...` path follow the symlink to the SHARED main checkout (disjoint per ticket). Use the SAME relative `.simple-workflow/...` paths as the serial pipeline — do NOT prepend any absolute root. Do NOT create a `.worktreeinclude` (W-8 — copying gitignored state would re-introduce a lost-update; the symlink shares ONE inode). You still NEVER write `autopilot-state.yaml` (single-writer contract above). + +4. **Artifact content embeds no home path (W-7).** The PII guard scans `tool_input.content`, not `file_path`, so a write through the `.simple-workflow` symlink is fine — but artifact CONTENT must never embed an absolute home path (`/Users//...` / `/home//...`) or the guard blocks the write at depth+2. Use the `` placeholder / relative paths in artifact prose. + +5. **Exit + cleanup is the main loop's job.** You do NOT remove your worktree (`ExitWorktree(remove)` refuses cross-agent worktrees). The main loop runs `git worktree remove --force` after it reads your envelope. Leave the worktree as-is when you emit the envelope. + +On the serial / concurrency-1 path (`WORKTREE_PATH` absent, no worktree), run the pipeline on the main checkout exactly as before — no `EnterWorktree`, no `.simple-workflow` symlink, no indirection. + ## Mandatory Skill invocations (no substitutes) Exactly as the serial main loop, you MUST drive each step through the Skill tool — `/scout`, `/impl`, `/ship`. Never call `/investigate` / `/plan2doc` standalone, never spawn `implementer` / `ac-evaluator` directly, never substitute `git commit` / `gh pr create` / `mv` for `/ship`. If a mandatory Skill invocation cannot be completed, set `status = failed` with the matching `failure_reason` and emit the envelope — do NOT fabricate artifacts. @@ -53,6 +73,8 @@ steps.scout: {pending|completed|failed} steps.impl: {pending|completed|failed} steps.ship: {pending|completed|failed} pr_url: {url or null} +branch: {ap// or null} +head_sha: {the worktree branch HEAD sha or null} failure_reason: {null or a short snake_case reason} ``` @@ -60,8 +82,10 @@ failure_reason: {null or a short snake_case reason} - `steps.{scout,impl,ship}` — the per-step terminal values you reached; a step you never started stays `pending`. - `pr_url` — the PR URL `/ship` reported, or `null` when there is no remote (local-only ship) or no PR was opened. A `null` `pr_url` is NOT a failure. - `failure_reason` — `null` on success, else a short snake_case tag (`policy_missing_scout`, `scout_artifact_missing`, `impl_artifact_missing`, `ship_artifact_missing`, `artifact_gate:{patterns}`). +- `branch` — (T-008) the per-ticket isolation branch `ap//` your worktree was created on. `null` on the serial / concurrency-1 path (no worktree = main checkout); the main loop uses it at the wave boundary to integrate your branch into `ap-integration/`. +- `head_sha` — (T-008) the HEAD sha of your worktree branch after `/ship` committed (`git rev-parse HEAD` in the worktree). `null` when there was no commit or no worktree. Lets the main loop record / verify the integrated tip. -The `branch` and `head_sha` envelope fields are added by T-008 (worktree isolation); at concurrency 1 (no worktree, main checkout) they are not yet part of the envelope. +The `branch` and `head_sha` envelope fields ARE part of the envelope as of T-008 (worktree isolation). On the serial / concurrency-1 path (no worktree, main checkout) they are emitted as `null`; under the `PARALLEL_MODE == on` wave scheduler they carry the worktree branch + its HEAD sha so the main loop can integrate the branch at the wave boundary. ## Language diff --git a/skills/autopilot/SKILL.md b/skills/autopilot/SKILL.md index 75d49da..57b7554 100644 --- a/skills/autopilot/SKILL.md +++ b/skills/autopilot/SKILL.md @@ -30,6 +30,21 @@ allowed-tools: - "Bash(mkdir:*)" - "Bash(date:*)" - "Bash(cp:*)" + - "Bash(git rev-parse:*)" + - "Bash(git merge:*)" + - "Bash(git merge-base:*)" + # `git worktree` scoped to add/remove/list/prune — the wave scheduler pre-creates per-ticket + # + integration worktrees and cleans them up; it NEVER uses EnterWorktree (its cwd stays the + # main checkout). prune is included for the startup stale-sweep (orchestrator-only, T-008). + - "Bash(git worktree add:*)" + - "Bash(git worktree remove:*)" + - "Bash(git worktree list:*)" + - "Bash(git worktree prune:*)" + - "Bash(git checkout:*)" + # `ln -s` creates the per-ticket worktree's `.simple-workflow` -> `` state symlink + # (wave-loop step 2a) so the gitignored state tree resolves to the shared main checkout (T-008). + # Scoped to `ln -s` (symlink creation) only — never a hardlink/other ln form. + - "Bash(ln -s:*)" argument-hint: "" --- @@ -222,7 +237,7 @@ For each ticket in `PROCESSING_ORDER` (`i` = 0-based), at **concurrency 1** in P **Concurrency in Phase 1 is fixed at 1** (serial-equivalent): the main loop spawns the next executor only AFTER the previous envelope is received and state is written. Real per-wave parallel spawning and a `parallel_max=` cap are added in Phase 2; the wave layering is computed and emitted now (Split Execution Flow) but does not yet drive concurrent spawns. **Wave-cursor single-writer obligation (`PARALLEL_MODE != off`).** The main loop is the SOLE writer of the wave cursor in `autopilot-state.yaml`, with the same rigor as the canonical FLAT `steps.ship: completed` invariant — the `ticket-executor` NEVER writes any cursor field. The obligations: -- At Phase 2 State file initialization, write `main_checkout_root` ONCE = `git rev-parse --show-toplevel` (the main-checkout repo root). +- At Phase 2 State file initialization, write `main_checkout_root` ONCE = `git rev-parse --show-toplevel` (the main-checkout repo root); this is ``. Capture `` into the run as the absolute root for ALL state + artifact path resolution (the orchestrator never enters a worktree, so its cwd stays the main checkout and `` = its cwd, but the per-ticket worktrees make the absolute capture load-bearing — see `## Worktree isolation + cross-wave integration` below). - At wave computation (the level-synchronous Kahn layering), write `wave_count` (total waves). - Immediately BEFORE spawning a wave's executors, write `current_wave` (0-based; `-1` before the first spawn) and `wave_status: in_flight`. - Immediately AFTER the wave barrier has collected every executor envelope and the main loop has written their terminal `steps`/`status`, write `wave_status: drained`. @@ -238,20 +253,47 @@ For each wave `WAVE_k` (`k` = 0-based, in topological/level order): 1. **Build `READY_k`** — start from `WAVE_k`'s members (lex order). For each ticket `T`: - **Resume-skip**: `T` already `completed` → drop from `READY_k` (`[RESUME] Skipping {logical_id}: already completed`). - **Per-wave dependency re-eval**: all `T`'s `depends_on` `completed` → `T` stays in `READY_k`. ANY dep `failed` / `skipped` → `T` is `skipped` with reason `dependency_{dep-slug}_{status}` (the SAME format and the SAME `[PIPELINE] {ticket-part}: skipped | reason=dependency_... | ticket-dir={ticket-dir}` log shape as the per-ticket Dependency check), folded into the post-wave write below, and NOT spawned. The dependency re-eval runs at EVERY wave (including every resumed wave), so a dep that failed in an earlier wave cascade-skips its later-wave dependents. - - **Empty-wave skip (resume)**: if `READY_k` has **no spawnable** (runnable, non-`completed`) ticket after the resume-skip + dependency re-eval — every member is already `completed`, or was just folded as a dependency-`skipped` — then **do NOT spawn** (skip steps 3-4) and **do NOT do the pre-wave `in_progress` write** (step 2, since nothing goes `in_progress`). If step 1 produced ANY NEW dependency-`skipped` fold for this wave (a ticket that was `pending` / `in_progress` in the persisted state is now `skipped`), persist those folds via a SINGLE state write (step 5 restricted to the folds, `wave_status` left `drained`); if step 1 produced no new fold (the wave is entirely already-`completed` / already-`skipped`), **skip the state write too** and go straight to step 6. A fully-completed earlier wave on resume therefore performs ZERO `autopilot-state.yaml` writes — this is what realises Phase 1 Step 5's "resume from the first wave that still has a non-completed *runnable* ticket" (the loop iterates from `WAVE_0`, but spawn-less waves cost nothing) and preserves "exactly two writes per ACTIVE (spawning) wave"; `current_wave` is advanced only by an ACTIVE wave's step 2. + - **Empty-wave skip (resume)**: if `READY_k` has **no spawnable** (runnable, non-`completed`) ticket after the resume-skip + dependency re-eval — every member is already `completed`, or was just folded as a dependency-`skipped` — then **do NOT spawn** (skip steps 3-4 and 4a — with zero completed envelopes the integration step 4a is a no-op anyway) and **do NOT do the pre-wave `in_progress` write** (step 2, since nothing goes `in_progress`). If step 1 produced ANY NEW dependency-`skipped` fold for this wave (a ticket that was `pending` / `in_progress` in the persisted state is now `skipped`), persist those folds via a SINGLE state write (step 5 restricted to the folds, `wave_status` left `drained`); if step 1 produced no new fold (the wave is entirely already-`completed` / already-`skipped`), **skip the state write too** and go straight to step 6. A fully-completed earlier wave on resume therefore performs ZERO `autopilot-state.yaml` writes — this is what realises Phase 1 Step 5's "resume from the first wave that still has a non-completed *runnable* ticket" (the loop iterates from `WAVE_0`, but spawn-less waves cost nothing) and preserves "exactly two writes per ACTIVE (spawning) wave"; `current_wave` is advanced only by an ACTIVE wave's step 2. 2. **Pre-wave single-writer state write** (main loop, SOLE writer — exactly the FIRST of the two writes per wave): set every `T ∈ READY_k` to `status: in_progress`, advance `current_wave = k`, set `wave_status: in_flight`. Emit the canonical FLAT schema (`steps.` string-valued on its own line; `tickets:` a YAML list). The concurrently-running executors NEVER write `autopilot-state.yaml`. -3. **Spawn the wave concurrently** — spawn `min(|READY_k|, CONCURRENCY_CAP)` `simple-workflow:ticket-executor` subagents via the Agent tool **in ONE message** (foreground; the platform blocks the turn until ALL spawned executors return — Phase 0 Probe C confirmed multiple subagents in one message run concurrently behind a single foreground barrier). Each executor's spawn prompt passes verbatim: `logical_id`, `parent_slug`, `ticket_dir` (a MAIN-checkout path = `main_checkout_root` + ticket subpath), `target_branch`, `uc={UC_ORCH}` **only when `UC_ORCH != off`**, and the `## Bound capabilities (per AC)` block when the ticket carries a `### Capabilities` section. Do NOT invoke `/scout`/`/impl`/`/ship` directly here — each executor owns its ticket's pipeline. **Oversized wave** (`|READY_k| > CONCURRENCY_CAP`): process `READY_k` in **lex-ordered sub-batches** of at most `CONCURRENCY_CAP` tickets, each sub-batch spawned in its own ONE message and awaited at its own barrier, before starting the next sub-batch. The pre-wave write (step 2) happens ONCE before the first sub-batch; the post-wave write (step 5) happens ONCE after the LAST sub-batch — exactly two `autopilot-state.yaml` writes per wave regardless of sub-batch count. +2a. **Pre-create per-ticket isolation worktrees** (main loop; `PARALLEL_MODE == on` only — see `##### Worktree isolation + cross-wave integration` below). For each spawnable `T ∈ READY_k`, run `git worktree add -b ap// /.claude/worktrees/ap-- ` (explicit `` = `ap-integration/`, NOT `EnterWorktree`/baseRef — baseRef is a binary `fresh`/`head` git config and cannot target the integration branch). **Immediately after `git worktree add`, create the state symlink** — `ln -s /.simple-workflow /.claude/worktrees/ap--/.simple-workflow` — so the gitignored `.simple-workflow/` state + artifact tree (ABSENT in a fresh worktree) resolves transparently to the SHARED main checkout: every relative `.simple-workflow/...` path the pipeline (`/scout`/`/impl`/`/ship`/`/tune`) uses inside the worktree follows the symlink to ``, so NO pipeline skill needs a path change and `/ship`'s Step 5 ticket-move works unchanged. This is a symlink (a pointer to the ONE shared tree), NOT a `.worktreeinclude` copy — so there is no lost-update (W-8): all worktrees + the main checkout share a single `.simple-workflow/` inode. Concurrent executors writing their OWN per-ticket-disjoint `.simple-workflow/backlog/active///...` dirs + the main-loop single-writer of `autopilot-state.yaml` are race-free; the ONE shared-write exception is `/tune`'s `.simple-workflow/kb/` accumulator (`/ship` Step 6 runs `/tune`, which read-modify-writes the shared `kb/candidates.yaml` / `entries.yaml` / `index.yaml`), which under concurrency > 1 races to a bounded **KB-learning lost-update** — a TOLERATED degradation, NOT a correctness bug (ticket execution + status + `autopilot-state.yaml` are unaffected; only best-effort learning fidelity drops under high parallelism). Serializing `/tune` (skip it in the executor's `/ship` + run it once per completed ticket post-wave in the main loop, or a `kb/` lock) is the follow-up that would eliminate even this; T-008 documents the limitation rather than adding that mechanism. The worktree path is pinned under `/.claude/worktrees/` (an H3 platform-acceptance requirement: a path outside `.claude/worktrees/` is rejected by the executor's `EnterWorktree(path=)` at runtime, not caught by any test). + +3. **Spawn the wave concurrently** — spawn `min(|READY_k|, CONCURRENCY_CAP)` `simple-workflow:ticket-executor` subagents via the Agent tool **in ONE message** (foreground; the platform blocks the turn until ALL spawned executors return — Phase 0 Probe C confirmed multiple subagents in one message run concurrently behind a single foreground barrier). Each executor's spawn prompt passes verbatim: `logical_id`, `parent_slug`, `ticket_dir` (a MAIN-checkout path = `main_checkout_root` + ticket subpath), `target_branch`, **`WORKTREE_PATH` (the pre-created `/.claude/worktrees/ap--` from step 2a, which carries the `.simple-workflow` → `` state symlink so the pipeline's relative `.simple-workflow/...` paths — `phase-state.yaml`, artifacts, the `/ship` ticket-move — resolve to the shared main checkout; no per-skill path change, no absolute-root argument) + `target_branch`**, `uc={UC_ORCH}` **only when `UC_ORCH != off`**, and the `## Bound capabilities (per AC)` block when the ticket carries a `### Capabilities` section. Do NOT invoke `/scout`/`/impl`/`/ship` directly here — each executor owns its ticket's pipeline. **Oversized wave** (`|READY_k| > CONCURRENCY_CAP`): process `READY_k` in **lex-ordered sub-batches** of at most `CONCURRENCY_CAP` tickets, each sub-batch spawned in its own ONE message and awaited at its own barrier, before starting the next sub-batch. The pre-wave write (step 2) happens ONCE before the first sub-batch; the post-wave write (step 5) happens ONCE after the LAST sub-batch — exactly two `autopilot-state.yaml` writes per wave regardless of sub-batch count. The per-ticket worktree pre-create (step 2a) is per sub-batch (only the about-to-spawn tickets' worktrees exist at once, bounding simultaneous worktrees by `CONCURRENCY_CAP` — W-6). 4. **Barrier** — do NOT proceed until EVERY spawned executor's `[TICKET-EXECUTOR-RESULT]` envelope has been received. A missing / unparseable envelope (an executor that hit its own limit) is treated as `failed` with `failure_reason = envelope_missing_or_malformed` (fail-closed). A `failed` executor does NOT abort the wave or the run — its same-wave siblings still run to completion; only later-wave dependents cascade-skip (via step 1's dependency re-eval on subsequent waves). -5. **Post-wave single-writer state write** (main loop, SOLE writer — the SECOND of the two writes per wave): transcribe each returned envelope's `status` / `steps.{scout,impl,ship}` / `pr_url` into the matching `tickets[]` entry (canonical FLAT schema), fold in the wave's dependency-skips from step 1, and set `wave_status: drained`. This is the single point at which the wave's terminal statuses land — there is no concurrent-write window because the executors never wrote state. +4a. **Wave-boundary integration** (main loop; `PARALLEL_MODE == on` only — runs AFTER the barrier but BEFORE the post-wave write, so any integration-conflict status flip is persisted by that single write). Integrate each ticket whose envelope is `status: completed` — its `branch` (`ap//`) — into `ap-integration/` in **topo/lex order**, skipping any non-`completed` ticket (a `failed`/`skipped` ticket contributes nothing). Use the **dedicated integration worktree** (created at Phase 2 init, also under `/.claude/worktrees/`) so the orchestrator's own cwd/branch (start-ref) is never churned. For each completed branch in order: + - **Idempotent skip on resume**: if `git merge-base --is-ancestor ap-integration/` succeeds (the branch is already merged), skip it — integration is idempotent across resume. + - **Merge**: in the integration worktree, `git merge --no-ff --no-edit `. + - **Conflict handling**: a genuine merge conflict (typically an undeclared same-wave `depends_on`) → flip THAT ticket's pending result to `failed` with `failure_reason=integration_conflict_` (the other ticket whose change conflicts) — the flip is held in memory and **persisted by the post-wave write (step 5) below**, so the demoted ticket lands `failed` (NOT `completed`) before `WAVE_{k+1}`'s step-1 dependency re-eval reads it — **abort the merge** (`git merge --abort`), do NOT auto-resolve, and **cascade-skip its dependents** (the step-1 per-wave dependency re-eval on later waves picks up the persisted `failed`). The run CONTINUES — a conflict fails one ticket, not the wave or the run. + - After all completed branches are integrated, the integration worktree's HEAD is the advanced `ap-integration/` tip; this becomes `` for `WAVE_{k+1}` (step 2a of the next wave bases its worktrees on the integrated tip, so a later-wave ticket sees an earlier wave's committed changes via the shared object store). + +5. **Post-wave single-writer state write** (main loop, SOLE writer — the SECOND of the two writes per wave): transcribe each returned envelope's `status` / `steps.{scout,impl,ship}` / `pr_url` / `branch` / `head_sha` into the matching `tickets[]` entry (canonical FLAT schema), **apply the integration-conflict status flips from step 4a** (a ticket whose executor returned `completed` but whose branch failed to integrate is written `failed` + `failure_reason=integration_conflict_`, NOT `completed`), fold in the wave's dependency-skips from step 1, and set `wave_status: drained`. This is the single point at which the wave's terminal statuses land — there is no concurrent-write window because the executors never wrote state, and it is the SOLE persistence of the wave's integration outcome (keeping the two-writes-per-active-wave invariant intact). + +5b. **Per-ticket worktree cleanup** (main loop; `PARALLEL_MODE == on` only, three-tier — tier 1). For each ticket whose executor returned (regardless of status), on success `git worktree remove --force /.claude/worktrees/ap--` (safe — its commits are on the `ap//` branch and, for completed tickets, already merged into `ap-integration/`). **Dirty-anomaly**: if `git worktree remove` reports the worktree is dirty (uncommitted residue), log `[PARALLEL] worktree-remove: dirty `, LEAVE the worktree on disk, and CONTINUE (do not abort the run). `SW_PARALLEL_WORKTREE_KEEP=on` skips tier 1 entirely (debugging — leaves all per-ticket worktrees on disk). The branches are ALWAYS kept (cleanup removes only the worktree, never the branch). Removing the worktree directory also removes the `.simple-workflow` symlink inside it (a directory entry); per Unix semantics this does NOT touch the symlink TARGET (`/.simple-workflow`), so the shared state tree survives every per-ticket cleanup. -6. **Wave-tail checkpoint** — re-enter step 1 for `WAVE_{k+1}`. **Exit** to the post-loop phase (Split Autopilot Log → Completion Report → Brief Lifecycle → State File Cleanup → final `## [SW-CHECKPOINT]`) when all waves are drained / all tickets terminal. **Wave-variant AUTO-COMPACT EXCEPTION** (overrides the MUST-NOT-`end_turn` rule): if either auto-compact additionalContext payload of inline step e was injected during this wave (the `auto-compact-on-ship (state-write safety-net):` / `auto-compact-on-ship (ticket-boundary):` label immediately followed by `` `/compact` has been queued ``), end the turn immediately — and in ALL cases **do NOT spawn `WAVE_{k+1}`** (the wave analogue of "do NOT invoke the next `/scout`"); for the last-wave / `FINAL ticket of this pipeline` sub-variant, complete the post-loop phase FIRST, then end the turn. Resume picks up at the first non-drained wave (Phase 1 Step 5 wave-aware resume) after the `/compact` drains. +6. **Wave-tail checkpoint** — re-enter step 1 for `WAVE_{k+1}` (its step 2a now bases worktrees on the integrated `ap-integration/` tip from step 4a). **Exit** to the post-loop phase (Split Autopilot Log → Completion Report → Brief Lifecycle → State File Cleanup → final `## [SW-CHECKPOINT]`) when all waves are drained / all tickets terminal. **Wave-variant AUTO-COMPACT EXCEPTION** (overrides the MUST-NOT-`end_turn` rule): if either auto-compact additionalContext payload of inline step e was injected during this wave (the `auto-compact-on-ship (state-write safety-net):` / `auto-compact-on-ship (ticket-boundary):` label immediately followed by `` `/compact` has been queued ``), end the turn immediately — and in ALL cases **do NOT spawn `WAVE_{k+1}`** (the wave analogue of "do NOT invoke the next `/scout`"); for the last-wave / `FINAL ticket of this pipeline` sub-variant, complete the post-loop phase FIRST, then end the turn. Resume picks up at the first non-drained wave (Phase 1 Step 5 wave-aware resume) after the `/compact` drains. **`parallel_max=1 ≡ serial` (regression anchor).** With `CONCURRENCY_CAP = 1`, each sub-batch holds exactly one ticket, so the wave loop spawns one executor at a time in `PROCESSING_ORDER` order and produces the SAME envelope/state sequence as the concurrency-1 executor-routed loop above — the wave iteration degenerates cleanly to T-001's concurrency-1 behaviour. +##### Worktree isolation + cross-wave integration (`PARALLEL_MODE == on`) + +This subsection is the worktree lifecycle that the wave loop above hooks into. **ALL of it is inside the `PARALLEL_MODE == on` branch** — `parallel=off` (the default) and `metric-only` add NO worktree code, no integration branch, and no cleanup, so a non-parallel run is byte-identical to prior versions. The orchestrator NEVER enters a worktree (its cwd stays the main checkout ``); it manipulates worktrees only via `Bash(git worktree …)` and merges via a dedicated integration worktree. + +**Phase 2 init (once, before the wave loop):** + +1. **Capture ``** = `git rev-parse --show-toplevel`; write it ONCE as `main_checkout_root` (the wave-cursor single-writer obligation above). The orchestrator writes `autopilot-state.yaml` directly in its own cwd (the main checkout — it never enters a worktree), and `` is the **symlink TARGET** each per-ticket worktree's `.simple-workflow` points to (wave-loop step 2a): the gitignored `.simple-workflow/` tree is ABSENT in every fresh worktree, so the symlink is what makes a worktree-relative `.simple-workflow/...` resolve to the shared main checkout. **W-8 (`.worktreeinclude` copy) is rejected in favour of the symlink** — copying state into each worktree would re-introduce the lost-update the single-writer rule prevents, whereas a symlink is a pointer to ONE shared `.simple-workflow/` inode (no copy, no divergence); the existing per-ticket-disjoint writes + the main-loop single-writer of `autopilot-state.yaml` keep concurrent executors race-free (the one shared-write exception — `/tune`'s `kb/` learning accumulator — is a documented bounded-degradation limitation, see wave-loop step 2a). +2. **Create the integration branch** `ap-integration/` from the session start ref (the orchestrator's current HEAD). This is a **local-only orchestration artifact** — NOT pushed, NOT the PR target. Wave 0's worktrees base on it; each wave boundary advances it (wave-loop step 4a); wave `k>0` bases on the advanced tip so cross-wave dependencies see committed changes. +3. **Create the dedicated integration worktree** ALSO under `/.claude/worktrees/` (e.g. `/.claude/worktrees/ap-integration-`), checked out on `ap-integration/`. **Recovery (resume after an interrupted merge):** the startup stale-sweep (step 4) scopes to this parent's per-ticket `ap--*` worktrees and does NOT match the differently-named `ap-integration-` worktree, so if a prior run died mid-merge inside it the worktree is still registered AND its branch may sit in a `MERGE_HEAD` (mid-merge) state. BEFORE this `git worktree add`, if `git worktree list` already contains `/.claude/worktrees/ap-integration-`, first run `git -C merge --abort 2>/dev/null || true` (drop any half-finished merge) then `git worktree remove --force `, and only THEN re-create it cleanly. The wave-boundary merges (step 4a) run here so the orchestrator's own cwd/branch (start-ref) is never churned. +4. **Startup stale-sweep** (tier 3 cleanup): `git worktree prune` (drop registrations for deleted dirs), then remove ONLY this parent's stale worktrees — `git worktree remove --force` each existing `/.claude/worktrees/ap--*` left by an abandoned prior run. **Never touch unrelated worktrees** (a different parent's `ap--*`, or any non-`ap-` worktree) — match strictly on this parent's `ap--` prefix. + +**`BASE_REF` lifecycle.** `BASE_REF` starts at `ap-integration/` (= the session start ref at wave 0). After each wave's integration (step 4a) `BASE_REF` is the advanced `ap-integration/` tip, so `WAVE_{k+1}`'s per-ticket worktrees (step 2a) branch from the integrated result of all earlier waves. + +**Post-loop (after all waves drained, before Split Autopilot Log):** remove the dedicated integration worktree (`git worktree remove --force /.claude/worktrees/ap-integration-`) — tier 2 cleanup. **KEEP the branches** (`ap-integration/` and every `ap//`) — cleanup removes worktrees only, never branches. `SW_PARALLEL_WORKTREE_KEEP=on` skips this tier-2 removal too (debugging). + +**`/ship` + PR are unchanged.** Each ticket still ships its own PR against `target_branch` (`/ship ticket-dir=`, NO `merge=true`). The integration branch is local-only and never the PR target; integrating into it is purely an orchestration step so later waves see earlier changes. The no-remote path still reaches `steps.ship: completed` locally AND the local integration merge still runs (a strength: cross-wave dependents see earlier changes even offline). `/ship`'s Step 5 logic is **byte-unchanged**: it runs in the executor's worktree, but the worktree's `.simple-workflow` symlink (step 2a) resolves its relative `.simple-workflow/...` paths — including the ticket-move — to ``, so no `/ship` path edit is needed (the `/ship` Step 5 note documents only this symlink resolution, W-3). + ### Split Autopilot Log Write overall `autopilot-log.md` at `briefs/active/{parent-slug}/` (or `briefs/done/` post-move; no brief dir → `product_backlog/{parent-slug}/`) AND per-ticket logs in each ticket dir (`done/...` if `/ship` Step 5 reached, else `active/...`). Per-ticket logs required. Frontmatter + per-ticket subsection + six common sections (`## Pipeline Execution`, `## Warnings`, `## Human Overrides`, `## KB Overrides`, `## Decisions Made`, `## Unreached Gates`) + Manual Bash Fallback rendering (`manual_bash_fallbacks[]` SSoT in `autopilot-state.yaml`; per-step `invocation_method == manual-bash` derived) live in [references/autopilot-log.md](references/autopilot-log.md). diff --git a/skills/autopilot/references/state-file.md b/skills/autopilot/references/state-file.md index 3e86e87..2b0fba1 100644 --- a/skills/autopilot/references/state-file.md +++ b/skills/autopilot/references/state-file.md @@ -243,15 +243,44 @@ steps.scout: {pending|completed|failed} steps.impl: {pending|completed|failed} steps.ship: {pending|completed|failed} pr_url: {url or null} +branch: {ap// or null} +head_sha: {worktree branch HEAD sha or null} failure_reason: {null or a short snake_case reason} ``` The main loop maps the envelope onto the canonical FLAT `steps:` schema (each `steps.` a string on its own line) and the ticket's `status`. A `null` `pr_url` is NOT a failure (a local-only ship with no remote still -reports `steps.ship: completed`). The `branch` / `head_sha` fields are -added with worktree isolation (Phase 2); they are absent at Phase 1 -concurrency 1 (no worktree, main checkout). +reports `steps.ship: completed`). The `branch` / `head_sha` fields ARE part +of the envelope as of T-008 (worktree isolation): + +- `branch` — the per-ticket isolation branch `ap//` the + executor's worktree was created on. `null` on the serial / concurrency-1 + path (no worktree = main checkout). On the `PARALLEL_MODE == on` wave + scheduler the main loop reads it at the wave boundary to integrate the + ticket's branch into `ap-integration/` (wave-loop step 4a). +- `head_sha` — the HEAD sha of that branch after `/ship` committed + (`git rev-parse HEAD` in the worktree); `null` when there was no commit or + no worktree. Lets the main loop record / verify the integrated tip. + +### `ap-integration/` — local-only orchestration branch (`PARALLEL_MODE == on`) + +The wave scheduler creates a per-parent integration branch +`ap-integration/` at Phase 2 init from the session start ref. It is a +**local-only orchestration artifact**: it is **NOT pushed** to any remote and +is **NOT the PR target** (each ticket still ships its OWN PR against the repo +default branch via `/ship ticket-dir=`, no +`merge=true`). At each wave boundary the main loop merges every +`status: completed` ticket's `ap//` branch into +`ap-integration/` (`--no-ff --no-edit`, topo/lex order, idempotent via +`git merge-base --is-ancestor`), so wave `k>0`'s per-ticket worktrees base on +the integrated tip and a cross-wave dependent sees its dependency's committed +changes (shared object store). A genuine conflict fails THAT ticket +(`failure_reason=integration_conflict_`) + cascade-skips its +dependents; the run continues (no auto-resolve). The branch and every +per-ticket `ap//` branch are KEPT after the run (cleanup +removes only the worktrees); on the serial (`PARALLEL_MODE == off`) path no +integration branch is created (byte-identical). ## Wave cursor (`PARALLEL_MODE != off`) — orchestrator-written, hook-read @@ -295,9 +324,12 @@ wave-parallel scheduler performs **exactly TWO** `autopilot-state.yaml` writes per wave: a **pre-wave write** (`READY_k` → `in_progress`, `current_wave` advanced, `wave_status: in_flight`) immediately before the wave's executors are spawned, and a **post-wave write** (transcribe each -returned envelope's `status`/`steps`/`pr_url`, fold the wave's -dependency-skips, set `wave_status: drained`) immediately after the -foreground barrier collects every envelope. The concurrently-running +returned envelope's `status`/`steps`/`pr_url`/`branch`/`head_sha`, apply any +integration-conflict status flip, fold the wave's dependency-skips, set +`wave_status: drained`) after the wave-boundary integration (SKILL.md step +4a) completes — which itself runs after the foreground barrier has collected +every envelope, so the post-wave write is the SINGLE persistence of both the +executor outcomes and the integration result. The concurrently-running `ticket-executor` subagents NEVER write `autopilot-state.yaml` — the main loop is the SOLE writer, so there is no concurrent-write window even though the wave's members are `in_progress` in parallel. An oversized wave diff --git a/skills/ship/SKILL.md b/skills/ship/SKILL.md index 960ca93..6d2e036 100644 --- a/skills/ship/SKILL.md +++ b/skills/ship/SKILL.md @@ -211,6 +211,9 @@ Failure paths (no-changes, no-remote, push failure, gh-auth failure, merge confl 4. **Post-commit verification**: `git status`. If tree still dirty or `git log -1 --format=%H` unchanged, report and stop. 5. **Ticket completion** (moves the ticket to `.simple-workflow/backlog/done/`): If `.simple-workflow/backlog/active/` exists, list it. Determine `ticket-dir`: + + > **Worktree path-resolution (W-3, autopilot `PARALLEL_MODE == on`).** Under the parallel wave scheduler, `/ship` runs inside a per-ticket executor worktree (cwd = `/.claude/worktrees/ap--`). The gitignored `.simple-workflow/` state tree is ABSENT in a fresh worktree, but the scheduler created a `.simple-workflow` → `/.simple-workflow` **symlink** inside the worktree (autopilot wave-loop step 2a), so EVERY bare relative `.simple-workflow/...` path in Step 5 — the 5.b ticket-move (`active/` → `done/`, the `mkdir -p` / `mv` / `rmdir` targets) AND the 5.d post-move rewrite surfaces (5.d.1 audit-round files, 5.d.2 the brief-side `autopilot-state.yaml`, 5.d.3 the autopilot-log) AND the no-remote local-ship path below — **transparently follows the symlink to the shared main checkout**. Step 5 therefore needs NO change and NO `ARTIFACT_ROOT` argument: the SAME bare relative paths resolve to `` via the symlink under a worktree, and to the cwd (= the main checkout) on the serial `/autopilot` / manual `/ship` path — behaviour is byte-identical in both. The per-ticket PR (`/ship ticket-dir=`, NO `merge=true`) and the no-remote `steps.ship: completed` carve-out are untouched. + - **Explicit `ticket-dir=`**: If provided, check `.simple-workflow/backlog/active/{dir-name}`. Exists → use it (skip branch matching). Else print WARNING "ticket-dir '{dir-name}' not found in .simple-workflow/backlog/active/ — falling back to branch name matching." and fall through. - **Fallback — branch matching**: For each dir in `.simple-workflow/backlog/active/`, strip the leading `NNN-` (e.g. `001-add-search-feature` → `add-search-feature`). If branch contains this slug, set `ticket-dir` to the full dir name. - No match → skip silently. diff --git a/tests/test-skill-contracts.sh b/tests/test-skill-contracts.sh index 958e3c7..d5468a0 100644 --- a/tests/test-skill-contracts.sh +++ b/tests/test-skill-contracts.sh @@ -10954,6 +10954,195 @@ assert_true \ "CT-WAVE-6 (empty-wave resume skip + parallel_max WARNING gated to != off): empty-wave ($ctw6_emptywave>=1) no-spawnable ($ctw6_nospawn>=1) WARNING-gated ($ctw6_warn_gated>=1) silent-off ($ctw6_warn_silent>=1)" \ "$ctw6_result" +# ============================================================================= +# CT-WORKTREE-* (T-008): per-executor worktree isolation + cross-wave integration. +# Each sub-check is NON-vacuous (HEAD=0; removing its target prose flips it). Modelled +# on the CT-WAVE-* set above (same Cat PARALLEL surfaces + assert_true idiom). +# ============================================================================= +PAR_SHIP="$REPO_DIR/skills/ship/SKILL.md" +PAR_ACEVAL="$REPO_DIR/agents/ac-evaluator.md" + +# CT-WORKTREE-1 (the single most important mechanical decision: explicit `git worktree add `, +# NOT EnterWorktree/baseRef). The wave loop pre-creates per-ticket worktrees off the integration +# branch via an EXPLICIT `git worktree add -b ... ` and DOCUMENTS that baseRef cannot +# target an arbitrary ref. Both halves must be present (the positive add-prose + the negative +# baseRef rejection). +ctwt1_explicit_add=$(grep -cF 'git worktree add -b ap//' "$PAR_AUTOPILOT" || true) +ctwt1_not_baseref=$(grep -ciE 'NOT .EnterWorktree./baseRef|baseRef is a binary .* git config and cannot target' "$PAR_AUTOPILOT" || true) +ctwt1_base_ref=$(grep -cF 'BASE_REF' "$PAR_AUTOPILOT" || true) +ctwt1_result="false" +if [ "$ctwt1_explicit_add" -ge 1 ] && [ "$ctwt1_not_baseref" -ge 1 ] && [ "$ctwt1_base_ref" -ge 1 ]; then ctwt1_result="true"; fi +assert_true \ + "CT-WORKTREE-1 (explicit git worktree add , NOT baseRef): explicit-add ($ctwt1_explicit_add>=1) baseRef-rejected ($ctwt1_not_baseref>=1) BASE_REF ($ctwt1_base_ref>=1)" \ + "$ctwt1_result" + +# CT-WORKTREE-2 (H3: per-ticket AND integration worktree paths BOTH pinned under .claude/worktrees/). +# A path outside .claude/worktrees/ is rejected at runtime by EnterWorktree(path=) and caught by NO +# test, so the prose pin is the only guard. Assert the per-ticket pin, the integration-worktree pin, +# and the H3 platform-acceptance note all appear in SKILL.md. +ctwt2_perticket=$(grep -cF '/.claude/worktrees/ap--' "$PAR_AUTOPILOT" || true) +ctwt2_integration=$(grep -cF '/.claude/worktrees/ap-integration-' "$PAR_AUTOPILOT" || true) +ctwt2_h3=$(grep -ciE 'H3 platform-acceptance|rejected by the executor.s .EnterWorktree' "$PAR_AUTOPILOT" || true) +# The executor side ALSO documents the .claude/worktrees/ pin + the acceptance constraint. +ctwt2_exec_pin=$(grep -cF '.claude/worktrees/ap--' "$PAR_EXECUTOR" || true) +ctwt2_result="false" +if [ "$ctwt2_perticket" -ge 1 ] && [ "$ctwt2_integration" -ge 1 ] && [ "$ctwt2_h3" -ge 1 ] \ + && [ "$ctwt2_exec_pin" -ge 1 ]; then ctwt2_result="true"; fi +assert_true \ + "CT-WORKTREE-2 (H3 .claude/worktrees/ pin both worktrees): per-ticket ($ctwt2_perticket>=1) integration ($ctwt2_integration>=1) H3-note ($ctwt2_h3>=1) executor-pin ($ctwt2_exec_pin>=1)" \ + "$ctwt2_result" + +# CT-WORKTREE-3 (state/artifact resolution via the .simple-workflow symlink + main_checkout_root init). +# The gitignored .simple-workflow/ tree is ABSENT in a worktree, so the scheduler creates a +# .simple-workflow -> symlink (step 2a) and the pipeline's relative .simple-workflow/... +# paths resolve to the shared main checkout through it; main_checkout_root is written ONCE at init; +# W-8 (.worktreeinclude copy) is rejected in favour of the symlink (shared inode, no lost-update). +ctwt3_symlink=$(grep -cF 'ln -s /.simple-workflow' "$PAR_AUTOPILOT" || true) +ctwt3_main_root_write=$(grep -ciE 'write .main_checkout_root. ONCE' "$PAR_AUTOPILOT" || true) +ctwt3_exec_symlink=$(grep -ciE 'State symlink|follow the symlink' "$PAR_EXECUTOR" || true) +ctwt3_w8=$(grep -ciE 'worktreeinclude' "$PAR_AUTOPILOT" || true) +ctwt3_result="false" +if [ "$ctwt3_symlink" -ge 1 ] && [ "$ctwt3_main_root_write" -ge 1 ] && [ "$ctwt3_exec_symlink" -ge 1 ] \ + && [ "$ctwt3_w8" -ge 1 ]; then ctwt3_result="true"; fi +assert_true \ + "CT-WORKTREE-3 (state/artifact via .simple-workflow symlink + main_checkout_root init + W-8): symlink-create ($ctwt3_symlink>=1) main_checkout_root-write ($ctwt3_main_root_write>=1) executor-symlink ($ctwt3_exec_symlink>=1) .worktreeinclude-rejected ($ctwt3_w8>=1)" \ + "$ctwt3_result" + +# CT-WORKTREE-4 (wave-boundary integration: --no-ff --no-edit, topo/lex order, conflict -> +# integration_conflict_ + cascade-skip + run continues, idempotent merge-base --is-ancestor, +# advance BASE_REF). The integration sequence prose in SKILL.md. +ctwt4_noff=$(grep -cF 'merge --no-ff --no-edit' "$PAR_AUTOPILOT" || true) +ctwt4_conflict=$(grep -cF 'integration_conflict_' "$PAR_AUTOPILOT" || true) +ctwt4_idempotent=$(grep -cF 'git merge-base --is-ancestor' "$PAR_AUTOPILOT" || true) +ctwt4_continues=$(grep -ciE 'run CONTINUES|run continues' "$PAR_AUTOPILOT" || true) +ctwt4_result="false" +if [ "$ctwt4_noff" -ge 1 ] && [ "$ctwt4_conflict" -ge 1 ] && [ "$ctwt4_idempotent" -ge 1 ] \ + && [ "$ctwt4_continues" -ge 1 ]; then ctwt4_result="true"; fi +assert_true \ + "CT-WORKTREE-4 (wave-boundary integration): --no-ff --no-edit ($ctwt4_noff>=1) conflict-reason ($ctwt4_conflict>=1) idempotent-is-ancestor ($ctwt4_idempotent>=1) run-continues ($ctwt4_continues>=1)" \ + "$ctwt4_result" + +# CT-WORKTREE-5 (integration-branch local-only doc + no-merge=true + per-ticket-PR preservation). +# ap-integration/ is NOT pushed / NOT the PR target; each ticket ships its OWN PR with no +# merge=true. Asserted on BOTH SKILL.md and state-file.md (the doc surface). +ctwt5_localonly=$(grep -ciE 'local-only orchestration (artifact|branch)' "$PAR_AUTOPILOT" || true) +ctwt5_notpushed=$(grep -ciE 'NOT pushed' "$PAR_AUTOPILOT" || true) +ctwt5_nomerge=$(grep -cF 'NO `merge=true`' "$PAR_AUTOPILOT" || true) +ctwt5_statefile=$(grep -ciE 'local-only orchestration (artifact|branch)' "$PAR_STATEFILE" || true) +ctwt5_result="false" +if [ "$ctwt5_localonly" -ge 1 ] && [ "$ctwt5_notpushed" -ge 1 ] && [ "$ctwt5_nomerge" -ge 1 ] \ + && [ "$ctwt5_statefile" -ge 1 ]; then ctwt5_result="true"; fi +assert_true \ + "CT-WORKTREE-5 (integration-branch local-only + no-merge + per-ticket PR): local-only ($ctwt5_localonly>=1) not-pushed ($ctwt5_notpushed>=1) no-merge=true ($ctwt5_nomerge>=1) state-file-doc ($ctwt5_statefile>=1)" \ + "$ctwt5_result" + +# CT-WORKTREE-6 (three-tier cleanup + parent-scoped stale-sweep). (1) per-ticket worktree remove +# --force on success + dirty-anomaly leave-and-log, (2) integration worktree removed post-loop +# (branches kept), (3) startup stale-sweep prune + remove ONLY this parent's ap--* (never +# unrelated). SW_PARALLEL_WORKTREE_KEEP=on skips tiers 1+2. +ctwt6_remove_force=$(grep -cF 'git worktree remove --force' "$PAR_AUTOPILOT" || true) +ctwt6_dirty=$(grep -cF '[PARALLEL] worktree-remove: dirty' "$PAR_AUTOPILOT" || true) +ctwt6_prune=$(grep -cF 'git worktree prune' "$PAR_AUTOPILOT" || true) +ctwt6_parent_scoped=$(grep -ciE 'Never touch unrelated worktrees|this parent.s ap--' "$PAR_AUTOPILOT" || true) +ctwt6_keep_knob=$(grep -cF 'SW_PARALLEL_WORKTREE_KEEP' "$PAR_AUTOPILOT" || true) +ctwt6_branches_kept=$(grep -ciE 'KEEP the branches|branches are ALWAYS kept|never the branch' "$PAR_AUTOPILOT" || true) +ctwt6_result="false" +if [ "$ctwt6_remove_force" -ge 1 ] && [ "$ctwt6_dirty" -ge 1 ] && [ "$ctwt6_prune" -ge 1 ] \ + && [ "$ctwt6_parent_scoped" -ge 1 ] && [ "$ctwt6_keep_knob" -ge 1 ] && [ "$ctwt6_branches_kept" -ge 1 ]; then ctwt6_result="true"; fi +assert_true \ + "CT-WORKTREE-6 (three-tier cleanup + parent-scoped sweep): remove-force ($ctwt6_remove_force>=1) dirty-log ($ctwt6_dirty>=1) prune ($ctwt6_prune>=1) parent-scoped ($ctwt6_parent_scoped>=1) KEEP-knob ($ctwt6_keep_knob>=1) branches-kept ($ctwt6_branches_kept>=1)" \ + "$ctwt6_result" + +# CT-WORKTREE-7 (SW_PARALLEL_WORKTREE_KEEP knob in CLAUDE.md, (B) harness-own co-located WITH the +# knob line — like CT-WAVE-3 was tightened: dropping the label on THIS entry while pre-existing +# entries keep theirs still fails the sub-check, not a bare repo-wide harness-own count). +ctwt7_knob=$(grep -cF 'SW_PARALLEL_WORKTREE_KEEP' "$PAR_CLAUDEMD" || true) +ctwt7_bsub=$(grep -iE 'SW_PARALLEL_WORKTREE_KEEP' "$PAR_CLAUDEMD" | grep -ciE 'harness-own' || true) +ctwt7_default_off=$(grep -iE 'SW_PARALLEL_WORKTREE_KEEP' "$PAR_CLAUDEMD" | grep -ciE 'default .off.' || true) +ctwt7_result="false" +if [ "$ctwt7_knob" -ge 1 ] && [ "$ctwt7_bsub" -ge 1 ] && [ "$ctwt7_default_off" -ge 1 ]; then ctwt7_result="true"; fi +assert_true \ + "CT-WORKTREE-7 (SW_PARALLEL_WORKTREE_KEEP knob in CLAUDE.md): knob ($ctwt7_knob>=1) (B)harness-own-colocated ($ctwt7_bsub>=1) default-off ($ctwt7_default_off>=1)" \ + "$ctwt7_result" + +# CT-WORKTREE-8 (ticket-executor worktree grant mirrors ac-evaluator). ac-evaluator carries the +# scoped Bash(git worktree add/remove/list) grant (NOT prune/lock); the executor mirrors that intent. +# Compare the two grants: ac-evaluator has all three scoped sub-commands in its tools: list; the +# executor documents the SAME add/list/remove scoping (NOT prune/lock) + the EnterWorktree pipeline. +acev_add=$(grep -cF 'Bash(git worktree add:*)' "$PAR_ACEVAL" || true) +acev_remove=$(grep -cF 'Bash(git worktree remove:*)' "$PAR_ACEVAL" || true) +acev_list=$(grep -cF 'Bash(git worktree list:*)' "$PAR_ACEVAL" || true) +exec_mirror=$(grep -ciE 'mirror.*ac-evaluator|ac-evaluator.s scoped' "$PAR_EXECUTOR" || true) +exec_scope=$(grep -ciE 'add. / .remove. / .list. (ONLY|only)|add/list/remove' "$PAR_EXECUTOR" || true) +exec_not_prune=$(grep -ciE 'NOT prune|never .prune|never .{0,4}prune' "$PAR_EXECUTOR" || true) +exec_enter=$(grep -cF 'EnterWorktree(path=)' "$PAR_EXECUTOR" || true) +ctwt8_result="false" +if [ "$acev_add" -ge 1 ] && [ "$acev_remove" -ge 1 ] && [ "$acev_list" -ge 1 ] \ + && [ "$exec_mirror" -ge 1 ] && [ "$exec_scope" -ge 1 ] && [ "$exec_not_prune" -ge 1 ] \ + && [ "$exec_enter" -ge 1 ]; then ctwt8_result="true"; fi +assert_true \ + "CT-WORKTREE-8 (executor worktree grant mirrors ac-evaluator): acev add ($acev_add>=1) remove ($acev_remove>=1) list ($acev_list>=1); executor mirror-note ($exec_mirror>=1) add/list/remove-scope ($exec_scope>=1) NOT-prune ($exec_not_prune>=1) EnterWorktree ($exec_enter>=1)" \ + "$ctwt8_result" + +# CT-WORKTREE-9 (envelope branch/head_sha additions, flipped to PRESENT). The ticket-executor envelope +# now carries branch + head_sha (T-008); the "NOT yet part of the envelope" note is FLIPPED to "ARE +# part". Asserted on BOTH ticket-executor.md and state-file.md. +ctwt9_exec_branch=$(grep -cF 'branch: {ap// or null}' "$PAR_EXECUTOR" || true) +ctwt9_exec_headsha=$(grep -ciE 'head_sha:' "$PAR_EXECUTOR" || true) +ctwt9_exec_flip=$(grep -ciE 'ARE part of the envelope as of T-008' "$PAR_EXECUTOR" || true) +ctwt9_state_branch=$(grep -ciE 'branch. / .head_sha. fields ARE part' "$PAR_STATEFILE" || true) +ctwt9_result="false" +if [ "$ctwt9_exec_branch" -ge 1 ] && [ "$ctwt9_exec_headsha" -ge 1 ] && [ "$ctwt9_exec_flip" -ge 1 ] \ + && [ "$ctwt9_state_branch" -ge 1 ]; then ctwt9_result="true"; fi +assert_true \ + "CT-WORKTREE-9 (envelope branch/head_sha PRESENT, flipped): executor branch ($ctwt9_exec_branch>=1) head_sha ($ctwt9_exec_headsha>=1) flip-note ($ctwt9_exec_flip>=1) state-file ($ctwt9_state_branch>=1)" \ + "$ctwt9_result" + +# CT-WORKTREE-10 (parallel=off byte-identity: ALL worktree code is inside the PARALLEL_MODE == on +# branch). The dedicated worktree subsection declares the off/metric-only forks add NO worktree code; +# the integration/cleanup steps are gated to PARALLEL_MODE == on only. +ctwt10_subsection=$(grep -cF 'Worktree isolation + cross-wave integration (`PARALLEL_MODE == on`)' "$PAR_AUTOPILOT" || true) +ctwt10_off_noworktree=$(grep -ciE 'add(s)? NO worktree code' "$PAR_AUTOPILOT" || true) +ctwt10_byte_identical=$(grep -ciE 'non-parallel run is byte-identical' "$PAR_AUTOPILOT" || true) +ctwt10_on_only=$(grep -cF 'PARALLEL_MODE == on` only' "$PAR_AUTOPILOT" || true) +ctwt10_result="false" +if [ "$ctwt10_subsection" -ge 1 ] && [ "$ctwt10_off_noworktree" -ge 1 ] && [ "$ctwt10_byte_identical" -ge 1 ] \ + && [ "$ctwt10_on_only" -ge 1 ]; then ctwt10_result="true"; fi +assert_true \ + "CT-WORKTREE-10 (parallel=off byte-identity, worktree code inside == on): subsection ($ctwt10_subsection>=1) off-no-worktree ($ctwt10_off_noworktree>=1) byte-identical ($ctwt10_byte_identical>=1) on-only-gated ($ctwt10_on_only>=1)" \ + "$ctwt10_result" + +# CT-WORKTREE-11 (/ship path-resolution audit pins .simple-workflow/ to under a worktree, +# W-3). ship/SKILL.md Step 5 documents that EVERY .simple-workflow/... path (5.b move, 5.d rewrite, +# no-remote) resolves to absolute, NOT a worktree-relative path; per-ticket PR + no-remote +# carve-out unchanged. +ctwt11_w3=$(grep -ciE 'Worktree path-resolution .W-3' "$PAR_SHIP" || true) +ctwt11_symlink=$(grep -ciE 'follows the symlink to the shared main checkout|resolve to .. via the symlink' "$PAR_SHIP" || true) +ctwt11_nochange=$(grep -ciE 'Step 5 therefore needs NO change|NO .ARTIFACT_ROOT. argument' "$PAR_SHIP" || true) +ctwt11_unchanged=$(grep -ciE 'carve-out are untouched' "$PAR_SHIP" || true) +ctwt11_result="false" +if [ "$ctwt11_w3" -ge 1 ] && [ "$ctwt11_symlink" -ge 1 ] && [ "$ctwt11_nochange" -ge 1 ] \ + && [ "$ctwt11_unchanged" -ge 1 ]; then ctwt11_result="true"; fi +assert_true \ + "CT-WORKTREE-11 (/ship Step 5 symlink-resolution W-3): W-3-note ($ctwt11_w3>=1) symlink-resolve ($ctwt11_symlink>=1) Step5-no-change ($ctwt11_nochange>=1) PR/no-remote-untouched ($ctwt11_unchanged>=1)" \ + "$ctwt11_result" + +# CT-WORKTREE-12 (T-008 verify-round fixes: the user-chosen .simple-workflow symlink mechanism [W-3] +# + integration reordered BEFORE the post-wave write so an integration-conflict status flip IS +# persisted [cascade-skip correctness] + the integration-worktree mid-merge recovery guard). All on +# the autopilot SKILL.md. The adversarial T-008 review (lens 2 FAIL on W-3 doc-only; my own +# integration-ordering finding; lens 1 mid-merge nit) required all three; removing any one fails this. +ctwt12_symlink_nocopy=$(grep -ciE 'NOT a .\.worktreeinclude. copy|share a single' "$PAR_AUTOPILOT" || true) +ctwt12_integration_order=$(grep -ciE 'runs AFTER the barrier but BEFORE the post-wave write' "$PAR_AUTOPILOT" || true) +ctwt12_flip_persist=$(grep -cF 'apply the integration-conflict status flips from step 4a' "$PAR_AUTOPILOT" || true) +ctwt12_recovery=$(grep -cF 'Recovery (resume after an interrupted merge)' "$PAR_AUTOPILOT" || true) +ctwt12_result="false" +if [ "$ctwt12_symlink_nocopy" -ge 1 ] && [ "$ctwt12_integration_order" -ge 1 ] && [ "$ctwt12_flip_persist" -ge 1 ] \ + && [ "$ctwt12_recovery" -ge 1 ]; then ctwt12_result="true"; fi +assert_true \ + "CT-WORKTREE-12 (verify-round: symlink-no-copy + integration-before-postwrite + conflict-flip-persist + mid-merge-recovery): symlink-no-copy ($ctwt12_symlink_nocopy>=1) integration-order ($ctwt12_integration_order>=1) flip-persist ($ctwt12_flip_persist>=1) mid-merge-recovery ($ctwt12_recovery>=1)" \ + "$ctwt12_result" + echo "" # ============================================================================= From fad0913f9c520d23b41fe31976a7db62989448f3 Mon Sep 17 00:00:00 2001 From: aimsise Date: Fri, 26 Jun 2026 00:13:18 +0900 Subject: [PATCH 09/10] release(v9.0.0)!: default-on flip (uc + parallel) + wave-parallel autopilot BREAKING: two run-defaults flip from opt-in to on for a bare invocation. uc (ultracode orchestration) default off->on (T-002 prose, reaffirmed here): M+ tickets run their AC evaluation as a parallel multi-verifier panel. parallel (wave-parallel ticket execution) default off->on: a multi-ticket /autopilot (and /brief chain=on) run executes wave-by-wave through one ticket-executor subagent per topologically-ready ticket, each in an isolated git worktree, merged at wave boundaries, instead of the inline serial loop. The parallel flip is the FINAL hunk, gated on the full Phase 2 rework (T-004/5/6 wave-aware Stop/checkpoint/auto-compact) + the wave scheduler (T-007) + worktree isolation (T-008) ALL landing in this release AND on the T-005 R-SUBSTOP spike resolving to RELOCATE (SubagentStop fires with the executor transcript and the checkpoint guards enforce). uc has no hook dependency and rides v9.0.0 for the breaking-when-in-doubt bundling. Safety inverts to the OPT-OUT path, byte-identical to v8.7.0: uc=off restores the single-evaluator Agent path; parallel=off (or SW_PARALLEL_TICKETS_MODE=off / SW_PARALLEL_HOOKS_MODE=off) restores the inline serial loop (no ticket-executor spawn, no wave cursor, no worktree machinery, parallel_mode: state field omitted, every hook firing as before). L3/R4: an unknown parallel= (and uc=) value fails SAFE to off (uniform fail-closed direction), the parallel coercion observable via [PARALLEL-MODE] mode=off active=n reason=invocation-unknown-value-failsafe. mode= alias removal DEFERRED (decision: defer): the deprecated /brief mode=auto|manual alias stays functional; every shipped reference (the runtime WARNING, README signature, the mode-independence guard note, the agent-spawn-prompts precedence note) was corrected this release from 'removed in v9.0.0' to 'a future major (deferred from v9.0.0)' so no v9.0.0 artifact claims its own removal in the shipping version. CHANGELOG ### Removed records the deferral honestly. Documented limitation (decision: ship): under concurrency > 1, /ship Step 6 /tune read-modify-writes the shared .simple-workflow/kb/ accumulator, racing to a bounded KB-learning lost-update (best-effort fidelity drop, NOT a correctness bug; ticket execution + status + autopilot-state.yaml unaffected). Serializing /tune is the documented follow-up. Adversarial verify: a 2-lens pass FAILED on a mode= deferral inconsistency (README falsely claimed the alias was removed in v9.0.0); fixed; a final re-verify is PASS_WITH_NITS (0 blockers). plugin.json 9.0.0 == newest CHANGELOG [9.0.0] (CT-MODE-14); real ISO date 2026-06-25. A GitHub Discussions migration guide is drafted (gitignored .docs/, post at release time). No agent contract or spawn-prompt changed (agents/ untouched). Tests: skill-contracts 898/898 (+ CT-PARALLEL-7..10: default flip, parallel=off byte-identity, bare-default-on routes to wave-parallel, unknown->off failsafe; CT-UC-ORCH-5 uc=off byte-identity), path 145/145, accept-set-verify 32/32, state-transition-guard 16/16, ShellCheck clean, DECONTAM 0. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Mw2bH4wbEPeebXsvSG6rWe --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 22 +++++ CLAUDE.md | 4 +- README.md | 4 +- skills/autopilot/SKILL.md | 16 ++-- skills/autopilot/references/state-file.md | 15 ++-- skills/brief/SKILL.md | 15 ++-- .../references/agent-spawn-prompts.md | 2 +- tests/test-skill-contracts.sh | 83 ++++++++++++++++++- 9 files changed, 133 insertions(+), 30 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index fb15c4f..a5e8e7f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "simple-workflow", "description": "The Claude Code plugin for an end-to-end AI development workflow — ticket management, multi-agent code review, security audit, and automated PR creation — built on a Harness for long-running AI agents with strict context management and cross-session learning.", - "version": "8.7.0", + "version": "9.0.0", "author": { "name": "aimsise", "url": "https://github.com/aimsise" diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d4c3c8..05bc014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [9.0.0] — 2026-06-25 + +**TL;DR.** **BREAKING (default flips).** Two run-defaults flip from opt-in to **on** for a bare invocation: **ultracode orchestration** (`uc`) and **wave-parallel ticket execution** (`parallel`). A bare `/autopilot ` (and `/brief ` under the `chain=on` default) now writes `ultracode_mode: on` + `parallel_mode: on`, routes M+ tickets through the parallel multi-verifier eval panel, and executes a multi-ticket run wave-by-wave through one `ticket-executor` subagent per topologically-ready ticket (each in an isolated git worktree, merged at wave boundaries) instead of the inline serial loop. The safety guarantee inverts to the **opt-out** path: `uc=off` restores the v8.7.0 single-evaluator Agent path; `parallel=off` (or the `SW_PARALLEL_TICKETS_MODE=off` / `SW_PARALLEL_HOOKS_MODE=off` env kill switches) restores the v8.7.0 inline serial loop — **byte-identical** in artifacts, state, and verdict. The `parallel` flip rides on the full Phase 2 hook rework (T-004/5/6 wave-aware Stop / checkpoint / auto-compact), the wave scheduler (T-007), and worktree isolation + cross-wave integration (T-008), all landed in this release, AND on the **T-005 R-SUBSTOP spike resolving to RELOCATE** (the checkpoint guards relocate to `SubagentStop`, fire on the executor transcript, and enforce there). A fat-fingered `parallel=` value fails **safe to `off`** (the proven serial path, uniform with `uc=` unknown→off), surfaced via `[PARALLEL-MODE] mode=off active=n reason=invocation-unknown-value-failsafe`. + +### Changed + +- **BREAKING — `uc` (ultracode orchestration) default flips off→on.** With no `uc=` token, `/autopilot` / `/impl` / (`chain=on`) `/brief` now resolve `UC_ORCH = on`: M+ tickets run their AC evaluation as a parallel multi-verifier panel via the Workflow tool. **Migration:** pass `uc=off` to restore the v8.7.0 single-evaluator Agent path (byte-identical — no Workflow dispatch, no UC-FLOOR raise). Token cost rises for every M+ ticket on a bare run because of the added panel lenses (see the README "Sub-agents consume API tokens" note); `uc=off` is the cost-revert. The `uc` flip has no hook dependency and is independent of the spike; it rides v9.0.0 for the "breaking when in doubt" bundling. +- **BREAKING — `parallel` (wave-parallel ticket execution) default flips off→on.** With no `parallel=` token, `/autopilot` (and `/brief chain=on`) now resolve `PARALLEL_MODE = on` and execute a multi-ticket run wave-parallel through `ticket-executor` subagents in isolated worktrees. **Migration:** pass `parallel=off` (per-invocation) or set `SW_PARALLEL_TICKETS_MODE=off` / `SW_PARALLEL_HOOKS_MODE=off` (the env panic button for a `/brief`-chained invocation you cannot edit) to restore the v8.7.0 inline serial loop — byte-identical: no `ticket-executor` spawn, no wave cursor, no worktree machinery, the `parallel_mode:` state field omitted, every hook firing exactly as in v8.7.0. This flip is gated on the full Phase 2 rework + the T-005 spike verdict being RELOCATE (both satisfied in this release); defaulting parallel on before the rework — or with the checkpoint guards blind — would silently kill auto-compaction across long runs and/or blind the guards on every run, the precise failures Phase 2 prevents. +- **Unknown-value posture is uniform fail-safe → off (L3 / R4).** An unknown `parallel=` (and `uc=`) invocation value coerces to **off** (the proven serial / Agent path), reconciling the argument-side parser with the hook-side `resolve_parallel_mode` (both fail closed to off). For `parallel`, the coercion is observable: `[PARALLEL-MODE] mode=off active=n reason=invocation-unknown-value-failsafe`. **Migration:** none — this only hardens the opt-out guarantee so a typo never silently opts INTO the less-proven parallel machinery; the prior "coerce unknown to the on default" posture is rejected as inverting the conservative direction. + +### Removed + +- **`mode=` alias removal — DEFERRED, NOT executed in v9.0.0.** The deprecated `/brief mode=auto|manual` alias (superseded by `chain=on|off`) was slated for removal in v9.0.0, but its removal is **out of scope for this release** (T-009 changes default *values*, not the alias surface). The alias and its `WARNING: 'mode=' is deprecated and will be removed in a future major version …` deprecation line still ship and remain functional. The removal is carried forward to a future major; every shipped reference to it (the runtime `/brief` WARNING, the README `/brief` signature, the `mode independence guard` note, and the `agent-spawn-prompts.md` precedence note) was corrected this release to name **"a future major"** rather than v9.0.0, so no v9.0.0 artifact claims its own removal in the version that is shipping. (Recorded honestly here rather than claiming a removal that did not happen — an R4 doc-truthfulness obligation.) + +### Verification + +- `bash tests/test-skill-contracts.sh` **898/898** (+ **CT-PARALLEL-7..10**: the parallel default off→on flip, the explicit `parallel=off` byte-identity opt-out, the bare-default-on-routes-to-wave-parallel anchor, and the unknown→off fail-safe; the existing **CT-PARALLEL-1..6** / **CT-PARALLEL-CURSOR-*** / **CT-PARALLEL-SUBSTOP-*** / **CT-WAVE-*** / **CT-WORKTREE-1..12** greens carry; **CT-UC-ORCH-5** reaffirms the `uc=off` byte-identity); `bash tests/test-path-consistency.sh` **145/145**; `bash tests/test-accept-set-verify.sh` **32/32**. `plugin.json` `9.0.0` == newest CHANGELOG `[9.0.0]` (CT-MODE-14, which reads the newest entry dynamically); the real ISO date is verified by the manual pre-flight (CT-MODE-13 is hardcoded to the `[6.0.0]` header and does NOT guard the `[9.0.0]` date). +- **Opt-out byte-identity.** `/autopilot {slug} uc=off parallel=off` is byte-identical to v8.7.0 (inline serial loop + Agent-path evaluator): the `parallel=off` lane omits the `parallel_mode:` state field and adds no code path (the serial-fork literals `parallel=off … adds NO code path` / `the serial loop is untouched` survive verbatim); `uc=off` preserves the `byte-identical to v8.5.0` literal. The two args are a uniform 2-peer set (one `[*-MODE] mode=… active=… reason=…` emit shape, one opt-out-byte-identical contract, one `chain=off`-silent rule, one unknown→off fail-safe rule). +- **Phase 2 rework + scheduler + worktree evidence (landed this release).** T-004/5/6 wave-aware hooks (autopilot-continue wave-aware continuation; checkpoint guards relocated to `SubagentStop` with the parallel main-`Stop` stand-down; per-wave-drained auto-compact re-key) ship green; T-007 wave scheduler + `parallel_max` cap + the H2 cascade-skip carve-out fix; T-008 worktree isolation + cross-wave integration via the shared-tree `.simple-workflow` symlink. +- **R-SUBSTOP spike verdict = RELOCATE.** The T-005 spike resolved to RELOCATE (confirmed empirically: `SubagentStop` fires with the executor transcript and the checkpoint guards enforce on it), which is the first-class release input that gates the `parallel` default flip. Under RELOCATE the flip ships; had it resolved to STAND-DOWN, the parallel default would have stayed opt-in for v9.0.0 with a barrier-not-guards caveat. +- **Documented limitation (T-008).** Under concurrency > 1 the `/ship` Step 6 `/tune` step read-modify-writes the shared `.simple-workflow/kb/` accumulator, racing to a bounded **KB-learning lost-update** — a tolerated best-effort-learning fidelity drop, NOT a correctness bug (ticket execution + status + `autopilot-state.yaml` are unaffected). Serializing `/tune` is the documented follow-up. + ## [8.7.0] — 2026-06-24 **TL;DR.** Adds **forward-direction lossless verification (MR-ROUNDTRIP)** — the write-side counterpart to the parse-side MR-CANONICAL in the executed accept-set sweep. When a ticket pairs a reader (parse / decode) with a **canonical writer** (format / serialize / encode) that advertises a **round-trip / lossless / exact / canonical** guarantee, the `ac-evaluator` now drives exactly-representable values **through the real writer** and verifies `parse(format(x)) === x` over a grammar-derived **inter-anchor intermediate band** (including the just-below-promotion extremes where significant-figure rounding would fire), with an independent `parse∘format` oracle built from first principles — catching a writer that silently rounds an exactly-representable value to a lossy canonical string at `rc=0`, a class invisible to every parse-side relation. A deterministic **W-axis writer-pairing trigger** (`/impl` Step 3a) recognizes the paired reader+writer even when the only lexical cue sits on the reader, and a recognition-independent hook backstop (`P5` forward-depth, `P6` round-trip-mislabel) makes a shallow or mislabeled forward sweep un-shippable. Strictly **meet-or-beat**: the W-axis relation only ADDS coverage; absent a paired canonical-writer boundary it never triggers, and the same kill switches (`SW_ACCEPT_SET_CONFORMANCE_MODE=off` / `constraints.accept_set_conformance: off`) revert byte-for-byte. Validated dogfood58 → dogfood59: a paired `parse` + `format` subject drove `boundary=W roundtrip=y intermediate-sampled=y` end-to-end across three tickets (corpus 9190 IEC + 9004 SI, 0 divergences), shipped a correct + doc-truthful writer (the silent-loss regression class foreclosed in both code and README), and locked the round-trip + Unicode-digit complement RED in the committed test suite. diff --git a/CLAUDE.md b/CLAUDE.md index aa3fe67..d2b55c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,8 +38,8 @@ This rule was distilled from a v6.7.0 dogfood incident in which a verify hook ne - `SW_STATE_FIELD_GUARD_MODE` — default `metric-only`. Gates the **HOOK_OWNED_FIELDS enforcement** in `hooks/pre-write-safety.sh` / `hooks/pre-edit-safety.sh`. The registry now ships with `.runtime_metrics` (an append-only telemetry list written exclusively by the six runtime-metrics writer hooks — Foundation 3 / ST-03), so a model Write/Edit that would clobber hook-appended `runtime_metrics` entries (a lost-update) is detectable. Values: `on` (emit `decision:block` whose reason NAMES the violated field — e.g. `.runtime_metrics` — and references `docs/state-schema.md`), `metric-only` (default — log `[STATE-FIELD-GUARD] metric-only: would block ...` to stderr and ALLOW, so populating the registry does NOT change the shipped allow-by-default behaviour), `off` (allow silently). Unknown values collapse to `metric-only`. **Promotion**: ship at `metric-only`; after one dogfood confirms no false trips, set `=on` to enforce. Detection covers the inline `runtime_metrics: []` → `[…]` (and blank-out) form; a multi-line list mutation expressed without an inline value change is not detected (acceptable under the metric-only default). - `SW_ACCEPT_SET_CONFORMANCE_MODE` — default `on` (v8.5.0). Controls `hooks/accept-set-verify.sh`, the PostToolUse(Write|Edit) gate that deterministically verifies the persisted `## Accept-set sweep` section of an `eval-round-{n}.md` report (the Advertised-Accept-Set Conformance observability line written by `agents/ac-evaluator.md`'s Persistence-First terminal rewrite). The hook reads the EMITTED line and applies the lens's OWN self-incrimination rule with zero model recall — BLOCKING on a triggered boundary not run (P1), an alphabet/unicode (A/U-axis) sweep that skipped the astral complement (P2), or an authoritative divergence not driven to FAIL (P4); plus a non-blocking ADVISORY note when an A/U-axis corpus falls below `SW_AASC_CORPUS_FLOOR` (P3 — corpus-size is a weak depth proxy, so a thin corpus is surfaced, NEVER blocked; the dogfood51 confirmation run showed flooring it false-trips a legitimately-thin-but-conformant sweep, so P3 was demoted to advisory). The keyed (K) / canonical-writer (W) axes are exempt from the astral/corpus checks (a reflection-derived key corpus is legitimately small), `caveat=no-runnable-artifact` exempts P1/P2 and the P3 advisory (a compiled-language fail-open degradation), and the `## Accept-set sweep` header is matched case-insensitively (a mis-cased header cannot let a whole report skip the gate). Values: `on` (DEFAULT, v8.5.0 — enforce: emit a PostToolUse `decision:block` whose reason names the violated predicate, surfacing it to the evaluator to re-run the sweep), `metric-only` (observe — log `[ACCEPT-SET-VERIFY] metric-only: would block ...` to stderr and ALLOW), `off` (explicit opt-out, silent). Unknown values collapse to `metric-only` (since the default is `on`, the var is set only to DOWNGRADE, so a typo on a downgrade value falls back to the safe observe mode — never a surprise enforce, never a silent disable). The exit code is ALWAYS 0 (fail-OPEN — the hook can never break a Write/Edit; the only non-allow influence is the `on`-mode block JSON), and a missing `jq` is a silent `exit 0`. **Promoted to `on` by default in v8.5.0** after dogfood51/52 (14 real conformant reports → 0 false-trips) + a live `decision:block` de-risk (dogfood53: the block surfaces cleanly and the evaluator handles it gracefully without thrash, and is NOT pressured into fabricating a conformant line); set `metric-only` to revert to observe-only, `off` to disable. Verified by `tests/test-accept-set-verify.sh`. This is the runtime, recognition-independent half of the per-brief `constraints.accept_set_conformance` switch (which gates whether the EXECUTED sweep runs at all). - `SW_AASC_CORPUS_FLOOR` — default `256`. The ADVISORY threshold for the P3 thin-corpus NOTE in `hooks/accept-set-verify.sh` (a thinner A/U corpus is surfaced to stderr as `[ACCEPT-SET-VERIFY] advisory: ...`, NEVER blocked), applied ONLY to a `triggered=y ran=y` alphabet (A) or unicode-transform (U) boundary line — the axes whose mandated complement (the Unicode decimal-digit property across the BMP and astral planes) is naturally large. A descriptive corpus-size (e.g. `5-canonical-forms`) is parsed by its leading integer so an annotation cannot dodge the note. Lower it (e.g. `=8`) to silence the note on a legitimately small advertised accept-set; raise it to surface more sweeps. Honoured only when `SW_ACCEPT_SET_CONFORMANCE_MODE` is not `off`. -- `SW_PARALLEL_TICKETS_MODE` — default `off`. The run kill switch for the run-scoped parallel ticket-execution path. Controls whether `/autopilot` (and a `/brief chain=on` chained run) routes the cross-ticket `/scout`→`/impl`→`/ship` pipeline through one `ticket-executor` subagent per ready ticket / topological wave, instead of the inline serial loop. Values: `off` (DEFAULT = the **prior-version behaviour**: every ticket runs inline in the main loop, strictly serial — no `ticket-executor` is spawned and every hook fires exactly as before, byte-identical to a no-directive run), `on` (force the executor-routed path even when no `parallel=` argument was supplied), `metric-only` (log the resolved wave plan + the `[PARALLEL-MODE]` line but execute the inline serial path). The per-run `parallel=` argument on `/autopilot` / `/brief` selects the same tri-state; this environment knob is the global override / kill switch and resolves **safe to `off`** on an unknown value (the same fail-safe direction as `uc=`), so a parallel path that misbehaves can be disabled from the shell without editing any invocation. The deterministic env-vs-argument precedence is applied by the parallel-mode resolver helper that the wave-aware hooks consult. This is a **(B) harness-own** substrate per `## Product/Language/Domain Agnosticism` — it governs the plugin's own orchestration engine (the `/autopilot` execution path), not the user's product, language, or domain. -- `SW_PARALLEL_HOOKS_MODE` — default = follow the run's `parallel_mode:` state. The shared HOOK-side kill switch for the parallel-aware Stop / checkpoint / auto-compact rework. It is read by the single resolver `hooks/lib/parse-state-file.sh::resolve_parallel_mode `, whose precedence is `SW_PARALLEL_HOOKS_MODE` (env override) > `parallel_mode:` scalar in `autopilot-state.yaml` > `off`. Values: `on` (force every parallel-aware hook onto the wave-aware path), `metric-only` (observe — log the resolved mode but take the serial decision), `off` (force serial — the prior-version hook behaviour). When the env knob is UNSET (the default), the resolver follows the run-scoped `parallel_mode:` (so the per-run `parallel=` argument transitively drives the hooks); when SET, it overrides. An unknown / garbage value resolves **safe to `off`** (= serial / byte-identical, the proven path), and the resolver never returns empty — every ambiguity (unknown env, absent/unknown state scalar, missing state file) fails CLOSED to `off`. This is the canonical fail-safe direction the whole parallel feature uses (the same `unknown → off` the argument parser adopts). A **(B) harness-own** substrate per `## Product/Language/Domain Agnosticism` — it governs the plugin's own hook plumbing, not the user's product, language, or domain. +- `SW_PARALLEL_TICKETS_MODE` — default `off` (env-knob-unset; **the run default is now `on`** as of v9.0.0 — a bare `/autopilot` / `/brief chain=on` runs wave-parallel). Setting `=off` is the serial-revert kill switch: the **byte-identical opt-out** that forces the v8.7.0 inline serial loop. Controls whether `/autopilot` (and a `/brief chain=on` chained run) routes the cross-ticket `/scout`→`/impl`→`/ship` pipeline through one `ticket-executor` subagent per ready ticket / topological wave, instead of the inline serial loop. Values: `off` (force the **prior-version behaviour** = serial revert / byte-identical opt-out: every ticket runs inline in the main loop, strictly serial — no `ticket-executor` is spawned and every hook fires exactly as before, byte-identical to a v8.7.0 run), `on` (force the executor-routed path — now also the absent-`parallel=`-token run default), `metric-only` (log the resolved wave plan + the `[PARALLEL-MODE]` line but execute the inline serial path). The per-run `parallel=` argument on `/autopilot` / `/brief` selects the same tri-state; this environment knob is the global override / serial kill switch and resolves **safe to `off`** on an unknown value (the same fail-safe direction as `uc=`), so a parallel path that misbehaves can be disabled from the shell without editing any invocation. **R4 / L3 unknown-value posture (v9.0.0):** an unknown `parallel=` *invocation argument* coerces to **off** (fail-safe to the proven serial path — consistent with `uc=` unknown→off and with `resolve_parallel_mode`'s own unknown→off rule), SURFACED via `[PARALLEL-MODE] mode=off active=n reason=invocation-unknown-value-failsafe`; a fat-fingered value runs the byte-identical serial path, never the less-proven parallel machinery (the prior "coerce unknown to the on default" posture is rejected as inverting the conservative direction). The deterministic env-vs-argument precedence is applied by the parallel-mode resolver helper that the wave-aware hooks consult. This is a **(B) harness-own** substrate per `## Product/Language/Domain Agnosticism` — it governs the plugin's own orchestration engine (the `/autopilot` execution path), not the user's product, language, or domain. +- `SW_PARALLEL_HOOKS_MODE` — default = follow the run's `parallel_mode:` state (**now `on` by default** as of v9.0.0, since a bare run writes `parallel_mode: on`). The shared HOOK-side serial-revert kill switch for the parallel-aware Stop / checkpoint / auto-compact rework: setting `=off` forces the prior-version (byte-identical opt-out) hook behaviour regardless of the run's `parallel_mode:`. It is read by the single resolver `hooks/lib/parse-state-file.sh::resolve_parallel_mode `, whose precedence is `SW_PARALLEL_HOOKS_MODE` (env override) > `parallel_mode:` scalar in `autopilot-state.yaml` > `off`. Values: `on` (force every parallel-aware hook onto the wave-aware path), `metric-only` (observe — log the resolved mode but take the serial decision), `off` (force serial = byte-identical opt-out — the prior-version hook behaviour). When the env knob is UNSET (the default), the resolver follows the run-scoped `parallel_mode:` (so the per-run `parallel=` argument transitively drives the hooks; with the v9.0.0 run default `on`, an unset env knob now follows the parallel path on a bare run); when SET, it overrides. An unknown / garbage value resolves **safe to `off`** (= serial / byte-identical, the proven path), and the resolver never returns empty — every ambiguity (unknown env, absent/unknown state scalar, missing state file) fails CLOSED to `off`. This is the canonical fail-safe direction the whole parallel feature uses (the same `unknown → off` the argument parser adopts). A **(B) harness-own** substrate per `## Product/Language/Domain Agnosticism` — it governs the plugin's own hook plumbing, not the user's product, language, or domain. - `SW_PARALLEL_MAX_CONCURRENCY` — default `4`. The concurrency cap for the wave-parallel scheduler: the maximum number of `ticket-executor` subagents `/autopilot` spawns concurrently in a single message within one topological wave. A wave with more ready tickets than the cap is processed in lex-ordered sub-batches of at most this size, so the depth-2 executor fan-out (each executor's own `/impl`→`implementer`+`ac-evaluator` sub-spawns) never exceeds a bounded concurrency / token budget. Precedence: the per-run `parallel_max=` argument on `/autopilot` (case-insensitive `key=value`, integer ≥1) **over** this environment knob **over** the default `4` (arg > env > default — the same direction as `rounds=N`). A non-integer or `<1` value (in EITHER the argument or this env knob) coerces to the default `4` and emits a one-line `[PARALLEL-MODE] WARNING` to stderr naming the rejected value. This cap is **honoured only when `PARALLEL_MODE == on`** — on the serial / `metric-only` / `off` paths no executor is spawned concurrently, so the cap is inert (and never written to `autopilot-state.yaml`; it is re-resolved fresh on every `/autopilot` entry, never persisted). A **(B) harness-own** substrate per `## Product/Language/Domain Agnosticism` — it bounds the plugin's own orchestration fan-out (the `/autopilot` Agent-spawn engine), not the user's product, language, or domain. - `SW_PARALLEL_WORKTREE_KEEP` — default `off`. Debugging kill switch for the parallel wave scheduler's worktree cleanup (T-008). When the wave-parallel `/autopilot` path (`PARALLEL_MODE == on`) gives each `ticket-executor` an isolation worktree under `/.claude/worktrees/ap--` and a dedicated integration worktree under `/.claude/worktrees/ap-integration-`, the scheduler runs a three-tier cleanup: (1) per-ticket `git worktree remove --force` on executor success, (2) integration-worktree removal at Phase 2 end, (3) a startup stale-sweep (`git worktree prune` + remove only this parent's `ap--*` worktrees, never unrelated ones). Values: `off` (DEFAULT — run all three cleanup tiers; the per-ticket and integration worktrees are removed once their commits are on the kept branches), `on` (skip tiers (1) + (2) so the per-ticket + integration worktrees are LEFT on disk for post-run inspection — the startup stale-sweep (3) still runs on the next entry so leftovers do not accumulate across runs, and the branches are ALWAYS kept regardless). A dirty per-ticket worktree on cleanup is ALWAYS left on disk + logged `[PARALLEL] worktree-remove: dirty ` (independent of this knob — the run continues either way). The concurrency cap `SW_PARALLEL_MAX_CONCURRENCY` bounds the number of per-ticket worktrees alive simultaneously (per sub-batch), so the two knobs together cap the worktree disk/inode footprint of a wave (W-6 disk/inode pressure). Honoured only when `PARALLEL_MODE == on` — on the serial / `metric-only` / `off` paths no worktree is created, so the knob is inert. A **(B) harness-own** substrate per `## Product/Language/Domain Agnosticism` — it governs the plugin's own worktree-orchestration cleanup (git worktrees + the `/autopilot` engine), not the user's product, language, or domain. diff --git a/README.md b/README.md index db0d675..4c24785 100644 --- a/README.md +++ b/README.md @@ -74,10 +74,12 @@ claude plugin install simple-workflow@aimsise-simple-workflow --scope project Inside an active Claude Code session, type `/brief ` and the plugin handles the rest end-to-end: codebase investigation, requirements interview, ticket creation, implementation, multi-agent review, and pull request. -Full argument signature: `/brief [chain=on|off] [uc=on|off]` (default `chain=on`, `uc=on`). The `chain=on|off` form is canonical; `mode=auto|manual` is a deprecated legacy alias (`chain=on` ≡ `mode=auto`, `chain=off` ≡ `mode=manual`). +Full argument signature: `/brief [chain=on|off] [uc=on|off|metric-only] [parallel=on|off]` (default `chain=on`, `uc=on`, `parallel=on`). The `chain=on|off` form is canonical; `mode=auto|manual` is a deprecated legacy alias (`chain=on` ≡ `mode=auto`, `chain=off` ≡ `mode=manual`) — still accepted in v9.0.0 with a deprecation warning; slated for removal in a future major (deferred from v9.0.0). `/autopilot ` accepts the same `[uc=on|off|metric-only] [parallel=on|off]` tokens. **ultracode orchestration** is **on by default**: non-trivial (M+) tickets run their AC evaluation as a parallel multi-verifier panel via Claude Code's Workflow tool (forwarded `/brief` → `/autopilot` → each `/impl` and preserved across auto-`/compact`/resume; tier-appropriate model — Sonnet at `thorough`, Opus at `exhaustive`). Pass **`uc=off`** to revert to the byte-identical single-evaluator Agent path. Default-on applies under `chain=on` (the `/brief` default); under `chain=off` there is no chained `/autopilot`, so `uc` resolves `off`. Also accepted (as `uc=on|off|metric-only`) on `/autopilot ` and `/impl …`. Details: `skills/impl/SKILL.md`. +**Wave-parallel ticket execution** is also **on by default** (v9.0.0): under `/autopilot` (and `/brief chain=on`) a multi-ticket run executes its `/scout`→`/impl`→`/ship` pipeline through one `ticket-executor` subagent per topologically-ready ticket / wave (each in an isolated git worktree, merged at wave boundaries), instead of the inline serial loop. Pass **`parallel=off`** to revert to the byte-identical v8.7.0 inline serial loop; the `SW_PARALLEL_TICKETS_MODE=off` / `SW_PARALLEL_HOOKS_MODE=off` environment kill switches force serial globally (the panic button when you cannot edit a `/brief`-chained invocation). A fat-fingered `parallel=` value fails **safe to `off`** (the proven serial path), surfaced via `[PARALLEL-MODE] mode=off active=n reason=invocation-unknown-value-failsafe`. Default-on applies under `chain=on`; under `chain=off` there is no chained `/autopilot`, so `parallel` resolves `off`. + | Mode | Command | Result | |------|---------|--------| | Full automation (default, `chain=on`) | `/brief ` | Idea → PR with zero intervention; large scopes are auto-split into multiple tickets and executed in dependency order | diff --git a/skills/autopilot/SKILL.md b/skills/autopilot/SKILL.md index 57b7554..d3e9685 100644 --- a/skills/autopilot/SKILL.md +++ b/skills/autopilot/SKILL.md @@ -45,7 +45,7 @@ allowed-tools: # (wave-loop step 2a) so the gitignored state tree resolves to the shared main checkout (T-008). # Scoped to `ln -s` (symlink creation) only — never a hardlink/other ln form. - "Bash(ln -s:*)" -argument-hint: "" +argument-hint: " [uc=on|off|metric-only] [parallel=on|off]" --- ## Pre-computed Context @@ -87,7 +87,7 @@ Target parent-slug: $ARGUMENTS Before detecting `{parent-slug}`, extract the optional `uc=` token from `$ARGUMENTS` using the SAME case-insensitive `key=value` convention the pipeline already uses for other key=value args (mirrors `/impl` `rounds=N` at its Step 1a and `/brief` `chain=`): scan for a `uc=` token (key matched case-insensitively), accept `` ∈ `on` | `off` | `metric-only`, and **strip that token from `$ARGUMENTS`** so the remaining-text/parent-slug detection below is unaffected. Resolve `UC_ORCH` from the stripped value; **when the `uc=` token is absent, `UC_ORCH = on`** (the default — ultracode orchestration is on by default). Emit the resolution line — the SHARED CONTRACT marker `[UC-ORCH-MODE] mode={on|off|metric-only} active={y|n} reason={invocation|default}` to stderr (`active=y` only when `mode=on`, else `active=n`) — using `reason=invocation` when the value came from an explicit `uc=` token, and `reason=default` when the token was absent (the on-by-default path). `UC_ORCH` is carried through Phase 2 to each per-ticket `/impl` invocation (Step 3c) and recorded in the run-scoped state file (Phase 2 State file initialization). With no `uc=` token the resolution defaults to `on`, which routes M+ tickets through the eval-panel (additive verification); pass `uc=off` to restore the v8.7.0 Agent path. The bare-`{parent-slug}` parse below is unaffected either way (the `uc=` token is stripped before it runs). -After resolving `UC_ORCH`, similarly extract the optional `parallel=` token from `$ARGUMENTS` using the SAME case-insensitive `key=value` convention: scan for a `parallel=` token (key matched case-insensitively), accept `` ∈ `on` | `off` | `metric-only`, and **strip that token from `$ARGUMENTS`** so the parent-slug detection below is unaffected. Resolve `PARALLEL_MODE` from the stripped value; **when the `parallel=` token is absent, `PARALLEL_MODE = off`** (default). An unrecognised value resolves **safe to `off`** (the same fail-safe direction as `uc=`). **Emit the resolution line ONLY when `PARALLEL_MODE != off`** — the SHARED CONTRACT marker `[PARALLEL-MODE] mode={on|metric-only} active={y|n} reason=invocation` to stderr (`active=y` only when `mode=on`, else `active=n`), using `reason=invocation` here in Argument Parsing. **When `PARALLEL_MODE == off` (the default or absent token, or an unknown value coerced to off), emit NO `[PARALLEL-MODE]` line at all** — a non-parallel run is byte-identical to prior versions with zero new stderr noise. (This is a deliberate stricter-than-`uc=` posture: `[UC-ORCH-MODE]` is emitted even for `uc=off`, but the `parallel=off` lane is the load-bearing byte-identical opt-out / rollback path for the parallel feature, so it stays completely silent.) `PARALLEL_MODE` is **orthogonal** to `UC_ORCH` (composable: `/autopilot {slug} uc=on parallel=on`); it is carried through Phase 2 to select the per-ticket execution path (current inline serial branch vs one `ticket-executor` subagent per ticket) and recorded in the run-scoped state file (Phase 2 State file initialization). This is additive: with no `parallel=` token the resolution defaults to `off`, Phase 2 takes the current inline serial path, and behaviour is byte-identical to prior versions. A `SW_PARALLEL_TICKETS_MODE` environment knob (documented in `CLAUDE.md`) is the run kill switch that forces serial; its deterministic env-vs-argument precedence is applied by the parallel-mode resolver helper introduced alongside the wave-cursor schema. +After resolving `UC_ORCH`, similarly extract the optional `parallel=` token from `$ARGUMENTS` using the SAME case-insensitive `key=value` convention: scan for a `parallel=` token (key matched case-insensitively), accept `` ∈ `on` | `off` | `metric-only`, and **strip that token from `$ARGUMENTS`** so the parent-slug detection below is unaffected. Resolve `PARALLEL_MODE` from the stripped value; **when the `parallel=` token is absent, `PARALLEL_MODE = on`** (the v9.0.0 default — wave-parallel ticket execution is on by default). An unknown `parallel=` resolves **safe to `off`** (the proven serial path — the same fail-safe direction as `uc=` unknown→off and as `resolve_parallel_mode`'s own unknown→off; a fat-fingered value never silently opts INTO the parallel machinery), and **the coercion is observable**: emit `[PARALLEL-MODE] mode=off active=n reason=invocation-unknown-value-failsafe` to stderr so the fail-safe is surfaced. **Emit the resolution line whenever `PARALLEL_MODE != off`** — the SHARED CONTRACT marker `[PARALLEL-MODE] mode={on|metric-only} active={y|n} reason={invocation|default}` to stderr (`active=y` only when `mode=on`, else `active=n`), using `reason=invocation` when the value came from an explicit `parallel=` token and **`reason=default` when the token was absent (the on-by-default path — emit `[PARALLEL-MODE] mode=on active=y reason=default`)**. **When `PARALLEL_MODE == off` (an explicit `parallel=off` token, OR the env kill switch, but NOT the absent-token default — which now resolves `on`), emit NO `[PARALLEL-MODE]` line at all EXCEPT the `reason=invocation-unknown-value-failsafe` coercion emit above** — an explicit `parallel=off` run is byte-identical to prior versions with zero new stderr noise. (The `parallel=off` lane is the load-bearing byte-identical opt-out / rollback path for the parallel feature, so it stays completely silent; the only `off`-path stderr is the unknown-value coercion line, which exists precisely to surface a typo that landed on the fail-safe serial path.) `PARALLEL_MODE` is **orthogonal** to `UC_ORCH` (composable: `/autopilot {slug} uc=on parallel=on`); it is carried through Phase 2 to select the per-ticket execution path (current inline serial branch vs one `ticket-executor` subagent per ticket) and recorded in the run-scoped state file (Phase 2 State file initialization). With no `parallel=` token the resolution defaults to `on`, Phase 2 takes the executor-routed wave-parallel path; an explicit `parallel=off` (or the env kill switch, or an unknown value coerced to off) takes the inline serial path, and behaviour is byte-identical to prior versions. A `SW_PARALLEL_TICKETS_MODE` environment knob (documented in `CLAUDE.md`) is the run kill switch that forces serial; its deterministic env-vs-argument precedence is applied by the parallel-mode resolver helper introduced alongside the wave-cursor schema. After resolving `PARALLEL_MODE`, similarly extract the optional `parallel_max=` token from `$ARGUMENTS` using the SAME case-insensitive `key=value` convention: scan for a `parallel_max=` token (key matched case-insensitively), and **strip that token from `$ARGUMENTS`** so the parent-slug detection below is unaffected. Resolve the concurrency cap `CONCURRENCY_CAP` with **arg > env > default** precedence (the same direction as `/impl` `rounds=N`): the stripped `parallel_max=` argument takes priority, else the `SW_PARALLEL_MAX_CONCURRENCY` environment variable, else the **default `4`**. The accepted value is an **integer ≥ 1**; a **non-integer or `< 1` value** (in EITHER the argument or the env knob) is **coerced to the default `4`** (the token is still stripped from `$ARGUMENTS` regardless, so parent-slug detection is unaffected). **The coercion WARNING is emitted ONLY when `PARALLEL_MODE != off`** — a single one-line `[PARALLEL-MODE] WARNING parallel_max={rejected-value} invalid (non-integer or <1); using default 4` to stderr. **When `PARALLEL_MODE == off` (the byte-identical serial lane), NO `[PARALLEL-MODE] WARNING` is emitted even if `SW_PARALLEL_MAX_CONCURRENCY` carries a garbage value** — the cap is inert on the serial path, so a serial run stays byte-identical with zero new stderr (the same silent-when-`off` posture as the `[PARALLEL-MODE]` resolution marker above; a global `SW_PARALLEL_MAX_CONCURRENCY=garbage` must not leak a WARNING into an explicit `parallel=off` rollback run). `CONCURRENCY_CAP` is **honoured only when `PARALLEL_MODE == on`** (the wave scheduler); on the `metric-only` and `off` paths it is inert (no concurrent spawn). `parallel_max` is a per-invocation knob: it is resolved fresh on every `/autopilot` entry and is **NOT persisted** to `autopilot-state.yaml` (it is recomputed on resume, like the wave layering itself). Documented in `CLAUDE.md` as `SW_PARALLEL_MAX_CONCURRENCY`. @@ -157,13 +157,13 @@ re-emit the same block. 4. **Human override detection**: compare each gate in `autopilot-policy.yaml` to defaults for `risk_tolerance`. `conservative` defaults + `moderate` defaults: in [references/state-file.md](references/state-file.md). `aggressive` defaults: moderate + `aggressive ship_ci_pending.timeout_minutes: 60`, `aggressive constraints.max_total_rounds: 12`, `aggressive constraints.allow_breaking_changes: true`. Gate differs + `# kb-suggested` → `kb_override` else `human_override`. Render to `## Human Overrides` / `## KB Overrides`; `## Decisions Made` distinguishes `human_override` from `kb_override`. **Exclude `kb_override`** from `## Human Overrides`. No diff → "No human overrides detected." -5. **State recovery**: absent `autopilot-state.yaml` → `resume_mode = false`. Else `resume_mode = true`; emit `[RESUME] ...` summary (resume msg, execution mode, progress N/total, per-ticket status). **When `resume_mode == true`, READ the top-level `ultracode_mode:` scalar from the recovered `autopilot-state.yaml`** (use the same top-level YAML scalar read the rest of the pipeline relies on; `hooks/lib/parse-state-file.sh::parse_yaml_scalar ultracode_mode` is the canonical reader) and **reconstruct `UC_ORCH`** from it for the remainder of the run: a value of `on` / `off` / `metric-only` resolves directly, while a missing / `null` / empty value defaults `UC_ORCH = off` — this missing-field case only arises for a legacy state file written before `ultracode_mode` existed (a v9.0.0+ run always persists the field at state init, default `on`, so the on-by-default carries cleanly through resume; defaulting a field-less legacy state to `off` keeps that resume faithful to how it originally ran). Emit the SHARED CONTRACT resolution line `[UC-ORCH-MODE] mode={on|off|metric-only} active={y|n} reason=resume` to stderr (`active=y` only when `mode=on`), and add the reconstructed mode to the `[RESUME] ...` summary so the run-scoped orchestration mode is visible on resume. This re-read is what carries `uc` across auto-compact / resume without any hook change. **Likewise READ the top-level `parallel_mode:` scalar** from the recovered state (`hooks/lib/parse-state-file.sh::parse_yaml_scalar parallel_mode`) and **reconstruct `PARALLEL_MODE`** for the remainder of the run: `on` / `off` / `metric-only` resolves directly, while a missing / `null` / empty value defaults `PARALLEL_MODE = off`. **Only when `PARALLEL_MODE != off`**, emit the SHARED CONTRACT line `[PARALLEL-MODE] mode={on|metric-only} active={y|n} reason=resume` to stderr (`active=y` only when `mode=on`) and add the reconstructed parallel mode to the `[RESUME] ...` summary; **when `PARALLEL_MODE == off`, emit no `[PARALLEL-MODE]` line and add nothing to the `[RESUME] ...` summary** (a resumed non-parallel run is byte-identical to prior versions). This re-read carries the parallel execution mode across auto-compact / resume exactly as `ultracode_mode` carries `uc`. **Wave-aware resume (`PARALLEL_MODE == on`):** waves are **recomputed deterministically** from the (unchanged) dependency graph — NO wave index is persisted, so resume re-runs the level-synchronous Kahn layering and re-resolves `CONCURRENCY_CAP` from Argument Parsing (`parallel_max=` is likewise not persisted). The run **resumes from the first wave that still has a non-`completed` runnable ticket**; within each resumed wave, `READY_k` filters out already-`completed` tickets (`[RESUME] Skipping {logical_id}: already completed`) and re-runs the per-wave dependency re-eval (so a dependency that failed/skipped before the interruption still cascade-skips its dependents on resume). The wave cursor (`current_wave` / `wave_status`) is re-derived from the per-ticket terminal statuses, not read as authority (see [references/state-file.md](references/state-file.md) Wave-cursor resume semantics). If `started` is older than 7 days, emit `[RESUME] WARNING` to delete `autopilot-state.yaml` and re-run. Carry `ticket_mapping`. Per-ticket: `completed` → skip (`[RESUME] Skipping {logical_id}: already completed`); `failed`/`skipped` → retry first non-completed; `in_progress` → re-run; `pending` → normal. If state recovery cannot continue (e.g. unparseable `autopilot-state.yaml`, hostile working tree detected during this step, or any other Phase 1 precondition that newly fails here), emit `[AUTOPILOT-POLICY] gate=unexpected_error action=stop reason=state_recovery_hard_stop` and write `## Stop Reason` with `tag: policy_gate_stop` plus a resume hint of the form `Resume after fixing X with: /autopilot {parent-slug}`; never escalate to `AskUserQuestion`. Any existing verbatim `ERROR:` / `[RESUME] WARNING` literal continues to be emitted alongside the new `policy_gate_stop` exit path. +5. **State recovery**: absent `autopilot-state.yaml` → `resume_mode = false`. Else `resume_mode = true`; emit `[RESUME] ...` summary (resume msg, execution mode, progress N/total, per-ticket status). **When `resume_mode == true`, READ the top-level `ultracode_mode:` scalar from the recovered `autopilot-state.yaml`** (use the same top-level YAML scalar read the rest of the pipeline relies on; `hooks/lib/parse-state-file.sh::parse_yaml_scalar ultracode_mode` is the canonical reader) and **reconstruct `UC_ORCH`** from it for the remainder of the run: a value of `on` / `off` / `metric-only` resolves directly, while a missing / `null` / empty value defaults `UC_ORCH = off` — this missing-field case only arises for a legacy state file written before `ultracode_mode` existed (a v9.0.0+ run always persists the field at state init, default `on`, so the on-by-default carries cleanly through resume; defaulting a field-less legacy state to `off` keeps that resume faithful to how it originally ran). Emit the SHARED CONTRACT resolution line `[UC-ORCH-MODE] mode={on|off|metric-only} active={y|n} reason=resume` to stderr (`active=y` only when `mode=on`), and add the reconstructed mode to the `[RESUME] ...` summary so the run-scoped orchestration mode is visible on resume. This re-read is what carries `uc` across auto-compact / resume without any hook change. **Likewise READ the top-level `parallel_mode:` scalar** from the recovered state (`hooks/lib/parse-state-file.sh::parse_yaml_scalar parallel_mode`) and **reconstruct `PARALLEL_MODE`** for the remainder of the run: `on` / `off` / `metric-only` resolves directly, while a missing / `null` / empty value defaults `PARALLEL_MODE = off` — this missing-field case only arises for a legacy state file written before `parallel_mode` existed OR an explicit `parallel=off` run (which omits the field by design); a v9.0.0+ bare run always persists `parallel_mode: on` at state init, so the on-by-default carries cleanly through resume, and defaulting a field-less legacy/opt-out state to `off` keeps that resume faithful to how it originally ran. **Only when `PARALLEL_MODE != off`**, emit the SHARED CONTRACT line `[PARALLEL-MODE] mode={on|metric-only} active={y|n} reason=resume` to stderr (`active=y` only when `mode=on`) and add the reconstructed parallel mode to the `[RESUME] ...` summary; **when `PARALLEL_MODE == off`, emit no `[PARALLEL-MODE]` line and add nothing to the `[RESUME] ...` summary** (a resumed non-parallel run is byte-identical to prior versions). This re-read carries the parallel execution mode across auto-compact / resume exactly as `ultracode_mode` carries `uc`. **Wave-aware resume (`PARALLEL_MODE == on`):** waves are **recomputed deterministically** from the (unchanged) dependency graph — NO wave index is persisted, so resume re-runs the level-synchronous Kahn layering and re-resolves `CONCURRENCY_CAP` from Argument Parsing (`parallel_max=` is likewise not persisted). The run **resumes from the first wave that still has a non-`completed` runnable ticket**; within each resumed wave, `READY_k` filters out already-`completed` tickets (`[RESUME] Skipping {logical_id}: already completed`) and re-runs the per-wave dependency re-eval (so a dependency that failed/skipped before the interruption still cascade-skips its dependents on resume). The wave cursor (`current_wave` / `wave_status`) is re-derived from the per-ticket terminal statuses, not read as authority (see [references/state-file.md](references/state-file.md) Wave-cursor resume semantics). If `started` is older than 7 days, emit `[RESUME] WARNING` to delete `autopilot-state.yaml` and re-run. Carry `ticket_mapping`. Per-ticket: `completed` → skip (`[RESUME] Skipping {logical_id}: already completed`); `failed`/`skipped` → retry first non-completed; `in_progress` → re-run; `pending` → normal. If state recovery cannot continue (e.g. unparseable `autopilot-state.yaml`, hostile working tree detected during this step, or any other Phase 1 precondition that newly fails here), emit `[AUTOPILOT-POLICY] gate=unexpected_error action=stop reason=state_recovery_hard_stop` and write `## Stop Reason` with `tag: policy_gate_stop` plus a resume hint of the form `Resume after fixing X with: /autopilot {parent-slug}`; never escalate to `AskUserQuestion`. Any existing verbatim `ERROR:` / `[RESUME] WARNING` literal continues to be emitted alongside the new `policy_gate_stop` exit path. ## Phase 2: Pipeline Execution ### State file initialization -Skip if `resume_mode = true`. Brief-level `autopilot-state.yaml` ≠ per-ticket `phase-state.yaml`. Write at `briefs/active/{parent-slug}/` (else `product_backlog/{parent-slug}/`); hooks also accept `briefs/done/{parent-slug}/`. Fields: `ticket_mapping`, per-ticket `ticket_dir:` + `status` + `steps` + `invocation_method` ∈ `skill`/`manual-bash`/`unknown`, append-only `runtime_metrics: []` (`hooks/autopilot-continue.sh` + `hooks/pre-compact-save.sh` only; skills MUST NOT write). **Also write the top-level field `ultracode_mode: {on|off|metric-only}` from the `UC_ORCH` resolved in Argument Parsing** — a run-scoped orchestration mode (sibling of `version` / `parent_slug` / `started` / `execution_mode` / `total_tickets` / `ticket_mapping` / `tickets`). It records the run-scoped continuity value so Phase 1 Step 5 can re-read it on resume; the default `UC_ORCH = on` writes `ultracode_mode: on` (round-trips to `on` on resume; an explicit `uc=off` writes `ultracode_mode: off`). This is run-state, NOT a permanent policy flag — it lives only in `autopilot-state.yaml` (moved to `briefs/done/` on completion), never in `autopilot-policy.yaml`. **Also write the top-level field `parallel_mode: {on|metric-only}` from the `PARALLEL_MODE` resolved in Argument Parsing, but ONLY when `PARALLEL_MODE != off`** — a sibling run-scoped field with the identical lifecycle (init-written, resume-replayed at Phase 1 Step 5, moved to `briefs/done/` on completion, never a policy flag). **When `PARALLEL_MODE == off` (the default), OMIT the `parallel_mode:` field entirely** so the state file is byte-identical to prior versions (which had no `parallel_mode` field); Phase 1 Step 5 reconstructs `off` from an absent / `null` field. This deliberately differs from `ultracode_mode:` (which is written even for `off`): the `parallel=off` lane carries the byte-identical-**state** guarantee of AC-1, so it must not add a field. **The wave layering and the `parallel_max=` concurrency cap are NOT persisted** — only `parallel_mode:` (the tri-state) and the OPTIONAL observability wave cursor (`wave_count` / `current_wave` / `wave_status` / `main_checkout_root`, `PARALLEL_MODE != off` only) are written; the waves themselves and `CONCURRENCY_CAP` are **recomputed** from the dependency graph + Argument Parsing on every entry (resume re-derives them, never reads a persisted wave index). Schema documented in [references/state-file.md](references/state-file.md). **MUST emit `tickets:` as a YAML list** of dash-prefixed `- logical_id: …` mappings — NOT a map keyed by `logical_id`. The map form silently bypasses the hook-layer skip-transition guard (`parse_proposed_tickets`) and the Stop-hook loop-guard counters (`parse_ticket_statuses`); field evidence `test_simple_workflow28`. Hook tolerance was added in WI-4 as a safety net only; SKILL prose remains the enforcement. Loop-guard emits `[AUTOPILOT-STALL] ...`. Schema invariants (including `tickets:` list-vs-map) + precedence + counters + kill switch + `boundary`/`stop_reason` domains in [references/state-file.md](references/state-file.md) + [references/stop-reason-taxonomy.md](references/stop-reason-taxonomy.md). +Skip if `resume_mode = true`. Brief-level `autopilot-state.yaml` ≠ per-ticket `phase-state.yaml`. Write at `briefs/active/{parent-slug}/` (else `product_backlog/{parent-slug}/`); hooks also accept `briefs/done/{parent-slug}/`. Fields: `ticket_mapping`, per-ticket `ticket_dir:` + `status` + `steps` + `invocation_method` ∈ `skill`/`manual-bash`/`unknown`, append-only `runtime_metrics: []` (`hooks/autopilot-continue.sh` + `hooks/pre-compact-save.sh` only; skills MUST NOT write). **Also write the top-level field `ultracode_mode: {on|off|metric-only}` from the `UC_ORCH` resolved in Argument Parsing** — a run-scoped orchestration mode (sibling of `version` / `parent_slug` / `started` / `execution_mode` / `total_tickets` / `ticket_mapping` / `tickets`). It records the run-scoped continuity value so Phase 1 Step 5 can re-read it on resume; the default `UC_ORCH = on` writes `ultracode_mode: on` (round-trips to `on` on resume; an explicit `uc=off` writes `ultracode_mode: off`). This is run-state, NOT a permanent policy flag — it lives only in `autopilot-state.yaml` (moved to `briefs/done/` on completion), never in `autopilot-policy.yaml`. **Also write the top-level field `parallel_mode: {on|metric-only}` from the `PARALLEL_MODE` resolved in Argument Parsing, but ONLY when `PARALLEL_MODE != off`** — a sibling run-scoped field with the identical lifecycle (init-written, resume-replayed at Phase 1 Step 5, moved to `briefs/done/` on completion, never a policy flag). **When `PARALLEL_MODE == off` (an explicit `parallel=off` opt-out — NOT the absent-token default, which now resolves `on`), OMIT the `parallel_mode:` field entirely** so the state file is byte-identical to prior versions (which had no `parallel_mode` field); Phase 1 Step 5 reconstructs `off` from an absent / `null` field. This deliberately differs from `ultracode_mode:` (which is written even for `off`): the `parallel=off` lane carries the byte-identical-**state** guarantee of AC-1, so it must not add a field. **The wave layering and the `parallel_max=` concurrency cap are NOT persisted** — only `parallel_mode:` (the tri-state) and the OPTIONAL observability wave cursor (`wave_count` / `current_wave` / `wave_status` / `main_checkout_root`, `PARALLEL_MODE != off` only) are written; the waves themselves and `CONCURRENCY_CAP` are **recomputed** from the dependency graph + Argument Parsing on every entry (resume re-derives them, never reads a persisted wave index). Schema documented in [references/state-file.md](references/state-file.md). **MUST emit `tickets:` as a YAML list** of dash-prefixed `- logical_id: …` mappings — NOT a map keyed by `logical_id`. The map form silently bypasses the hook-layer skip-transition guard (`parse_proposed_tickets`) and the Stop-hook loop-guard counters (`parse_ticket_statuses`); field evidence `test_simple_workflow28`. Hook tolerance was added in WI-4 as a safety net only; SKILL prose remains the enforcement. Loop-guard emits `[AUTOPILOT-STALL] ...`. Schema invariants (including `tickets:` list-vs-map) + precedence + counters + kill switch + `boundary`/`stop_reason` domains in [references/state-file.md](references/state-file.md) + [references/stop-reason-taxonomy.md](references/stop-reason-taxonomy.md). ### Split Execution Flow @@ -173,7 +173,7 @@ Parse `SPLIT_PLAN` frontmatter + tickets, build dependency graph, run topologica > **Non-interactive orchestrator contract**: see `## Non-interactive orchestrator contract (3-tier, risk_tolerance-aware)` above. Per-ticket pipeline inherits the same 3-tier matrix; the only mid-pipeline `end_turn` is the auto-compact exception in step e. -**Execution-path routing (`PARALLEL_MODE`)**: when `PARALLEL_MODE == off` (the default), execute the inline serial per-ticket steps below exactly as in prior versions — byte-identical, and every hook behaves as before. When `PARALLEL_MODE != off`, execute the **executor-routed** variant in `##### Executor-routed per-ticket pipeline` (below the inline loop) INSTEAD of the inline steps: the dependency check, terminal statuses, and artifact set are equivalent, but each ticket's `/scout`→`/impl`→`/ship` runs inside a `ticket-executor` subagent and the main loop is the single writer of `autopilot-state.yaml`. In Phase 1 the executor path runs at **concurrency 1** (one executor at a time, in `PROCESSING_ORDER` order, no worktree = main checkout), so its terminal result matches the inline path. +**Execution-path routing (`PARALLEL_MODE`)**: when `PARALLEL_MODE == off` (an explicit `parallel=off` opt-out or the env kill switch — the absent-token default now resolves `on`), execute the inline serial per-ticket steps below exactly as in prior versions — byte-identical, and every hook behaves as before. When `PARALLEL_MODE != off`, execute the **executor-routed** variant in `##### Executor-routed per-ticket pipeline` (below the inline loop) INSTEAD of the inline steps: the dependency check, terminal statuses, and artifact set are equivalent, but each ticket's `/scout`→`/impl`→`/ship` runs inside a `ticket-executor` subagent and the main loop is the single writer of `autopilot-state.yaml`. In Phase 1 the executor path runs at **concurrency 1** (one executor at a time, in `PROCESSING_ORDER` order, no worktree = main checkout), so its terminal result matches the inline path. For each ticket in `PROCESSING_ORDER` (`i` = 0-based): @@ -232,7 +232,7 @@ For each ticket in `PROCESSING_ORDER` (`i` = 0-based), at **concurrency 1** in P 3. **Receive the `[TICKET-EXECUTOR-RESULT]` envelope** and, as the single writer, transcribe its `steps.{scout,impl,ship}` / `status` / PR URL into this ticket's `autopilot-state.yaml` entry (canonical FLAT schema — `steps.` is a string on its own line). A `failed` envelope marks the ticket `failed`; dependents are skipped by the step-1 dependency check on later iterations. The artifact-presence gate already ran inside the executor; the main loop trusts the envelope `status` and MAY re-confirm `done/` presence opportunistically. -4. **Loop-tail** — re-enter for `i+1`. The auto-compact exception and the MUST-NOT-`end_turn` rule of inline step e still apply at the main-loop level; the executor is a foreground subagent that blocks the main loop while it runs, so a premature main-loop `end_turn` cannot occur mid-ticket. **Phase 1 known limitation**: because `/scout`/`/impl`/`/ship` run inside the executor and off the main transcript, the main-transcript-scanning Stop / checkpoint / auto-compact hooks do not observe them; this is reworked when the hooks become wave-aware. `PARALLEL_MODE == off` (the default) keeps every hook byte-identical. +4. **Loop-tail** — re-enter for `i+1`. The auto-compact exception and the MUST-NOT-`end_turn` rule of inline step e still apply at the main-loop level; the executor is a foreground subagent that blocks the main loop while it runs, so a premature main-loop `end_turn` cannot occur mid-ticket. **Phase 1 known limitation**: because `/scout`/`/impl`/`/ship` run inside the executor and off the main transcript, the main-transcript-scanning Stop / checkpoint / auto-compact hooks do not observe them; this is reworked when the hooks become wave-aware. `PARALLEL_MODE == off` (the explicit `parallel=off` opt-out) keeps every hook byte-identical. **Concurrency in Phase 1 is fixed at 1** (serial-equivalent): the main loop spawns the next executor only AFTER the previous envelope is received and state is written. Real per-wave parallel spawning and a `parallel_max=` cap are added in Phase 2; the wave layering is computed and emitted now (Split Execution Flow) but does not yet drive concurrent spawns. @@ -246,7 +246,7 @@ These are additive optional fields read by the parallel-aware hooks (the Phase 2 ##### Wave-parallel pipeline (`PARALLEL_MODE == on`) -Taken INSTEAD of the concurrency-1 executor loop above when `PARALLEL_MODE == on`. It replaces the flat `for each ticket in PROCESSING_ORDER` iteration with a `for each wave` iteration, using the EXACT in-degree/Kahn wave layering already computed in Split Execution Flow (the `Wave {k}: {NNN-slug}, ...` lines) — no second graph. `metric-only` does NOT take this path: it logs the `Wave {k}:` plan but executes the serial inline per-ticket loop (no executor, no concurrent spawn, no wave-cursor write). `parallel=off` (the default) adds NO code path here at all (byte-identical to prior versions — the serial loop is untouched). `CONCURRENCY_CAP` is the cap resolved in Argument Parsing (`parallel_max=` arg > `SW_PARALLEL_MAX_CONCURRENCY` env > default 4). +Taken INSTEAD of the concurrency-1 executor loop above when `PARALLEL_MODE == on`. It replaces the flat `for each ticket in PROCESSING_ORDER` iteration with a `for each wave` iteration, using the EXACT in-degree/Kahn wave layering already computed in Split Execution Flow (the `Wave {k}: {NNN-slug}, ...` lines) — no second graph. `metric-only` does NOT take this path: it logs the `Wave {k}:` plan but executes the serial inline per-ticket loop (no executor, no concurrent spawn, no wave-cursor write). `parallel=off` (the explicit opt-out) adds NO code path here at all (byte-identical to prior versions — the serial loop is untouched). `CONCURRENCY_CAP` is the cap resolved in Argument Parsing (`parallel_max=` arg > `SW_PARALLEL_MAX_CONCURRENCY` env > default 4). For each wave `WAVE_k` (`k` = 0-based, in topological/level order): @@ -279,7 +279,7 @@ For each wave `WAVE_k` (`k` = 0-based, in topological/level order): ##### Worktree isolation + cross-wave integration (`PARALLEL_MODE == on`) -This subsection is the worktree lifecycle that the wave loop above hooks into. **ALL of it is inside the `PARALLEL_MODE == on` branch** — `parallel=off` (the default) and `metric-only` add NO worktree code, no integration branch, and no cleanup, so a non-parallel run is byte-identical to prior versions. The orchestrator NEVER enters a worktree (its cwd stays the main checkout ``); it manipulates worktrees only via `Bash(git worktree …)` and merges via a dedicated integration worktree. +This subsection is the worktree lifecycle that the wave loop above hooks into. **ALL of it is inside the `PARALLEL_MODE == on` branch** — `parallel=off` (the explicit opt-out) and `metric-only` add NO worktree code, no integration branch, and no cleanup, so a non-parallel run is byte-identical to prior versions. The orchestrator NEVER enters a worktree (its cwd stays the main checkout ``); it manipulates worktrees only via `Bash(git worktree …)` and merges via a dedicated integration worktree. **Phase 2 init (once, before the wave loop):** diff --git a/skills/autopilot/references/state-file.md b/skills/autopilot/references/state-file.md index 2b0fba1..0c49b5b 100644 --- a/skills/autopilot/references/state-file.md +++ b/skills/autopilot/references/state-file.md @@ -24,7 +24,7 @@ started: {ISO-8601 via `date -u +%Y-%m-%dT%H:%M:%SZ`} execution_mode: split total_tickets: {N} ultracode_mode: on # OPTIONAL run-scoped orchestration mode: on | off | metric-only (default on) -parallel_mode: on # OPTIONAL run-scoped parallel exec mode: on | metric-only — WRITTEN ONLY when != off; ABSENT (-> off) on a default serial run +parallel_mode: on # OPTIONAL run-scoped parallel exec mode: on | metric-only — default on (absent on a fresh run writes on); WRITTEN ONLY when != off; ABSENT (-> off) on an explicit parallel=off opt-out run wave_count: 3 # OPTIONAL wave cursor (PARALLEL_MODE != off only): total topological waves current_wave: 1 # OPTIONAL wave cursor: 0-based index of the wave just spawned (-1 before the first spawn) wave_status: in_flight # OPTIONAL wave cursor: in_flight | drained (of current_wave) @@ -77,22 +77,25 @@ Field summary (the 7 top-level fields plus `runtime_metrics:`): same top-level scalar path as the other fields, e.g. `parse_yaml_scalar ultracode_mode`). - `parallel_mode` — OPTIONAL. Run-scoped parallel execution mode; value - domain `on` | `off` | `metric-only` (default `off` when absent). A near- + domain `on` | `off` | `metric-only`. **Default `on` (run-scoped; absent on a + fresh run writes `on`)** as of v9.0.0 — a bare `/autopilot` run now persists + `parallel_mode: on`. A near- complete mirror of `ultracode_mode` with the same lifecycle: set once at Phase 2 state-file initialization from the `parallel=` invocation argument resolved in Argument Parsing **but written ONLY when `!= off`**, re-read on resume at Phase 1 Step 5 (`parse_yaml_scalar parallel_mode`) to reconstruct the run's execution path, and moved to `briefs/done/` with the rest of the file on completion. It is **run-scoped run-state, NOT a - permanent policy flag** — a fresh run with no `parallel=` argument (or - `parallel=off`) **OMITS the field entirely**, keeping the state file + permanent policy flag** — only an explicit `parallel=off` opt-out (or the + `SW_PARALLEL_TICKETS_MODE=off` kill switch resolving the run to `off`) + **OMITS the field entirely**, keeping the state file byte-identical to a pre-parallel version, and resume reconstructs `off` from the absent field; it never lives in `autopilot-policy.yaml`. The one deliberate difference from `ultracode_mode` (which is written even for `off`) is exactly this omit-on-`off`, required by the `parallel=off` byte-identical-state guarantee. At Phase 2 it selects whether each ticket - runs through the current inline serial branch (`off` / absent) or a - `ticket-executor` subagent (`on` / `metric-only`); it is **orthogonal to + runs through the current inline serial branch (explicit `off`) or a + `ticket-executor` subagent (`on` / absent-default / `metric-only`); it is **orthogonal to `ultracode_mode`** (the two compose). The `SW_PARALLEL_TICKETS_MODE` environment knob can force it to `off` as a run kill switch. - `runtime_metrics:` — append-only metrics list (see schema below). diff --git a/skills/brief/SKILL.md b/skills/brief/SKILL.md index 60149c6..fa001cd 100644 --- a/skills/brief/SKILL.md +++ b/skills/brief/SKILL.md @@ -54,7 +54,7 @@ User input: $ARGUMENTS Parse `$ARGUMENTS`: - **Preferred new key — `chain=`**: extract `chain=` if present. **Value normalization**: trim whitespace and lowercase the value (`chain=ON`, `chain=Off`, `chain= on ` normalize to `on`/`off`). Token `chain=` is matched case-insensitively. Accepted: `on` (default if neither `chain=` nor `mode=` is supplied), `off`. Any other value → stop and emit `ERROR: invalid chain=. Use chain=on or chain=off` (substituting the offending value); do NOT create the brief directory, do NOT write `brief.md`, do NOT write `autopilot-policy.yaml`, and do NOT write `auto-kick.yaml`; exit non-zero. Mapping for downstream / legacy reasoning: `chain=on` ≡ `mode=auto`, `chain=off` ≡ `mode=manual`. -- **Deprecated alias — `mode=`**: extract `mode=` if present. **Value normalization**: trim whitespace and lowercase the value (`mode=AUTO`, `mode=Manual`, `mode= auto ` normalize to `auto`/`manual`). Token `mode=` is matched case-insensitively. Accepted: `auto` (treated as `chain=on`), `manual` (treated as `chain=off`). Any other value → stop and emit the invalid-mode error (see ## Error Handling for exact message + side-effect contract). **When `mode=` is supplied, also emit to stderr the single line `WARNING: 'mode=' is deprecated and will be removed in v9.0.0. Use 'chain=on' instead of 'mode=auto', 'chain=off' instead of 'mode=manual'.`** (verbatim literal, including the leading `WARNING:` and the surrounding single quotes). The warning is informational; processing continues with the `mode=` value mapped to the equivalent `chain=` value. +- **Deprecated alias — `mode=`**: extract `mode=` if present. **Value normalization**: trim whitespace and lowercase the value (`mode=AUTO`, `mode=Manual`, `mode= auto ` normalize to `auto`/`manual`). Token `mode=` is matched case-insensitively. Accepted: `auto` (treated as `chain=on`), `manual` (treated as `chain=off`). Any other value → stop and emit the invalid-mode error (see ## Error Handling for exact message + side-effect contract). **When `mode=` is supplied, also emit to stderr the single line `WARNING: 'mode=' is deprecated and will be removed in a future major version. Use 'chain=on' instead of 'mode=auto', 'chain=off' instead of 'mode=manual'.`** (verbatim literal, including the leading `WARNING:` and the surrounding single quotes). The warning is informational; processing continues with the `mode=` value mapped to the equivalent `chain=` value. - **Simultaneous specification — `chain=` and `mode=` both present**: stop and emit `ERROR: 'chain=' and 'mode=' cannot be combined. Use 'chain=' (preferred).` Do NOT silent-rewrite; do NOT pick one and ignore the other. Do NOT create the brief directory, do NOT write `brief.md`, do NOT write `autopilot-policy.yaml`, and do NOT write `auto-kick.yaml`. Exit non-zero. (This mirrors the v6.0.0 `auto=true` defensive stance — no silent rewrites of ambiguous argument intent.) - **Default when both keys are omitted**: `chain=on` (equivalent to legacy `mode=auto`). The default preserves the prior `mode=auto`-default behavior so existing user-typed `/brief ""` invocations continue to chain into `/create-ticket` and `/autopilot`. - **`auto=true` removal (v6.0.0)**: if `auto=true` (case-insensitive) appears in `$ARGUMENTS`, stop and emit the v6.0.0 removal error (see ## Error Handling). The removal is intentional and `auto=true` is NOT silently rewritten. @@ -63,9 +63,8 @@ Parse `$ARGUMENTS`: - When `chain` resolves to `off` (≡ legacy `mode=manual`) AND the parsed `uc=` value is `on` or `metric-only` → emit to stderr the single line `WARNING: uc=on ignored when chain=off (no chained /autopilot to receive it)` (verbatim literal, including the leading `WARNING:`) and set `resolved_uc = off`. This is a **WARNING, not an error**: there is no chained `/autopilot` in `chain=off` mode to receive the value, so `uc` is ignored; processing continues normally (the brief is still written, Step 3 manual guidance still runs). Do NOT stop, do NOT exit non-zero, do NOT suppress any artifact. - When `chain` resolves to `off` AND the parsed `uc=` value is `off` (or `uc=` is omitted) → `resolved_uc = off` silently (no warning — nothing to ignore). - Any value other than `on`/`off`/`metric-only` → treat as `off` (this argument is an observe-only opt-in; an unrecognized value never blocks brief creation). `resolved_uc` is carried forward to Finalization Step 2. -- **parallel execution — `parallel=` (additive, run-scoped opt-in)**: extract `parallel=` if present, using the SAME case-insensitive `key=value` convention as `uc=` above (token `parallel=` matched case-insensitively; trim whitespace and lowercase the value, so `parallel=ON`, `parallel=Off`, `parallel= metric-only ` normalize to `on`/`off`/`metric-only`). Accepted values: `on`, `off`, `metric-only`. **Default when `parallel=` is omitted: `off`.** Resolve the effective `parallel` value (`resolved_parallel`) AFTER the `chain` value has been resolved above, exactly mirroring `resolved_uc`: - - When `parallel=` is omitted → `resolved_parallel = off`. - - When `chain` resolves to `on` → `resolved_parallel` = the parsed `parallel=` value (`on`, `off`, or `metric-only`). The value is carried into the Finalization Step 2 chained handoff (forwarded to the chained `/autopilot`, which records it as run-scoped state and selects each ticket's execution path; `/brief` itself does not act on `parallel` beyond forwarding it). +- **parallel execution — `parallel=` (additive, run-scoped opt-in)**: extract `parallel=` if present, using the SAME case-insensitive `key=value` convention as `uc=` above (token `parallel=` matched case-insensitively; trim whitespace and lowercase the value, so `parallel=ON`, `parallel=Off`, `parallel= metric-only ` normalize to `on`/`off`/`metric-only`). Accepted values: `on`, `off`, `metric-only`. **Default when `parallel=` is omitted: `on` under `chain=on` (the `/brief` default), `off` under `chain=off`.** Resolve the effective `parallel` value (`resolved_parallel`) AFTER the `chain` value has been resolved above, exactly mirroring `resolved_uc`: + - When `chain` resolves to `on` (the `/brief` default) → `resolved_parallel` = the explicitly parsed `parallel=` value (`on`, `off`, or `metric-only`) if one was supplied, ELSE `on` (the v9.0.0 default — wave-parallel ticket execution is on by default). The value is carried into the Finalization Step 2 chained handoff (forwarded to the chained `/autopilot`, which records it as run-scoped state and selects each ticket's execution path; `/brief` itself does not act on `parallel` beyond forwarding it). - When `chain` resolves to `off` AND the parsed `parallel=` value is `on` or `metric-only` → emit to stderr the single line `WARNING: parallel=on ignored when chain=off (no chained /autopilot to receive it)` (verbatim literal, including the leading `WARNING:`) and set `resolved_parallel = off`. This is a **WARNING, not an error**: there is no chained `/autopilot` in `chain=off` mode to receive the value; processing continues normally (the brief is still written). Do NOT stop, do NOT exit non-zero, do NOT suppress any artifact. - When `chain` resolves to `off` AND the parsed `parallel=` value is `off` (or `parallel=` is omitted) → `resolved_parallel = off` silently (no warning — nothing to ignore). - Any value other than `on`/`off`/`metric-only` → treat as `off` (fail-safe; an unrecognized value never blocks brief creation). `resolved_parallel` is carried forward to Finalization Step 2. @@ -89,7 +88,7 @@ Parse `$ARGUMENTS`: Conduct an iterative Q&A to gather comprehensive requirements. -**mode independence guard (load-bearing — preserved during the `chain=` / `mode=` deprecation period; defensive prose now covers BOTH the new `chain` argument AND the legacy `mode` alias)**: Phase 2 Structured Interview (Socratic) **MUST** run regardless of the parsed `mode` value (`auto` or `manual`, including when `mode=` is omitted and defaults to `auto`). The same independence rule applies to the new canonical `chain` argument introduced in vX.Y.0 — Phase 2 MUST run regardless of the parsed `chain` value (`on` or `off`, including when `chain=` is omitted and defaults to `on` ≡ legacy `mode=auto`). The `mode` argument **MUST NOT** be interpreted as a signal to skip, shorten, or bypass Phase 2 — it has **no effect whatsoever** on Phase 2's execution. The same MUST-NOT applies to the new `chain` argument: it is independence-protected from Phase 2 alongside `mode`, and has no effect whatsoever on Phase 2's execution. Any non-interactive wording elsewhere in this skill (e.g. the Finalization Phase Step 2 chain-confirmation context gated on `mode=auto` ≡ `chain=on`) is scoped to that specific step and **MUST NOT** be generalized to Phase 2. The **ONLY** condition under which Phase 2 is skipped is the existing **"Non-interactive environment fallback"** below (triggered strictly by `AskUserQuestion` itself being unavailable or returning an error, e.g. `claude -p` / CI automation without a TTY) — **not** by the value of `mode` or `chain`. This guard is scheduled for removal in v9.0.0 alongside the `mode=` alias: `chain={on,off}` does not lexically suggest interview-policy, so the defensive prose becomes unnecessary once the alias is gone and Phase 2 independence can be left implicit. +**mode independence guard (load-bearing — preserved during the `chain=` / `mode=` deprecation period; defensive prose now covers BOTH the new `chain` argument AND the legacy `mode` alias)**: Phase 2 Structured Interview (Socratic) **MUST** run regardless of the parsed `mode` value (`auto` or `manual`, including when `mode=` is omitted and defaults to `auto`). The same independence rule applies to the new canonical `chain` argument introduced in vX.Y.0 — Phase 2 MUST run regardless of the parsed `chain` value (`on` or `off`, including when `chain=` is omitted and defaults to `on` ≡ legacy `mode=auto`). The `mode` argument **MUST NOT** be interpreted as a signal to skip, shorten, or bypass Phase 2 — it has **no effect whatsoever** on Phase 2's execution. The same MUST-NOT applies to the new `chain` argument: it is independence-protected from Phase 2 alongside `mode`, and has no effect whatsoever on Phase 2's execution. Any non-interactive wording elsewhere in this skill (e.g. the Finalization Phase Step 2 chain-confirmation context gated on `mode=auto` ≡ `chain=on`) is scoped to that specific step and **MUST NOT** be generalized to Phase 2. The **ONLY** condition under which Phase 2 is skipped is the existing **"Non-interactive environment fallback"** below (triggered strictly by `AskUserQuestion` itself being unavailable or returning an error, e.g. `claude -p` / CI automation without a TTY) — **not** by the value of `mode` or `chain`. This guard is scheduled for removal in a future major (deferred from v9.0.0) alongside the `mode=` alias: `chain={on,off}` does not lexically suggest interview-policy, so the defensive prose becomes unnecessary once the alias is gone and Phase 2 independence can be left implicit. **Caps (load-bearing for contract)**: - At most **3 questions per round** (single `AskUserQuestion` call holds up to 3 items). @@ -163,7 +162,7 @@ interview_complete: {true|false} ``` - `chain` (canonical, vX.Y.0+) is the literal scalar (`on` or `off`) derived from the parsed argument in the Argument Parsing step (`chain=on|off` if supplied; otherwise mapped from the deprecated `mode=auto|manual` alias as `auto → on`, `manual → off`; otherwise the default `on`). Sample frontmatter MUST include this `chain: (on|off)` line. -- `mode` (legacy, retained through the deprecation period — slated for removal in v9.0.0, the next major) is the literal scalar (`auto` or `manual`) parsed from `$ARGUMENTS` in the Argument Parsing step (defaulting to `auto` when both `chain=` and `mode=` are omitted; equivalent to the derived `chain` value via `on ↔ auto` / `off ↔ manual`). During this deprecation window both keys are written to every new brief so legacy `/create-ticket` consumers that have not yet adopted the `chain:` reader keep working unchanged. It is REQUIRED in v6.0.0+ (carried forward as `mode: {auto|manual}` per the deprecation alias contract). Super-legacy briefs written before v6.0.0 may lack this key entirely; downstream readers (notably `/create-ticket brief=`) follow the precedence rule **`chain:` precedes `mode:`** — `chain:` is read first if present, otherwise `mode:` is read for backward compatibility, otherwise the reader defaults to `chain=on` (≡ `mode: auto`). +- `mode` (legacy, retained through the deprecation period — slated for removal in a future major, deferred from v9.0.0) is the literal scalar (`auto` or `manual`) parsed from `$ARGUMENTS` in the Argument Parsing step (defaulting to `auto` when both `chain=` and `mode=` are omitted; equivalent to the derived `chain` value via `on ↔ auto` / `off ↔ manual`). During this deprecation window both keys are written to every new brief so legacy `/create-ticket` consumers that have not yet adopted the `chain:` reader keep working unchanged. It is REQUIRED in v6.0.0+ (carried forward as `mode: {auto|manual}` per the deprecation alias contract). Super-legacy briefs written before v6.0.0 may lack this key entirely; downstream readers (notably `/create-ticket brief=`) follow the precedence rule **`chain:` precedes `mode:`** — `chain:` is read first if present, otherwise `mode:` is read for backward compatibility, otherwise the reader defaults to `chain=on` (≡ `mode: auto`). - `interview_complete` is the literal scalar recorded at the end of Phase 2 (`true` if at least one round ran to a user response; `false` if Phase 2 was skipped via the non-interactive fallback or no round produced a response). - **Do NOT emit `split:`** (obsolete — decomposition is `/create-ticket`'s job). - **Do NOT emit `ticket_count:`** (obsolete — decomposition is `/create-ticket`'s job). @@ -219,7 +218,7 @@ f. Otherwise (`/create-ticket` succeeded), proceed to the final invocation: ### Step 3 — `chain=off` (≡ legacy `mode=manual`; no chained handoff) -> Legacy-alias heading kept inline for backward search compatibility: this section is also reachable as **Step 3 — `mode=manual`** while the `mode=` alias remains accepted (slated for removal in v9.0.0 alongside the alias). +> Legacy-alias heading kept inline for backward search compatibility: this section is also reachable as **Step 3 — `mode=manual`** while the `mode=` alias remains accepted (slated for removal in a future major, deferred from v9.0.0, alongside the alias). @@ -270,7 +269,7 @@ In all three shapes (`chain=on` success ≡ legacy `mode=auto` success, `chain=o - **`auto=true` argument (v6.0.0 removal)**: Print `ERROR: 'auto=true' has been removed in v6.0.0; use 'mode=auto' or 'mode=manual'` (legacy literal preserved verbatim; the equivalent vX.Y.0+ form is `chain=on` or `chain=off`) and stop. Do NOT create the brief directory, do NOT write `brief.md`, do NOT write `autopilot-policy.yaml`, and do NOT write `auto-kick.yaml`. Exit non-zero. - **Invalid `chain=` (vX.Y.0+ canonical form)**: Print `ERROR: invalid chain=. Use chain=on or chain=off` (substituting the offending value) and stop. Do NOT create the brief directory, do NOT write `brief.md`, do NOT write `autopilot-policy.yaml`, and do NOT write `auto-kick.yaml`. Exit non-zero. - **Invalid `mode=` (legacy alias path)**: Print `ERROR: invalid mode=. Use mode=auto or mode=manual` (substituting the offending value) and stop. Do NOT create the brief directory, do NOT write `brief.md`, do NOT write `autopilot-policy.yaml`, and do NOT write `auto-kick.yaml`. Exit non-zero. (The deprecation warning for `mode=` itself is emitted only when the alias is *valid*; invalid `mode=` values short-circuit to this error without the warning.) -- **Deprecated `mode=` alias supplied (warning, not an error)**: When `mode=` is parsed with a valid value (`auto` or `manual`) and `chain=` is NOT also supplied, emit to stderr the single line `WARNING: 'mode=' is deprecated and will be removed in v9.0.0. Use 'chain=on' instead of 'mode=auto', 'chain=off' instead of 'mode=manual'.` (verbatim literal). Processing continues — the alias is mapped to the equivalent `chain=` value and the brief is written normally. +- **Deprecated `mode=` alias supplied (warning, not an error)**: When `mode=` is parsed with a valid value (`auto` or `manual`) and `chain=` is NOT also supplied, emit to stderr the single line `WARNING: 'mode=' is deprecated and will be removed in a future major version. Use 'chain=on' instead of 'mode=auto', 'chain=off' instead of 'mode=manual'.` (verbatim literal). Processing continues — the alias is mapped to the equivalent `chain=` value and the brief is written normally. - **Simultaneous `chain=` and `mode=` specification (ERROR, exit non-zero)**: Print `ERROR: 'chain=' and 'mode=' cannot be combined. Use 'chain=' (preferred).` and stop. Do NOT silent-rewrite to either key. Do NOT create the brief directory, do NOT write `brief.md`, do NOT write `autopilot-policy.yaml`, and do NOT write `auto-kick.yaml`. Exit non-zero. - **Researcher failure**: Report error. Continue to Phase 2 without investigation summary. - **AskUserQuestion failure in Phase 2**: Skip Phase 2, proceed with researcher findings only; set `interview_complete: false`. diff --git a/skills/create-ticket/references/agent-spawn-prompts.md b/skills/create-ticket/references/agent-spawn-prompts.md index b343d90..1d98bae 100644 --- a/skills/create-ticket/references/agent-spawn-prompts.md +++ b/skills/create-ticket/references/agent-spawn-prompts.md @@ -152,7 +152,7 @@ d. Max 2 rounds (initial + 1 revision). If still FAIL, run the autopilot-policy ### Autopilot-policy escalation (gates.ticket_quality_fail) -After 2 rounds of FAIL, check `{ticket-dir}/autopilot-policy.yaml` at `.simple-workflow/backlog/product_backlog/{parent-slug}/{ticket-dir}/`. If missing **and** `brief=` was given **AND** `brief_mode == auto` (resolved per the canonical precedence rule **chain: precedes mode:** introduced in vX.Y.0: `chain: on` → `brief_mode == auto`, `chain: off` → `brief_mode == manual`; when `chain:` is absent the legacy `mode:` field is read for backward compatibility; super-legacy briefs lacking both keys are treated as `auto` ≡ `chain=on`. The deprecated `mode=` argument supplied to `/brief` is mirrored into the brief frontmatter's `mode:` field, so this fallback path continues to honour pre-vX.Y.0 briefs unchanged. The `mode:` field will be removed in v9.0.0; the precedence rule converges on the `chain:` reader at that point.), also check `{brief-parent-dir}/autopilot-policy.yaml` (e.g. `.simple-workflow/backlog/briefs/active/{slug}/`). When `brief_mode == manual` (≡ `chain: off`), the brief-parent `autopilot-policy.yaml` fallback is **skipped** — manual-mode runs do not pull retry-strategy from autopilot policy and proceed directly to the interactive flow below. +After 2 rounds of FAIL, check `{ticket-dir}/autopilot-policy.yaml` at `.simple-workflow/backlog/product_backlog/{parent-slug}/{ticket-dir}/`. If missing **and** `brief=` was given **AND** `brief_mode == auto` (resolved per the canonical precedence rule **chain: precedes mode:** introduced in vX.Y.0: `chain: on` → `brief_mode == auto`, `chain: off` → `brief_mode == manual`; when `chain:` is absent the legacy `mode:` field is read for backward compatibility; super-legacy briefs lacking both keys are treated as `auto` ≡ `chain=on`. The deprecated `mode=` argument supplied to `/brief` is mirrored into the brief frontmatter's `mode:` field, so this fallback path continues to honour pre-vX.Y.0 briefs unchanged. The `mode:` field will be removed in a future major (deferred from v9.0.0); the precedence rule converges on the `chain:` reader at that point.), also check `{brief-parent-dir}/autopilot-policy.yaml` (e.g. `.simple-workflow/backlog/briefs/active/{slug}/`). When `brief_mode == manual` (≡ `chain: off`), the brief-parent `autopilot-policy.yaml` fallback is **skipped** — manual-mode runs do not pull retry-strategy from autopilot policy and proceed directly to the interactive flow below. - If a policy file is present, read `gates.ticket_quality_fail`: - `retry_with_feedback` + retry count < `max_retries` → continue retrying. Print `[AUTOPILOT-POLICY] gate=ticket_quality_fail action=retry_with_feedback round={n}`. diff --git a/tests/test-skill-contracts.sh b/tests/test-skill-contracts.sh index d5468a0..67408be 100644 --- a/tests/test-skill-contracts.sh +++ b/tests/test-skill-contracts.sh @@ -10695,10 +10695,10 @@ assert_true \ "$par5_result" # CT-PARALLEL-6 (env kill switch + (B) harness-own). CLAUDE.md documents -# SW_PARALLEL_TICKETS_MODE (default off = prior serial behaviour) as the run kill switch and -# marks it (B) harness-own substrate. +# SW_PARALLEL_TICKETS_MODE (default off env-knob = byte-identical serial-revert opt-out; the run +# default is now ON in v9.0.0) as the serial kill switch and marks it (B) harness-own substrate. par6_knob=$(grep -cF 'SW_PARALLEL_TICKETS_MODE' "$PAR_CLAUDEMD" || true) -par6_killswitch=$(grep -cF 'run kill switch for the run-scoped parallel ticket-execution' "$PAR_CLAUDEMD" || true) +par6_killswitch=$(grep -ciE 'serial-revert kill switch.*byte-identical opt-out.*forces the v8\.7\.0 inline serial loop' "$PAR_CLAUDEMD" || true) par6_bsubstrate=$(grep -cF '(B) harness-own' "$PAR_CLAUDEMD" || true) par6_result="false" if [ "$par6_knob" -ge 1 ] && [ "$par6_killswitch" -ge 1 ] && [ "$par6_bsubstrate" -ge 1 ]; then par6_result="true"; fi @@ -10706,6 +10706,83 @@ assert_true \ "CT-PARALLEL-6 (env kill switch): SW_PARALLEL_TICKETS_MODE documented ($par6_knob>=1) kill-switch prose ($par6_killswitch>=1) (B) harness-own ($par6_bsubstrate>=1)" \ "$par6_result" +# ----------------------------------------------------------------------------- +# T-009: parallel default off->on flip (RELOCATE branch) + opt-out byte-identity +# + unknown->off fail-safe. The four CTs below pin the v9.0.0 default flip: +# (7) the absent-token default is now `on` and emits reason=default; +# (8) the explicit parallel=off lane stays byte-identical (omits the state +# field + adds no code path — the serial-fork literals survive verbatim); +# (9) a bare /autopilot (default, now on) is NOT byte-identical (it routes +# through the executor + wave-parallel path); +# (10) an unknown parallel= resolves to off (L3) and SURFACES it +# via reason=invocation-unknown-value-failsafe (the conservative +# fail-safe direction, uniform with uc= unknown->off). +# ----------------------------------------------------------------------------- + +# CT-PARALLEL-7 (parallel default off->on flip, RELOCATE branch). The absent-token +# default is now `on` at the two spawner sites (autopilot Argument Parsing absent clause, +# brief parallel= bullet) + the state-file doc; the absent-default path EMITS the +# `reason=default` resolution line (matching the uc= peer convention). +par7_autopilot_default=$(grep -cF 'the `parallel=` token is absent, `PARALLEL_MODE = on`' "$PAR_AUTOPILOT" || true) +par7_autopilot_reason_default=$(grep -cF 'when the token was absent (the on-by-default path — emit `[PARALLEL-MODE] mode=on active=y reason=default`)' "$PAR_AUTOPILOT" || true) +par7_brief_default=$(grep -cF 'ELSE `on` (the v9.0.0 default' "$PAR_BRIEF" || true) +par7_statefile_default=$(grep -ciE 'Default .on. .run-scoped; absent on a' "$PAR_STATEFILE" || true) +par7_result="false" +if [ "$par7_autopilot_default" -ge 1 ] && [ "$par7_autopilot_reason_default" -ge 1 ] \ + && [ "$par7_brief_default" -ge 1 ] && [ "$par7_statefile_default" -ge 1 ]; then par7_result="true"; fi +assert_true \ + "CT-PARALLEL-7 (parallel default off->on flip): autopilot absent-default-on ($par7_autopilot_default>=1) reason=default emit ($par7_autopilot_reason_default>=1) brief default-on ($par7_brief_default>=1) state-file default-on ($par7_statefile_default>=1)" \ + "$par7_result" + +# CT-PARALLEL-8 (explicit parallel=off byte-identity opt-out, R-c1 drift guard). The +# explicit `parallel=off` lane MUST stay byte-identical: it OMITS the parallel_mode: state +# field, adds NO code path (the serial loop is untouched), and the worktree/wave machinery is +# entirely inside the PARALLEL_MODE == on branch. These serial-fork literals survive the flip +# verbatim — only the absent/default clause was reworded (off was NEVER the load-bearing carrier +# of the default before; it is the opt-out now). +par8_omit_field=$(grep -cF 'OMIT the `parallel_mode:` field entirely' "$PAR_AUTOPILOT" || true) +par8_no_code_path=$(grep -ciE 'parallel=off.* adds NO code path' "$PAR_AUTOPILOT" || true) +par8_serial_untouched=$(grep -cF 'the serial loop is untouched' "$PAR_AUTOPILOT" || true) +par8_off_byteident=$(grep -cF 'byte-identical to prior versions' "$PAR_AUTOPILOT" || true) +par8_statefile_omit=$(grep -cF 'OMITS the field entirely' "$PAR_STATEFILE" || true) +par8_result="false" +if [ "$par8_omit_field" -ge 1 ] && [ "$par8_no_code_path" -ge 1 ] && [ "$par8_serial_untouched" -ge 1 ] \ + && [ "$par8_off_byteident" -ge 1 ] && [ "$par8_statefile_omit" -ge 1 ]; then par8_result="true"; fi +assert_true \ + "CT-PARALLEL-8 (explicit parallel=off byte-identity opt-out): omit field ($par8_omit_field>=1) no-code-path ($par8_no_code_path>=1) serial-untouched ($par8_serial_untouched>=1) byte-identical ($par8_off_byteident>=1) state-file omit ($par8_statefile_omit>=1)" \ + "$par8_result" + +# CT-PARALLEL-9 (bare /autopilot default-on is NOT byte-identical — it routes through the +# executor + wave-parallel path). The default-on path must genuinely select the executor-routed +# wave-parallel branch (NOT the inline serial loop): the absent-token resolution defaults to `on` +# and Phase 2 takes the executor-routed wave-parallel path. This is the inverse of the opt-out +# byte-identity CT above — it pins that the NEW default actually engages the parallel machinery. +par9_default_routes=$(grep -cF 'With no `parallel=` token the resolution defaults to `on`, Phase 2 takes the executor-routed wave-parallel path' "$PAR_AUTOPILOT" || true) +par9_wave_pipeline=$(grep -cF 'Wave-parallel pipeline (`PARALLEL_MODE == on`)' "$PAR_AUTOPILOT" || true) +par9_spawn_concurrent=$(grep -cF 'Spawn the wave concurrently' "$PAR_AUTOPILOT" || true) +par9_result="false" +if [ "$par9_default_routes" -ge 1 ] && [ "$par9_wave_pipeline" -ge 1 ] && [ "$par9_spawn_concurrent" -ge 1 ]; then par9_result="true"; fi +assert_true \ + "CT-PARALLEL-9 (bare default-on routes to wave-parallel, NOT byte-identical): default-routes-on ($par9_default_routes>=1) wave-pipeline section ($par9_wave_pipeline>=1) concurrent spawn ($par9_spawn_concurrent>=1)" \ + "$par9_result" + +# CT-PARALLEL-10 (unknown parallel= -> off fail-safe, L3 / R4). An unknown value +# coerces to off (the proven serial path — the SAME conservative direction as uc= unknown->off +# and resolve_parallel_mode's own unknown->off) AND surfaces it via the observable +# reason=invocation-unknown-value-failsafe emit. Documented in autopilot SKILL.md (the resolver) +# + CLAUDE.md (the R4 posture). This removes the prior deliberate asymmetry (parallel unknown->on +# would have inverted the conservative direction). +par10_autopilot_failsafe=$(grep -cF 'reason=invocation-unknown-value-failsafe' "$PAR_AUTOPILOT" || true) +par10_autopilot_safe_off=$(grep -cF 'An unknown `parallel=` resolves **safe to `off`**' "$PAR_AUTOPILOT" || true) +par10_claude_failsafe=$(grep -cF 'reason=invocation-unknown-value-failsafe' "$PAR_CLAUDEMD" || true) +par10_claude_coerce_off=$(grep -ciE 'unknown .parallel=.* coerces to .*off' "$PAR_CLAUDEMD" || true) +par10_result="false" +if [ "$par10_autopilot_failsafe" -ge 1 ] && [ "$par10_autopilot_safe_off" -ge 1 ] \ + && [ "$par10_claude_failsafe" -ge 1 ] && [ "$par10_claude_coerce_off" -ge 1 ]; then par10_result="true"; fi +assert_true \ + "CT-PARALLEL-10 (unknown parallel= -> off fail-safe L3): autopilot failsafe-emit ($par10_autopilot_failsafe>=1) autopilot safe-to-off ($par10_autopilot_safe_off>=1) CLAUDE.md failsafe-emit ($par10_claude_failsafe>=1) CLAUDE.md coerce-off ($par10_claude_coerce_off>=1)" \ + "$par10_result" + PAR_PSF="$REPO_DIR/hooks/lib/parse-state-file.sh" # CT-PARALLEL-CURSOR-1 (wave-cursor schema + cursor-write obligation + hook kill switch, T-003). From 5f2abc0cba94f09317436abb74abc6c9ec5988c8 Mon Sep 17 00:00:00 2001 From: aimsise Date: Fri, 26 Jun 2026 01:48:51 +0900 Subject: [PATCH 10/10] fix(autopilot): critical pre-release review fixes (C1-C4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 25-agent / 6-dimension adversarial whole-branch review (each finding independently re-verified) before the v9.0.0 publish caught 4 CROSS-CUTTING critical bugs that the per-ticket reviews missed. All fixed + re-verified clean (both lenses PASS_WITH_NITS, 0 blockers/majors). C1 (empty-wave cursor stall, hooks/autopilot-continue.sh): under parallel default-on, cascading dependency failures that leave later waves EMPTY left current_wave un-advanced (empty waves skip the cursor-advancing step 2), so the Stop hook kept selecting spawn_next ((current_wave+1) parallel= arg > absent-token default on; unknown env -> off fail-safe) + correct the false 'applied by the resolver helper' claim in BOTH skills/autopilot/SKILL.md AND CLAUDE.md (two-knob architecture: TICKETS_MODE = run/skill side, HOOKS_MODE = hook side). CT-PARALLEL-11 (non-vacuous). C3 (test-phase-state-contracts.sh RED on the branch): the no-rm-of-phase-state.yaml guard pattern rm[[:space:]].*phase-state.yaml false-positive-FAILed on a dense T-008 prose line ('the platfoRM blocks ... phase-state.yaml') — run-all.sh was failing. FIX: anchor rm to a shell-command boundary ((^|[^[:alnum:]_])rm[[:space:]][^|;&]*phase-state.yaml) + the same hardening on the sibling impl-state.yaml guard; positive control (real 'rm phase-state.yaml' / 'rm -f .../phase-state.yaml' still caught) + negative control (platform/transform/confirm prose no longer matches) verified. 14/14; run-all.sh = ALL TEST SUITES PASSED. C4 (metric-only routing contradiction): SKILL.md:176 (T-001) said 'PARALLEL_MODE != off -> executor-routed' (capturing metric-only) while SKILL.md:249 (T-007) said 'metric-only = serial inline, no executor' — mutually exclusive. FIX: split the routing gate into THREE explicit cases (off = inline serial byte-identical; metric-only = inline serial + wave-plan log, no executor/spawn/cursor/worktree; on = executor-routed), retitle the executor section + wave-cursor obligation + state-file clause to '== on', and align agents/ticket-executor.md (description + body) to 'parallel_mode == on (NOT metric-only)'. CT-PARALLEL-12 (non-vacuous). Tests: skill-contracts 900/900 (CT-PARALLEL-11/12 added), path 145/145, accept-set 32/32, phase-state-contracts 14/14, autopilot-continue 65/65, run-all.sh ALL PASSED, ShellCheck clean, DECONTAM 0, CT-MODE-14 version sync. Residual minor nits (non-blocking, recorded): C1 redundant parse_active_steps call, C3 theoretical 'NOT rm' prose (no corpus match), C2/C4 implicit single-emission/silence cross-refs. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Mw2bH4wbEPeebXsvSG6rWe --- CHANGELOG.md | 3 +- CLAUDE.md | 2 +- agents/ticket-executor.md | 4 +- hooks/autopilot-continue.sh | 19 ++++++++ skills/autopilot/SKILL.md | 16 ++++--- tests/test-autopilot-continue.sh | 69 +++++++++++++++++++++++++++++ tests/test-phase-state-contracts.sh | 17 ++++++- tests/test-skill-contracts.sh | 36 +++++++++++++++ 8 files changed, 154 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05bc014..6c5bb5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,11 +21,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Verification -- `bash tests/test-skill-contracts.sh` **898/898** (+ **CT-PARALLEL-7..10**: the parallel default off→on flip, the explicit `parallel=off` byte-identity opt-out, the bare-default-on-routes-to-wave-parallel anchor, and the unknown→off fail-safe; the existing **CT-PARALLEL-1..6** / **CT-PARALLEL-CURSOR-*** / **CT-PARALLEL-SUBSTOP-*** / **CT-WAVE-*** / **CT-WORKTREE-1..12** greens carry; **CT-UC-ORCH-5** reaffirms the `uc=off` byte-identity); `bash tests/test-path-consistency.sh` **145/145**; `bash tests/test-accept-set-verify.sh` **32/32**. `plugin.json` `9.0.0` == newest CHANGELOG `[9.0.0]` (CT-MODE-14, which reads the newest entry dynamically); the real ISO date is verified by the manual pre-flight (CT-MODE-13 is hardcoded to the `[6.0.0]` header and does NOT guard the `[9.0.0]` date). +- `bash tests/test-skill-contracts.sh` **900/900** (+ **CT-PARALLEL-7..12**: the parallel default off→on flip, the explicit `parallel=off` byte-identity opt-out, the bare-default-on-routes-to-wave-parallel anchor, the unknown→off fail-safe, the **`SW_PARALLEL_TICKETS_MODE` env-override** wiring [CT-PARALLEL-11], and the **metric-only → inline-serial-with-wave-log routing** [CT-PARALLEL-12]; the existing **CT-PARALLEL-1..6** / **CT-PARALLEL-CURSOR-*** / **CT-PARALLEL-SUBSTOP-*** / **CT-WAVE-*** / **CT-WORKTREE-1..12** greens carry; **CT-UC-ORCH-5** reaffirms the `uc=off` byte-identity); `bash tests/test-path-consistency.sh` **145/145**; `bash tests/test-accept-set-verify.sh` **32/32**; `bash tests/run-all.sh` reports **ALL TEST SUITES PASSED** (incl `test-phase-state-contracts.sh` **14/14** + `test-autopilot-continue.sh` **65/65**). `plugin.json` `9.0.0` == newest CHANGELOG `[9.0.0]` (CT-MODE-14, which reads the newest entry dynamically); the real ISO date is verified by the manual pre-flight (CT-MODE-13 is hardcoded to the `[6.0.0]` header and does NOT guard the `[9.0.0]` date). - **Opt-out byte-identity.** `/autopilot {slug} uc=off parallel=off` is byte-identical to v8.7.0 (inline serial loop + Agent-path evaluator): the `parallel=off` lane omits the `parallel_mode:` state field and adds no code path (the serial-fork literals `parallel=off … adds NO code path` / `the serial loop is untouched` survive verbatim); `uc=off` preserves the `byte-identical to v8.5.0` literal. The two args are a uniform 2-peer set (one `[*-MODE] mode=… active=… reason=…` emit shape, one opt-out-byte-identical contract, one `chain=off`-silent rule, one unknown→off fail-safe rule). - **Phase 2 rework + scheduler + worktree evidence (landed this release).** T-004/5/6 wave-aware hooks (autopilot-continue wave-aware continuation; checkpoint guards relocated to `SubagentStop` with the parallel main-`Stop` stand-down; per-wave-drained auto-compact re-key) ship green; T-007 wave scheduler + `parallel_max` cap + the H2 cascade-skip carve-out fix; T-008 worktree isolation + cross-wave integration via the shared-tree `.simple-workflow` symlink. - **R-SUBSTOP spike verdict = RELOCATE.** The T-005 spike resolved to RELOCATE (confirmed empirically: `SubagentStop` fires with the executor transcript and the checkpoint guards enforce on it), which is the first-class release input that gates the `parallel` default flip. Under RELOCATE the flip ships; had it resolved to STAND-DOWN, the parallel default would have stayed opt-in for v9.0.0 with a barrier-not-guards caveat. - **Documented limitation (T-008).** Under concurrency > 1 the `/ship` Step 6 `/tune` step read-modify-writes the shared `.simple-workflow/kb/` accumulator, racing to a bounded **KB-learning lost-update** — a tolerated best-effort-learning fidelity drop, NOT a correctness bug (ticket execution + status + `autopilot-state.yaml` are unaffected). Serializing `/tune` is the documented follow-up. +- **Pre-release multi-angle branch review (4 cross-cutting criticals found + fixed).** A 25-agent / 6-dimension adversarial review of the whole branch (each finding independently re-verified) caught 4 critical bugs the per-ticket reviews missed; all fixed + re-verified clean before release. **(C1)** an empty-wave cursor stall — under parallel default-on, cascading dependency failures that empty later waves left `current_wave` un-advanced, so `hooks/autopilot-continue.sh` blocked "spawn next wave" until the loop guard fired; fixed with an all-terminal guard (the serial path already handled it; fixture `T-004-4b`, negative-control proven). **(C2)** the `SW_PARALLEL_TICKETS_MODE` kill switch was unwired — the resolver reads `SW_PARALLEL_HOOKS_MODE` and `/autopilot` never checked `SW_PARALLEL_TICKETS_MODE`, so the documented panic button did nothing; fixed by wiring an env-override (`env > arg > default`, unknown→off) into Argument Parsing + correcting the false "applied by the resolver helper" claim in `skills/autopilot/SKILL.md` + `CLAUDE.md`. **(C3)** `test-phase-state-contracts.sh` false-positive-FAILed on a dense prose line (`platfo`**`rm`** … `phase-state.yaml`) — the suite was RED on the branch; fixed by anchoring the `rm` grep to a shell-command boundary (genuine `rm phase-state.yaml` still caught; positive/negative controls verified). **(C4)** a metric-only routing contradiction (T-001 "`!= off` → executor" vs T-007 "metric-only = serial"); resolved by splitting the routing gate into three explicit cases (`off` / `metric-only` serial-with-wave-log / `on` executor-routed) + aligning `agents/ticket-executor.md` to `== on`. Guarded by `CT-PARALLEL-11`/`12` + `T-004-4b` + the tightened `test-phase-state-contracts.sh`. ## [8.7.0] — 2026-06-24 diff --git a/CLAUDE.md b/CLAUDE.md index d2b55c3..95703b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ This rule was distilled from a v6.7.0 dogfood incident in which a verify hook ne - `SW_STATE_FIELD_GUARD_MODE` — default `metric-only`. Gates the **HOOK_OWNED_FIELDS enforcement** in `hooks/pre-write-safety.sh` / `hooks/pre-edit-safety.sh`. The registry now ships with `.runtime_metrics` (an append-only telemetry list written exclusively by the six runtime-metrics writer hooks — Foundation 3 / ST-03), so a model Write/Edit that would clobber hook-appended `runtime_metrics` entries (a lost-update) is detectable. Values: `on` (emit `decision:block` whose reason NAMES the violated field — e.g. `.runtime_metrics` — and references `docs/state-schema.md`), `metric-only` (default — log `[STATE-FIELD-GUARD] metric-only: would block ...` to stderr and ALLOW, so populating the registry does NOT change the shipped allow-by-default behaviour), `off` (allow silently). Unknown values collapse to `metric-only`. **Promotion**: ship at `metric-only`; after one dogfood confirms no false trips, set `=on` to enforce. Detection covers the inline `runtime_metrics: []` → `[…]` (and blank-out) form; a multi-line list mutation expressed without an inline value change is not detected (acceptable under the metric-only default). - `SW_ACCEPT_SET_CONFORMANCE_MODE` — default `on` (v8.5.0). Controls `hooks/accept-set-verify.sh`, the PostToolUse(Write|Edit) gate that deterministically verifies the persisted `## Accept-set sweep` section of an `eval-round-{n}.md` report (the Advertised-Accept-Set Conformance observability line written by `agents/ac-evaluator.md`'s Persistence-First terminal rewrite). The hook reads the EMITTED line and applies the lens's OWN self-incrimination rule with zero model recall — BLOCKING on a triggered boundary not run (P1), an alphabet/unicode (A/U-axis) sweep that skipped the astral complement (P2), or an authoritative divergence not driven to FAIL (P4); plus a non-blocking ADVISORY note when an A/U-axis corpus falls below `SW_AASC_CORPUS_FLOOR` (P3 — corpus-size is a weak depth proxy, so a thin corpus is surfaced, NEVER blocked; the dogfood51 confirmation run showed flooring it false-trips a legitimately-thin-but-conformant sweep, so P3 was demoted to advisory). The keyed (K) / canonical-writer (W) axes are exempt from the astral/corpus checks (a reflection-derived key corpus is legitimately small), `caveat=no-runnable-artifact` exempts P1/P2 and the P3 advisory (a compiled-language fail-open degradation), and the `## Accept-set sweep` header is matched case-insensitively (a mis-cased header cannot let a whole report skip the gate). Values: `on` (DEFAULT, v8.5.0 — enforce: emit a PostToolUse `decision:block` whose reason names the violated predicate, surfacing it to the evaluator to re-run the sweep), `metric-only` (observe — log `[ACCEPT-SET-VERIFY] metric-only: would block ...` to stderr and ALLOW), `off` (explicit opt-out, silent). Unknown values collapse to `metric-only` (since the default is `on`, the var is set only to DOWNGRADE, so a typo on a downgrade value falls back to the safe observe mode — never a surprise enforce, never a silent disable). The exit code is ALWAYS 0 (fail-OPEN — the hook can never break a Write/Edit; the only non-allow influence is the `on`-mode block JSON), and a missing `jq` is a silent `exit 0`. **Promoted to `on` by default in v8.5.0** after dogfood51/52 (14 real conformant reports → 0 false-trips) + a live `decision:block` de-risk (dogfood53: the block surfaces cleanly and the evaluator handles it gracefully without thrash, and is NOT pressured into fabricating a conformant line); set `metric-only` to revert to observe-only, `off` to disable. Verified by `tests/test-accept-set-verify.sh`. This is the runtime, recognition-independent half of the per-brief `constraints.accept_set_conformance` switch (which gates whether the EXECUTED sweep runs at all). - `SW_AASC_CORPUS_FLOOR` — default `256`. The ADVISORY threshold for the P3 thin-corpus NOTE in `hooks/accept-set-verify.sh` (a thinner A/U corpus is surfaced to stderr as `[ACCEPT-SET-VERIFY] advisory: ...`, NEVER blocked), applied ONLY to a `triggered=y ran=y` alphabet (A) or unicode-transform (U) boundary line — the axes whose mandated complement (the Unicode decimal-digit property across the BMP and astral planes) is naturally large. A descriptive corpus-size (e.g. `5-canonical-forms`) is parsed by its leading integer so an annotation cannot dodge the note. Lower it (e.g. `=8`) to silence the note on a legitimately small advertised accept-set; raise it to surface more sweeps. Honoured only when `SW_ACCEPT_SET_CONFORMANCE_MODE` is not `off`. -- `SW_PARALLEL_TICKETS_MODE` — default `off` (env-knob-unset; **the run default is now `on`** as of v9.0.0 — a bare `/autopilot` / `/brief chain=on` runs wave-parallel). Setting `=off` is the serial-revert kill switch: the **byte-identical opt-out** that forces the v8.7.0 inline serial loop. Controls whether `/autopilot` (and a `/brief chain=on` chained run) routes the cross-ticket `/scout`→`/impl`→`/ship` pipeline through one `ticket-executor` subagent per ready ticket / topological wave, instead of the inline serial loop. Values: `off` (force the **prior-version behaviour** = serial revert / byte-identical opt-out: every ticket runs inline in the main loop, strictly serial — no `ticket-executor` is spawned and every hook fires exactly as before, byte-identical to a v8.7.0 run), `on` (force the executor-routed path — now also the absent-`parallel=`-token run default), `metric-only` (log the resolved wave plan + the `[PARALLEL-MODE]` line but execute the inline serial path). The per-run `parallel=` argument on `/autopilot` / `/brief` selects the same tri-state; this environment knob is the global override / serial kill switch and resolves **safe to `off`** on an unknown value (the same fail-safe direction as `uc=`), so a parallel path that misbehaves can be disabled from the shell without editing any invocation. **R4 / L3 unknown-value posture (v9.0.0):** an unknown `parallel=` *invocation argument* coerces to **off** (fail-safe to the proven serial path — consistent with `uc=` unknown→off and with `resolve_parallel_mode`'s own unknown→off rule), SURFACED via `[PARALLEL-MODE] mode=off active=n reason=invocation-unknown-value-failsafe`; a fat-fingered value runs the byte-identical serial path, never the less-proven parallel machinery (the prior "coerce unknown to the on default" posture is rejected as inverting the conservative direction). The deterministic env-vs-argument precedence is applied by the parallel-mode resolver helper that the wave-aware hooks consult. This is a **(B) harness-own** substrate per `## Product/Language/Domain Agnosticism` — it governs the plugin's own orchestration engine (the `/autopilot` execution path), not the user's product, language, or domain. +- `SW_PARALLEL_TICKETS_MODE` — default `off` (env-knob-unset; **the run default is now `on`** as of v9.0.0 — a bare `/autopilot` / `/brief chain=on` runs wave-parallel). Setting `=off` is the serial-revert kill switch: the **byte-identical opt-out** that forces the v8.7.0 inline serial loop. Controls whether `/autopilot` (and a `/brief chain=on` chained run) routes the cross-ticket `/scout`→`/impl`→`/ship` pipeline through one `ticket-executor` subagent per ready ticket / topological wave, instead of the inline serial loop. Values: `off` (force the **prior-version behaviour** = serial revert / byte-identical opt-out: every ticket runs inline in the main loop, strictly serial — no `ticket-executor` is spawned and every hook fires exactly as before, byte-identical to a v8.7.0 run), `on` (force the executor-routed path — now also the absent-`parallel=`-token run default), `metric-only` (log the resolved wave plan + the `[PARALLEL-MODE]` line but execute the inline serial path). The per-run `parallel=` argument on `/autopilot` / `/brief` selects the same tri-state; this environment knob is the global override / serial kill switch and resolves **safe to `off`** on an unknown value (the same fail-safe direction as `uc=`), so a parallel path that misbehaves can be disabled from the shell without editing any invocation. **R4 / L3 unknown-value posture (v9.0.0):** an unknown `parallel=` *invocation argument* coerces to **off** (fail-safe to the proven serial path — consistent with `uc=` unknown→off and with `resolve_parallel_mode`'s own unknown→off rule), SURFACED via `[PARALLEL-MODE] mode=off active=n reason=invocation-unknown-value-failsafe`; a fat-fingered value runs the byte-identical serial path, never the less-proven parallel machinery (the prior "coerce unknown to the on default" posture is rejected as inverting the conservative direction). The deterministic precedence for THIS run/skill-side knob — `SW_PARALLEL_TICKETS_MODE` env (known value) > `parallel=` argument > absent-token default `on`, unknown env → `off` — is applied by `/autopilot`'s own Argument Parsing (`skills/autopilot/SKILL.md`), NOT by the resolver helper; the SEPARATE hook-side knob `SW_PARALLEL_HOOKS_MODE` is the one read by `resolve_parallel_mode` that the wave-aware hooks consult (the documented two-knob architecture: `SW_PARALLEL_TICKETS_MODE` = run/skill side, `SW_PARALLEL_HOOKS_MODE` = hook side). This is a **(B) harness-own** substrate per `## Product/Language/Domain Agnosticism` — it governs the plugin's own orchestration engine (the `/autopilot` execution path), not the user's product, language, or domain. - `SW_PARALLEL_HOOKS_MODE` — default = follow the run's `parallel_mode:` state (**now `on` by default** as of v9.0.0, since a bare run writes `parallel_mode: on`). The shared HOOK-side serial-revert kill switch for the parallel-aware Stop / checkpoint / auto-compact rework: setting `=off` forces the prior-version (byte-identical opt-out) hook behaviour regardless of the run's `parallel_mode:`. It is read by the single resolver `hooks/lib/parse-state-file.sh::resolve_parallel_mode `, whose precedence is `SW_PARALLEL_HOOKS_MODE` (env override) > `parallel_mode:` scalar in `autopilot-state.yaml` > `off`. Values: `on` (force every parallel-aware hook onto the wave-aware path), `metric-only` (observe — log the resolved mode but take the serial decision), `off` (force serial = byte-identical opt-out — the prior-version hook behaviour). When the env knob is UNSET (the default), the resolver follows the run-scoped `parallel_mode:` (so the per-run `parallel=` argument transitively drives the hooks; with the v9.0.0 run default `on`, an unset env knob now follows the parallel path on a bare run); when SET, it overrides. An unknown / garbage value resolves **safe to `off`** (= serial / byte-identical, the proven path), and the resolver never returns empty — every ambiguity (unknown env, absent/unknown state scalar, missing state file) fails CLOSED to `off`. This is the canonical fail-safe direction the whole parallel feature uses (the same `unknown → off` the argument parser adopts). A **(B) harness-own** substrate per `## Product/Language/Domain Agnosticism` — it governs the plugin's own hook plumbing, not the user's product, language, or domain. - `SW_PARALLEL_MAX_CONCURRENCY` — default `4`. The concurrency cap for the wave-parallel scheduler: the maximum number of `ticket-executor` subagents `/autopilot` spawns concurrently in a single message within one topological wave. A wave with more ready tickets than the cap is processed in lex-ordered sub-batches of at most this size, so the depth-2 executor fan-out (each executor's own `/impl`→`implementer`+`ac-evaluator` sub-spawns) never exceeds a bounded concurrency / token budget. Precedence: the per-run `parallel_max=` argument on `/autopilot` (case-insensitive `key=value`, integer ≥1) **over** this environment knob **over** the default `4` (arg > env > default — the same direction as `rounds=N`). A non-integer or `<1` value (in EITHER the argument or this env knob) coerces to the default `4` and emits a one-line `[PARALLEL-MODE] WARNING` to stderr naming the rejected value. This cap is **honoured only when `PARALLEL_MODE == on`** — on the serial / `metric-only` / `off` paths no executor is spawned concurrently, so the cap is inert (and never written to `autopilot-state.yaml`; it is re-resolved fresh on every `/autopilot` entry, never persisted). A **(B) harness-own** substrate per `## Product/Language/Domain Agnosticism` — it bounds the plugin's own orchestration fan-out (the `/autopilot` Agent-spawn engine), not the user's product, language, or domain. - `SW_PARALLEL_WORKTREE_KEEP` — default `off`. Debugging kill switch for the parallel wave scheduler's worktree cleanup (T-008). When the wave-parallel `/autopilot` path (`PARALLEL_MODE == on`) gives each `ticket-executor` an isolation worktree under `/.claude/worktrees/ap--` and a dedicated integration worktree under `/.claude/worktrees/ap-integration-`, the scheduler runs a three-tier cleanup: (1) per-ticket `git worktree remove --force` on executor success, (2) integration-worktree removal at Phase 2 end, (3) a startup stale-sweep (`git worktree prune` + remove only this parent's `ap--*` worktrees, never unrelated ones). Values: `off` (DEFAULT — run all three cleanup tiers; the per-ticket and integration worktrees are removed once their commits are on the kept branches), `on` (skip tiers (1) + (2) so the per-ticket + integration worktrees are LEFT on disk for post-run inspection — the startup stale-sweep (3) still runs on the next entry so leftovers do not accumulate across runs, and the branches are ALWAYS kept regardless). A dirty per-ticket worktree on cleanup is ALWAYS left on disk + logged `[PARALLEL] worktree-remove: dirty ` (independent of this knob — the run continues either way). The concurrency cap `SW_PARALLEL_MAX_CONCURRENCY` bounds the number of per-ticket worktrees alive simultaneously (per sub-batch), so the two knobs together cap the worktree disk/inode footprint of a wave (W-6 disk/inode pressure). Honoured only when `PARALLEL_MODE == on` — on the serial / `metric-only` / `off` paths no worktree is created, so the knob is inert. A **(B) harness-own** substrate per `## Product/Language/Domain Agnosticism` — it governs the plugin's own worktree-orchestration cleanup (git worktrees + the `/autopilot` engine), not the user's product, language, or domain. diff --git a/agents/ticket-executor.md b/agents/ticket-executor.md index 349e3dd..dd60663 100644 --- a/agents/ticket-executor.md +++ b/agents/ticket-executor.md @@ -1,10 +1,10 @@ --- name: ticket-executor -description: "Execute one autopilot ticket's complete per-ticket pipeline (Policy guard -> /scout -> /impl -> /ship -> artifact-presence gate) as a subagent and return a single structured [TICKET-EXECUTOR-RESULT] envelope. Spawned once per ready ticket by the /autopilot main loop when parallel_mode != off. Never writes autopilot-state.yaml (the main loop is the single writer)." +description: "Execute one autopilot ticket's complete per-ticket pipeline (Policy guard -> /scout -> /impl -> /ship -> artifact-presence gate) as a subagent and return a single structured [TICKET-EXECUTOR-RESULT] envelope. Spawned once per ready ticket by the /autopilot main loop when parallel_mode == on (NOT on metric-only, which runs the serial inline loop with only a wave-plan log; NOT on off). Never writes autopilot-state.yaml (the main loop is the single writer)." maxTurns: 250 --- -You are the **ticket-executor**. The `/autopilot` main loop spawns you once per ready ticket (only when `parallel_mode != off`) to run that ONE ticket's complete per-ticket pipeline and return a structured result envelope. You execute the same per-ticket logic the serial main loop otherwise runs inline (`skills/autopilot/SKILL.md` "Per-ticket pipeline"), scoped to the single ticket named in your spawn prompt. +You are the **ticket-executor**. The `/autopilot` main loop spawns you once per ready ticket (only when `parallel_mode == on` — NOT on `metric-only`, which runs the inline serial per-ticket loop and merely logs the wave plan; NOT on `off`) to run that ONE ticket's complete per-ticket pipeline and return a structured result envelope. You execute the same per-ticket logic the serial main loop otherwise runs inline (`skills/autopilot/SKILL.md` "Per-ticket pipeline"), scoped to the single ticket named in your spawn prompt. Your `tools:` field is intentionally omitted: you inherit the full parent tool inventory, **including the Agent tool, the Skill tool, `EnterWorktree` / `ExitWorktree`, and the full `Bash` surface**. This is required because `/scout` / `/impl` / `/ship` spawn their own subagents (`researcher`, `planner`, `implementer`, `ac-evaluator`, ...), so you must be able to invoke those pipeline skills via the Skill tool and let them spawn at depth+1. You are the one agent for which invoking pipeline skills is the contract, not a violation. diff --git a/hooks/autopilot-continue.sh b/hooks/autopilot-continue.sh index 3df7cce..6a13cb8 100755 --- a/hooks/autopilot-continue.sh +++ b/hooks/autopilot-continue.sh @@ -443,6 +443,25 @@ if [ "$PARALLEL_MODE" != "off" ]; then WAVE_DECISION="spawn_next" fi + # ALL-TERMINAL guard (C1: empty-wave cursor stall). When cascading dependency + # failures leave one or more LATER waves EMPTY (every member already + # cascade-skipped), `current_wave` is never advanced past the last ACTIVE wave + # (per SKILL.md, the cursor advances only by an ACTIVE wave's step 2). The + # cursor then sits at wave_status=drained with (current_wave+1) < wave_count, + # which selects spawn_next above and would BLOCK forever asking to spawn a + # wave that is itself empty — until only the FILE_COUNT loop guard releases it. + # If EVERY ticket is already terminal (failed/skipped/completed), + # parse_active_steps emits no `:` line; in that case there is + # nothing left to spawn, so override to terminal_check and fall through to the + # SAME all-terminal exit-0 logic the serial path uses below. This mirrors the + # serial path exactly (it reaches parse_active_steps, finds nothing, exits 0). + if [ "$WAVE_DECISION" = "spawn_next" ]; then + GUARD_ACTIVE_LINES=$(parse_active_steps "$STATE_FILE" 2>/dev/null || true) + if [ -z "$GUARD_ACTIVE_LINES" ]; then + WAVE_DECISION="terminal_check" + fi + fi + if [ "$PARALLEL_MODE" = "metric-only" ]; then # Observe-only: log the would-be decision and take the existing serial # control flow unchanged (no block emitted here, no early exit). diff --git a/skills/autopilot/SKILL.md b/skills/autopilot/SKILL.md index d3e9685..2c622fd 100644 --- a/skills/autopilot/SKILL.md +++ b/skills/autopilot/SKILL.md @@ -87,7 +87,7 @@ Target parent-slug: $ARGUMENTS Before detecting `{parent-slug}`, extract the optional `uc=` token from `$ARGUMENTS` using the SAME case-insensitive `key=value` convention the pipeline already uses for other key=value args (mirrors `/impl` `rounds=N` at its Step 1a and `/brief` `chain=`): scan for a `uc=` token (key matched case-insensitively), accept `` ∈ `on` | `off` | `metric-only`, and **strip that token from `$ARGUMENTS`** so the remaining-text/parent-slug detection below is unaffected. Resolve `UC_ORCH` from the stripped value; **when the `uc=` token is absent, `UC_ORCH = on`** (the default — ultracode orchestration is on by default). Emit the resolution line — the SHARED CONTRACT marker `[UC-ORCH-MODE] mode={on|off|metric-only} active={y|n} reason={invocation|default}` to stderr (`active=y` only when `mode=on`, else `active=n`) — using `reason=invocation` when the value came from an explicit `uc=` token, and `reason=default` when the token was absent (the on-by-default path). `UC_ORCH` is carried through Phase 2 to each per-ticket `/impl` invocation (Step 3c) and recorded in the run-scoped state file (Phase 2 State file initialization). With no `uc=` token the resolution defaults to `on`, which routes M+ tickets through the eval-panel (additive verification); pass `uc=off` to restore the v8.7.0 Agent path. The bare-`{parent-slug}` parse below is unaffected either way (the `uc=` token is stripped before it runs). -After resolving `UC_ORCH`, similarly extract the optional `parallel=` token from `$ARGUMENTS` using the SAME case-insensitive `key=value` convention: scan for a `parallel=` token (key matched case-insensitively), accept `` ∈ `on` | `off` | `metric-only`, and **strip that token from `$ARGUMENTS`** so the parent-slug detection below is unaffected. Resolve `PARALLEL_MODE` from the stripped value; **when the `parallel=` token is absent, `PARALLEL_MODE = on`** (the v9.0.0 default — wave-parallel ticket execution is on by default). An unknown `parallel=` resolves **safe to `off`** (the proven serial path — the same fail-safe direction as `uc=` unknown→off and as `resolve_parallel_mode`'s own unknown→off; a fat-fingered value never silently opts INTO the parallel machinery), and **the coercion is observable**: emit `[PARALLEL-MODE] mode=off active=n reason=invocation-unknown-value-failsafe` to stderr so the fail-safe is surfaced. **Emit the resolution line whenever `PARALLEL_MODE != off`** — the SHARED CONTRACT marker `[PARALLEL-MODE] mode={on|metric-only} active={y|n} reason={invocation|default}` to stderr (`active=y` only when `mode=on`, else `active=n`), using `reason=invocation` when the value came from an explicit `parallel=` token and **`reason=default` when the token was absent (the on-by-default path — emit `[PARALLEL-MODE] mode=on active=y reason=default`)**. **When `PARALLEL_MODE == off` (an explicit `parallel=off` token, OR the env kill switch, but NOT the absent-token default — which now resolves `on`), emit NO `[PARALLEL-MODE]` line at all EXCEPT the `reason=invocation-unknown-value-failsafe` coercion emit above** — an explicit `parallel=off` run is byte-identical to prior versions with zero new stderr noise. (The `parallel=off` lane is the load-bearing byte-identical opt-out / rollback path for the parallel feature, so it stays completely silent; the only `off`-path stderr is the unknown-value coercion line, which exists precisely to surface a typo that landed on the fail-safe serial path.) `PARALLEL_MODE` is **orthogonal** to `UC_ORCH` (composable: `/autopilot {slug} uc=on parallel=on`); it is carried through Phase 2 to select the per-ticket execution path (current inline serial branch vs one `ticket-executor` subagent per ticket) and recorded in the run-scoped state file (Phase 2 State file initialization). With no `parallel=` token the resolution defaults to `on`, Phase 2 takes the executor-routed wave-parallel path; an explicit `parallel=off` (or the env kill switch, or an unknown value coerced to off) takes the inline serial path, and behaviour is byte-identical to prior versions. A `SW_PARALLEL_TICKETS_MODE` environment knob (documented in `CLAUDE.md`) is the run kill switch that forces serial; its deterministic env-vs-argument precedence is applied by the parallel-mode resolver helper introduced alongside the wave-cursor schema. +After resolving `UC_ORCH`, similarly extract the optional `parallel=` token from `$ARGUMENTS` using the SAME case-insensitive `key=value` convention: scan for a `parallel=` token (key matched case-insensitively), accept `` ∈ `on` | `off` | `metric-only`, and **strip that token from `$ARGUMENTS`** so the parent-slug detection below is unaffected. **ENV OVERRIDE (run/skill-side kill switch — applied BEFORE writing `PARALLEL_MODE` from the stripped token):** read the `SW_PARALLEL_TICKETS_MODE` environment variable; when it is **set to a known value** (`on` | `off` | `metric-only`) it takes **PRECEDENCE over the `parallel=` argument AND over the absent-token default** — resolve `PARALLEL_MODE` directly from the env value and ignore the `parallel=` token for the purpose of `PARALLEL_MODE` resolution. When the env value is **set but unknown / garbage**, coerce `PARALLEL_MODE` to **`off`** (the fail-safe serial path — uniform with the arg-side unknown→off below and `resolve_parallel_mode`'s own unknown→off; a fat-fingered env value never silently opts INTO the parallel machinery), and surface it via the same observable emit (`[PARALLEL-MODE] mode=off active=n reason=invocation-unknown-value-failsafe` to stderr). When `SW_PARALLEL_TICKETS_MODE` is **unset/empty**, fall through to the `parallel=` argument and the absent-token default below — the env knob is inert. The deterministic precedence is therefore: **env `SW_PARALLEL_TICKETS_MODE` (known value) > env `SW_PARALLEL_TICKETS_MODE` (unknown → off) > `parallel=` argument > absent-token default `on`**. This is the skill/run-side kill switch (`SW_PARALLEL_TICKETS_MODE`, documented in `CLAUDE.md`), mirroring how `SW_PARALLEL_HOOKS_MODE` overrides the `parallel_mode:` state scalar inside `resolve_parallel_mode` at the hook layer (the two-knob architecture: TICKETS_MODE = run/skill side, HOOKS_MODE = hook side). It makes `SW_PARALLEL_TICKETS_MODE=off` the operator panic-button for a `/brief`-chained invocation whose `parallel=` argument you cannot edit — set it in the shell before launching `claude` and the skill resolves `PARALLEL_MODE=off` (serial) regardless of the chained-in `parallel=` token. The `parallel=` token parse and its own unknown→off fail-safe below remain intact; the env override is applied in FRONT of them. After applying the env override (when it was unset/empty, so the `parallel=` token governs): resolve `PARALLEL_MODE` from the stripped value; **when the `parallel=` token is absent, `PARALLEL_MODE = on`** (the v9.0.0 default — wave-parallel ticket execution is on by default; this absent-token default applies only when no `SW_PARALLEL_TICKETS_MODE` env override was applied above). An unknown `parallel=` resolves **safe to `off`** (the proven serial path — the same fail-safe direction as `uc=` unknown→off and as `resolve_parallel_mode`'s own unknown→off; a fat-fingered value never silently opts INTO the parallel machinery), and **the coercion is observable**: emit `[PARALLEL-MODE] mode=off active=n reason=invocation-unknown-value-failsafe` to stderr so the fail-safe is surfaced. **Emit the resolution line whenever `PARALLEL_MODE != off`** — the SHARED CONTRACT marker `[PARALLEL-MODE] mode={on|metric-only} active={y|n} reason={invocation|default}` to stderr (`active=y` only when `mode=on`, else `active=n`), using `reason=invocation` when the value came from an explicit `parallel=` token and **`reason=default` when the token was absent (the on-by-default path — emit `[PARALLEL-MODE] mode=on active=y reason=default`)**. **When `PARALLEL_MODE == off` (an explicit `parallel=off` token, OR the env kill switch, but NOT the absent-token default — which now resolves `on`), emit NO `[PARALLEL-MODE]` line at all EXCEPT the `reason=invocation-unknown-value-failsafe` coercion emit above** — an explicit `parallel=off` run is byte-identical to prior versions with zero new stderr noise. (The `parallel=off` lane is the load-bearing byte-identical opt-out / rollback path for the parallel feature, so it stays completely silent; the only `off`-path stderr is the unknown-value coercion line, which exists precisely to surface a typo that landed on the fail-safe serial path.) `PARALLEL_MODE` is **orthogonal** to `UC_ORCH` (composable: `/autopilot {slug} uc=on parallel=on`); it is carried through Phase 2 to select the per-ticket execution path (current inline serial branch vs one `ticket-executor` subagent per ticket) and recorded in the run-scoped state file (Phase 2 State file initialization). With no `parallel=` token the resolution defaults to `on`, Phase 2 takes the executor-routed wave-parallel path (this absent-token default holds only when no `SW_PARALLEL_TICKETS_MODE` env override was applied above); an explicit `parallel=off` (or the `SW_PARALLEL_TICKETS_MODE=off` env kill switch, or an unknown value coerced to off) takes the inline serial path, and behaviour is byte-identical to prior versions. The `SW_PARALLEL_TICKETS_MODE` environment knob (documented in `CLAUDE.md`) is the run/skill-side kill switch that forces serial; its deterministic env-vs-argument precedence (**env `SW_PARALLEL_TICKETS_MODE` > `parallel=` arg > absent-token default `on`**, unknown env → off) is applied by THIS Argument Parsing block above — BEFORE `PARALLEL_MODE` is written — exactly as `SW_PARALLEL_HOOKS_MODE` overrides the `parallel_mode:` state scalar inside `resolve_parallel_mode` at the hook layer. After resolving `PARALLEL_MODE`, similarly extract the optional `parallel_max=` token from `$ARGUMENTS` using the SAME case-insensitive `key=value` convention: scan for a `parallel_max=` token (key matched case-insensitively), and **strip that token from `$ARGUMENTS`** so the parent-slug detection below is unaffected. Resolve the concurrency cap `CONCURRENCY_CAP` with **arg > env > default** precedence (the same direction as `/impl` `rounds=N`): the stripped `parallel_max=` argument takes priority, else the `SW_PARALLEL_MAX_CONCURRENCY` environment variable, else the **default `4`**. The accepted value is an **integer ≥ 1**; a **non-integer or `< 1` value** (in EITHER the argument or the env knob) is **coerced to the default `4`** (the token is still stripped from `$ARGUMENTS` regardless, so parent-slug detection is unaffected). **The coercion WARNING is emitted ONLY when `PARALLEL_MODE != off`** — a single one-line `[PARALLEL-MODE] WARNING parallel_max={rejected-value} invalid (non-integer or <1); using default 4` to stderr. **When `PARALLEL_MODE == off` (the byte-identical serial lane), NO `[PARALLEL-MODE] WARNING` is emitted even if `SW_PARALLEL_MAX_CONCURRENCY` carries a garbage value** — the cap is inert on the serial path, so a serial run stays byte-identical with zero new stderr (the same silent-when-`off` posture as the `[PARALLEL-MODE]` resolution marker above; a global `SW_PARALLEL_MAX_CONCURRENCY=garbage` must not leak a WARNING into an explicit `parallel=off` rollback run). `CONCURRENCY_CAP` is **honoured only when `PARALLEL_MODE == on`** (the wave scheduler); on the `metric-only` and `off` paths it is inert (no concurrent spawn). `parallel_max` is a per-invocation knob: it is resolved fresh on every `/autopilot` entry and is **NOT persisted** to `autopilot-state.yaml` (it is recomputed on resume, like the wave layering itself). Documented in `CLAUDE.md` as `SW_PARALLEL_MAX_CONCURRENCY`. @@ -163,7 +163,7 @@ re-emit the same block. ### State file initialization -Skip if `resume_mode = true`. Brief-level `autopilot-state.yaml` ≠ per-ticket `phase-state.yaml`. Write at `briefs/active/{parent-slug}/` (else `product_backlog/{parent-slug}/`); hooks also accept `briefs/done/{parent-slug}/`. Fields: `ticket_mapping`, per-ticket `ticket_dir:` + `status` + `steps` + `invocation_method` ∈ `skill`/`manual-bash`/`unknown`, append-only `runtime_metrics: []` (`hooks/autopilot-continue.sh` + `hooks/pre-compact-save.sh` only; skills MUST NOT write). **Also write the top-level field `ultracode_mode: {on|off|metric-only}` from the `UC_ORCH` resolved in Argument Parsing** — a run-scoped orchestration mode (sibling of `version` / `parent_slug` / `started` / `execution_mode` / `total_tickets` / `ticket_mapping` / `tickets`). It records the run-scoped continuity value so Phase 1 Step 5 can re-read it on resume; the default `UC_ORCH = on` writes `ultracode_mode: on` (round-trips to `on` on resume; an explicit `uc=off` writes `ultracode_mode: off`). This is run-state, NOT a permanent policy flag — it lives only in `autopilot-state.yaml` (moved to `briefs/done/` on completion), never in `autopilot-policy.yaml`. **Also write the top-level field `parallel_mode: {on|metric-only}` from the `PARALLEL_MODE` resolved in Argument Parsing, but ONLY when `PARALLEL_MODE != off`** — a sibling run-scoped field with the identical lifecycle (init-written, resume-replayed at Phase 1 Step 5, moved to `briefs/done/` on completion, never a policy flag). **When `PARALLEL_MODE == off` (an explicit `parallel=off` opt-out — NOT the absent-token default, which now resolves `on`), OMIT the `parallel_mode:` field entirely** so the state file is byte-identical to prior versions (which had no `parallel_mode` field); Phase 1 Step 5 reconstructs `off` from an absent / `null` field. This deliberately differs from `ultracode_mode:` (which is written even for `off`): the `parallel=off` lane carries the byte-identical-**state** guarantee of AC-1, so it must not add a field. **The wave layering and the `parallel_max=` concurrency cap are NOT persisted** — only `parallel_mode:` (the tri-state) and the OPTIONAL observability wave cursor (`wave_count` / `current_wave` / `wave_status` / `main_checkout_root`, `PARALLEL_MODE != off` only) are written; the waves themselves and `CONCURRENCY_CAP` are **recomputed** from the dependency graph + Argument Parsing on every entry (resume re-derives them, never reads a persisted wave index). Schema documented in [references/state-file.md](references/state-file.md). **MUST emit `tickets:` as a YAML list** of dash-prefixed `- logical_id: …` mappings — NOT a map keyed by `logical_id`. The map form silently bypasses the hook-layer skip-transition guard (`parse_proposed_tickets`) and the Stop-hook loop-guard counters (`parse_ticket_statuses`); field evidence `test_simple_workflow28`. Hook tolerance was added in WI-4 as a safety net only; SKILL prose remains the enforcement. Loop-guard emits `[AUTOPILOT-STALL] ...`. Schema invariants (including `tickets:` list-vs-map) + precedence + counters + kill switch + `boundary`/`stop_reason` domains in [references/state-file.md](references/state-file.md) + [references/stop-reason-taxonomy.md](references/stop-reason-taxonomy.md). +Skip if `resume_mode = true`. Brief-level `autopilot-state.yaml` ≠ per-ticket `phase-state.yaml`. Write at `briefs/active/{parent-slug}/` (else `product_backlog/{parent-slug}/`); hooks also accept `briefs/done/{parent-slug}/`. Fields: `ticket_mapping`, per-ticket `ticket_dir:` + `status` + `steps` + `invocation_method` ∈ `skill`/`manual-bash`/`unknown`, append-only `runtime_metrics: []` (`hooks/autopilot-continue.sh` + `hooks/pre-compact-save.sh` only; skills MUST NOT write). **Also write the top-level field `ultracode_mode: {on|off|metric-only}` from the `UC_ORCH` resolved in Argument Parsing** — a run-scoped orchestration mode (sibling of `version` / `parent_slug` / `started` / `execution_mode` / `total_tickets` / `ticket_mapping` / `tickets`). It records the run-scoped continuity value so Phase 1 Step 5 can re-read it on resume; the default `UC_ORCH = on` writes `ultracode_mode: on` (round-trips to `on` on resume; an explicit `uc=off` writes `ultracode_mode: off`). This is run-state, NOT a permanent policy flag — it lives only in `autopilot-state.yaml` (moved to `briefs/done/` on completion), never in `autopilot-policy.yaml`. **Also write the top-level field `parallel_mode: {on|metric-only}` from the `PARALLEL_MODE` resolved in Argument Parsing, but ONLY when `PARALLEL_MODE != off`** — a sibling run-scoped field with the identical lifecycle (init-written, resume-replayed at Phase 1 Step 5, moved to `briefs/done/` on completion, never a policy flag). **When `PARALLEL_MODE == off` (an explicit `parallel=off` opt-out — NOT the absent-token default, which now resolves `on`), OMIT the `parallel_mode:` field entirely** so the state file is byte-identical to prior versions (which had no `parallel_mode` field); Phase 1 Step 5 reconstructs `off` from an absent / `null` field. This deliberately differs from `ultracode_mode:` (which is written even for `off`): the `parallel=off` lane carries the byte-identical-**state** guarantee of AC-1, so it must not add a field. **The wave layering and the `parallel_max=` concurrency cap are NOT persisted** — only `parallel_mode:` (the tri-state) and the OPTIONAL observability wave cursor (`wave_count` / `current_wave` / `wave_status` / `main_checkout_root`, `PARALLEL_MODE == on` only — `metric-only` logs the wave plan but writes NO cursor, and `off` writes none) are written; the waves themselves and `CONCURRENCY_CAP` are **recomputed** from the dependency graph + Argument Parsing on every entry (resume re-derives them, never reads a persisted wave index). Schema documented in [references/state-file.md](references/state-file.md). **MUST emit `tickets:` as a YAML list** of dash-prefixed `- logical_id: …` mappings — NOT a map keyed by `logical_id`. The map form silently bypasses the hook-layer skip-transition guard (`parse_proposed_tickets`) and the Stop-hook loop-guard counters (`parse_ticket_statuses`); field evidence `test_simple_workflow28`. Hook tolerance was added in WI-4 as a safety net only; SKILL prose remains the enforcement. Loop-guard emits `[AUTOPILOT-STALL] ...`. Schema invariants (including `tickets:` list-vs-map) + precedence + counters + kill switch + `boundary`/`stop_reason` domains in [references/state-file.md](references/state-file.md) + [references/stop-reason-taxonomy.md](references/stop-reason-taxonomy.md). ### Split Execution Flow @@ -173,7 +173,11 @@ Parse `SPLIT_PLAN` frontmatter + tickets, build dependency graph, run topologica > **Non-interactive orchestrator contract**: see `## Non-interactive orchestrator contract (3-tier, risk_tolerance-aware)` above. Per-ticket pipeline inherits the same 3-tier matrix; the only mid-pipeline `end_turn` is the auto-compact exception in step e. -**Execution-path routing (`PARALLEL_MODE`)**: when `PARALLEL_MODE == off` (an explicit `parallel=off` opt-out or the env kill switch — the absent-token default now resolves `on`), execute the inline serial per-ticket steps below exactly as in prior versions — byte-identical, and every hook behaves as before. When `PARALLEL_MODE != off`, execute the **executor-routed** variant in `##### Executor-routed per-ticket pipeline` (below the inline loop) INSTEAD of the inline steps: the dependency check, terminal statuses, and artifact set are equivalent, but each ticket's `/scout`→`/impl`→`/ship` runs inside a `ticket-executor` subagent and the main loop is the single writer of `autopilot-state.yaml`. In Phase 1 the executor path runs at **concurrency 1** (one executor at a time, in `PROCESSING_ORDER` order, no worktree = main checkout), so its terminal result matches the inline path. +**Execution-path routing (`PARALLEL_MODE`)** — three explicit cases, NO `!= off` shorthand (the `metric-only` case routes serial, NOT to the executor): + +- **`PARALLEL_MODE == off`** (an explicit `parallel=off` opt-out or the `SW_PARALLEL_TICKETS_MODE=off` env kill switch — the absent-token default now resolves `on`): execute the inline serial per-ticket steps below exactly as in prior versions — byte-identical, and every hook behaves as before. No `ticket-executor` spawn, no wave-cursor write, no worktree. +- **`PARALLEL_MODE == metric-only`** (a serial dry-run for observability): execute the **inline serial per-ticket loop below — identical control flow to `off`** (no `ticket-executor` spawn, no concurrent spawn, no wave-cursor write, no worktree), but **ADDITIONALLY emit the `Wave {k}: …` plan log** (the level-synchronous Kahn layering from Split Execution Flow) for observability. `metric-only` does NOT take the executor-routed / wave-parallel path; it only LOGS the wave plan while running the serial inline loop. This matches the `CONCURRENCY_CAP` "inert on the metric-only and off paths" note in Argument Parsing, the worktree-exclusion note (`metric-only` adds NO worktree code), and the hook behaviour. +- **`PARALLEL_MODE == on`**: execute the **executor-routed** variant in `##### Executor-routed per-ticket pipeline` (below the inline loop) INSTEAD of the inline steps: the dependency check, terminal statuses, and artifact set are equivalent, but each ticket's `/scout`→`/impl`→`/ship` runs inside a `ticket-executor` subagent and the main loop is the single writer of `autopilot-state.yaml`. In Phase 1 the executor path runs at **concurrency 1** (one executor at a time, in `PROCESSING_ORDER` order, no worktree = main checkout), so its terminal result matches the inline path; in Phase 2 it drives the concurrent wave scheduler. For each ticket in `PROCESSING_ORDER` (`i` = 0-based): @@ -220,9 +224,9 @@ For each ticket in `PROCESSING_ORDER` (`i` = 0-based): 4. **Per-ticket error handling**: any step failure → ticket `failed`, log error, next ticket (do NOT stop pipeline). Dependents skipped (step 2). Independent tickets still run. -##### Executor-routed per-ticket pipeline (`PARALLEL_MODE != off`) +##### Executor-routed per-ticket pipeline (`PARALLEL_MODE == on`) -Taken INSTEAD of the inline loop above when `PARALLEL_MODE != off`. The dependency graph, `PROCESSING_ORDER`, resume-skip semantics, and per-ticket terminal statuses are unchanged; the difference is that each ticket's pipeline runs inside a `ticket-executor` subagent (`agents/ticket-executor.md`) and the **main loop is the single writer** of `autopilot-state.yaml`. +Taken INSTEAD of the inline loop above when `PARALLEL_MODE == on` (NOT on `metric-only` — that case runs the inline serial loop with a wave-plan log per the routing gate above; NOT on `off`). The dependency graph, `PROCESSING_ORDER`, resume-skip semantics, and per-ticket terminal statuses are unchanged; the difference is that each ticket's pipeline runs inside a `ticket-executor` subagent (`agents/ticket-executor.md`) and the **main loop is the single writer** of `autopilot-state.yaml`. For each ticket in `PROCESSING_ORDER` (`i` = 0-based), at **concurrency 1** in Phase 1 (one executor in flight at a time): @@ -236,7 +240,7 @@ For each ticket in `PROCESSING_ORDER` (`i` = 0-based), at **concurrency 1** in P **Concurrency in Phase 1 is fixed at 1** (serial-equivalent): the main loop spawns the next executor only AFTER the previous envelope is received and state is written. Real per-wave parallel spawning and a `parallel_max=` cap are added in Phase 2; the wave layering is computed and emitted now (Split Execution Flow) but does not yet drive concurrent spawns. -**Wave-cursor single-writer obligation (`PARALLEL_MODE != off`).** The main loop is the SOLE writer of the wave cursor in `autopilot-state.yaml`, with the same rigor as the canonical FLAT `steps.ship: completed` invariant — the `ticket-executor` NEVER writes any cursor field. The obligations: +**Wave-cursor single-writer obligation (`PARALLEL_MODE == on`).** The main loop is the SOLE writer of the wave cursor in `autopilot-state.yaml`, with the same rigor as the canonical FLAT `steps.ship: completed` invariant — the `ticket-executor` NEVER writes any cursor field. (`metric-only` writes NO wave cursor — it logs the wave plan only — and `off` writes none either; the cursor fields exist only on the `== on` path.) The obligations: - At Phase 2 State file initialization, write `main_checkout_root` ONCE = `git rev-parse --show-toplevel` (the main-checkout repo root); this is ``. Capture `` into the run as the absolute root for ALL state + artifact path resolution (the orchestrator never enters a worktree, so its cwd stays the main checkout and `` = its cwd, but the per-ticket worktrees make the absolute capture load-bearing — see `## Worktree isolation + cross-wave integration` below). - At wave computation (the level-synchronous Kahn layering), write `wave_count` (total waves). - Immediately BEFORE spawning a wave's executors, write `current_wave` (0-based; `-1` before the first spawn) and `wave_status: in_flight`. diff --git a/tests/test-autopilot-continue.sh b/tests/test-autopilot-continue.sh index 1a8aacb..40e47bf 100644 --- a/tests/test-autopilot-continue.sh +++ b/tests/test-autopilot-continue.sh @@ -1549,6 +1549,75 @@ fi rm -f /tmp/.autopilot-continue-wave-last /tmp/.autopilot-notool-wave-last cleanup_test_repo +# ------------------------------------------------------------ +# T-004-4b: C1 empty-wave cursor stall — drained + waves-remaining BUT every +# ticket already terminal (cascading failure) → must NOT emit a spawn-next block; +# falls through to the all-terminal allow, mirroring the serial path. +# ------------------------------------------------------------ +echo "--- T-004-4b: C1 empty-wave cascade — drained+remaining+all-terminal → allow stop (no spawn-next block) ---" +setup_test_repo +# Reproduce the confirmed-critical default-on regression: wave 0 = [A fails], +# wave 1 = [B cascade-skip], wave 2 = [C cascade-skip]. The cursor advances only +# by an ACTIVE wave's step 2, so the two empty later waves never advance it: +# final state is current_wave 0, wave_status drained, wave_count 3, and EVERY +# ticket terminal (1 failed + 2 dependency-skipped, NO pending/in_progress step). +# Without the ALL-TERMINAL guard, 0+1 < 3 selects spawn_next and the hook emits a +# `decision: block` asking to "spawn the next wave" forever (the next wave is +# itself empty) until the FILE_COUNT loop guard fires [AUTOPILOT-STALL]. With the +# guard, parse_active_steps emits nothing → WAVE_DECISION overridden to +# terminal_check → fall through to the same all-terminal exit-0 the serial path +# reaches. +mkdir -p ".simple-workflow/backlog/briefs/active/wave-empty-cascade" +{ + echo "version: 1" + echo "slug: wave-empty-cascade" + echo "started: 2026-04-15T00:00:00Z" + echo "execution_mode: split" + echo "parallel_mode: on" + echo "wave_status: drained" + echo "current_wave: 0" + echo "wave_count: 3" + echo "total_tickets: 3" + echo "tickets:" + echo " - logical_id: ticket-a" + echo " ticket_dir: 001-a" + echo " status: failed" + echo " steps:" + echo " create-ticket: completed" + echo " scout: completed" + echo " impl: failed" + echo " ship: skipped" + echo " - logical_id: ticket-b" + echo " ticket_dir: 002-b" + echo " status: skipped" + echo " steps:" + echo " create-ticket: completed" + echo " scout: skipped" + echo " impl: skipped" + echo " ship: skipped" + echo " - logical_id: ticket-c" + echo " ticket_dir: 003-c" + echo " status: skipped" + echo " steps:" + echo " create-ticket: completed" + echo " scout: skipped" + echo " impl: skipped" + echo " ship: skipped" +} > ".simple-workflow/backlog/briefs/active/wave-empty-cascade/autopilot-state.yaml" +run_autopilot_hook '{"session_id":"wave-empty-cascade"}' "$TEST_REPO" +TESTS_TOTAL=$((TESTS_TOTAL + 1)) +DECISION=$(echo "$LAST_STDOUT" | jq -r '.decision // ""' 2>/dev/null || echo "") +if [ "$LAST_EXIT_CODE" -eq 0 ] && [ "$DECISION" != "block" ]; then + echo -e " ${GREEN}PASS${NC} empty-wave cascade: no spawn-next block, fell through to all-terminal allow (exit 0)" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e " ${RED}FAIL${NC} empty-wave cascade: expected exit 0 with no spawn-next block (C1 stall regression)" + echo -e " Exit=$LAST_EXIT_CODE Decision='$DECISION' Stdout: $LAST_STDOUT" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +rm -f /tmp/.autopilot-continue-wave-empty-cascade /tmp/.autopilot-notool-wave-empty-cascade +cleanup_test_repo + # ------------------------------------------------------------ # T-004-5: R-ORDER-SENTINEL — fresh sentinel + drained + remaining → YIELD # ------------------------------------------------------------ diff --git a/tests/test-phase-state-contracts.sh b/tests/test-phase-state-contracts.sh index 936dd43..82c152a 100755 --- a/tests/test-phase-state-contracts.sh +++ b/tests/test-phase-state-contracts.sh @@ -116,8 +116,18 @@ fi # The contractual invariant in schema doc §6 is "Skills MUST NOT delete # phase-state.yaml at any point". An `rm` in a SKILL.md or hook body is # a direct violation. +# +# The `rm` must be matched at a SHELL COMMAND BOUNDARY, not as a substring +# of an English word. A naive `rm[[:space:]].*phase-state\.yaml` pattern +# false-positives on dense prose: the two letters "rm" inside "platform" +# (or "transform"/"confirm") followed by a space tokenize as "rm ", and a +# greedy `.*` then spans the rest of the line to reach a far-away +# "phase-state.yaml" mention. We anchor `rm` to start-of-line or a +# non-word character before it ([^[:alnum:]_]) so it must be a standalone +# command token, and we bound the span between `rm` and the filename with +# [^|;&]* so it cannot leap across shell command separators. TESTS_TOTAL=$((TESTS_TOTAL + 1)) -PHASE_STATE_DELETES=$(grep -rnE 'rm[[:space:]].*phase-state\.yaml' \ +PHASE_STATE_DELETES=$(grep -rnE '(^|[^[:alnum:]_])rm[[:space:]][^|;&]*phase-state\.yaml' \ "$REPO_DIR/skills/" \ "$REPO_DIR/hooks/" 2>/dev/null || true) if [ -z "$PHASE_STATE_DELETES" ]; then @@ -132,8 +142,11 @@ fi # --- 6. No skill uses `rm` on legacy impl-state.yaml --- # Per PR E Task 2 / AC 2.2: cleanup of the legacy file must use the # `mv ... .bak` form, never `rm`. This preserves auditability. +# Same shell-command-boundary anchoring as test 5 so a prose substring +# ("platform"/"transform"/"confirm" + a later "impl-state.yaml" mention) +# cannot tokenize as an `rm` command and false-trip the guard. TESTS_TOTAL=$((TESTS_TOTAL + 1)) -IMPL_STATE_DELETES=$(grep -rnE 'rm[[:space:]].*impl-state\.yaml' \ +IMPL_STATE_DELETES=$(grep -rnE '(^|[^[:alnum:]_])rm[[:space:]][^|;&]*impl-state\.yaml' \ "$REPO_DIR/skills/" 2>/dev/null || true) if [ -z "$IMPL_STATE_DELETES" ]; then echo -e " ${GREEN}PASS${NC} no rm on legacy impl-state.yaml in skills/" diff --git a/tests/test-skill-contracts.sh b/tests/test-skill-contracts.sh index 67408be..f208a24 100644 --- a/tests/test-skill-contracts.sh +++ b/tests/test-skill-contracts.sh @@ -10783,6 +10783,42 @@ assert_true \ "CT-PARALLEL-10 (unknown parallel= -> off fail-safe L3): autopilot failsafe-emit ($par10_autopilot_failsafe>=1) autopilot safe-to-off ($par10_autopilot_safe_off>=1) CLAUDE.md failsafe-emit ($par10_claude_failsafe>=1) CLAUDE.md coerce-off ($par10_claude_coerce_off>=1)" \ "$par10_result" +# CT-PARALLEL-11 (SW_PARALLEL_TICKETS_MODE run/skill-side kill switch is WIRED in Argument +# Parsing, C2). The resolver helper reads SW_PARALLEL_HOOKS_MODE (hook side) — NOT +# SW_PARALLEL_TICKETS_MODE — so the run/skill-side knob MUST be applied by /autopilot's own +# Argument Parsing block. This CT pins that SKILL.md names SW_PARALLEL_TICKETS_MODE as an ENV +# OVERRIDE applied BEFORE PARALLEL_MODE is written, that it takes PRECEDENCE over the parallel= +# arg AND the absent-token default, and that the deterministic precedence chain +# (env > arg > absent-default `on`, unknown env -> off) is spelled out. +par11_env_override=$(grep -cF 'ENV OVERRIDE (run/skill-side kill switch — applied BEFORE writing `PARALLEL_MODE`' "$PAR_AUTOPILOT" || true) +par11_precedence_over_arg=$(grep -cF 'PRECEDENCE over the `parallel=` argument AND over the absent-token default' "$PAR_AUTOPILOT" || true) +par11_precedence_chain=$(grep -cF 'env `SW_PARALLEL_TICKETS_MODE` (known value) > env `SW_PARALLEL_TICKETS_MODE` (unknown → off) > `parallel=` argument > absent-token default `on`' "$PAR_AUTOPILOT" || true) +par11_result="false" +if [ "$par11_env_override" -ge 1 ] && [ "$par11_precedence_over_arg" -ge 1 ] \ + && [ "$par11_precedence_chain" -ge 1 ]; then par11_result="true"; fi +assert_true \ + "CT-PARALLEL-11 (SW_PARALLEL_TICKETS_MODE env override wired in Argument Parsing): env-override step ($par11_env_override>=1) precedence-over-arg ($par11_precedence_over_arg>=1) env>arg>default chain ($par11_precedence_chain>=1)" \ + "$par11_result" + +# CT-PARALLEL-12 (metric-only routes to the inline serial loop + wave-plan log, NOT +# executor-routed, C4). The routing gate splits the old `!= off` branch into an explicit +# `== metric-only` (serial dry-run that LOGS the Wave {k}: plan but spawns NO executor) and +# `== on` (executor-routed) case; the executor-routed section header reads `== on` and excludes +# metric-only; ticket-executor.md is spawned only on `== on`. This pins the contradiction is +# resolved in favour of metric-only == serial dry-run. +par12_metric_serial_case=$(grep -cF '**`PARALLEL_MODE == metric-only`** (a serial dry-run for observability)' "$PAR_AUTOPILOT" || true) +par12_metric_identical_off=$(grep -cF 'execute the **inline serial per-ticket loop below — identical control flow to `off`**' "$PAR_AUTOPILOT" || true) +par12_metric_not_executor=$(grep -cF '`metric-only` does NOT take the executor-routed / wave-parallel path' "$PAR_AUTOPILOT" || true) +par12_executor_header_on=$(grep -cF 'Taken INSTEAD of the inline loop above when `PARALLEL_MODE == on` (NOT on `metric-only`' "$PAR_AUTOPILOT" || true) +par12_executor_agent_on=$(grep -cF 'parallel_mode == on (NOT on metric-only' "$PAR_EXECUTOR" || true) +par12_result="false" +if [ "$par12_metric_serial_case" -ge 1 ] && [ "$par12_metric_identical_off" -ge 1 ] \ + && [ "$par12_metric_not_executor" -ge 1 ] && [ "$par12_executor_header_on" -ge 1 ] \ + && [ "$par12_executor_agent_on" -ge 1 ]; then par12_result="true"; fi +assert_true \ + "CT-PARALLEL-12 (metric-only -> inline serial loop + wave-plan log, NOT executor-routed): metric-only serial case ($par12_metric_serial_case>=1) identical-to-off ($par12_metric_identical_off>=1) not-executor-routed ($par12_metric_not_executor>=1) executor-header==on ($par12_executor_header_on>=1) executor-agent==on ($par12_executor_agent_on>=1)" \ + "$par12_result" + PAR_PSF="$REPO_DIR/hooks/lib/parse-state-file.sh" # CT-PARALLEL-CURSOR-1 (wave-cursor schema + cursor-write obligation + hook kill switch, T-003).