pi-gauntlet 8.1.1 → 8.1.3

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## v8.1.3 - 2026-11-01
4
+
5
+ - The loop engine runs on one per-row budget: a fresh ledger carries `allowance: <maxIterationsPerRow>` (default 4) in place of `cap:`, a `4; allowance N` or `5; allowance N` approval reply overrides the setting, and each `retry <n> | <hint>` decision adds one attempt to that row. The driver drops funded rounds, `cap +N`, the total-iteration cap and amend-gated escalation; a row's last allowed attempt runs once on `escalationLoop.implModel` under option 4 and option 5 alike, and exit-verification failure gets one escalated repair per flow. Human stops offer only `retry <n> | <hint>` and `rows +N` and print each open row as `<attempts> of <allowance>`; the task tracker labels rows `<attempts>/<allowance>: <criterion text>` while task names stay `row N`. A legacy `cap:` ledger migrates on resume through a parent `loop: migrate allowance` commit. (#108)
6
+
7
+ ## v8.1.2 - 2026-11-01
8
+
9
+ - `gatekeep-pr` selects the viewer's newest submitted, non-dismissed review and, when its commit is an ancestor of the assessed head, sends the reviewer its posted items and only the intervening code delta. The report counts addressed items; contract checks still judge the whole head. Assess pins its review to the assessed commit and checks the response; own-PR comment reviews supply the same bounded baseline.
10
+
3
11
  ## v8.1.1 - 2026-11-01
4
12
 
5
13
  - Loop verification and finish run the deduplicated gate set of recorded row checks in their owning checkouts; CI on push owns the full suite. Gate output stays in session logs, repair children receive log paths, and the driver reads only a 20-line tail at the verify-step-1 human stop. Workers record selective commands with TDD and test-economy rules; ledgers without `checks:` stop instead of falling back to a full run. Loop PR summaries identify scoped verification; unmatched gate commands can leave `verification_runs` at zero and trigger the expected ship advisory without authorizing a full local run. (#109)
package/README.md CHANGED
@@ -39,7 +39,7 @@ Concretely, one change through the gauntlet:
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. A leading run of GitHub references (`#N`, `owner/repo#N`, or a `/pull/N` or `/issues/N` URL), separated by whitespace or commas, resolves each reference as a PR first: related open, same-repository, non-fork PRs on one base form one rework subject, including drafts. Admission runs in listed order and relatedness is checked before provisioning; the first retained PR is home, whose existing branch rebases onto the fetched PR base by default, then member commits replay onto it in listed order. The first intent word `merge-forward` opts out and keeps the merge path. An unresolved conflict after its scoped helper aborts that step and continues to the spec with the repair as Task 1. A single non-PR reference follows the ticket path. The first non-reference token starts intent text; mid-sentence references and other ticket/free-text subjects keep their existing paths. The combined code, PR-carried specs, claims, and review threads are the brainstorm input, with each PR's provenance retained; only home's bound ticket supplies acceptance criteria, while member tickets supply context. A single PR retains the same rework path without member replays or a close offer. The combined state is judged against constraints from the fetched current base HEAD, not the fork point or the PRs' proposed policy edits; see the [PR-subject contract](skills/brainstorming/reference/pr-subject.md) and [PR-head worktree owner](skills/using-git-worktrees/reference/pr-head.md). **`brainstorming`** sets up an isolated worktree (on the existing branch for PR rework), explores the codebase, and turns the subject 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.** Options 1/2/4/5 approve and pick an engine; option 6 approves the content only: it writes `**Engine:** deferred` under `**Amend-grant:** none`, pushes the branch, opens a draft PR carrying just the spec, prints the URL, and ends the flow - no plan, no loop, no handoff brief. For a coordinated spec, it first pushes every companion branch to origin, then opens one home draft PR; it creates no companion PRs. A later `brainstorming #N` reconstructs the recorded companions from their origin branches before re-presenting the implementation gate; the [publication and re-gate owner](skills/brainstorming/reference/spec-pr.md) defines all-member preflight, recovery, and cleanup. The branch stays; a gauntlet-owned worktree is removed, a host-owned checkout is reported and left. Option 6 is withheld on a PR subject (`Withheld: draft PR - #N already exists`) and when the branch's diff against the default branch is not spec-only. A later `brainstorming #N` on that PR classifies it from the diff alone: one added spec carrying the marker plus banner-only predecessor edits re-presents the gate with 1/2/4/5 (3 only when the spec commit carries a council audit) and skips gather, questionary, council, and scout; a spec-only diff without the marker prints `spec-only PR without the gate marker - running lint and council` and takes the normal brainstorm path with the carried spec as the draft; any other diff takes the rework path. Approval at that re-gate writes `**Engine:**`, `**Amend-grant:**`, and `**PR:** <owner/repo#N>` as a new commit on the PR branch and chains into planning or the loop as usual. Before approval, PR rework's sole write/run exception outside the spec directories permits only the base sync (rebase or merge), each member replay or merge, their conflict helpers' edits to the conflicted files, and their scoped conflict tests; other subjects have no implementation code yet.
40
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 in waves, with task implementers followed by one integrated `wave-reviewer` after the scoped test gate passes. The reviewer checks anchored spec clauses and code quality together; repairs stay on the current tree and pass the same gate before re-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
- The loop alternative to step 3 runs one fresh `loop-implementer` per seed or row iteration, without implement-phase reviewers. Stagnation stops for a human decision. Under amend permission, row-allowance exhaustion and a failed verify-step gate each get one bounded escalated-model round within the existing cap. Ledger refresh completes implement when every row is closed; verify step 1 runs the gate set: deduplicated (owning checkout, command) pairs from done rows' recorded checks, with ownership from the ledger. Gate output goes to logs; the driver keeps exit codes and paths, not test output. CI on push owns the full run.
42
+ The loop alternative to step 3 runs one fresh `loop-implementer` per seed or row iteration, without implement-phase reviewers. Each row starts with the shared allowance (default 4); approval replies such as `4; allowance 6` override it for one run. Under either loop approval option, the last allowed attempt escalates once per row on `escalationLoop.implModel` (the main-loop model when unset); one exit-verification repair per flow uses the same model. Exhaustion and stagnation stop for a human decision: `retry <n> | <hint>` adds one attempt to that row, while `rows +N` raises every row's allowance. Attempts never reset and there is no total iteration cap. Ledger refresh completes implement when every row is closed; verify step 1 runs the gate set: deduplicated (owning checkout, command) pairs from done rows' recorded checks, with ownership from the ledger. Gate output goes to logs; the driver keeps exit codes and paths, not test output. CI on push owns the full run.
43
43
 
44
44
  4. **verify**: the parent first runs the plan's full verification command set for waves, or the ledger gate set 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 the same set before the next review or conformance gate. Loop conformance repairs and finish also use the gate set, never the ledger's full `verification:` command. 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.
45
45
  5. **`finishing-a-development-branch`**: PR, draft PR, squash, keep, or discard; PR rework's row 1 offers a lease push of the rebased home branch to the existing PR (`Base:` records in `merge:` form push non-force). A spec whose header carries `**PR:**` (approved at a spec-PR re-gate) and no `## Rework record` replaces the two create rows with `Push to PR #N and mark it ready for review` and `Push to PR #N, keep it a draft` (`Push to PR #N (already ready for review)` when the PR is already ready): both push without force after strip-and-seal, the first then runs `gh pr ready`, neither rewrites the PR body, and both are withheld when origin's branch head is no longer an ancestor of local HEAD. For coordinated specs, these [header-linked rows 1/2](skills/finishing-a-development-branch/reference/spec-pr.md#push-rows-12) deliver companions in recorded order before one home seal carrying every companion PR URL, then push home and apply its readiness choice. Row 1 makes companion drafts ready too; row 2 creates drafts but never converts an already-ready PR back to draft. Companion URLs appear in the shipped summary, companions first and home last, not in the unchanged home PR body; keep that summary for cross-repo review because successful home delivery removes receipts. A spec still carrying `**Engine:** deferred` stops finishing (and `gauntlet-resume`) with `spec approved for handoff only - run brainstorming #N` before any phase change, verification, or push. The PR-rework push appends a `## Rework` block naming absorbed PRs while retaining the original description bytes; the header-linked `**PR:**` push leaves the body untouched. Only after a successful rework push and home-description edit does one gated confirmation show the exact close comments and eligible member targets, linking home; declining or any other finish option leaves members open. Closing never deletes member branches, and finish posts no review-thread replies. Its `Before:` handle includes the full portable pre-rework SHA and the local `refs/gauntlet/pre-rework/pr-<N>` ref in the rework checkout. The [finishing owner](skills/finishing-a-development-branch/SKILL.md#pr-subject-row-1-push-to-the-pr-branch) defines the landing and body-update contract. 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.
@@ -87,6 +87,8 @@ One finish choice spans all legs. PR, draft PR and squash deliver companions in
87
87
 
88
88
  ## Architecture
89
89
 
90
+ A `gatekeep-pr` run after your newest submitted, non-dismissed review uses that review's commit as its baseline when it is an ancestor of the assessed head. The reviewer checks your posted items and only the intervening code delta; the report shows `Prior review: <k> of <n> items addressed`. Contract checks still judge the whole head. See the [review task](skills/gatekeep-pr/reference/assessment.md#step-4-review-task).
91
+
90
92
  `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. An explicit no-argument invocation on either harness discovers your open PRs in origin, authored by you or directly awaiting your review (not team requests), excludes drafts and the `APPROVED` and `CHANGES_REQUESTED` states, and sorts by additions plus deletions, then PR number, ascending. One match gates directly on the single-PR path; multiple matches enter pi's batch path, while Claude Code stops with the sorted `discovered, smallest first:` list. Every report shows the refreshed viewer `Role:` under `Delivers`, not in the batch summary row. See the [parent-child contract](skills/gatekeep-pr/reference/batch.md).
91
93
 
92
94
  The course, target/scope baseline, repair and sync usage, and poll deadline live in one OS temporary continuation record, whose path appears in the tracker or checklist and `show evidence`. Gatekeep initializes independent repair-round and sync budgets to `2 x closureReview.maxFixRounds` (default `5`, no-tool fallback `2 x 5`). Only source-changing repair waves spend rounds; conflict-only syncs spend the separate sync budget, and description-only work, watches, and re-reviews spend neither. Obstacle `deliver` extends each cap by `2 x` the resolved value without resetting usage; a timeout continuation instead opens one more watch window without extending caps. Only the gate's own push automatically renews the CI window (`timeout minutes`, default `15`). The verify-phase runtime guard still uses `1 x` the resolved value and can block a gatekeep dispatch earlier.
@@ -598,7 +600,7 @@ Anything a wrapper skill contains beyond trigger phrases is misplaced - move it
598
600
 
599
601
  Settled skill-owned turns carry a machine-readable first line identifying a gate, question, or terminal outcome. Programs answering these turns use the [gate inventory and reply contract](doc/gate-contract.md); the rendered row bodies define valid replies.
600
602
 
601
- 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.
603
+ Choose experimental loop options 4/5, configure the per-row allowance and worker model, and operate the planless ledger through [Loop engine operations](doc/configuration.md#loop-engine-operations). The menu shows the resolved allowance; `; allowance N` overrides it for one run. Resume preserves an existing allowance or migrates a legacy cap after pending recovery. Waves remains the default.
602
604
 
603
605
  | Want to ... | Read |
604
606
  |---|---|
@@ -613,7 +615,8 @@ The conformance gate's model, the spec council's roster, and the phase-tracker's
613
615
  | `closureReview.enforce` | phase-tracker | `true` | conformance gate on/off - [detail](./doc/configuration.md#conformance-gate-model) |
614
616
  | `closureReview.model` | phase-tracker | unset (inherits) | conformance reviewer model - [detail](./doc/configuration.md#conformance-gate-model) |
615
617
  | `closureReview.maxFixRounds` | phase-tracker | `5` | fix rounds before escalation - [detail](./doc/configuration.md#conformance-gate-model) |
616
- | `escalationLoop.implModel` | phase-tracker | unset (main loop) | implementer model on escalation - [detail](./doc/configuration.md#escalation-loop) |
618
+ | `escalationLoop.implModel` | phase-tracker | unset (main loop) | execution escalation under either loop option; review escalation remains separate - [detail](./doc/configuration.md#escalation-loop) |
619
+ | `loopEngine.maxIterationsPerRow` | phase-tracker | `4` (floor `1`) | fresh-ledger per-row allowance; run override on approval 4/5 - [detail](./doc/configuration.md#setting-and-command-resolution) |
617
620
  | `phaseModels.plan` / `.implement` / `.verify` / `.ship` | phase-tracker | unset (no switch) | `provider/id[:level]` applied persistently at that phase's start - [detail](./doc/configuration.md#phase-models) |
618
621
  | `flowGuards.enforce` | phase-tracker | `true` | phase gates on/off - [detail](./doc/configuration.md#phase-tracker) |
619
622
  | `flowGuards.specDirs` | phase-tracker, plan-tracker; telemetry and spec-index consumers | `["doc/specs", "docs/specs"]` | where specs and sibling plans live - [detail](./doc/configuration.md#phase-tracker) |
@@ -16,7 +16,7 @@ const ledger = (rows = ["1 | open | 0 |", "2 | cut | 0 | deferred: #70", "3 | do
16
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
17
 
18
18
  test("ledger admission bypasses task checks and permits empty open evidence", () => {
19
- for (const input of [ledger(), ledger().replace("iterations: 0\n", "iterations: 0\ncap: 97\n")]) {
19
+ for (const input of [ledger(), ...["cap: 97", "allowance: 4"].map(header => ledger().replace("iterations: 0\n", `iterations: 0\n${header}\n`))]) {
20
20
  assert.deepEqual(checkPlan(input, ledgerSpec, { exists: () => false, glob: () => [] }).findings, []);
21
21
  }
22
22
  });
@@ -1271,6 +1271,22 @@ const emptyPort: FsPort = { exists: () => false, glob: () => [] };
1271
1271
  test("coordinated ledger expands shared ownership and defaults unlisted ACs home", () => {
1272
1272
  assert.deepEqual(checkPlan(coordinatedLedger(), coordinatedSpec, emptyPort).findings, []);
1273
1273
  });
1274
+ test("coordinated ledger budget header admits allowance or legacy cap, never both", () => {
1275
+ for (const key of ["allowance", "cap"]) {
1276
+ for (const value of ["1", "4", "9007199254740991"]) {
1277
+ const input = coordinatedLedger().replace("cap: 20", `${key}: ${value}`);
1278
+ assert.deepEqual(checkPlan(input, coordinatedSpec, emptyPort).findings, [], `${key}: ${value}`);
1279
+ }
1280
+ for (const value of ["", "0", "-1", "1.5", "04", "four", "9007199254740992"]) {
1281
+ const input = coordinatedLedger().replace("cap: 20", `${key}: ${value}`);
1282
+ assert.ok(checkPlan(input, coordinatedSpec, emptyPort).findings.some(f => f.check === "ledger"), `${key}: ${value}`);
1283
+ }
1284
+ }
1285
+ for (const header of ["", "allowance: 4\nallowance: 4", "cap: 20\ncap: 20", "allowance: 4\ncap: 20"]) {
1286
+ const input = coordinatedLedger().replace("cap: 20", header);
1287
+ assert.ok(checkPlan(input, coordinatedSpec, emptyPort).findings.some(f => f.check === "ledger"), header);
1288
+ }
1289
+ });
1274
1290
  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"]) {
1275
1291
  test(`coordinated scope rejects ${JSON.stringify(scope)}`, () => {
1276
1292
  assert.ok(checkPlan(coordinatedLedger(), coordinatedSpec.replace("scope: AC 1, AC 2", scope), emptyPort).findings.some(f => f.check === "leg-scope"));
@@ -1205,9 +1205,10 @@ function coordinatedMetadata(lines: string[], legs: ReturnType<typeof parseLegs>
1205
1205
  const sha = /^[a-f0-9]{7,40}$/;
1206
1206
  const base = one("base", sha);
1207
1207
  const iterations = one("iterations", /^(?:0|[1-9]\d*)$/);
1208
- const cap = one("cap", /^[1-9]\d*$/);
1208
+ const budgets = [...header("allowance"), ...header("cap")];
1209
+ if (budgets.length !== 1) add(budgets[0]?.i ?? 0, "expected exactly one allowance or cap header");
1210
+ else if (!/^[1-9]\d*$/.test(budgets[0].value) || !Number.isSafeInteger(Number(budgets[0].value))) add(budgets[0].i, "allowance or cap must be a positive safe integer");
1209
1211
  if (!Number.isSafeInteger(Number(iterations))) add(0, "iterations must be a safe integer");
1210
- if (!Number.isSafeInteger(Number(cap))) add(0, "cap must be a safe integer");
1211
1212
  for (const [i, l] of lines.entries()) {
1212
1213
  const m = /^(base|seeded) ([^:]+):/.exec(l);
1213
1214
  if (m && !legs.some(leg => leg.repo === m[2].toLowerCase())) add(i, "foreign member header");
@@ -46,6 +46,18 @@ test("reopens match distinct string names, not position", () => {
46
46
  assert.equal(countReopens([{ status: "complete" }], [{ status: "in_progress" }]), 0);
47
47
  });
48
48
 
49
+ test("allowance label changes between ledger refreshes never change reopen identity", () => {
50
+ const before = [
51
+ { name: "row 3", status: "complete", label: "2/5" },
52
+ { name: "row 4 (repo-b)", status: "complete", label: "1/4" },
53
+ { name: "G1: check", status: "complete" },
54
+ ];
55
+ const relabeled = before.map(task => task.label ? { ...task, label: "2/6" } : task);
56
+ assert.equal(countReopens(before, relabeled), 0);
57
+ assert.equal(countReopens(before, relabeled.map(task => ({ ...task, status: "in_progress" }))), 3);
58
+ assert.equal(countReopens(before, relabeled.map(task => ({ ...task, status: "pending" }))), 0);
59
+ });
60
+
49
61
  test("insertedText joins edit newText values or returns write content", () => {
50
62
  assert.equal(insertedText({ path: "x", edits: [{ oldText: "a", newText: "b" }, { oldText: "c", newText: "d" }] }), "b\nd");
51
63
  assert.equal(insertedText({ path: "x", content: "body" }), "body");
@@ -536,7 +536,9 @@ test("ledger refresh completes only started implement and keeps failed, in-fligh
536
536
  for (const allCut of [true, false]) {
537
537
  const dir = tempCwd({ piGauntlet: { flowGuards: { enforce: false } } });
538
538
  const path = join(dir, "ledger.md");
539
- const save = (status: string, pending = "") => writeFileSync(path, `# Ledger - loop engine\n${pending}\n| row | status | attempts | evidence |\n|---|---|---|---|\n| 1 | ${status} | 0 | check |\n\n## Decisions\n`);
539
+ execFileSync("git", ["init", "-q", dir]);
540
+ writeFileSync(join(dir, "spec.md"), "# Spec\n\n## Acceptance criteria\n- [ ] The tracker shows a readable step\n in-scope\n");
541
+ const save = (status: string, pending = "") => writeFileSync(path, `# Ledger - loop engine\n**Spec:** \`spec.md\`\n${pending}\n| row | status | attempts | evidence |\n|---|---|---|---|\n| 1 | ${status} | 0 | check |\n\n## Decisions\n`);
540
542
  save(allCut ? "cut" : "failed");
541
543
  const branch = implementBranch().slice(0, -1);
542
544
  const h = harness({ cwd: dir, branch });
@@ -1,13 +1,14 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "node:test";
3
- import { mkdtempSync, writeFileSync } from "node:fs";
3
+ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { execFileSync } from "node:child_process";
4
5
  import { tmpdir } from "node:os";
5
6
  import { join } from "node:path";
6
7
  import registerPlanTracker, { validateSnapshot } from "./plan-tracker.ts";
7
8
 
8
9
  type ToolResult = {
9
10
  content: { type: string; text: string }[];
10
- details: { action: string; tasks: { name: string; status: string }[]; error?: string };
11
+ details: { action: string; tasks: { name: string; status: string; label?: string }[]; error?: string };
11
12
  };
12
13
 
13
14
  function harness(branch: unknown[] = [], cwd = process.cwd()) {
@@ -384,14 +385,25 @@ test("legacy-invalid snapshot: first update is rejected listing legacy offenders
384
385
  assert.deepEqual(repaired.details.tasks.map((t) => t.status), ["complete", "skipped", "complete"]);
385
386
  });
386
387
 
388
+ const descriptions = [
389
+ "Keep cut requirements visible", "Reject malformed allowance replies", "Verify loop behavior and recovery",
390
+ "Report failed checks", "Preserve completed work", "Start the next criterion",
391
+ ];
392
+ const spec = (criteria = descriptions) => `# Spec\n\n## Acceptance criteria\n${criteria.map(text => `- [ ] ${text}`).join("\n")}\n`;
393
+ function ledgerFixture(prefix: string) {
394
+ const dir = mkdtempSync(join(tmpdir(), prefix));
395
+ execFileSync("git", ["init", "-q", dir]);
396
+ writeFileSync(join(dir, "spec.md"), spec(descriptions.map(text => `${text}\n in-scope`)));
397
+ return dir;
398
+ }
387
399
  const ledger = (rows: string[], tail = "", pending = "") => [
388
- "# Ledger - loop engine", pending,
400
+ "# Ledger - loop engine", "**Spec:** `spec.md`", pending,
389
401
  "| row | status | attempts | evidence |", "|---|---|---|---|",
390
402
  ...rows.map(row => `| ${row} |`), "", "## Decisions", tail,
391
403
  ].join("\n");
392
404
 
393
405
  test("refresh projects ledger states, numeric pending targets and live leg mappings", async () => {
394
- const dir = mkdtempSync(join(tmpdir(), "plan-refresh-"));
406
+ const dir = ledgerFixture("plan-refresh-");
395
407
  const path = join(dir, "ledger.md");
396
408
  const cases = [
397
409
  {
@@ -425,7 +437,7 @@ test("refresh projects ledger states, numeric pending targets and live leg mappi
425
437
  });
426
438
 
427
439
  test("refresh replaces only whole ledger names, preserves Gn statuses and replays its snapshot", async () => {
428
- const dir = mkdtempSync(join(tmpdir(), "plan-refresh-kept-"));
440
+ const dir = ledgerFixture("plan-refresh-kept-");
429
441
  const path = join(dir, "ledger.md");
430
442
  writeFileSync(path, ledger(["1 | open | 0 |", "2 | open | 0 |", "3 | done | 1 | passed"], "row 3 done | passed | row 1"));
431
443
  const h = harness();
@@ -437,9 +449,9 @@ test("refresh replaces only whole ledger names, preserves Gn statuses and replay
437
449
  const result = await h.call({ action: "refresh", path });
438
450
  assert.equal(result.details.error, undefined);
439
451
  assert.deepEqual(result.details.tasks, [
440
- { name: "row 3", status: "complete" },
452
+ { name: "row 3", status: "complete", label: descriptions[2] },
441
453
  { name: "G1: closed", status: "complete" }, { name: "G2: working", status: "in_progress" },
442
- { name: "row 1", status: "pending" }, { name: "row 2", status: "pending" },
454
+ { name: "row 1", status: "pending", label: descriptions[0] }, { name: "row 2", status: "pending", label: descriptions[1] },
443
455
  { name: "row 4 notes", status: "pending" }, { name: "G3: pending", status: "pending" },
444
456
  ]);
445
457
  assert.deepEqual(validateSnapshot(result.details.tasks as any), []);
@@ -449,8 +461,235 @@ test("refresh replaces only whole ledger names, preserves Gn statuses and replay
449
461
  assert.equal(replay.widget(), h.widget());
450
462
  });
451
463
 
464
+ test("allowance labels count retry decisions, preserve identity and render on every status surface", async () => {
465
+ const dir = ledgerFixture("plan-allowance-");
466
+ const path = join(dir, "ledger.md");
467
+ const text = ledger([
468
+ "1 | cut | 3 | deferred: #1", "2 | done | 0 | baseline", "3 | open | 2 | check",
469
+ "4 | failed | 7 | failed", "5 | done | 1 | passed", "6 | open | 0 | check",
470
+ ], [
471
+ "row 3 retry | hint | allowed attempts 5", "row 3 retry | next | allowed attempts 99",
472
+ "row 3 escalated | implModel round | allowed attempts 100", "cap +20 | historical | cap 40",
473
+ "row 4 retry | hint | allowed attempts 8", "row 5 done | passed | row 3",
474
+ "## Notes", "row 3 retry | not a decision | allowed attempts 101",
475
+ "## Leg mapping", "| row | ac | leg |", "|---|---|---|",
476
+ "| 1 | 1 | repo-a |", "| 2 | 2 | repo-a |", "| 3 | 3 | repo-b |",
477
+ "| 4 | 4 | repo-a |", "| 5 | 5 | repo-b |", "| 6 | 6 | repo-a |",
478
+ ].join("\n"), "allowance: 4\npending: 3 | repo-b | abc1234");
479
+ writeFileSync(path, text);
480
+ const h = harness();
481
+ await h.call({ action: "init", tasks: [{ name: "G1: fixed", status: "complete" }, "2/5: G2: pending"] });
482
+ const result = await h.call({ action: "refresh", path });
483
+ assert.equal(result.details.error, undefined);
484
+ assert.deepEqual(result.details.tasks, [
485
+ { name: "row 1 (repo-a)", status: "skipped", label: `${descriptions[0]} (repo-a)` },
486
+ { name: "row 2 (repo-a)", status: "skipped", label: `${descriptions[1]} (repo-a)` },
487
+ { name: "row 3 (repo-b)", status: "in_progress", label: `2/6: ${descriptions[2]} (repo-b)` },
488
+ { name: "row 4 (repo-a)", status: "failed", label: `7/5: ${descriptions[3]} (repo-a)` },
489
+ { name: "row 5 (repo-b)", status: "complete", label: `1/4: ${descriptions[4]} (repo-b)` },
490
+ { name: "G1: fixed", status: "complete" },
491
+ { name: "row 6 (repo-a)", status: "pending", label: `0/4: ${descriptions[5]} (repo-a)` },
492
+ { name: "2/5: G2: pending", status: "pending" },
493
+ ]);
494
+ assert.match(h.widget()!, /2\/6: Verify loop behavior and recovery \(repo-b\)$/);
495
+ assert.doesNotMatch(h.widget()!, /row 3/);
496
+ assert.match(result.content[0].text, /\[2\] row 3 \(repo-b\) \| 2\/6: Verify loop behavior and recovery \(repo-b\)/);
497
+ const status = await h.call({ action: "status" });
498
+ assert.match(status.content[0].text, /\[4\] row 5 \(repo-b\) \| 1\/4: Preserve completed work \(repo-b\)/);
499
+ const rendered = h.tool().renderResult(status, {}, h.theme).text!;
500
+ assert.match(rendered, /row 3 \(repo-b\) \| 2\/6: Verify loop behavior and recovery \(repo-b\)/);
501
+ assert.match(rendered, /row 4 \(repo-a\) \| 7\/5: Report failed checks \(repo-a\)/);
502
+ const replay = harness([{ type: "message", message: { role: "toolResult", toolName: "plan_tracker", details: status.details } }]);
503
+ await replay.fire("session_start");
504
+ assert.equal(replay.widget(), h.widget());
505
+ assert.deepEqual((await replay.call({ action: "status" })).details.tasks, status.details.tasks);
506
+
507
+ writeFileSync(path, text.replace("allowance: 4", "allowance: 7"));
508
+ const raised = await h.call({ action: "refresh", path });
509
+ assert.deepEqual(raised.details.tasks.map(t => [t.name, t.status]), result.details.tasks.map(t => [t.name, t.status]));
510
+ assert.equal(raised.details.tasks.find(t => t.name === "row 3 (repo-b)")!.label, `2/9: ${descriptions[2]} (repo-b)`);
511
+ assert.equal(raised.details.tasks.find(t => t.name === "row 5 (repo-b)")!.label, `1/7: ${descriptions[4]} (repo-b)`);
512
+ assert.equal(raised.details.tasks.length, result.details.tasks.length);
513
+ writeFileSync(path, text.replace("allowance: 4", "cap: 49"));
514
+ const legacy = await h.call({ action: "refresh", path });
515
+ assert.equal(legacy.details.error, undefined);
516
+ assert.deepEqual(legacy.details.tasks.filter(t => t.name.startsWith("row ")).map(t => t.label),
517
+ descriptions.map((description, i) => `${description} (${[2, 4].includes(i) ? "repo-b" : "repo-a"})`));
518
+ });
519
+
520
+ test("allowance labels tolerate surrounding whitespace in the header value", async () => {
521
+ const dir = ledgerFixture("plan-allowance-whitespace-");
522
+ const path = join(dir, "ledger.md");
523
+ const h = harness();
524
+ const labels = [];
525
+ for (const header of ["allowance: 4", "allowance: 4", "allowance: 4 "]) {
526
+ writeFileSync(path, ledger(["1 | open | 0 |"], "", header));
527
+ const result = await h.call({ action: "refresh", path });
528
+ assert.equal(result.details.error, undefined, header);
529
+ labels.push(result.details.tasks[0].label);
530
+ }
531
+ assert.deepEqual(labels, Array(3).fill(`0/4: ${descriptions[0]}`));
532
+ });
533
+
534
+ test("single-repo labels are projection-only and cannot enter through init or add", async () => {
535
+ const dir = ledgerFixture("plan-label-input-");
536
+ const path = join(dir, "ledger.md");
537
+ writeFileSync(path, ledger(["3 | open | 2 | check"], "row 3 retry | hint | allowed attempts 99", "allowance: 4"));
538
+ const h = harness();
539
+ const projected = await h.call({ action: "refresh", path });
540
+ assert.deepEqual(projected.details.tasks, [{ name: "row 3", status: "pending", label: `2/5: ${descriptions[2]}` }]);
541
+ assert.match(h.widget()!, /2\/5: Verify loop behavior and recovery$/);
542
+ const init = await h.call({ action: "init", tasks: [{ name: "row 3", status: "in_progress", label: "9/9" }] });
543
+ assert.deepEqual(init.details.tasks, [{ name: "row 3", status: "in_progress" }]);
544
+ const added = await h.call({ action: "add", tasks: [{ name: "G1: check", status: "pending", label: "9/9" }] });
545
+ assert.ok(added.details.tasks.every(t => !Object.hasOwn(t, "label")));
546
+ const objectSchema = h.parameters().args[0].tasks.args[0].args[0].args[0][1];
547
+ assert.deepEqual(Object.keys(objectSchema.args[0]), ["name", "status"]);
548
+ });
549
+
550
+ test("refresh resolves the owning root, normalizes original AC text, and keeps labels stable across status changes", async () => {
551
+ const dir = ledgerFixture("plan-descriptions-");
552
+ const nested = join(dir, "doc", "plans");
553
+ mkdirSync(nested, { recursive: true });
554
+ const path = join(nested, "ledger.md");
555
+ writeFileSync(join(dir, "spec.md"), [
556
+ "# Spec", "```md", "## Acceptance criteria", "- [ ] Not a criterion", "```",
557
+ "## Acceptance Criteria", "- [x] Keep cut requirements", " visible",
558
+ " deferred: #1", " disposition continuation", "- [ ] Reject malformed allowance replies",
559
+ " elsewhere: repo-b#2", "- [ ] Verify loop behavior", " and recovery | row 99 is only text",
560
+ " venue: staging - observe completion", "## Design", "- [ ] Not an acceptance criterion",
561
+ ].join("\n"));
562
+ const text = ledger(["1 | cut | 0 | deferred", "2 | done | 0 | baseline", "3 | open | 2 | check"], "", "allowance: 4");
563
+ writeFileSync(path, text);
564
+ const h = harness([], nested);
565
+ const first = await h.call({ action: "refresh", path: "ledger.md" });
566
+ assert.equal(first.details.error, undefined);
567
+ assert.deepEqual(first.details.tasks.map(t => t.label), [
568
+ "Keep cut requirements visible", descriptions[1], "2/4: Verify loop behavior and recovery | row 99 is only text",
569
+ ]);
570
+ assert.match(first.content[0].text, /row 3 \| 2\/4: Verify loop behavior and recovery \| row 99 is only text/);
571
+ for (const [status, tail, pending] of [
572
+ ["failed", "", ""], ["failed", "", "pending: 3 | home | abc1234"],
573
+ ["done", "row 3 done | passed | none", ""],
574
+ ]) {
575
+ writeFileSync(path, text.replace("| 3 | open", `| 3 | ${status}`).replace("allowance: 4", `allowance: 4\n${pending}`) + tail);
576
+ const next = await h.call({ action: "refresh", path });
577
+ assert.equal(next.details.error, undefined);
578
+ assert.deepEqual(next.details.tasks.map(t => [t.name, t.label]), first.details.tasks.map(t => [t.name, t.label]));
579
+ }
580
+ writeFileSync(join(dir, "spec.md"), spec([descriptions[0], descriptions[1], "Verify amended behavior"]));
581
+ const amended = await h.call({ action: "refresh", path });
582
+ assert.equal(amended.details.tasks[2].name, "row 3");
583
+ assert.equal(amended.details.tasks[2].label, "2/4: Verify amended behavior");
584
+ assert.equal(amended.details.tasks.length, 3);
585
+ });
586
+
587
+ test("coordinated labels use mapped original AC indices, including shared criteria", async () => {
588
+ const dir = ledgerFixture("plan-original-ac-");
589
+ const path = join(dir, "ledger.md");
590
+ writeFileSync(path, ledger(["1 | cut | 0 | cut", "2 | open | 1 | check", "3 | open | 0 | check"], [
591
+ "## Leg mapping", "| row | ac | leg |", "|---|---|---|",
592
+ "| 1 | 2 | repo-a |", "| 2 | 3 | repo-a |", "| 3 | 3 | repo-b |",
593
+ ].join("\n"), "allowance: 4"));
594
+ const result = await harness().call({ action: "refresh", path });
595
+ assert.equal(result.details.error, undefined);
596
+ assert.deepEqual(result.details.tasks.map(t => [t.name, t.label]), [
597
+ ["row 1 (repo-a)", `${descriptions[1]} (repo-a)`],
598
+ ["row 2 (repo-a)", `1/4: ${descriptions[2]} (repo-a)`],
599
+ ["row 3 (repo-b)", `0/4: ${descriptions[2]} (repo-b)`],
600
+ ]);
601
+ });
602
+
603
+ test("refresh rejects an unresolvable repository root without changing initialized tasks", async (t) => {
604
+ const dir = mkdtempSync(join(tmpdir(), "plan-no-checkout-"));
605
+ try {
606
+ execFileSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { stdio: "ignore" });
607
+ t.skip("temporary directory is inside a git repository");
608
+ return;
609
+ } catch {}
610
+ writeFileSync(join(dir, "spec.md"), spec(["Keep prior tasks"]));
611
+ const path = join(dir, "ledger.md");
612
+ writeFileSync(path, ledger(["1 | open | 0 |"], "", "allowance: 4"));
613
+ const h = harness([], dir);
614
+ const initial = await h.call({ action: "init", tasks: [{ name: "G1: closed", status: "complete" }, "G2: pending"] });
615
+ const result = await h.call({ action: "refresh", path });
616
+ assert.match(result.details.error!, /cannot resolve repository root for Spec reference/);
617
+ assert.equal(result.details.tasks, undefined);
618
+ assert.deepEqual((await h.call({ action: "status" })).details.tasks, initial.details.tasks);
619
+ });
620
+
621
+ test("refresh rejects missing specs, missing criteria and empty descriptions atomically", async () => {
622
+ const dir = ledgerFixture("plan-spec-errors-");
623
+ const path = join(dir, "ledger.md");
624
+ const valid = ledger(["1 | open | 0 | check"], "", "allowance: 4");
625
+ writeFileSync(path, valid);
626
+ const h = harness();
627
+ const initial = await h.call({ action: "refresh", path });
628
+ assert.equal(initial.details.error, undefined);
629
+ const widget = h.widget();
630
+ const cases = [
631
+ { text: valid.replace("**Spec:** `spec.md`", ""), spec: spec(), error: /missing Spec reference/ },
632
+ { text: valid.replace("`spec.md`", "`absent.md`"), spec: spec(), error: /cannot read spec .*absent\.md:/ },
633
+ { text: valid, spec: "# Spec\n## Design\n- [ ] Not an AC", error: /row 1: spec .*spec\.md AC 1 is missing/ },
634
+ { text: valid, spec: spec([" \n deferred: #1"]), error: /row 1: spec .*spec\.md AC 1 has an empty description/ },
635
+ { text: ledger(["7 | open | 0 | check"]), spec: spec(), error: /row 7: spec .*spec\.md AC 7 is missing/ },
636
+ { text: valid + "\n## Leg mapping\n| row | ac | leg |\n|---|---|---|\n| 1 | 9 | repo-a |", spec: spec(), error: /row 1: spec .*spec\.md AC 9 is missing/ },
637
+ ];
638
+ for (const row of cases) {
639
+ writeFileSync(path, row.text);
640
+ writeFileSync(join(dir, "spec.md"), row.spec);
641
+ const result = await h.call({ action: "refresh", path });
642
+ assert.match(result.details.error!, row.error);
643
+ assert.equal(result.details.tasks, undefined);
644
+ assert.equal(h.widget(), widget);
645
+ assert.deepEqual((await h.call({ action: "status" })).details.tasks, initial.details.tasks);
646
+ }
647
+ });
648
+
649
+ test("status rows round-trip through the brief identity grammar without description drift or duplicate tasks", async () => {
650
+ const contract = readFileSync(new URL("../skills/gauntlet-resume/reference/brief-contract.md", import.meta.url), "utf8");
651
+ const suffix = new RegExp(contract.match(/suffix pattern `([^`]+)`/)![1]);
652
+ const prefix = new RegExp(contract.match(/prefix pattern `([^`]+)`/)![1]);
653
+ const canonical = new RegExp(contract.match(/canonical pattern `([^`]+)`/)![1]);
654
+ const restoreName = (name: string) => {
655
+ const labeled = suffix.exec(name);
656
+ if (labeled) return labeled[1];
657
+ const plain = name.replace(prefix, "");
658
+ return canonical.test(plain) ? plain : name;
659
+ };
660
+ for (const [input, expected] of [
661
+ ["row 3", "row 3"], ["2/5: row 3", "row 3"], ["2/5: row 3 (repo-b)", "row 3 (repo-b)"],
662
+ ["row 3 (repo-b) | 2/5: Verify | row 99", "row 3 (repo-b)"],
663
+ ["2/5: G3: pending", "2/5: G3: pending"], ["G1: row 3 | description", "G1: row 3 | description"],
664
+ ["row 3 notes | description", "row 3 notes | description"],
665
+ ]) assert.equal(restoreName(input), expected);
666
+ const dir = ledgerFixture("plan-brief-roundtrip-");
667
+ const path = join(dir, "ledger.md");
668
+ writeFileSync(join(dir, "spec.md"), spec([descriptions[0], descriptions[1], `${descriptions[2]} | row 99 is text`]));
669
+ const text = ledger(["2 | cut | 0 | cut", "3 | done | 2 | passed"], "row 3 done | passed | none", "allowance: 5");
670
+ writeFileSync(path, text);
671
+ const h = harness();
672
+ await h.call({ action: "init", tasks: [{ name: "G1: failed", status: "failed" }, { name: "G2: repair", status: "in_progress" }, "2/5: G3: pending"] });
673
+ await h.call({ action: "refresh", path });
674
+ const status = await h.call({ action: "status" });
675
+ const brief = status.content[0].text;
676
+ const statuses: Record<string, string> = { "\u2713": "complete", "\u2192": "in_progress", "\u2717": "failed", "\u2298": "skipped", "\u25cb": "pending" };
677
+ const restored = [...brief.matchAll(/^\s*([\u2713\u2192\u2717\u2298\u25cb]) \[\d+\] (.+)$/gm)]
678
+ .map(([, glyph, name]) => ({ name: restoreName(name), status: statuses[glyph] }));
679
+ assert.deepEqual(restored, status.details.tasks.map(({ name, status }) => ({ name, status })));
680
+ const resumed = harness();
681
+ const init = await resumed.call({ action: "init", tasks: restored });
682
+ assert.equal(init.details.error, undefined);
683
+ assert.ok(init.details.tasks.every(t => !Object.hasOwn(t, "label")));
684
+ writeFileSync(path, text.replace("allowance: 5", "allowance: 6"));
685
+ const refreshed = await resumed.call({ action: "refresh", path });
686
+ assert.deepEqual(refreshed.details.tasks.map(({ name, status }) => ({ name, status })), restored);
687
+ assert.match(refreshed.content[0].text, /row 3 \| 2\/6: Verify loop behavior and recovery \| row 99 is text/);
688
+ assert.equal(refreshed.details.tasks.filter(t => t.name === "row 3").length, 1);
689
+ });
690
+
452
691
  test("refresh rejects unreadable or malformed ledgers atomically, including absent numeric pending rows", async () => {
453
- const dir = mkdtempSync(join(tmpdir(), "plan-refresh-error-"));
692
+ const dir = ledgerFixture("plan-refresh-error-");
454
693
  const path = join(dir, "ledger.md");
455
694
  const valid = ledger(["1 | open | 0 |"]);
456
695
  const h = harness();
@@ -461,8 +700,9 @@ test("refresh rejects unreadable or malformed ledgers atomically, including abse
461
700
  { name: "unreadable path", path: join(dir, "absent.md"), text: valid, error: /refresh: cannot read .*absent\.md:/ },
462
701
  { name: "wrong discriminator", path, text: "# Plan", error: /line 1 is "# Plan", not a loop ledger/ },
463
702
  { name: "missing header", path, text: valid.replace("| row | status | attempts | evidence |", "| row | state | attempts | evidence |"), error: /row table malformed at line \d+/ },
464
- { name: "nonnumeric row", path, text: valid.replace("| 1 | open", "| x | open"), error: /row table malformed at line 5/ },
465
- { name: "invalid status", path, text: valid.replace("| 1 | open", "| 1 | unknown"), error: /row table malformed at line 5/ },
703
+ { name: "nonnumeric row", path, text: valid.replace("| 1 | open", "| x | open"), error: /row table malformed at line 6/ },
704
+ { name: "invalid status", path, text: valid.replace("| 1 | open", "| 1 | unknown"), error: /row table malformed at line 6/ },
705
+ ...["x", "-1", "1.5", ""].map(attempts => ({ name: `invalid attempts ${attempts}`, path, text: valid.replace("| 0 |", `| ${attempts} |`), error: /row table malformed at line 6/ })),
466
706
  { name: "absent pending row", path, text: "pending: 2 | home | abc1234", error: /pending: names row 2 which is absent/ },
467
707
  { name: "zero pending row", path, text: "pending: 0 | home | abc1234", error: /pending: names row 0 which is absent/ },
468
708
  { name: "leading-zero absent row", path, text: "pending: 02 | home | abc1234", error: /pending: names row 2 which is absent/ },