pi-gauntlet 7.40.1 → 7.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +14 -2
  3. package/agents/code-reviewer.md +2 -0
  4. package/agents/implementer.md +2 -0
  5. package/agents/loop-implementer.md +10 -0
  6. package/agents/plan-writer.md +1 -1
  7. package/agents/spec-reviewer.md +2 -0
  8. package/bin/gauntlet-telemetry-seal.mjs +18 -3
  9. package/bin/gauntlet-telemetry-seal.test.mjs +30 -0
  10. package/extensions/lib/plan-check.test.ts +116 -0
  11. package/extensions/lib/plan-check.ts +149 -20
  12. package/extensions/lib/telemetry-record.test.ts +11 -0
  13. package/extensions/lib/telemetry-record.ts +7 -0
  14. package/extensions/phase-tracker.test.ts +201 -3
  15. package/extensions/phase-tracker.ts +77 -9
  16. package/extensions/verify-before-ship.test.ts +240 -2
  17. package/extensions/verify-before-ship.ts +180 -25
  18. package/package.json +2 -2
  19. package/skills/brainstorming/SKILL.md +1 -1
  20. package/skills/brainstorming/gatherer.md +14 -1
  21. package/skills/brainstorming/reference/amendment-surface.md +13 -1
  22. package/skills/brainstorming/reference/legs.md +187 -0
  23. package/skills/brainstorming/reference/spec-finalization.md +14 -0
  24. package/skills/brainstorming/reference/ticket-acceptance.md +2 -2
  25. package/skills/finishing-a-development-branch/SKILL.md +2 -2
  26. package/skills/finishing-a-development-branch/reference/legs.md +76 -0
  27. package/skills/gauntlet-handoff/SKILL.md +2 -2
  28. package/skills/gauntlet-resume/SKILL.md +3 -2
  29. package/skills/gauntlet-resume/reference/brief-contract.md +37 -3
  30. package/skills/gauntlet-resume/reference/reconstruction.md +4 -0
  31. package/skills/gauntlet-resume/reference/seed.md +2 -0
  32. package/skills/loop-implementation/SKILL.md +89 -2
  33. package/skills/subagent-driven-development/SKILL.md +10 -0
  34. package/skills/using-git-worktrees/SKILL.md +58 -21
  35. package/skills/writing-plans/SKILL.md +8 -0
  36. package/skills/writing-plans/reference/plan-contract.md +9 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## v7.41.0 - 2026-10-29
4
+
5
+ ### Added
6
+
7
+ - Coordinated multi-repo runs: one brainstorm spans ordered repository legs under one shared branch, home spec, handoff brief, and ship choice. `- sibling:` clone URLs in the overrides file are the eligible companions; a run coordinates only the companions the ask or ticket selects, resolved beside the home primary by repo name. Each leg gets its own worktree, baseline, verification, review, and telemetry seal; `plan_check` admits leg-aware plans; both implement engines, resume, and finishing fan out per leg and ship through one choice with per-leg receipts for partial-ship retry. A single-member run finishes through its recorded `contract:` lines and carries a `Baseline <repo>:` plan header; legacy specs without `## Legs` read as home-only. (#99)
8
+
9
+ ### Changed
10
+
11
+ - The phase tracker no longer credits an interrupted conformance-reviewer or cadence helper as a completed step. (jjuraszek/pi-cohort#36)
12
+ - Eval reports may be per-sample partials of the samples a change's own hunks touch when a rebase onto the base branch re-stales samples the change never touched. (#99)
13
+
3
14
  ## v7.40.1 - 2026-10-29
4
15
 
5
16
  ### Changed
package/README.md CHANGED
@@ -73,6 +73,18 @@ Use `/skill:shape-ticket --skip-overlap <ask>` to bypass discovery explicitly. R
73
73
 
74
74
  Everything between gate 1 and gate 2 - task breakdown, implementation, both review passes - runs without you in the loop, once gate 1 picks an engine; option 6 parks the approved spec in a draft PR and planning starts only when someone runs `brainstorming #N` on it and approves at the re-gate. Changing an approved spec later goes through brainstorming's `Amending an approved spec`: a fresh-context reviewer clears evidence-backed factual corrections on its own, escalations reach you as one readable batch, and only a redraw is a full stop - not a third numbered gate. That's the mechanism. What follows is the machinery behind it.
75
75
 
76
+ ### Multi-repo runs
77
+
78
+ A run has one home repository and one leg per selected repository. The home's `## shape-ticket` `- sibling:` declarations define eligible companions, not automatic membership: the ask must select a declared companion by repo identifier, the declared clone URL's `NAME`, or the URL itself, or the ticket must select it through a matching repo-group heading. Unselected siblings are not resolved. Members are ordered home first, then selected siblings in declaration order. Every member gets its own worktree with the same full branch and derived worktree name.
79
+
80
+ The approved home spec records `## Legs`, including each leg's scope, rules and resolved `contract:`. Each companion commits a leg file with the same filename in its own spec directory, carrying the home pointer, Problem, binding decisions and its own leg block. Plan and ledger stay home-owned; task `Leg:` selects the checkout for implementation and review. Committed artifacts use portable repo identifiers, not machine-local paths. See the [membership and artifact contract](skills/brainstorming/reference/legs.md).
81
+
82
+ Home overrides and settings govern flow policy. Each leg's own overrides and settings govern checkout operations, directories and verification; absent configuration uses defaults. Recorded `contract:` lines are authoritative for every spec with `## Legs`, including one-member runs: amend the home block and affected companion file before using a changed contract. Finish does not replace them by rereading overrides. Legacy specs without `## Legs` are home-only and retain the finish-time reread.
83
+
84
+ Handoff carries every member in the brief's `legs:` block. Resume starts only from the home primary, validates companion repository, branch and home pointer, and announces per-leg HEAD and dirty drift. A missing companion requires a live-validated completion receipt. The [brief contract](skills/gauntlet-resume/reference/brief-contract.md#coordinated-brief-grammar) owns the grammar and admission rules.
85
+
86
+ One finish choice spans all legs. PR, draft PR and squash deliver companions in declaration order, then seal and deliver home; keep preserves all legs, and discard lists all legs before one confirmation. Verification and conformance run per leg; home's passing tests do not credit companions. Delivery failure stops without rollback and reports shipped and unshipped legs. A retry from the brief validates home-owned receipts and skips only proven deliveries; stale PR heads are updated, and failed post-squash verification reruns without another squash. See the [delivery and recovery contract](skills/finishing-a-development-branch/reference/legs.md).
87
+
76
88
  ## Architecture
77
89
 
78
90
  `gatekeep-pr` selects one course per run: `assess` posts open blockers as one review (`request-changes` on someone else's PR, `comment` on your own), posts nothing with zero blockers, and performs no repair or sync; `deliver` repairs blockers, syncs, pushes, watches CI, and re-reviews until readiness, an obstacle, or a cap exit. `deliver with nits` stores `deliver-nits` and includes current and later nits. A ready first report skips the course pick and renders the final menu directly. Delivery rebases your own non-bot branch with `--force-with-lease` unless its history contains merge commits; someone else's, bot-authored, and merge-containing heads merge base instead. Final `merge` and `approve` remain separate picks; approval is offered only on someone else's non-draft PR with zero blockers. See the [course loop](skills/gatekeep-pr/reference/post-selection-loop.md) and [menu templates](skills/gatekeep-pr/reference/menus.md). In pi, `/skill:gatekeep-pr 12 15 18 [--rebase [base]]` gates a set of PR numbers or same-repository URLs, with one fresh `pr-gatekeeper` child and one worktree per PR. The parent shows a summary and unchanged per-PR menus in input order, queues every pick until the last menu, then executes the queued work in parallel. The set must fit pi-cohort's `parallel.maxTasks` (default `8`); it is never chunked. With `--rebase`, the parent asks once per set for eligible other-author or unreadable-author heads, excluding no-op heads and heads with merge commits. Multi-PR mode rejects positional issue references and is pi-only; single-PR behavior on Claude Code is unchanged. See the [parent-child contract](skills/gatekeep-pr/reference/batch.md).
@@ -128,7 +140,7 @@ Run by a human against pi-cohort >= 7.1.0, before a pi-gauntlet release claims t
128
140
  ## Requirements
129
141
 
130
142
  - [pi-coding-agent](https://github.com/earendil-works/pi) ≥ 0.85.1 - tested minimum for sequential `phase_tracker` tool execution, which persists a tracker update before a later tool call in the same model message.
131
- - [pi-cohort](https://github.com/jjuraszek/pi-cohort) >= 7.6.3 - required peer package, including binary-safe worktree patches; it also lets a persona with `subagent` in `tools:` start with inherited extensions ([pi-cohort #26](https://github.com/jjuraszek/pi-cohort/issues/26)), which multi-PR `gatekeep-pr` needs. Skills that dispatch agents (`requesting-code-review`, `subagent-driven-development`, `dispatching-parallel-agents`, `writing-plans`, `forge-skill`, `shape-ticket`, `roasting-the-spec`) call `subagent({})`, which pi-cohort provides. pi-gauntlet does not vendor the dispatch tool; without pi-cohort those skills have nothing to call. Gauntlet execution dispatches explicitly use `async: false`; do not enable pi-cohort's incompatible `forceTopLevelAsync` setting. See pi-cohort's [dispatch configuration](https://github.com/jjuraszek/pi-cohort/blob/main/doc/configuration.md) for that setting's owner and semantics. A wave of N >= 1 tasks dispatches its implementers and first spec reviews as one chain call. `subagent-driven-development` stops before dispatch on unsupported installs; there is no older-runtime fallback.
143
+ - [pi-cohort](https://github.com/jjuraszek/pi-cohort) >= 7.6.4 - required peer package, including binary-safe worktree patches; it also lets a persona with `subagent` in `tools:` start with inherited extensions ([pi-cohort #26](https://github.com/jjuraszek/pi-cohort/issues/26)), which multi-PR `gatekeep-pr` needs. Skills that dispatch agents (`requesting-code-review`, `subagent-driven-development`, `dispatching-parallel-agents`, `writing-plans`, `forge-skill`, `shape-ticket`, `roasting-the-spec`) call `subagent({})`, which pi-cohort provides. pi-gauntlet does not vendor the dispatch tool; without pi-cohort those skills have nothing to call. Gauntlet execution dispatches explicitly use `async: false`; do not enable pi-cohort's incompatible `forceTopLevelAsync` setting. See pi-cohort's [dispatch configuration](https://github.com/jjuraszek/pi-cohort/blob/main/doc/configuration.md) for that setting's owner and semantics. A wave of N >= 1 tasks dispatches its implementers and first spec reviews as one chain call. `subagent-driven-development` stops before dispatch on unsupported installs; there is no older-runtime fallback.
132
144
 
133
145
  Both packages must be listed in your `.pi/settings.json#packages` array (pi adds them automatically when you `pi install`). pi-gauntlet and pi-cohort are versioned independently but release together whenever dispatch semantics change - pin compatible versions of both. At each pi-gauntlet release, set the pi-cohort peer minimum and README floor together to the newest released pi-cohort version; `/skill:release` checks the registry before proceeding.
134
146
 
@@ -333,7 +345,7 @@ The gauntlet overrides file is the one place a repo tunes gauntlet's Markdown su
333
345
  | `## verification-before-completion` | verification-before-completion; gatekeep-pr (verification command only); chase-bug (hotfix verification) | repo test entrypoint discovery | overrides | the canonical verification target |
334
346
  | `## Spec index` `- docs: <glob>` | `gauntlet-spec-index` bin | `**/doc/**/*.md`, `**/docs/**/*.md`, `README.md`, `AGENTS.md` | overrides | the docs corpus include list; quotes/backticks stripped, fixed exclusions still apply, no other keys read |
335
347
  | `## Issue tracker` keys | shape-ticket, linear, chase-bug, check-delivery, brainstorming (`repo:` and tracker selection) | capability ladder / `git remote` | overrides | which tracker, repo, and commands ticket work uses |
336
- | `## shape-ticket` `- sibling: <clone url>` | shape-ticket | single-repo audit | extends | sibling checkouts for an already-stated second observable leg are expected beside `PRIMARY` (the primary checkout, not the current worktree) under the repository name; missing siblings report the path to clone to and never block, the committed URL names the sibling in report lines and supplies the clone hint while identity comparison ignores the clone scheme, and the flow never clones, fetches, or creates a sibling; paths are not accepted (see the [detailed grammar](skills/shape-ticket/reference/repo-audit.md#prepare-the-run)) |
348
+ | `## shape-ticket` `- sibling: <clone url>` | shape-ticket (audit), brainstorming (membership), gauntlet-resume, finishing-a-development-branch | single-repo audit and run | extends | sibling checkouts for an already-stated second observable leg are expected beside `PRIMARY` (the primary checkout, not the current worktree) under the repository name; missing siblings report the path to clone to and never block, the committed URL names the sibling in report lines and supplies the clone hint while identity comparison ignores the clone scheme, and the flow never clones, fetches, or creates a sibling; paths are not accepted (see the [detailed grammar](skills/shape-ticket/reference/repo-audit.md#prepare-the-run)); only companions explicitly selected by the ask or ticket join the run |
337
349
  | `## Capabilities` | shape-ticket | tool discovery | extends | tools, commands, env hosts available to ticket authoring |
338
350
  | `## Ticket wording` | shape-ticket | the shipped wording contract | overrides | ticket self-containment rules |
339
351
  | `## Response channels` | chase-bug | ask the user | overrides | where bug responses are posted |
@@ -11,6 +11,8 @@ completionGuard: false
11
11
 
12
12
  You are a code reviewer. You find issues before they ship. You **do not edit code**. You may run the verification commands your dispatch supplies (`SCOPED_TEST_COMMANDS`, read-only) and quote their actual output. Run ONLY those. Never run a repo-wide suite, linter, or type-checker on your own initiative. Dispatch carries no commands: say so in your report; run nothing.
13
13
 
14
+ A dispatch that carries `Rules:` lines or a `Leg:` value: Read the combined spec/task `Rules:` files before acting, resolve repo-relative rules in that leg's checkout, read the home spec at its supplied absolute path, review the caller's mapped original AC and leg, and keep git operations and supplied scoped commands in that checkout. Report a rules file you cannot read instead of inventing its contents. A dispatch without them: review as usual and write nothing about rules.
15
+
14
16
  ## Review priorities, in order
15
17
 
16
18
  1. **Correctness** — Does the change do what it claims? Are edge cases handled? Off-by-one, null/undefined, empty input, concurrency.
@@ -12,6 +12,8 @@ defaultProgress: true
12
12
 
13
13
  You are an implementation specialist. You execute an approved plan using strict test-driven development. You do not redesign or expand scope.
14
14
 
15
+ Read the combined spec/task `Rules:` files before acting. Resolve those repo-relative files in the caller's `Leg:` checkout; report missing rules instead of inventing contents. Use the caller's mapped original AC and leg, with the authoritative spec at its supplied absolute home path. Keep code, scoped commands and git operations in the supplied checkout.
16
+
15
17
  ## Workflow
16
18
 
17
19
  1. **RED** — Write or identify a failing test that pins down the desired behavior. Run it. Confirm it fails for the right reason (not a typo, not an import error).
@@ -23,6 +23,8 @@ Limit each sentence to 200 characters. Use the seed contract below for `target:
23
23
 
24
24
  Read the task fields: `iteration`, `spec` (absolute path), `ledger` (absolute path), `base` (SHA), `target` (row number or `seed`), `previous decision` (line or `none`), and `hint` (human retry hint or `none`).
25
25
 
26
+ Read additive inputs `Leg:`, `Rules:`, mapped original AC and, for companions, exact `pending:` tuple. Read the combined spec/task `Rules:` files before acting, resolving them in the supplied leg checkout. Consume the caller's mapping, not ownership inferred from spec prose. Keep git operations, code and scoped tests in the supplied checkout with its per-leg base; read spec and ledger at their absolute home paths.
27
+
26
28
  ## Procedure
27
29
 
28
30
  1. Read the given spec in full before reading the ledger or code. Read the given ledger next.
@@ -46,6 +48,14 @@ Append exactly one decision line: `row 0 done | seeded <k> rows, closed <m> on b
46
48
 
47
49
  Stage the seeded evidence changes and ledger, commit exactly once with subject `loop: seed evidence`, check for a clean tree, and reply with the appended seed decision only.
48
50
 
51
+ ## Companion result commit
52
+
53
+ For a companion `pending:` input, replace ledger mutation/staging and decision appending in the procedure and output contract with the commit-body path below; leave the home ledger untouched. Limit work and seed checks to mapped entries for this leg. Apply the same one-commit and bounded evidence contracts. Prepare the resulting four-column `row | status | attempts | evidence` target row in memory, not a sidecar. Serialize it in the companion commit body instead of editing the home ledger; for seed serialize all and only this leg's non-cut rows, preserving their attempts and proven baseline closures. For ordinary work serialize only its target, attempts +1 and status done/failed.
54
+
55
+ Use the existing subject `loop: row <n> <status>` or `loop: seed evidence`. Put the exact dispatched `pending:` tuple and consumed `iteration:` on separate body lines, then a blank line, then the resulting pipe-delimited table rows without a header, then a blank line and the existing bounded decision line. Include the check name plus at most three semicolon-separated result lines and escaped pipes in each evidence cell. Compute seed decision counts from this leg only. Stage code/tests explicitly and commit exactly once in the companion; check clean status. Reply with the existing decision line only. Let the parent validate the commit and write the home ledger.
56
+
57
+ For coordinated home work, edit only mapped home entries and its seeded state under the ordinary ledger contract; preserve foreign entries, mappings, bases and all other headers.
58
+
49
59
  ## Boundaries
50
60
 
51
61
  Edit code and tests only for the target row, or the open rows during seed. Outside `target: seed`, never edit a foreign ledger row. Preserve row identifiers, row text, ledger headers, and existing decisions. Never edit the spec or the plan directory outside the supplied ledger. Never touch `main`. Leave whole-project verification and reviews to the parent.
@@ -30,7 +30,7 @@ The task names five absolute paths: the worktree, the approved spec, the plan pa
30
30
 
31
31
  1. Read the repo's `AGENTS.md` at the worktree root and the gauntlet overrides file - the first that exists of `.pi/gauntlet-overrides.md`, `gauntlet-overrides.md`, `doc/gauntlet-overrides.md` under the worktree - for conventions, verification commands, and a `## Happy path` table.
32
32
  2. Read the plan skill file (`SKILL.md`) from its `## File Structure` heading through the end of its `## Self-Review` section, and the whole plan contract file (`plan-contract.md`). That text is the plan contract; follow it as written.
33
- 3. Read the spec in full. Recon the code the spec touches by reading it directly: exact paths to create, modify, or delete; call sites and tests with line ranges; the scoped command that runs one test file; scoped and repo-wide lint and format commands; the full-suite verification entrypoint and whether it bundles style checks. Dispatch nothing.
33
+ 3. Read the spec in full. Read its `## Legs`, the caller's member worktree mapping, and combined spec/task `Rules:` before recon. Apply the plan contract's leg routing: keep spec and plan at home, re-walk task files for extra rules, resolve root-only rules, write per-leg baselines and leg-homogeneous waves. Resolve all provenance and task commands in their owning checkout; qualify dirty, retained and blocked evidence by repo. Recon the code the spec touches by reading it directly: exact paths to create, modify, or delete; call sites and tests with line ranges; the scoped command that runs one test file; scoped and repo-wide lint and format commands; the full-suite verification entrypoint and whether it bundles style checks. Dispatch nothing.
34
34
  4. Write the plan at the plan path with `write` as a full replacement. The plan path is the only file you write; never create or edit any other file.
35
35
  5. Run Baseline from the skill's Self-Review before `plan_check({ planPath })`. On Findings/Amend re-dispatch, read provenance once for the current base; retain evidence for unchanged commands whose recorded SHA is BASE or proven tree-equivalent per the skill's Baseline step 2. Rerun missing, invalid, or changed-command evidence, every command on any proof failure, and every command for `Findings: rerun baseline`. Rewrite SHAs only after execution or a recorded tree-equivalence. Baseline owns and regenerates its rows; preserve unresolved contract rows.
36
36
  6. Run the deterministic checker and judgment checks in Self-Review, including Contract decisions. Repair `baseline-line` and substantive baseline `open-questions` findings only by Baseline execution, never by authoring evidence or deleting/rewording a row. Normalize malformed Open Questions into rows without changing substantive text. Fix every other finding; with substantive rows remaining, reply `OPEN QUESTIONS <abs plan path>` immediately, without a passing check or repair rounds for those rows. Any ordinary edit returns to step 5.
@@ -11,6 +11,8 @@ completionGuard: false
11
11
 
12
12
  You are a spec compliance reviewer. Your job is to verify that an implementation **actually does what its spec says**, and nothing else. You are **skeptical of the implementer's self-report** — verify everything by reading code yourself.
13
13
 
14
+ Read the combined spec/task `Rules:` files before acting. Resolve repo-relative rules in the caller's `Leg:` review checkout. Read the authoritative home spec at the supplied absolute path and review the caller's mapped original AC and leg; report missing rules or authority as blocking findings. Keep whole-file reads in the supplied review tree.
15
+
14
16
  ## Process
15
17
 
16
18
  <!-- clause decomposition / snippet non-authority / whole-file reads / task-contract supplement: keep in lockstep with skills/subagent-driven-development/spec-reviewer-prompt.md — change them together or not at all -->
@@ -317,6 +317,7 @@ function foldAccumulators(a, b) {
317
317
  out.events_dropped = a.events_dropped + b.events_dropped;
318
318
  return out;
319
319
  }
320
+ var validLegs = (legs) => Array.isArray(legs) && legs.every((leg) => leg && typeof leg.repo === "string" && leg.repo.trim().length > 0 && (typeof leg.sha === "string" && leg.sha.trim().length > 0 && leg.pr === void 0 || typeof leg.pr === "string" && /^https?:\/\/\S+$/.test(leg.pr) && leg.sha === void 0));
320
321
  function newRecord(o) {
321
322
  return { schema: 1, spec: o.spec, run_id: o.runId, branch: o.branch, status: "in_progress", created_at: o.now, sessions: [o.session], derived: { engine: "waves", duration_s: 0, phases: {}, personas: {}, conformance_loops: 0, gates: { spec_rounds: 0, plan_rounds: 0, fix_round_grants: 0, task_reopens: 0 }, amendments: 0, spec_edits_after_ship: 0, spec_writes: {}, events_dropped: 0 }, accumulators: {}, events: [] };
322
323
  }
@@ -403,6 +404,7 @@ function parseRecord(text) {
403
404
  if (!doc || typeof doc !== "object") return void 0;
404
405
  const d = doc;
405
406
  if (d.schema !== 1 || typeof d.spec !== "string" || typeof d.run_id !== "string") return void 0;
407
+ if (d.legs !== void 0 && !validLegs(d.legs)) return void 0;
406
408
  const accumulators = Object.fromEntries(Object.entries(d.accumulators ?? {}).flatMap(
407
409
  ([session, block]) => block && typeof block === "object" && !Array.isArray(block) ? [[session, { ...emptyAccumulators(), ...block }]] : []
408
410
  ));
@@ -417,7 +419,7 @@ var fail = (code, message) => {
417
419
  };
418
420
  var usage = () => fail(1, "usage: gauntlet-telemetry-seal --worktree <abs path> --option <pr|squash> --base <ref> [--spec <spec path inside the checkout>] [--dir <telemetry dir>]");
419
421
  function parseArgs(argv) {
420
- const opts = { worktree: void 0, option: void 0, base: void 0, spec: void 0, dir: void 0 };
422
+ const opts = { worktree: void 0, option: void 0, base: void 0, spec: void 0, dir: void 0, legs: [] };
421
423
  for (let i = 0; i < argv.length; i++) {
422
424
  const a = argv[i];
423
425
  const value = argv[i + 1];
@@ -429,7 +431,15 @@ function parseArgs(argv) {
429
431
  else if (a === "--option") takes("option");
430
432
  else if (a === "--base") takes("base");
431
433
  else if (a === "--spec") takes("spec");
432
- else if (a === "--dir") takes("dir");
434
+ else if (a === "--leg") {
435
+ if (!value || value.startsWith("--")) fail(1, "invalid --leg " + value);
436
+ i++;
437
+ const split = value.indexOf("=");
438
+ const repo = value.slice(0, split), result = value.slice(split + 1);
439
+ const leg = /^https?:\/\//.test(result) ? { repo, pr: result } : { repo, sha: result };
440
+ if (split < 1 || !validLegs([leg])) fail(1, "invalid --leg " + value);
441
+ opts.legs.push(leg);
442
+ } else if (a === "--dir") takes("dir");
433
443
  else usage();
434
444
  }
435
445
  if (!opts.worktree || !isAbsolute2(opts.worktree) || !opts.base || !OPTIONS.has(opts.option)) usage();
@@ -469,10 +479,15 @@ async function seal(vcs, rec, o) {
469
479
  const prior = readFileSync(abs);
470
480
  const parsed = parseRecord(prior.toString("utf8"));
471
481
  if (!parsed) fail(2, `unparseable record ${rec}: schema 1 with spec and run_id required`);
482
+ const normalized = (legs) => [...new Set(legs.map((leg) => JSON.stringify([leg.repo.toLowerCase(), leg.sha ?? leg.pr])))].sort();
483
+ if (parsed.status !== "in_progress" && JSON.stringify(normalized(parsed.legs ?? [])) !== JSON.stringify(normalized(o.legs))) {
484
+ fail(1, `seal legs differ: stored ${JSON.stringify(parsed.legs ?? [])}; supplied ${JSON.stringify(o.legs)}`);
485
+ }
472
486
  if (parsed.status !== "in_progress") {
473
487
  if (parsed.status !== "shipped" || recordSealed(vcs, rec)) return console.log(`already sealed ${rec}`);
474
488
  } else {
475
489
  const now = isoNow();
490
+ if (o.legs.length) parsed.legs = o.legs;
476
491
  parsed.status = "shipped";
477
492
  parsed.shipped_at = now;
478
493
  parsed.events.push({ ts: now, session: parsed.sessions.at(-1) ?? "seal", phase: "ship", kind: "ship", option: o.option });
@@ -509,7 +524,7 @@ async function main() {
509
524
  if (!telemetry.enabled) return console.log("telemetry disabled");
510
525
  const dir = override?.dir ?? telemetry.dir;
511
526
  if (!validateBase(vcs, opts.base)) fail(1, `no base ref ${opts.base}`);
512
- const o = { option: opts.option, base: opts.base, dir, buckets: telemetry.buckets, planDirs };
527
+ const o = { legs: opts.legs, option: opts.option, base: opts.base, dir, buckets: telemetry.buckets, planDirs };
513
528
  if (opts.spec) return seal(vcs, recordPathFor(dir, rel), o);
514
529
  const diff = changedNames(vcs, opts.base);
515
530
  if (!diff.ok) fail(1, `${vcs.kind} diff failed: ${diff.stderr}`);
@@ -115,6 +115,36 @@ test("seals an untracked in_progress record: stamp, ship event, diff, re-derived
115
115
  assert.equal(porcelain(root), "");
116
116
  });
117
117
 
118
+ test("shipped CLI records companion legs and validates complete retry sets", (t) => {
119
+ const root = repo(); cleanup(t, root);
120
+ const args = ["--spec", SPEC, "--leg", "repo-b=abc1234", "--leg", "repo-c=https://example.test/pull/2?q=yes"];
121
+ const result = run(root, args);
122
+ assert.equal(result.status, 0, result.stderr);
123
+ assert.deepEqual(record(root).legs, [
124
+ { repo: "repo-b", sha: "abc1234" },
125
+ { repo: "repo-c", pr: "https://example.test/pull/2?q=yes" },
126
+ ]);
127
+ const sealedHead = head(root);
128
+ assert.equal(run(root, ["--spec", SPEC, "--leg", args[5], "--leg", args[3]]).status, 0);
129
+ assert.equal(head(root), sealedHead);
130
+ for (const legs of [[], ["--leg", "repo-b=abc1234"], ["--leg", "repo-b=changed", "--leg", args[5]]]) {
131
+ const retry = run(root, ["--spec", SPEC, ...legs]);
132
+ assert.equal(retry.status, 1);
133
+ assert.match(retry.stderr, /legs differ/);
134
+ assert.equal(head(root), sealedHead);
135
+ }
136
+ assert.deepEqual(headFiles(root), [REC]);
137
+ });
138
+
139
+ test("shipped CLI rejects malformed leg values before sealing", () => {
140
+ for (const value of ["missing", "=abc", "repo=", "repo= "]) {
141
+ const result = invoke(["--leg", value]);
142
+ assert.equal(result.status, 1);
143
+ assert.match(result.stderr, /invalid --leg/);
144
+ assert.ok(result.stderr.includes(value.trim()));
145
+ }
146
+ });
147
+
118
148
  test("docs/specs record seals with --spec (no path classification)", (t) => {
119
149
  const root = docsRepo(); cleanup(t, root);
120
150
  const r = run(root, ["--spec", SPEC2]);
@@ -1218,3 +1218,119 @@ test("tests-block: fenced **Tests:** lines are ignored", () => {
1218
1218
  );
1219
1219
  assert.deepEqual(tb(mutated), []);
1220
1220
  });
1221
+
1222
+ const coordinatedSpec = ledgerSpec + `\n## Legs
1223
+ ### Leg: repo-a
1224
+ branch: feature/run
1225
+ scope: AC 1, AC 2
1226
+ ### Leg: repo-b
1227
+ branch: feature/run
1228
+ scope: AC 1
1229
+ `;
1230
+ const coordinatedLedger = () => ledger(["1 | open | 0 |", "2 | open | 0 |", "3 | cut | 0 | deferred: #70", "4 | open | 0 |"])
1231
+ .replace("base: abc", "base: abc1234\nbase repo-a: abc1234\nbase repo-b: def5678\nseeded repo-a: no\nseeded repo-b: no\ncap: 20")
1232
+ + "\n## Leg mapping\n| row | ac | leg |\n|---|---|---|\n| 1 | 1 | repo-a |\n| 2 | 1 | repo-b |\n| 3 | 2 | repo-a |\n| 4 | 3 | repo-a |\n";
1233
+ const emptyPort: FsPort = { exists: () => false, glob: () => [] };
1234
+ test("coordinated ledger expands shared ownership and defaults unlisted ACs home", () => {
1235
+ assert.deepEqual(checkPlan(coordinatedLedger(), coordinatedSpec, emptyPort), []);
1236
+ });
1237
+ for (const scope of ["", "scope: AC 1\nscope: AC 2", "scope: AC 1, AC 1", "scope: AC 0", "scope: AC -1", "scope: AC 1.5", "scope: AC 4", "scope: 1", "scope: AC 1-2", "scope: AC 1,", "scope: none, AC 1"]) {
1238
+ test(`coordinated scope rejects ${JSON.stringify(scope)}`, () => {
1239
+ assert.ok(checkPlan(coordinatedLedger(), coordinatedSpec.replace("scope: AC 1, AC 2", scope), emptyPort).some(f => f.check === "leg-scope"));
1240
+ });
1241
+ }
1242
+ for (const [name, change] of Object.entries({
1243
+ missingMapping: (s: string) => s.replace("| 2 | 1 | repo-b |\n", ""),
1244
+ duplicateMapping: (s: string) => s + "| 2 | 1 | repo-b |\n",
1245
+ wrongOrder: (s: string) => s.replace("| 1 | 1 | repo-a |", "| 1 | 1 | repo-b |"),
1246
+ foreignBase: (s: string) => s + "base stranger: abc1234\n",
1247
+ missingSeed: (s: string) => s.replace("seeded repo-b: no\n", ""),
1248
+ invalidCap: (s: string) => s.replace("cap: 20", "cap: 0"),
1249
+ homePending: (s: string) => s + "pending: 1 | repo-a | abc1234\n",
1250
+ foreignTarget: (s: string) => s + "pending: 3 | repo-b | abc1234\n",
1251
+ duplicatePending: (s: string) => s + "pending: seed | repo-b | abc1234\npending: seed | repo-b | abc1234\n",
1252
+ })) test(`coordinated ledger rejects ${name}`, () => {
1253
+ assert.ok(checkPlan(change(coordinatedLedger()), coordinatedSpec, emptyPort).length);
1254
+ });
1255
+ test("coordinated ledger admits partial seeds, recovery tuples and later cap grants", () => {
1256
+ for (const target of ["2", "seed"]) assert.deepEqual(checkPlan(coordinatedLedger().replace("seeded repo-a: no", "seeded repo-a: yes").replace("cap: 20", "cap: 99") + `pending: ${target} | REPO-B | abc1234\n`, coordinatedSpec, emptyPort), []);
1257
+ });
1258
+
1259
+ const legPlanSpec = SPEC_TEXT + "\n## Legs\n### Leg: repo-a\nscope: none\n### Leg: repo-b\nscope: none\n";
1260
+ const legPlan = () => VALID_PLAN.replace("---", "Baseline repo-a: abc1234\nBaseline repo-b: def5678\n---")
1261
+ .replace("### Task 2: Implement naming", "## Wave 3 - Companion\n\n### Task 2: Implement naming\nLeg: REPO-B")
1262
+ .replace("- baseline: abc1234 pass\n\nThis task handles", "- baseline: def5678 pass\n\nThis task handles");
1263
+ test("plan accepts distinct leg baselines, case-insensitive membership and absent home Leg", () => {
1264
+ const probes: (string | undefined)[] = [];
1265
+ assert.deepEqual(checkPlan(legPlan(), legPlanSpec, { exists: (_p, leg) => { probes.push(leg); return true; }, glob: () => [] }), []);
1266
+ assert.ok(probes.includes("repo-b"));
1267
+ assert.ok(probes.includes("repo-a"));
1268
+ });
1269
+ test("wrong-leg baseline and mixed-leg waves fail", () => {
1270
+ assert.ok(checkPlan(legPlan().replace("def5678 pass", "abc1234 pass"), legPlanSpec, alwaysTruePort()).some(f => f.check === "baseline-line"));
1271
+ assert.ok(checkPlan(legPlan().replace("## Wave 3 - Companion\n\n", ""), legPlanSpec, alwaysTruePort()).some(f => f.check === "wave-leg"));
1272
+ });
1273
+ test("unknown legs fail before filesystem probing", () => {
1274
+ const probes: string[] = [];
1275
+ assert.ok(checkPlan(legPlan().replace("Leg: REPO-B", "Leg: stranger"), legPlanSpec, { exists: (p) => { probes.push(p); return true; }, glob: () => [] }).some(f => f.check === "leg-unknown"));
1276
+ assert.deepEqual(probes, []);
1277
+ });
1278
+ test("cross-leg creates do not supply Test paths or skipped baseline evidence", () => {
1279
+ const plan = legPlan().replaceAll("extensions/lib/fixture-task2.test.ts", "extensions/lib/fixture-task1.ts").replace("def5678 pass", "def5678 skipped - extensions/lib/fixture-task1.ts created by this task");
1280
+ const findings = checkPlan(plan, legPlanSpec, { exists: p => p !== "extensions/lib/fixture-task1.ts", glob: () => [] });
1281
+ assert.ok(findings.some(f => f.check === "tests-block" && /unknown/.test(f.reason)));
1282
+ assert.ok(findings.some(f => f.check === "baseline-line" && /not a Create/.test(f.reason)));
1283
+ });
1284
+ test("identical paths across legs do not overlap even in rejected mixed waves", () => {
1285
+ const plan = legPlan().replace("## Wave 3 - Companion\n\n", "").replaceAll("fixture-other.ts", "fixture-shared.ts");
1286
+ assert.deepEqual(checkPlan(plan, legPlanSpec, alwaysTruePort()).filter(f => f.check === "wave-file-disjointness"), []);
1287
+ });
1288
+
1289
+ for (const [name, change] of Object.entries({
1290
+ duplicateBase: (s: string) => s + "base repo-b: def5678\n",
1291
+ malformedSha: (s: string) => s.replace("base repo-b: def5678", "base repo-b: not-a-sha"),
1292
+ baseMismatch: (s: string) => s.replace("base: abc1234", "base: bbb1234"),
1293
+ invalidSeed: (s: string) => s.replace("seeded repo-a: no", "seeded repo-a: maybe"),
1294
+ duplicateSeed: (s: string) => s + "seeded repo-b: yes\n",
1295
+ negativeIterations: (s: string) => s.replace("iterations: 0", "iterations: -1"),
1296
+ unsafeIterations: (s: string) => s.replace("iterations: 0", "iterations: 9007199254740992"),
1297
+ fractionalCap: (s: string) => s.replace("cap: 20", "cap: 1.5"),
1298
+ unsafeCap: (s: string) => s.replace("cap: 20", "cap: 9007199254740992"),
1299
+ malformedPending: (s: string) => s + "pending: 2 | repo-b | not-a-sha\n",
1300
+ unknownPending: (s: string) => s + "pending: seed | stranger | abc1234\n",
1301
+ nonexistentPending: (s: string) => s + "pending: 99 | repo-b | abc1234\n",
1302
+ nonexistentAC: (s: string) => s.replace("| 4 | 3 | repo-a |", "| 4 | 4 | repo-a |"),
1303
+ duplicatePair: (s: string) => s.replace("| 2 | 1 | repo-b |", "| 2 | 1 | repo-a |"),
1304
+ unknownMapping: (s: string) => s.replace("| 2 | 1 | repo-b |", "| 2 | 1 | stranger |"),
1305
+ disposition: (s: string) => s.replace("| 3 | cut | 0 | deferred: #70 |", "| 3 | open | 0 | |"),
1306
+ })) test(`coordinated metadata rejects ${name}`, () => {
1307
+ assert.ok(checkPlan(change(coordinatedLedger()), coordinatedSpec, emptyPort).length);
1308
+ });
1309
+ test("scope textual order does not change original AC or member order, and design claims no ownership", () => {
1310
+ const spec = coordinatedSpec.replace("scope: AC 1, AC 2", "scope: AC 2, AC 1\ndesign: AC 3 touches lib/other.ts");
1311
+ assert.deepEqual(checkPlan(coordinatedLedger().replace("| 4 | open | 0 | |", "| 4 | done | 1 | checked a\\|b |"), spec, emptyPort), []);
1312
+ });
1313
+ test("scope none defaults all original ACs home, preserving cut numbering and ignoring nested checks", () => {
1314
+ const spec = coordinatedSpec.replace("- [ ] First", "- [ ] First\n - [ ] Nested").replace("scope: AC 1, AC 2", "scope: none").replace("scope: AC 1\n", "scope: none\n");
1315
+ const input = coordinatedLedger().replace("| 2 | open | 0 | |\n", "").replace("| 3 | cut", "| 2 | cut").replace("| 4 | open", "| 3 | open")
1316
+ .replace("| 2 | 1 | repo-b |\n", "").replace("| 3 | 2 | repo-a |", "| 2 | 2 | repo-a |").replace("| 4 | 3 | repo-a |", "| 3 | 3 | repo-a |");
1317
+ assert.deepEqual(checkPlan(input, spec, emptyPort), []);
1318
+ });
1319
+ test("single-member ledger preserves legacy headers and four-column bytes", () => {
1320
+ assert.deepEqual(checkPlan(ledger(), ledgerSpec + "\n## Legs\n### Leg: repo-a\n", emptyPort), []);
1321
+ });
1322
+ test("leg-local globs and plain Files: resolve at each normalized leg", () => {
1323
+ const calls: string[] = [];
1324
+ const plan = legPlan().replaceAll("**Files:**", "Files:").replace("Modify: extensions/lib/fixture-other.ts", "Modify: extensions/lib/companion-*.ts");
1325
+ assert.deepEqual(checkPlan(plan, legPlanSpec, { exists: () => true, glob: (pattern, leg) => { calls.push(`${leg}:${pattern}`); return ["extensions/lib/companion-one.ts"]; } }), []);
1326
+ assert.deepEqual(calls, ["repo-b:extensions/lib/companion-*.ts"]);
1327
+ });
1328
+ test("coordinated scope counts only original unfenced top-level AC rows", () => {
1329
+ const spec = coordinatedSpec.replace("- [ ] First", "```text\n- [ ] Example, not an AC\n```\n- [ ] First");
1330
+ assert.deepEqual(checkPlan(coordinatedLedger(), spec, emptyPort), []);
1331
+ assert.ok(checkPlan(coordinatedLedger(), spec.replace("scope: AC 1\n", "scope: AC 4\n"), emptyPort).some(f => f.check === "leg-scope"));
1332
+ });
1333
+ test("coordinated ledger rejects duplicate mapping sections", () => {
1334
+ const input = coordinatedLedger();
1335
+ assert.ok(checkPlan(input + input.slice(input.indexOf("## Leg mapping")), coordinatedSpec, emptyPort).length);
1336
+ });