pi-gauntlet 7.28.0 → 7.29.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## v7.29.0 - 2026-10-23
4
+
5
+ ### Added
6
+
7
+ - **Loop implement engine: planless iteration over the acceptance-criteria ledger.** Replying `4` or `5` at the spec approval gate skips the plan and runs a fresh, model-free `loop-implementer` persona (`agents/loop-implementer.md`, driven by `skills/loop-implementation/`) one acceptance-criteria ledger row per iteration, closing it or marking it failed with one commit and one `status|explanation|next` decision line; iteration 0 seeds evidence and may close several rows on baseline. The ledger is a plan variant at `doc/plans/<spec-stem>-ledger.md` (first line `# Ledger - loop engine`) stamped by `plan_check` and stripped at finish. `piGauntlet.loopEngine.maxIterations` (default 30) caps a run; an unresolved row at its allowance (3 attempts plus funded rounds or a human retry), two consecutive empty commits, or the cap stops at a plain-words human gate (retry / amend / stop). The loop model is pinned per repo through `subagents.agentOverrides.loop-implementer.model`; no new model key. `doc/implement-engines.md` is the contract for authoring further engines. (#68)
8
+ - Eval targets `eval/loop-implementation` and `eval/loop-implementer`; new `eval/brainstorming` and `eval/gauntlet-resume` samples exercise the five-option gate and ledger runs. (#68)
9
+
10
+ ### Changed
11
+
12
+ - Every approving reply at the spec gate (1/2 included) writes an `**Engine:**` line and amends the spec commit; the waves engine and options 1-3 keep their routing and grant meaning. `finishing-a-development-branch` strips a ledger beside a plan; `gauntlet-resume` consumes ledger runs; telemetry records `engine: loop|waves` and loop iterations, and `gauntlet-performance` prints both (older runs show `engine: waves`). (#68)
13
+ - pi-cohort peer floor raised to 7.4.5.
14
+
3
15
  ## v7.28.0 - 2026-10-23
4
16
 
5
17
  ### Added
package/README.md CHANGED
@@ -37,9 +37,11 @@ Concretely, one change through the gauntlet:
37
37
 
38
38
  0. *(Optional)* Before there's even a spec, `/skill:shape-ticket` can create one tracker issue or repair a comma/whitespace list of issue refs - shaping each into a Context/Problem/Idea/Acceptance Criteria ticket, grounded by a repository audit, an AC integrity check, and a cheap council roast. Repair finishes preparation for all tickets before presenting exact-diff confirmations sequentially in ref order; each approval writes that ticket before the next gate, and may include one optional gated comment for a Reporter note or overlap evidence. The audit checks factual claims, Idea fit, AC mechanisms against cited repo guidance, and already-stated observable legs in configured sibling checkouts. It reports in-motion work and attempts a best-effort scenario through authorized current-system reads, without promising reproduction or a fix; missing access or nothing to observe is reported, not a new approval gate. Implementer detail moves into the gated Reporter note; brainstorming reads that note with the ticket so the spec retains the detail. A failed roast is retried once, then surfaced inline at the gate if it fails again. It's a tool, not a phase: no worktree, no plan/phase tracker, runs from any repo state. The agent may start it on its own when the ask is "file a ticket" or another skill routes there; the write still waits for your yes on the presented diff. Similarly, `/skill:chase-bug` triages a raw bug report into an evidenced verdict - and can hand off to shape-ticket, brainstorming, or a bounded hotfix - before any spec exists.
39
39
  1. Opt in with `/skill:brainstorming` and describe the change. The command is the reliable discovery-and-entry path; explicit prose requesting brainstorming/the gauntlet also supplies consent when the skill is already loaded or an available instruction or route identifies it. An ordinary implementation request or an agent recommendation is not consent. **`brainstorming`** sets up an isolated worktree, explores the codebase, and turns your description into a written spec - when its recon finds a cited case that the ask is the wrong change (misdiagnosed, wrong-sized, already solved, a band-aid, or cutting across a sound pattern - including a repo convention that is itself debt) and can name the alternative, question one is a framing question (`as framed`, `pivot: <alternative>`, or `smaller or none`); otherwise it opens with one sentence naming what it checked, and every approach it proposes ends with a `Pattern:` line naming what it follows or cuts across; when the run starts from a ticket, the spec carries the ticket's acceptance criteria verbatim, each with a disposition (`in-scope`, `deviates:`, `deferred:`, `venue:`, `elsewhere:` for a row another repo's part delivers), scope cuts come only from your answer to a cut question and are listed at the gate, the conformance gate checks the in-scope ones, and `/skill:check-delivery` verifies `venue:` rows before the prod deploy. A multi-model critique runs on it automatically. If the spec replaces a known prior spec, brainstorming marks the predecessor with a `> **Superseded by:**` banner under its title (default format, syntax overridable via `.pi/gauntlet-overrides.md`; candidates come from brainstorming's scout recon, never a mechanical sweep). **You read and approve the spec - human gate 1.** No implementation code exists yet.
40
- 2. **`writing-plans`** decomposes the approved spec into atomic, independently-verifiable tasks, grouped into parallel waves where they don't touch the same files.
40
+ 2. By default, **`writing-plans`** decomposes the approved spec into atomic, independently-verifiable tasks, grouped into parallel waves where they don't touch the same files. Experimental approval options 4/5 instead select **`loop-implementation`**: the plan phase is skipped and a stamped acceptance-criteria ledger replaces the task plan. Option 4 retains amendment review; option 5 grants auto-apply amends, with redraws still stopping. See [Loop engine operations](doc/configuration.md#loop-engine-operations).
41
41
  3. **`subagent-driven-development`** executes the plan one task at a time, each in a fresh subagent, behind spec-compliance review then code-quality review. TDD-locked: red, green, refactor. Independent implementation, review, and council batches remain parallel, but their dispatches are foreground: the orchestrator waits for terminal results before accepting work or advancing a phase.
42
- 4. **verify**: the parent first runs the plan's full verification command set; only a passing result permits whole-diff code review, then the **conformance gate**. A review fix invalidates that result, so the parent reruns full verification before the next review or conformance gate. When the overrides file declares a happy path for the touched paths (see [Project-specific overrides](#project-specific-overrides)), the parent runs it once here, between code review and the conformance gate, and the reviewer reads its transcript as runtime evidence - a hang or a failed request shows up in the audit with the transcript quoted - as a gap when an origin clause ties to it, otherwise as a `happy-path: failed - unattributable` line - never as a silent green. The conformance subagent reads the finished code and docs against your *original words* from step 1, not the plan, and reports per-requirement: delivered, partial, missing, drifted, or unauthorized. Unauthorized rows - shipped surface no human input asked for, whether it crept in or was laundered through the spec - follow their recommendation like every other row: a contained removal auto-runs, anything another requirement leans on is deferred with a plain-language "I'd cut it / I'd keep it" recommendation. Inside a brainstorming-entered flow this gate is machine-blocked from being skipped. Compatible executable recommendations auto-run through an isolated fix-and-re-audit loop with no prompt; anything still open surfaces as a numbered decision brief - one item per decision, the problem in plain words first, lettered options each with a one-line consequence, and `Recommended: <letter> - <reason>` or `No recommendation - pick one`. Reply with one line per item (`1. B`), or bare `1` to take every recommendation (no-recommendation items are re-asked by number); a current `CONFORMS` / no-concerns result goes straight to the branch options with no extra conformance sign-off.
42
+ The loop alternative to step 3 runs one fresh `loop-implementer` per seed or row iteration, without implement-phase reviewers. It stops for a human decision on stagnation or a failed exit verification; passing full verification with every row closed enters the same verify gates.
43
+
44
+ 4. **verify**: the parent first runs the plan's full verification command set (the ledger's `verification:` command for loop); only a passing result permits whole-diff code review, then the **conformance gate**. A review fix invalidates that result, so the parent reruns full verification before the next review or conformance gate. When the overrides file declares a happy path for the touched paths (see [Project-specific overrides](#project-specific-overrides)), the parent runs it once here, between code review and the conformance gate, and the reviewer reads its transcript as runtime evidence - a hang or a failed request shows up in the audit with the transcript quoted - as a gap when an origin clause ties to it, otherwise as a `happy-path: failed - unattributable` line - never as a silent green. The conformance subagent reads the finished code and docs against your *original words* from step 1, not the plan, and reports per-requirement: delivered, partial, missing, drifted, or unauthorized. Unauthorized rows - shipped surface no human input asked for, whether it crept in or was laundered through the spec - follow their recommendation like every other row: a contained removal auto-runs, anything another requirement leans on is deferred with a plain-language "I'd cut it / I'd keep it" recommendation. Inside a brainstorming-entered flow this gate is machine-blocked from being skipped. Compatible executable recommendations auto-run through an isolated fix-and-re-audit loop with no prompt; anything still open surfaces as a numbered decision brief - one item per decision, the problem in plain words first, lettered options each with a one-line consequence, and `Recommended: <letter> - <reason>` or `No recommendation - pick one`. Reply with one line per item (`1. B`), or bare `1` to take every recommendation (no-recommendation items are re-asked by number); a current `CONFORMS` / no-concerns result goes straight to the branch options with no extra conformance sign-off.
43
45
  5. **`finishing-a-development-branch`**: PR, draft PR, squash, keep, or discard. Once a PR exists, run `/skill:gatekeep-pr <pr>` to verify it before merging - it judges the diff against the spec at the PR's head (the acceptance-criteria dispositions and Design you approved; the PR description when no spec applies) - rows the spec defers or deviates are settled, every other row needs its mechanism (code, tests, docs) - and against the rubric; whatever can be seen only after merge is left to step 6. **Human gate 2** - the only other decision you make.
44
46
  6. *(Optional)* Once the merge lands, `/skill:check-delivery <ref>` can prove delivery - default-branch landing, delivery target, per-AC evidence - before the tracker status advances. Explicit ask only, no auto-chain: deploys commonly lag merges by minutes to hours, so an auto-run would routinely check too early.
45
47
 
@@ -77,8 +79,8 @@ The run's grants, target/scope baseline, repair usage, and poll deadline live in
77
79
 
78
80
  pi-gauntlet ships three kinds of pieces, layered on top of pi-cohort's dispatch:
79
81
 
80
- - **20 skills** - the workflow logic. Fifteen activate automatically when pi sees the matching kind of task, and the workflow skills chain through their gates: `shape-ticket` (creates one tracker issue or repairs a comma/whitespace list of refs against a Context/Problem/Idea/Acceptance-Criteria template, with a grounded repo audit, an AC integrity check, a cheap council roast, and sequential per-ticket exact-diff write confirmations; model-invocable so other skills and commands can route "file a ticket" to it, the gate still needs your yes), `gatekeep-pr` (model-invocable on an explicit ask that names the PR and the verb; the gates still need your selection - is consent-gated pre-merge verification of a PR against the scope contract at its head - the approved spec's acceptance-criteria dispositions and Design when the PR carries one, else the PR's stated intent; the ticket is cross-checked, never judged here (`check-delivery` owns it) - read-only gathering, verification evidence resolved CI-first (green checks on the exact assessed head count as evidence; a pending commit status that GitHub does not report as blocking the gating actor (`mergeStateStatus` not `BLOCKED`) is not waited for - external-CI commit statuses included - while a running Actions check, an unknown merge state, and every other merge precondition still withhold; the project's verification command runs only as fallback), a rubric-based review, a compact bottom-up report (what the PR delivers, including rows deferred or deviating per spec, PR comments, nits, blockers, then a `mergeable` / `fixable` verdict) and a verb menu whose rows derive from the actor's permissions on the PR; nothing mutates (fixes, pushes, reviews, merges, tracker comments) until you pick a row, except the opt-in `--rebase [base]` sync that rebases, reviews, and lease-pushes the head before verification, or holds the rebased head locally when a conflict stop's scoped tests fail, so the normal `fix` row resolves it before the lease push; after your own merge two opt-in rows post the `AC coverage` block to the ticket or the PR; every terminal menu offers a recommended `cleanup` row that tears down the gate's worktree and branch and runs the overrides file's `## cleanup` steps - run it with `/skill:gatekeep-pr <pr> [--rebase [base]]`), `check-delivery` (model-invocable on an explicit ask that names the ticket and the verb; the gates still need your selection - is a post-merge detective control: proves an issue actually shipped (default-branch landing, delivery target, per-AC evidence) before its tracker status advances; it never writes a terminal status - run it with `/skill:check-delivery <ref>`), `writing-plans`, `roasting-the-spec`, `test-driven-development`, `subagent-driven-development`, `dispatching-parallel-agents`, `verification-before-completion`, `requesting-code-review`, `receiving-code-review`, `using-git-worktrees`, `finishing-a-development-branch`, `forge-skill`, `linear` (reads/searches/comments on/manages Linear tickets via the `linearis` CLI; owns all linearis mechanics and the `## Issue tracker` overrides schema; tracker-facing skills route to it). Five more require explicit entry (`disable-model-invocation: true`): `brainstorming` starts the gated workflow on explicit consent - use `/skill:brainstorming` for reliable discovery, or opt in through reachable explicit prose or a human-selected handoff; after spec approval, downstream planning and implementation still chain automatically. `chase-bug` is human-only bug triage: read-only root-cause discovery to an evidenced verdict menu (real bug -> ticket/brainstorm/hotfix/respond; five negative verdicts), then a gated response to the reporter for addressable origins (GitHub issue / tracker ticket) and a rendered verdict summary otherwise - it never fixes during triage; the hotfix row hands off to `skills/chase-bug/hotfix.md` after the menu - run it with `/skill:chase-bug`. `gauntlet-performance` reads the committed run telemetry (current repo by default, `--dir <path>` adds others) through the parse-only `gauntlet-performance` CLI and answers with one example-led recommendation, 3-5 cornerstone numbers, a council assessment when available, and a menu of at most three actions (render a report file, open the recommendation as a ticket via `shape-ticket`, drill into one run); it writes nothing unless you pick render - run it with `/skill:gauntlet-performance [--dir <path>]... [--since <version>]`. `gauntlet-handoff` ends a session whose flow a fresh session will continue: it invokes pi-cohort's `handoff` skill (`--out <path>` or `--key <name>`, default key = the run worktree's branch with `/` flattened to `-`, default file `<tmpdir>/pi-handoff/<key>.md`), then appends the gauntlet process-state section (phase/plan tracker status) per the shared contract `skills/gauntlet-resume/reference/brief-contract.md` - run it with `/skill:gauntlet-handoff [--out <path> | --key <name>]`. `gauntlet-resume` is the only way back into an interrupted flow from a fresh session: it takes a `gauntlet-handoff` brief (a file path, pasted text, or - with no arguments - a pick from the briefs under `<tmpdir>/pi-handoff/`), a bare worktree that already holds a spec, or a spec tracked on main under a configured spec dir (a spec seed: it creates or reuses the worktree through `using-git-worktrees` and pins the spec), restores phase/plan tracker state through the legal arming sequence (`start brainstorm`, `skip` with `resume:` reasons, `plan_check` before implement-or-later), and never infers approval from artifacts - run it with `/skill:gauntlet-resume [<brief-file> | <spec>.md | <worktree-name-or-path>]`.
81
- - **9 subagent personas** - the specialized child agents the skills dispatch via pi-cohort: `implementer`, `code-reviewer`, `spec-reviewer`, `conformance-reviewer`, `spec-summarizer`, `plan-writer`, `spec-council-member`, `spec-council-synthesizer`, `spec-council-applier`. See [doc/personas.md](./doc/personas.md) for what each one does and why its permissions are scoped the way they are.
82
+ - **21 skills** - the workflow logic. Sixteen activate automatically when pi sees the matching kind of task, and the workflow skills chain through their gates: `shape-ticket` (creates one tracker issue or repairs a comma/whitespace list of refs against a Context/Problem/Idea/Acceptance-Criteria template, with a grounded repo audit, an AC integrity check, a cheap council roast, and sequential per-ticket exact-diff write confirmations; model-invocable so other skills and commands can route "file a ticket" to it, the gate still needs your yes), `gatekeep-pr` (model-invocable on an explicit ask that names the PR and the verb; the gates still need your selection - is consent-gated pre-merge verification of a PR against the scope contract at its head - the approved spec's acceptance-criteria dispositions and Design when the PR carries one, else the PR's stated intent; the ticket is cross-checked, never judged here (`check-delivery` owns it) - read-only gathering, verification evidence resolved CI-first (green checks on the exact assessed head count as evidence; a pending commit status that GitHub does not report as blocking the gating actor (`mergeStateStatus` not `BLOCKED`) is not waited for - external-CI commit statuses included - while a running Actions check, an unknown merge state, and every other merge precondition still withhold; the project's verification command runs only as fallback), a rubric-based review, a compact bottom-up report (what the PR delivers, including rows deferred or deviating per spec, PR comments, nits, blockers, then a `mergeable` / `fixable` verdict) and a verb menu whose rows derive from the actor's permissions on the PR; nothing mutates (fixes, pushes, reviews, merges, tracker comments) until you pick a row, except the opt-in `--rebase [base]` sync that rebases, reviews, and lease-pushes the head before verification, or holds the rebased head locally when a conflict stop's scoped tests fail, so the normal `fix` row resolves it before the lease push; after your own merge two opt-in rows post the `AC coverage` block to the ticket or the PR; every terminal menu offers a recommended `cleanup` row that tears down the gate's worktree and branch and runs the overrides file's `## cleanup` steps - run it with `/skill:gatekeep-pr <pr> [--rebase [base]]`), `check-delivery` (model-invocable on an explicit ask that names the ticket and the verb; the gates still need your selection - is a post-merge detective control: proves an issue actually shipped (default-branch landing, delivery target, per-AC evidence) before its tracker status advances; it never writes a terminal status - run it with `/skill:check-delivery <ref>`), `writing-plans`, `roasting-the-spec`, `test-driven-development`, `subagent-driven-development`, `loop-implementation` (the plan-free implement driver that iterates a fresh `loop-implementer` over the spec's acceptance-criteria ledger when approval picks the loop engine), `dispatching-parallel-agents`, `verification-before-completion`, `requesting-code-review`, `receiving-code-review`, `using-git-worktrees`, `finishing-a-development-branch`, `forge-skill`, `linear` (reads/searches/comments on/manages Linear tickets via the `linearis` CLI; owns all linearis mechanics and the `## Issue tracker` overrides schema; tracker-facing skills route to it). Five more require explicit entry (`disable-model-invocation: true`): `brainstorming` starts the gated workflow on explicit consent - use `/skill:brainstorming` for reliable discovery, or opt in through reachable explicit prose or a human-selected handoff; after spec approval, downstream planning and implementation still chain automatically. `chase-bug` is human-only bug triage: read-only root-cause discovery to an evidenced verdict menu (real bug -> ticket/brainstorm/hotfix/respond; five negative verdicts), then a gated response to the reporter for addressable origins (GitHub issue / tracker ticket) and a rendered verdict summary otherwise - it never fixes during triage; the hotfix row hands off to `skills/chase-bug/hotfix.md` after the menu - run it with `/skill:chase-bug`. `gauntlet-performance` reads the committed run telemetry (current repo by default, `--dir <path>` adds others) through the parse-only `gauntlet-performance` CLI and answers with one example-led recommendation, 3-5 cornerstone numbers, a council assessment when available, and a menu of at most three actions (render a report file, open the recommendation as a ticket via `shape-ticket`, drill into one run); it writes nothing unless you pick render - run it with `/skill:gauntlet-performance [--dir <path>]... [--since <version>]`. `gauntlet-handoff` ends a session whose flow a fresh session will continue: it invokes pi-cohort's `handoff` skill (`--out <path>` or `--key <name>`, default key = the run worktree's branch with `/` flattened to `-`, default file `<tmpdir>/pi-handoff/<key>.md`), then appends the gauntlet process-state section (phase/plan tracker status) per the shared contract `skills/gauntlet-resume/reference/brief-contract.md` - run it with `/skill:gauntlet-handoff [--out <path> | --key <name>]`. `gauntlet-resume` is the only way back into an interrupted flow from a fresh session: it takes a `gauntlet-handoff` brief (a file path, pasted text, or - with no arguments - a pick from the briefs under `<tmpdir>/pi-handoff/`), a bare worktree that already holds a spec, or a spec tracked on main under a configured spec dir (a spec seed: it creates or reuses the worktree through `using-git-worktrees` and pins the spec), restores phase/plan tracker state through the legal arming sequence (`start brainstorm`, `skip` with `resume:` reasons, `plan_check` before implement-or-later), and never infers approval from artifacts - run it with `/skill:gauntlet-resume [<brief-file> | <spec>.md | <worktree-name-or-path>]`.
83
+ - **10 subagent personas** - the specialized child agents the skills dispatch via pi-cohort: `implementer`, `loop-implementer`, `code-reviewer`, `spec-reviewer`, `conformance-reviewer`, `spec-summarizer`, `plan-writer`, `spec-council-member`, `spec-council-synthesizer`, `spec-council-applier`. See [doc/personas.md](./doc/personas.md) for what each one does and why its permissions are scoped the way they are.
82
84
  - **4 runtime extensions** - the enforcement layer. `plan-tracker` and `phase-tracker` are tools skills call to track progress (with a TUI widget); `verify-before-ship` is a hook that warns if you push or open a PR without a passing test run since your last edit; a phase-tracker flow guard reminds on implement-phase commits missing spec/code review. In a brainstorming-entered flow, phase-tracker rejects `implement` or `verify` completion while tracker tasks remain pending or in progress; see [its configuration reference](./doc/configuration.md#phase-tracker). phase-tracker also registers `plan_check`, which verifies a plan against its spec and against the grammar in [skills/writing-plans/reference/plan-contract.md](./skills/writing-plans/reference/plan-contract.md), including that each task's `Tests:` commands are selective and never the full suite, and that every test command carries a passing base-commit result; a pass stamps the plan for implementation. `telemetry` records one YAML record per gauntlet run (phase timing, models, personas, gate/fix rounds, diff at ship) that ships in the squash beside the spec - it is written only inside a brainstorming-entered flow, kept out of history until `finishing-a-development-branch` seals it once with `gauntlet-telemetry-seal` (a `telemetry:` commit under git; the working-copy snapshot in a plain jj workspace) - and blocks a brainstorm `write` into an already-shipped spec; see [its configuration reference](./doc/configuration.md#telemetry). See [doc/configuration.md](./doc/configuration.md) for the settings each one reads.
83
85
 
84
86
  pi-gauntlet is **opinionated**: it recommends this pipeline for non-trivial changes, but starts `brainstorming` only after explicit user consent. Enforcement is opt-in by entry, not ambient: once brainstorming starts a flow, the phase-tracker extension mechanically blocks a phase from closing before its gate runs, and warns once if the main loop writes code during implement (subagents own implement-phase edits). A change made *without* entering the flow (a typo, a formatting run, a dependency bump - see "When to use / when NOT to use") is not gated; the discipline of routing real work through the pipeline is a convention the tooling supports, not a trap it springs on every edit.
@@ -122,7 +124,7 @@ Run by a human against pi-cohort >= 7.1.0, before a pi-gauntlet release claims t
122
124
  ## Requirements
123
125
 
124
126
  - [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.
125
- - [pi-cohort](https://github.com/jjuraszek/pi-cohort) >= 7.4.4 - required peer package, including binary-safe worktree patches. 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.
127
+ - [pi-cohort](https://github.com/jjuraszek/pi-cohort) >= 7.4.5 - required peer package, including binary-safe worktree patches. 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.
126
128
 
127
129
  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.
128
130
 
@@ -523,6 +525,12 @@ Anything a wrapper skill contains beyond trigger phrases is misplaced - move it
523
525
 
524
526
  ## Configuring the gates
525
527
 
528
+ Choose experimental loop options 4/5, configure the iteration cap and worker model, and operate the planless ledger through [Loop engine operations](doc/configuration.md#loop-engine-operations). Waves remains the default.
529
+
530
+ | Want to ... | Read |
531
+ |---|---|
532
+ | Author an implement engine | [Implement engine contract](doc/implement-engines.md) |
533
+
526
534
  The conformance gate's model, the spec council's roster, and the phase-tracker's flow guards are all configured per pi preset (or per repo, via `.pi/settings.json`). Keys below are relative to `piGauntlet`.
527
535
 
528
536
  | Key | Owner (extension) | Default | Effect |
@@ -0,0 +1,51 @@
1
+ ---
2
+ name: loop-implementer
3
+ description: 'One loop-engine iteration: closes one acceptance-criteria ledger row with evidence, commits once, appends one decision line.'
4
+ tools: read, write, edit, bash
5
+ defaultContext: fresh
6
+ inheritProjectContext: true
7
+ inheritSkills: false
8
+ systemPromptMode: replace
9
+ completionGuard: true
10
+ ---
11
+
12
+ You are the loop implementer. Complete one acceptance-criteria ledger iteration so the parent carries effects and evidence, not your reasoning.
13
+
14
+ ## Output contract
15
+
16
+ Append exactly one decision line to the ledger's `## Decisions` section and reply with that line only, with nothing before or after it:
17
+
18
+ `row <n> <done|failed> | <one sentence: what was done> | <one sentence: what the next iteration should not redo or should try differently>`
19
+
20
+ Limit each sentence to 200 characters. Use the seed contract below for `target: seed`.
21
+
22
+ ## Inputs
23
+
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
+
26
+ ## Procedure
27
+
28
+ 1. Read the given spec in full before reading the ledger or code. Read the given ledger next.
29
+ 2. Work only on the `target` row. Use `previous decision` and `hint` to avoid repeating work and to try a different approach; keep the target unchanged.
30
+ 3. Run `git diff --stat <base>`. Open full diff hunks only for files the target row's change touches, using `git diff <base> -- <target files>`; read the relevant code before editing it.
31
+ 4. Implement the target row. Write or run its evidence check: a test you wrote, or a named command or file assertion for a row you cannot automate. Run only that targeted check, never the ledger's project verification command or the full suite.
32
+ 5. Update only the target row's mutable cells: set `status` to `done` on a passing check or `failed` on a failing check, increment `attempts` by 1, and set `evidence` to one physical Markdown table cell containing the check name plus at most three result lines separated by semicolons. Escape pipes as `\|` and use plain text without angle-bracket markup. On failure, record the concrete cause in plain words: the spec-vs-code contradiction, failing assertion, or missing credential, tool, or fixture. Keep reasoning and transcripts out of evidence.
33
+ 6. Append the decision line defined in Output contract.
34
+ 7. Stage your target changes and the ledger explicitly. Commit exactly once with subject `loop: row <n> <status>`. Check `git status --porcelain` and leave the working tree clean.
35
+ 8. Reply with the appended decision line only.
36
+
37
+ ## Target seed
38
+
39
+ Use this contract for `target: seed`, normally iteration 0. After a human-authorized recovery discards an interrupted seed, repeat seeding at the consumed iteration count; keep the ledger's `iterations` unchanged.
40
+
41
+ Replace steps 2-6 with evidence seeding across all `open` rows. Keep the spec-first read order and scope diff hunks to the seeded rows' files.
42
+
43
+ For every `open` row, write its failing test or name its check and record the check in `evidence`. Close a row already satisfied by the codebase as `done` only with passing baseline evidence. Keep other open rows `open`. Keep every row's `attempts` at 0; never set `failed`. Preserve `cut` rows and their disposition evidence. Bound evidence as in step 5.
44
+
45
+ Append exactly one decision line: `row 0 done | seeded <k> rows, closed <m> on baseline | <first row to take>`.
46
+
47
+ 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
+
49
+ ## Boundaries
50
+
51
+ 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.
@@ -92,13 +92,15 @@ __export(runs_exports, {
92
92
  renderText: () => renderText
93
93
  });
94
94
  var key = "runs";
95
- var HEADER = ["run_id", "repo", "spec", "version", "status", "wall", "b/p/i/v/s min", "tokens", "cost", "models", "disp", "grants", "reopens", "loops", "findings", "council", "big"];
95
+ var HEADER = ["run_id", "repo", "spec", "version", "status", "engine", "iterations", "wall", "b/p/i/v/s min", "tokens", "cost", "models", "disp", "grants", "reopens", "loops", "findings", "council", "big"];
96
96
  var runRow = (r) => [
97
97
  r.run_id.slice(0, 8),
98
98
  r.repo,
99
99
  r.spec,
100
100
  r.version,
101
101
  `${dash(r.status)}${r.truncated ? "*" : ""}`,
102
+ r.engine,
103
+ r.iterations === null ? "" : String(r.iterations),
102
104
  fmtMin(r.wall_s),
103
105
  PHASES.map((p) => r.phase_min[p] === null ? "-" : String(Math.round(r.phase_min[p]))).join("/"),
104
106
  fmtCount(r.tokens),
@@ -377,6 +379,8 @@ function loadRecord(file, label) {
377
379
  return {
378
380
  row: {
379
381
  run_id: d.run_id,
382
+ engine: str(derived.engine) ?? "waves",
383
+ iterations: num(derived.iterations),
380
384
  repo: label,
381
385
  spec: basename(file, ".yaml"),
382
386
  version: semver(version) ? version.trim() : "unknown",
@@ -320,12 +320,12 @@ test("text output: header, shipped* marker, - cells, by version block, skipped l
320
320
  assert.equal(r.status, 0, r.stderr);
321
321
  assert.equal(r.lines[0], `corpus: ${root.split("/").pop()}=8`);
322
322
  assert.equal(r.lines[1], "runs (8)");
323
- assert.match(r.lines[2], /^run_id\s+repo\s+spec\s+version\s+status\s+wall\s+b\/p\/i\/v\/s min\s+tokens\s+cost\s+models\s+disp\s+grants\s+reopens\s+loops\s+findings\s+council\s+big$/);
323
+ assert.match(r.lines[2], /^run_id\s+repo\s+spec\s+version\s+status\s+engine\s+iterations\s+wall\s+b\/p\/i\/v\/s min\s+tokens\s+cost\s+models\s+disp\s+grants\s+reopens\s+loops\s+findings\s+council\s+big$/);
324
324
  const bRow = r.lines.find((l) => l.startsWith("bbbbbbbb"));
325
- assert.match(bRow, /\s5\.9\.1\s+shipped\*\s+-\s+10\/-\/-\/-\/-\s/);
325
+ assert.match(bRow, /\s5\.9\.1\s+shipped\*\s+waves\s+-\s+10\/-\/-\/-\/-\s/);
326
326
  assert.match(bRow, /\s-\s*$/);
327
327
  const aRow = r.lines.find((l) => l.startsWith("aaaaaaaa"));
328
- assert.match(aRow, /\sshipped\s+60m\s+10\/10\/10\/10\/10\s+6\.6k\s+2\.25\s+m\/alpha,m\/beta\s+7\s+1\s+0\s+2\s+1\/2\/3\s+4\s+-$/);
328
+ assert.match(aRow, /\sshipped\s+waves\s+60m\s+10\/10\/10\/10\/10\s+6\.6k\s+2\.25\s+m\/alpha,m\/beta\s+7\s+1\s+0\s+2\s+1\/2\/3\s+4\s+-$/);
329
329
  assert.ok(r.lines.includes("by version"));
330
330
  const v = r.lines.find((l) => l.startsWith("5.9.1"));
331
331
  assert.match(v, /^5\.9\.1\s+4\s+3\s+1\s+60m\/120m\s/);
@@ -499,3 +499,19 @@ test("council section: --since and no council data", (t) => {
499
499
  assert.match(none.lines[1], /^no records found/);
500
500
  assert.equal(none.lines.length, 2);
501
501
  });
502
+
503
+ test("runs expose engine and iterations for loop and legacy records in JSON and text", (t) => {
504
+ const root = gitRepo();
505
+ cleanup(t, root);
506
+ write(root, `${TDIR}/old.yaml`, COMPLETE);
507
+ write(root, `${TDIR}/loop.yaml`, TRUNCATED.replace("derived:\n", "derived:\n engine: loop\n iterations: 4\n"));
508
+ const rows = json(root).runs;
509
+ assert.deepEqual(rows.map((r) => [r.engine, r.iterations]), [["waves", null], ["loop", 4]]);
510
+ const rendered = invoke(root);
511
+ assert.equal(rendered.status, 0, rendered.stderr);
512
+ const text = rendered.stdout;
513
+ assert.match(text, /engine/);
514
+ assert.match(text, /iterations/);
515
+ assert.match(text, /^aaaaaaaa\s+.*\sshipped\s+waves\s+60m\s/m);
516
+ assert.match(text, /^bbbbbbbb\s+.*\sshipped\*\s+loop\s+4\s+-\s/m);
517
+ });
@@ -318,7 +318,7 @@ function foldAccumulators(a, b) {
318
318
  return out;
319
319
  }
320
320
  function newRecord(o) {
321
- return { schema: 1, spec: o.spec, run_id: o.runId, branch: o.branch, status: "in_progress", created_at: o.now, sessions: [o.session], derived: { 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: [] };
321
+ 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
322
  }
323
323
  var totalAccumulators = (rec) => Object.values(rec.accumulators).reduce((acc, block) => foldAccumulators(acc, block), emptyAccumulators());
324
324
  var seconds = (a, b) => Math.max(0, Math.round((Date.parse(b) - Date.parse(a)) / 1e3));
@@ -345,7 +345,7 @@ function derive(rec, now) {
345
345
  }
346
346
  for (const k of Object.keys(acc.phases)) phases[k] = compact({ ...phases[k] ?? {}, ...acc.phases[k] });
347
347
  const live = liveShipEvent(rec.events);
348
- return compact({ duration_s: seconds(rec.created_at, rec.shipped_at ?? rec.abandoned_at ?? now), phases, personas: acc.personas, reviews: Object.keys(acc.reviews).length ? acc.reviews : void 0, conformance_loops: acc.conformance_loops, conformance_open_gaps: acc.conformance_open_gaps, gates: compact({ ...acc.gates, ship_option: live?.option }), plan: rec.derived.plan, tests: rec.derived.tests, amendments: acc.amendments, spec_edits_after_ship: acc.spec_edits_after_ship, diff: rec.derived.diff, modified_files: rec.derived.modified_files, council: rec.derived.council, spec_writes: acc.spec_writes, events_dropped: acc.events_dropped });
348
+ return compact({ engine: rec.events.findLast((e) => e.kind === "engine")?.engine ?? "waves", iterations: acc.personas["loop-implementer"]?.dispatches, duration_s: seconds(rec.created_at, rec.shipped_at ?? rec.abandoned_at ?? now), phases, personas: acc.personas, reviews: Object.keys(acc.reviews).length ? acc.reviews : void 0, conformance_loops: acc.conformance_loops, conformance_open_gaps: acc.conformance_open_gaps, gates: compact({ ...acc.gates, ship_option: live?.option }), plan: rec.derived.plan, tests: rec.derived.tests, amendments: acc.amendments, spec_edits_after_ship: acc.spec_edits_after_ship, diff: rec.derived.diff, modified_files: rec.derived.modified_files, council: rec.derived.council, spec_writes: acc.spec_writes, events_dropped: acc.events_dropped });
349
349
  }
350
350
  var stripUndefined = (v) => {
351
351
  if (Array.isArray(v)) return v.map(stripUndefined);
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
3
3
  import {
4
4
  mergeGauntlet,
5
5
  resolveSpecCouncil,
6
+ resolveLoopEngine,
6
7
  resolveClosureReview,
7
8
  resolveEscalationLoop,
8
9
  resolvePhaseModels,
@@ -19,6 +20,15 @@ import {
19
20
  type PiGauntlet,
20
21
  } from "./gauntlet-settings.ts";
21
22
 
23
+ test("loopEngine resolves defaults, integers, clamps, and malformed values", () => {
24
+ for (const [raw, expected, warns] of [[undefined, 30, false], [7, 7, false], [0, 1, false], [-3, 1, false], [1.5, 30, true], ["5", 30, true]] as const) {
25
+ const result = resolveLoopEngine({ loopEngine: { maxIterations: raw } });
26
+ assert.equal(result.maxIterations, expected);
27
+ assert.equal(Boolean(result.warning), warns);
28
+ }
29
+ assert.deepEqual(resolveLoopEngine({}), { maxIterations: 30, warning: undefined });
30
+ });
31
+
22
32
  const CUSTOM_TEST_COMMANDS = ["make ci", "pytest"];
23
33
 
24
34
  test("mergeGauntlet: repo key replaces preset key whole-object", () => {
@@ -11,6 +11,7 @@ export interface PiGauntlet {
11
11
  flowGuards?: { enforce?: unknown; specDirs?: unknown };
12
12
  verifyBeforeShip?: { testCommands?: unknown; warningReference?: unknown };
13
13
  escalationLoop?: { implModel?: unknown };
14
+ loopEngine?: { maxIterations?: unknown };
14
15
  phaseModels?: { plan?: unknown; implement?: unknown; verify?: unknown; ship?: unknown };
15
16
  telemetry?: { enabled?: unknown; dir?: unknown; buckets?: unknown };
16
17
  }
@@ -92,6 +93,13 @@ export function resolveClosureReview(g: PiGauntlet): ClosureReviewResolved {
92
93
  return { model, enforce, maxFixRounds };
93
94
  }
94
95
 
96
+ export function resolveLoopEngine(g: PiGauntlet): { maxIterations: number; warning: string | undefined } {
97
+ const raw = g.loopEngine?.maxIterations;
98
+ if (raw === undefined) return { maxIterations: 30, warning: undefined };
99
+ if (typeof raw === "number" && Number.isInteger(raw)) return { maxIterations: Math.max(1, raw), warning: undefined };
100
+ return { maxIterations: 30, warning: "loopEngine.maxIterations must be an integer; using default 30" };
101
+ }
102
+
95
103
  export interface EscalationLoopResolved {
96
104
  implModel: string | undefined;
97
105
  }
@@ -2,6 +2,69 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { checkPlan, sha256, type FsPort, type PlanCheckFinding } from "./plan-check.ts";
4
4
 
5
+ const ledgerSpec = `# Spec
6
+ ## Acceptance criteria
7
+ - [ ] First
8
+ - [ ] Second
9
+ deferred: #70
10
+ - [ ] Third
11
+ venue: browser
12
+ ## Design
13
+ - [ ] Not an AC
14
+ `;
15
+ const ledger = (rows = ["1 | open | 0 |", "2 | cut | 0 | deferred: #70", "3 | done | 2 | browser check passed"]) =>
16
+ "# Ledger - loop engine\n**Spec:** `doc/specs/test.md`\nbase: abc\nverification: check\niterations: 0\n\n| row | status | attempts | evidence |\n|---|---|---|---|\n" + rows.map(r => `| ${r} |`).join("\n") + "\n\n## Decisions\n";
17
+
18
+ test("ledger admission bypasses task checks and permits empty open evidence", () => {
19
+ assert.deepEqual(checkPlan(ledger(), ledgerSpec, { exists: () => false, glob: () => [] }), []);
20
+ });
21
+ for (const [name, rows] of Object.entries({
22
+ missing: ["1 | open | 0 |"],
23
+ extra: ["1 | open | 0 |", "2 | cut | 0 | deferred: #70", "3 | open | 0 |", "4 | open | 0 |"],
24
+ duplicate: ["1 | open | 0 |", "1 | cut | 0 | deferred: #70", "3 | open | 0 |"],
25
+ order: ["3 | open | 0 |", "2 | cut | 0 | deferred: #70", "1 | open | 0 |"],
26
+ status: ["1 | closed | 0 | check", "2 | cut | 0 | deferred: #70", "3 | open | 0 |"],
27
+ cutDefault: ["1 | cut | 0 | check", "2 | cut | 0 | deferred: #70", "3 | open | 0 |"],
28
+ cutVenue: ["1 | open | 0 |", "2 | cut | 0 | deferred: #70", "3 | cut | 0 | check"],
29
+ openDeferred: ["1 | open | 0 |", "2 | open | 0 |", "3 | open | 0 |"],
30
+ attempts: ["1 | open | -1 |", "2 | cut | 0 | deferred: #70", "3 | open | 0 |"],
31
+ fractional: ["1 | open | 1.5 |", "2 | cut | 0 | deferred: #70", "3 | open | 0 |"],
32
+ })) test(`ledger rejects ${name}`, () => {
33
+ assert.ok(checkPlan(ledger(rows), ledgerSpec, { exists: () => false, glob: () => [] }).length);
34
+ });
35
+ test("ledger discriminant is the exact first line", () => {
36
+ for (const prefix of ["\n", " "]) assert.ok(checkPlan(prefix + ledger(), ledgerSpec, { exists: () => false, glob: () => [] }).some(f => f.check === "input"));
37
+ });
38
+ for (const disposition of ["in-scope", "venue: browser", "deviates: alternate", "deferred: #70", "elsewhere: #71"]) {
39
+ test(`ledger disposition ${disposition}`, () => {
40
+ const cut = /^(deviates|deferred|elsewhere):/.test(disposition);
41
+ const spec = `## Acceptance criteria\n- [ ] Works\n ${disposition}\n`;
42
+ assert.deepEqual(checkPlan(ledger([`1 | ${cut ? "cut" : "open"} | 0 | ${cut ? disposition : ""}`]), spec, { exists: () => false, glob: () => [] }), []);
43
+ assert.ok(checkPlan(ledger([`1 | ${cut ? "done" : "cut"} | 0 | check passed`]), spec, { exists: () => false, glob: () => [] }).length);
44
+ });
45
+ }
46
+ for (const status of ["done", "failed", "cut"]) test(`ledger empty evidence rejects ${status}`, () => {
47
+ const spec = `## Acceptance criteria\n- [ ] Works\n${status === "cut" ? " deferred: #70\n" : ""}`;
48
+ assert.ok(checkPlan(ledger([`1 | ${status} | 0 |`]), spec, { exists: () => false, glob: () => [] }).some(f => /evidence is required/.test(f.reason)));
49
+ });
50
+ for (const evidence of ["<check>", "TODO", "TBD", "etc.", "probably"]) {
51
+ test(`ledger rejects non-open evidence ${JSON.stringify(evidence)}`, () => {
52
+ assert.ok(checkPlan(ledger([`1 | failed | 1 | ${evidence}`, "2 | cut | 0 | deferred: #70", "3 | open | 0 |"]), ledgerSpec, { exists: () => false, glob: () => [] }).length);
53
+ });
54
+ }
55
+
56
+ test("ledger keeps nested checkboxes and the parent disposition in one AC row", () => {
57
+ const spec = "## Acceptance criteria\n- [ ] Parent\n - [ ] Nested detail\n deferred: #70\n";
58
+ assert.deepEqual(checkPlan(ledger(["1 | cut | 0 | deferred: #70"]), spec, { exists: () => false, glob: () => [] }), []);
59
+ assert.ok(checkPlan(ledger(["1 | open | 0 |"]), spec, { exists: () => false, glob: () => [] }).some(f => /status does not match/.test(f.reason)));
60
+ });
61
+
62
+ for (const evidence of ["xxx", "[fill in]", "something like", "check; expected a\\|b; passed"]) {
63
+ test(`ledger permits evidence outside the placeholder vocabulary ${JSON.stringify(evidence)}`, () => {
64
+ assert.deepEqual(checkPlan(ledger([`1 | done | 1 | ${evidence}`, "2 | cut | 0 | deferred: #70", "3 | open | 0 |"]), ledgerSpec, { exists: () => false, glob: () => [] }), []);
65
+ });
66
+ }
67
+
5
68
  const SPEC_TEXT = [
6
69
  "# Fixture Spec", // 1
7
70
  "", // 2
@@ -1134,8 +1134,50 @@ function checkWaiverLiteral(parsed: ParsedPlan): PlanCheckFinding[] {
1134
1134
  return findings;
1135
1135
  }
1136
1136
 
1137
+ function checkLedger(planText: string, specText: string): PlanCheckFinding[] {
1138
+ const findings: PlanCheckFinding[] = [];
1139
+ const dispositions: boolean[] = [];
1140
+ const specLines = specText.split("\n");
1141
+ const masked = fenceMask(specLines);
1142
+ let inAcceptance = false;
1143
+ for (const [i, line] of specLines.entries()) {
1144
+ if (masked[i]) continue;
1145
+ if (/^##\s+/.test(line)) {
1146
+ inAcceptance = /^## Acceptance criteria\s*$/i.test(line);
1147
+ continue;
1148
+ }
1149
+ if (!inAcceptance) continue;
1150
+ if (/^- \[[ xX]\]\s+/.test(line)) dispositions.push(false);
1151
+ else if (dispositions.length && /^\s*(?:deviates|deferred|elsewhere):/.test(line)) dispositions[dispositions.length - 1] = true;
1152
+ }
1153
+ const lines = planText.split("\n");
1154
+ const add = (i: number, reason: string) => findings.push({ check: "ledger", line: i + 1, text: lines[i] ?? "", reason });
1155
+ const header = lines.findIndex(l => /^\|\s*row\s*\|\s*status\s*\|\s*attempts\s*\|\s*evidence\s*\|$/.test(l));
1156
+ if (header < 0) {
1157
+ add(0, "missing ledger table");
1158
+ return findings;
1159
+ }
1160
+ if (!/^\|\s*-+\s*\|\s*-+\s*\|\s*-+\s*\|\s*-+\s*\|$/.test(lines[header + 1] ?? "")) add(header + 1, "missing ledger table separator");
1161
+ let count = 0;
1162
+ for (let i = header + 2; i < lines.length && lines[i].startsWith("|"); i++) {
1163
+ const cells = lines[i].split(/(?<!\\)\|/).slice(1, -1).map(c => c.trim());
1164
+ const [row, status, attempts, evidence] = cells;
1165
+ count++;
1166
+ if (cells.length !== 4 || !lines[i].endsWith("|")) { add(i, "expected four ledger cells"); continue; }
1167
+ if (row !== String(count) || count > dispositions.length) add(i, "expected exactly one row per spec AC in order");
1168
+ if (!["open", "done", "failed", "cut"].includes(status)) add(i, "invalid ledger status");
1169
+ else if ((status === "cut") !== dispositions[count - 1]) add(i, "status does not match spec disposition");
1170
+ if (!/^\d+$/.test(attempts) || !Number.isSafeInteger(Number(attempts))) add(i, "attempts must be a non-negative integer");
1171
+ if (!evidence && status !== "open") add(i, "evidence is required on non-open rows");
1172
+ if (/<[^>]*>|\b(?:TODO|TBD|probably)\b|etc\./i.test(evidence)) add(i, "placeholder in ledger evidence");
1173
+ }
1174
+ if (count !== dispositions.length || count === 0) add(header, "expected exactly one row per spec AC");
1175
+ return findings;
1176
+ }
1177
+
1137
1178
  export function checkPlan(planText: string, specText: string, fs: FsPort): PlanCheckFinding[] {
1138
1179
  try {
1180
+ if (planText.split("\n", 1)[0] === "# Ledger - loop engine") return checkLedger(planText, specText);
1139
1181
  const parsed = parsePlan(planText);
1140
1182
  const findings: PlanCheckFinding[] = [];
1141
1183
 
@@ -209,3 +209,22 @@ test("serializeRecord writes council.chair and each member as flow maps under bl
209
209
  const plain = newRecord({ spec: "doc/specs/a.md", session: "s1", now: "2026-09-17T10:00:00Z", runId: "r2" });
210
210
  assert.equal(parseRecord(serializeRecord(plain))!.derived.council, undefined);
211
211
  });
212
+
213
+ test("derive engine uses the latest event, defaults legacy runs, and counts loop dispatches across sessions", () => {
214
+ const rec = newRecord({ spec: "doc/specs/a.md", session: "s", now: "2026-01-01T00:00:00Z", runId: "r" });
215
+ const now = rec.created_at;
216
+ assert.equal(derive(rec, now).engine, "waves");
217
+ assert.equal("iterations" in derive(rec, now), false);
218
+ rec.events.push(ev({ kind: "engine", engine: "loop" }));
219
+ rec.accumulators.s = { ...emptyAccumulators(), personas: { "loop-implementer": { dispatches: 2, models: [] } } };
220
+ rec.accumulators.total = { ...emptyAccumulators(), personas: { "loop-implementer": { dispatches: 1, models: [] } } };
221
+ rec.derived = derive(rec, now);
222
+ const back = parseRecord(serializeRecord(rec))!;
223
+ assert.equal(back.derived.engine, "loop");
224
+ assert.equal(back.derived.iterations, 3);
225
+ assert.deepEqual(back.events, rec.events);
226
+ back.events.push(ev({ kind: "engine", engine: "waves" }));
227
+ assert.equal(derive(back, now).engine, "waves");
228
+ assert.equal(derive(back, now).iterations, 3);
229
+ assert.ok(capEvents(rec.events, 0).events.some((e) => e.kind === "engine"));
230
+ });
@@ -23,6 +23,7 @@ export const implementAutoCompletes = (phases: PhaseMap, tasks: { status: string
23
23
  export interface BaseEvent { ts: string; session: string; phase: PhaseKey }
24
24
  export type TelemetryEvent = BaseEvent & (
25
25
  | { kind: "phase"; action: PhaseAction; name?: Phase; model?: string; thinking?: string }
26
+ | { kind: "engine"; engine: "loop" | "waves" }
26
27
  | { kind: "plan_check"; pass: boolean; spec?: string; plan?: string }
27
28
  | { kind: "dispatch"; agent: string; model?: string; exit?: number }
28
29
  | { kind: "gate"; gate: "fix_round_grant" | "task_reopen"; rounds?: number }
@@ -33,7 +34,7 @@ export type TelemetryEvent = BaseEvent & (
33
34
  | { kind: "warning"; message: string }
34
35
  );
35
36
  // Lifecycle events are never dropped because derive depends on their complete history.
36
- const LIFECYCLE_KINDS = new Set(["phase", "ship", "ship_failed", "spec_renamed"]);
37
+ const LIFECYCLE_KINDS = new Set(["engine", "phase", "ship", "ship_failed", "spec_renamed"]);
37
38
  export const EVENT_CAP = 300;
38
39
  export function capEvents(events: TelemetryEvent[], cap = EVENT_CAP): { events: TelemetryEvent[]; dropped: number } {
39
40
  let kept = events, dropped = 0;
@@ -89,6 +90,7 @@ export function foldAccumulators(a: Accumulators, b: Accumulators): Accumulators
89
90
 
90
91
  export interface DiffSummary { base: string; commits: number; buckets: Record<string, BucketStat> }
91
92
  export interface Derived {
93
+ engine: "loop" | "waves"; iterations?: number;
92
94
  duration_s: number; phases: Partial<Record<PhaseKey, PhaseAcc & { started_at?: string; completed_at?: string; duration_s?: number; model?: string; thinking?: string }>>;
93
95
  personas: Record<string, PersonaAcc>; reviews?: Record<string, ReviewAcc>; conformance_loops: number; conformance_open_gaps?: number; gates: Gates & { ship_option?: string };
94
96
  plan?: { tasks: number; complete: number; failed: number; skipped: number }; tests?: { command: string; result: "pass" | "fail" }; amendments: number;
@@ -101,7 +103,7 @@ export interface TelemetryRecord {
101
103
  sessions: string[]; derived: Derived; accumulators: Record<string, Accumulators>; events: TelemetryEvent[];
102
104
  }
103
105
  export function newRecord(o: { spec: string; session: string; now: string; runId: string; branch?: string }): TelemetryRecord {
104
- return { schema: 1, spec: o.spec, run_id: o.runId, branch: o.branch, status: "in_progress", created_at: o.now, sessions: [o.session], derived: { 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: [] };
106
+ 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: [] };
105
107
  }
106
108
  export const totalAccumulators = (rec: TelemetryRecord): Accumulators => Object.values(rec.accumulators).reduce<Accumulators>((acc, block) => foldAccumulators(acc, block), emptyAccumulators());
107
109
  const seconds = (a: string, b: string): number => Math.max(0, Math.round((Date.parse(b) - Date.parse(a)) / 1000));
@@ -122,7 +124,7 @@ export function derive(rec: TelemetryRecord, now: string): Derived {
122
124
  }
123
125
  for (const k of Object.keys(acc.phases) as PhaseKey[]) phases[k] = compact({ ...(phases[k] ?? {}), ...acc.phases[k] });
124
126
  const live = liveShipEvent(rec.events);
125
- return compact({ duration_s: seconds(rec.created_at, rec.shipped_at ?? rec.abandoned_at ?? now), phases, personas: acc.personas, reviews: Object.keys(acc.reviews).length ? acc.reviews : undefined, conformance_loops: acc.conformance_loops, conformance_open_gaps: acc.conformance_open_gaps, gates: compact({ ...acc.gates, ship_option: live?.option }), plan: rec.derived.plan, tests: rec.derived.tests, amendments: acc.amendments, spec_edits_after_ship: acc.spec_edits_after_ship, diff: rec.derived.diff, modified_files: rec.derived.modified_files, council: rec.derived.council, spec_writes: acc.spec_writes, events_dropped: acc.events_dropped }) as Derived;
127
+ return compact({ engine: rec.events.findLast((e) => e.kind === "engine")?.engine ?? "waves", iterations: acc.personas["loop-implementer"]?.dispatches, duration_s: seconds(rec.created_at, rec.shipped_at ?? rec.abandoned_at ?? now), phases, personas: acc.personas, reviews: Object.keys(acc.reviews).length ? acc.reviews : undefined, conformance_loops: acc.conformance_loops, conformance_open_gaps: acc.conformance_open_gaps, gates: compact({ ...acc.gates, ship_option: live?.option }), plan: rec.derived.plan, tests: rec.derived.tests, amendments: acc.amendments, spec_edits_after_ship: acc.spec_edits_after_ship, diff: rec.derived.diff, modified_files: rec.derived.modified_files, council: rec.derived.council, spec_writes: acc.spec_writes, events_dropped: acc.events_dropped }) as Derived;
126
128
  }
127
129
 
128
130
  const stripUndefined = (v: unknown): unknown => {
@@ -1403,6 +1403,46 @@ test("gauntlet_setting escalationLoop: setting absent -> ctx-derived main-loop m
1403
1403
  assert.equal(res.details.implModel, "p/strong:high");
1404
1404
  });
1405
1405
 
1406
+ test("gauntlet_setting loopEngine resolves the key", async () => {
1407
+ const h = harness({ cwd: tempCwd({ piGauntlet: { loopEngine: { maxIterations: 8 } } }) });
1408
+ const tool = h.tools.find(t => t.name === "gauntlet_setting")!;
1409
+ const result = await tool.execute("loop", { key: "loopEngine" }, undefined, undefined, h.ctx) as any;
1410
+ assert.equal(result.details.key, "loopEngine");
1411
+ assert.equal(result.details.maxIterations, 8);
1412
+ });
1413
+
1414
+ test("gauntlet_setting loopEngine composes settings load warnings", async () => {
1415
+ const dir = tempCwd({});
1416
+ writeFileSync(join(dir, ".pi/settings.json"), "{");
1417
+ const h = harness({ cwd: dir });
1418
+ const result = await h.tools.find(t => t.name === "gauntlet_setting")!.execute("loop", { key: "loopEngine" }, undefined, undefined, h.ctx) as any;
1419
+ assert.equal(result.details.maxIterations, 30);
1420
+ assert.ok(result.details.errors.length);
1421
+ assert.match(result.details.warning, /gauntlet settings load error/);
1422
+ });
1423
+
1424
+ test("ledger handler stamps after brainstorm and skipped plan, then admits implement", async () => {
1425
+ const dir = gitPrimary().primary;
1426
+ mkdirSync(join(dir, "doc/specs"), { recursive: true });
1427
+ mkdirSync(join(dir, "doc/plans"), { recursive: true });
1428
+ writeFileSync(join(dir, "doc/specs/loop.md"), "# Spec\n## Acceptance criteria\n- [ ] Works\n");
1429
+ const planPath = join(dir, "doc/plans/loop-ledger.md");
1430
+ writeFileSync(planPath, "# Ledger - loop engine\n**Spec:** `doc/specs/loop.md`\nbase: abc\nverification: check\niterations: 0\n\n| row | status | attempts | evidence |\n|---|---|---|---|\n| 1 | open | 0 | |\n\n## Decisions\n");
1431
+ const h = harness({ cwd: dir });
1432
+ await h.emit("session_start");
1433
+ const phase = h.tools.find(t => t.name === "phase_tracker")!;
1434
+ const call = (action: string, p: string, reason?: string) => phase.execute("phase", { action, phase: p, reason }, undefined, undefined, h.ctx) as Promise<any>;
1435
+ await call("start", "brainstorm");
1436
+ await call("complete", "brainstorm");
1437
+ await call("skip", "plan", "loop engine");
1438
+ const checked = await h.tools.find(t => t.name === "plan_check")!.execute("check", { planPath }, undefined, undefined, h.ctx) as any;
1439
+ assert.equal(checked.details.status, "pass");
1440
+ assert.equal(checked.details.planSha256, sha256Hex(readFileSync(planPath)));
1441
+ const started = await call("start", "implement");
1442
+ assert.equal(started.details.error, undefined);
1443
+ assert.equal(started.details.phases.implement.status, "in_progress");
1444
+ });
1445
+
1406
1446
  test("gauntlet_setting flowGuards: default dirs + derived plan dirs; repo override replaces them", async () => {
1407
1447
  const h = harness({ cwd: tempCwd({ piGauntlet: {} }) });
1408
1448
  const tool = h.tools.find((t) => t.name === "gauntlet_setting")!;
@@ -20,6 +20,7 @@ import {
20
20
  planDirsFor,
21
21
  resolveClosureReview,
22
22
  resolveEscalationLoop,
23
+ resolveLoopEngine,
23
24
  resolveFlowGuards,
24
25
  resolvePhaseModels,
25
26
  type PhaseModelPhase,
@@ -889,7 +890,7 @@ export default function (pi: ExtensionAPI) {
889
890
  });
890
891
 
891
892
  const GauntletSettingParams = Type.Object({
892
- key: StringEnum(["specCouncil", "closureReview", "escalationLoop", "flowGuards"] as const, {
893
+ key: StringEnum(["specCouncil", "closureReview", "escalationLoop", "flowGuards", "loopEngine"] as const, {
893
894
  description: "Which gauntlet setting to resolve (merged repo-over-preset).",
894
895
  }),
895
896
  });
@@ -903,8 +904,11 @@ export default function (pi: ExtensionAPI) {
903
904
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
904
905
  const { gauntlet, errors } = loadGauntletSettings(ctx.cwd);
905
906
  const fg = resolveFlowGuards(gauntlet);
907
+ const loop = resolveLoopEngine(gauntlet);
906
908
  const payload =
907
- params.key === "specCouncil"
909
+ params.key === "loopEngine"
910
+ ? { key: "loopEngine" as const, ...loop, warning: [loop.warning, errors.length ? settingsErrorWarning(errors) : undefined].filter(Boolean).join("; ") || undefined, errors }
911
+ : params.key === "specCouncil"
908
912
  ? { key: "specCouncil" as const, ...resolveSpecCouncil(gauntlet), errors }
909
913
  : params.key === "closureReview"
910
914
  ? { key: "closureReview" as const, ...resolveClosureReview(gauntlet), errors }