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..6c5bb5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ 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` **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 **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 c50468c..95703b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,10 @@ 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 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. ## Language diff --git a/README.md b/README.md index 75644ba..4c24785 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,11 @@ 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|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. -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`. + +**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 | |------|---------|--------| @@ -194,7 +196,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/agents/ticket-executor.md b/agents/ticket-executor.md new file mode 100644 index 0000000..dd60663 --- /dev/null +++ b/agents/ticket-executor.md @@ -0,0 +1,92 @@ +--- +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 == 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 == 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. + +**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) + +**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 **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. + +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. + +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`. + +## 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. + +## 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} +branch: {ap// or null} +head_sha: {the worktree branch HEAD sha 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}`). +- `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 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 + +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/hooks/autopilot-continue.sh b/hooks/autopilot-continue.sh index ec8ade5..6a13cb8 100755 --- a/hooks/autopilot-continue.sh +++ b/hooks/autopilot-continue.sh @@ -404,6 +404,116 @@ 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 + + # 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). + 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/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/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/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/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/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/skills/autopilot/SKILL.md b/skills/autopilot/SKILL.md index 202880e..2c622fd 100644 --- a/skills/autopilot/SKILL.md +++ b/skills/autopilot/SKILL.md @@ -30,7 +30,22 @@ allowed-tools: - "Bash(mkdir:*)" - "Bash(date:*)" - "Bash(cp:*)" -argument-hint: "" + - "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: " [uc=on|off|metric-only] [parallel=on|off]" --- ## Pre-computed Context @@ -70,9 +85,13 @@ 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). -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. **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`. + +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,27 +157,33 @@ 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` — 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 = 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 = 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 -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 (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 > **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`)** — 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): 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}/`): @@ -199,6 +224,80 @@ 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 == on`) + +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): + +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 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. + +**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`. +- 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). + +##### 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 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): + +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 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`. + +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). + +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}` (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 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):** + +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/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..0c49b5b 100644 --- a/skills/autopilot/references/state-file.md +++ b/skills/autopilot/references/state-file.md @@ -11,8 +11,11 @@ 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:`), 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 @@ -20,7 +23,12 @@ 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 — 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) +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 @@ -55,18 +63,41 @@ 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`). +- `parallel_mode` — OPTIONAL. Run-scoped parallel execution mode; value + 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** — 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 (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). The `steps:` / `invocation_method:` maps no longer contain a `create-ticket` @@ -185,6 +216,143 @@ 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} +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 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 + +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. + +**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`/`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 +(`|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). + ## `autopilot-state.yaml` location precedence `/autopilot` chooses **one** location based on what is already on disk. @@ -343,8 +511,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/skills/brief/SKILL.md b/skills/brief/SKILL.md index 31916b3..fa001cd 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 @@ -54,17 +54,21 @@ 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. -- **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. -- 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: `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. +- 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. @@ -84,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). @@ -158,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). @@ -210,11 +214,11 @@ 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) -> 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). @@ -265,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/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/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-autopilot-continue.sh b/tests/test-autopilot-continue.sh index be3d1cd..40e47bf 100644 --- a/tests/test-autopilot-continue.sh +++ b/tests/test-autopilot-continue.sh @@ -1369,5 +1369,422 @@ 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-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 +# ------------------------------------------------------------ +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 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-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-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-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-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 diff --git a/tests/test-skill-contracts.sh b/tests/test-skill-contracts.sh index df3fad2..f208a24 100644 --- a/tests/test-skill-contracts.sh +++ b/tests/test-skill-contracts.sh @@ -10573,6 +10573,688 @@ 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 "" + +# ============================================================================= +# 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 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 -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 +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" + +# 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). +# 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" + +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" + +# 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" + +# ============================================================================= +# 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 ""