pi-gauntlet 8.0.1 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +6 -4
  3. package/agents/implementer.md +5 -2
  4. package/agents/loop-implementer.md +7 -5
  5. package/agents/plan-writer.md +3 -3
  6. package/agents/spec-council-member.md +1 -1
  7. package/bin/gauntlet-performance.mjs +10 -1
  8. package/bin/gauntlet-performance.test.mjs +85 -5
  9. package/bin/gauntlet-telemetry-seal.mjs +1 -1
  10. package/extensions/lib/plan-check.test.ts +4 -2
  11. package/extensions/lib/plan-check.ts +1 -1
  12. package/extensions/lib/telemetry-collect.test.ts +13 -6
  13. package/extensions/lib/telemetry-collect.ts +4 -3
  14. package/extensions/lib/telemetry-record.test.ts +14 -0
  15. package/extensions/lib/telemetry-record.ts +2 -2
  16. package/extensions/phase-tracker.test.ts +45 -0
  17. package/extensions/plan-tracker.test.ts +100 -1
  18. package/extensions/plan-tracker.ts +99 -3
  19. package/extensions/telemetry.test.ts +103 -0
  20. package/extensions/telemetry.ts +19 -6
  21. package/package.json +4 -3
  22. package/skills/brainstorming/reference/amendment-surface.md +3 -3
  23. package/skills/brainstorming/reference/spec-finalization.md +8 -5
  24. package/skills/forge-skill/SKILL.md +1 -1
  25. package/skills/gauntlet-handoff/SKILL.md +1 -1
  26. package/skills/gauntlet-resume/SKILL.md +4 -5
  27. package/skills/gauntlet-resume/reference/brief-contract.md +6 -6
  28. package/skills/gauntlet-resume/reference/reconstruction.md +28 -6
  29. package/skills/loop-implementation/SKILL.md +22 -27
  30. package/skills/loop-implementation/reference/coordinated-legs.md +4 -14
  31. package/skills/subagent-driven-development/SKILL.md +23 -8
  32. package/skills/subagent-driven-development/implementer-prompt.md +1 -1
  33. package/skills/subagent-driven-development/reference/dispatch-contract.md +3 -3
  34. package/skills/subagent-driven-development/reference/implement-amendments.md +3 -3
  35. package/skills/writing-plans/SKILL.md +5 -5
  36. package/skills/writing-plans/reference/plan-contract.md +1 -1
@@ -8,6 +8,7 @@ import { createHash } from "node:crypto";
8
8
  import { tmpdir } from "node:os";
9
9
  import { isAbsolute, join } from "node:path";
10
10
  import registerPhaseTracker from "./phase-tracker.ts";
11
+ import registerPlanTracker from "./plan-tracker.ts";
11
12
 
12
13
  const originalSubagentDepth = process.env.PI_SUBAGENT_DEPTH;
13
14
  process.env.PI_SUBAGENT_DEPTH = "0";
@@ -531,6 +532,50 @@ test("plan activity auto-completes implement with complete+skipped, not with fai
531
532
  assert.equal(await implementStatus([{ status: "complete" }, { status: "skipped" }], true), "in_progress");
532
533
  });
533
534
 
535
+ test("ledger refresh completes only started implement and keeps failed, in-flight and Gn work open", async () => {
536
+ for (const allCut of [true, false]) {
537
+ const dir = tempCwd({ piGauntlet: { flowGuards: { enforce: false } } });
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`);
540
+ save(allCut ? "cut" : "failed");
541
+ const branch = implementBranch().slice(0, -1);
542
+ const h = harness({ cwd: dir, branch });
543
+ registerPlanTracker(h.pi as any);
544
+ await h.emit("session_start");
545
+ const tracker = h.tools.find(t => t.name === "plan_tracker")!;
546
+ const phase = h.tools.find(t => t.name === "phase_tracker")!;
547
+ const status = async () => (await phase.execute("status", { action: "status" }, undefined, undefined, h.ctx) as any).details.phases.implement.status;
548
+ const refresh = async (target = path) => {
549
+ const result = await tracker.execute("refresh", { action: "refresh", path: target }, undefined, undefined, h.ctx) as any;
550
+ branch.push({ type: "message", message: { role: "toolResult", toolName: "plan_tracker", details: result.details } } as any);
551
+ await h.emitEvent("tool_execution_end", { toolName: "plan_tracker", result });
552
+ return result;
553
+ };
554
+ assert.equal((await refresh()).details.error, undefined);
555
+ assert.equal(await status(), "pending", "refresh before start cannot complete implement");
556
+ assert.equal((await startPhase(h, "implement")).status, "in_progress");
557
+ await refresh();
558
+ if (!allCut) {
559
+ assert.equal(await status(), "in_progress", "failed row keeps implement active");
560
+ save("failed", "pending: 1 | home | abc1234");
561
+ const inFlight = await refresh();
562
+ assert.equal(inFlight.details.tasks[0].status, "in_progress");
563
+ assert.equal(await status(), "in_progress");
564
+ assert.ok((await refresh(join(dir, "missing.md"))).details.error);
565
+ assert.equal(await status(), "in_progress", "invalid refresh cannot complete implement");
566
+ await tracker.execute("add", { action: "add", tasks: ["G1: conformance"] }, undefined, undefined, h.ctx);
567
+ save("done");
568
+ const withGap = await refresh();
569
+ assert.equal(withGap.details.tasks[1].status, "pending");
570
+ assert.equal(await status(), "in_progress", "kept Gn task blocks completion");
571
+ await tracker.execute("update", { action: "update", index: 1, status: "complete" }, undefined, undefined, h.ctx);
572
+ await refresh();
573
+ }
574
+ assert.equal(await status(), "complete");
575
+ assert.equal((await startPhase(h, "verify")).status, "in_progress");
576
+ }
577
+ });
578
+
534
579
  test("cold implement and verify completions ignore unfinished snapshots", async () => {
535
580
  for (const phase of ["implement", "verify"] as const) {
536
581
  const h = harness({
@@ -1,5 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "node:test";
3
+ import { mkdtempSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
3
6
  import registerPlanTracker, { validateSnapshot } from "./plan-tracker.ts";
4
7
 
5
8
  type ToolResult = {
@@ -7,7 +10,7 @@ type ToolResult = {
7
10
  details: { action: string; tasks: { name: string; status: string }[]; error?: string };
8
11
  };
9
12
 
10
- function harness(branch: unknown[] = []) {
13
+ function harness(branch: unknown[] = [], cwd = process.cwd()) {
11
14
  const tools: {
12
15
  name: string;
13
16
  description: string;
@@ -29,6 +32,7 @@ function harness(branch: unknown[] = []) {
29
32
  let widgetText: string | undefined;
30
33
  const theme = { fg: (_c: string, s: string) => s, bold: (s: string) => s };
31
34
  const ctx = {
35
+ cwd,
32
36
  hasUI: true,
33
37
  ui: {
34
38
  setWidget(_id: string, cb?: (tui: unknown, theme: unknown) => { text: string }) {
@@ -380,6 +384,101 @@ test("legacy-invalid snapshot: first update is rejected listing legacy offenders
380
384
  assert.deepEqual(repaired.details.tasks.map((t) => t.status), ["complete", "skipped", "complete"]);
381
385
  });
382
386
 
387
+ const ledger = (rows: string[], tail = "", pending = "") => [
388
+ "# Ledger - loop engine", pending,
389
+ "| row | status | attempts | evidence |", "|---|---|---|---|",
390
+ ...rows.map(row => `| ${row} |`), "", "## Decisions", tail,
391
+ ].join("\n");
392
+
393
+ test("refresh projects ledger states, numeric pending targets and live leg mappings", async () => {
394
+ const dir = mkdtempSync(join(tmpdir(), "plan-refresh-"));
395
+ const path = join(dir, "ledger.md");
396
+ const cases = [
397
+ {
398
+ name: "cut, baseline, dispatched, failed, in flight and pending without cap",
399
+ text: ledger(["1 | cut | 0 | deferred: #1", "2 | done | 0 | baseline", "3 | done | 1 | check \\| passed", "4 | failed | 1 | check failed", "5 | open | 0 |", "6 | open | 0 |"], "row 3 done | passed | row 4\n## Notes\nrow 2 done | not a decision | none", "pending: 5 | home | abc1234"),
400
+ expected: [["row 1", "skipped"], ["row 2", "skipped"], ["row 3", "complete"], ["row 4", "failed"], ["row 5", "in_progress"], ["row 6", "pending"]],
401
+ },
402
+ { name: "retry overrides failed", text: ledger(["1 | failed | 1 | failed"], "", "pending: 1 | home | abc1234"), expected: [["row 1", "in_progress"]] },
403
+ { name: "leading-zero target names its numeric row", text: ledger(["1 | open | 0 |"], "", "pending: 01 | home | abc1234"), expected: [["row 1", "in_progress"]] },
404
+ { name: "done beats stale pending", text: ledger(["1 | done | 1 | passed"], "row 1 done | passed | none", "pending: 1 | home | abc1234"), expected: [["row 1", "complete"]] },
405
+ { name: "cut beats stale pending and all cut are skipped", text: ledger(["1 | cut | 0 | deferred: #1"], "", "pending: 1 | home | abc1234"), expected: [["row 1", "skipped"]] },
406
+ { name: "seed names no row", text: ledger(["1 | failed | 1 | failed", "2 | open | 0 |"], "", "pending: seed | home | abc1234"), expected: [["row 1", "failed"], ["row 2", "pending"]] },
407
+ {
408
+ name: "mapped home and companion tasks sort non-pending first",
409
+ text: ledger(["1 | open | 0 |", "2 | done | 1 | passed", "3 | failed | 1 | failed"], "row 2 done | passed | row 3\n\n## Leg mapping\n| row | ac | leg |\n|---|---|---|\n| 1 | 1 | repo-a |\n| 2 | 1 | repo-b |\n| 3 | 2 | repo-a |", "pending: 3 | repo-a | abc1234"),
410
+ expected: [["row 2 (repo-b)", "complete"], ["row 3 (repo-a)", "in_progress"], ["row 1 (repo-a)", "pending"]],
411
+ },
412
+ ];
413
+ for (const row of cases) {
414
+ writeFileSync(path, row.text);
415
+ const h = harness([], dir);
416
+ const result = await h.call({ action: "refresh", path: "ledger.md" });
417
+ assert.equal(result.details.error, undefined, row.name);
418
+ assert.equal(result.details.action, "refresh", row.name);
419
+ assert.deepEqual(result.details.tasks.map(t => [t.name, t.status]), row.expected, row.name);
420
+ assert.deepEqual(validateSnapshot(result.details.tasks as any), [], row.name);
421
+ assert.deepEqual((await h.call({ action: "status" })).details.tasks, result.details.tasks, row.name);
422
+ const done = result.details.tasks.filter(t => ["complete", "skipped"].includes(t.status)).length;
423
+ assert.ok(h.widget()!.includes(`(${done}/${row.expected.length})`), row.name);
424
+ }
425
+ });
426
+
427
+ test("refresh replaces only whole ledger names, preserves Gn statuses and replays its snapshot", async () => {
428
+ const dir = mkdtempSync(join(tmpdir(), "plan-refresh-kept-"));
429
+ const path = join(dir, "ledger.md");
430
+ writeFileSync(path, ledger(["1 | open | 0 |", "2 | open | 0 |", "3 | done | 1 | passed"], "row 3 done | passed | row 1"));
431
+ const h = harness();
432
+ await h.call({ action: "init", tasks: [
433
+ { name: "row 99", status: "complete" }, { name: "row 8 (old-repo)", status: "failed" },
434
+ { name: "G1: closed", status: "complete" }, { name: "G2: working", status: "in_progress" },
435
+ "row 4 notes", "G3: pending",
436
+ ] });
437
+ const result = await h.call({ action: "refresh", path });
438
+ assert.equal(result.details.error, undefined);
439
+ assert.deepEqual(result.details.tasks, [
440
+ { name: "row 3", status: "complete" },
441
+ { name: "G1: closed", status: "complete" }, { name: "G2: working", status: "in_progress" },
442
+ { name: "row 1", status: "pending" }, { name: "row 2", status: "pending" },
443
+ { name: "row 4 notes", status: "pending" }, { name: "G3: pending", status: "pending" },
444
+ ]);
445
+ assert.deepEqual(validateSnapshot(result.details.tasks as any), []);
446
+ const replay = harness([{ type: "message", message: { role: "toolResult", toolName: "plan_tracker", details: result.details } }]);
447
+ await replay.fire("session_start");
448
+ assert.deepEqual((await replay.call({ action: "status" })).details.tasks, result.details.tasks);
449
+ assert.equal(replay.widget(), h.widget());
450
+ });
451
+
452
+ test("refresh rejects unreadable or malformed ledgers atomically, including absent numeric pending rows", async () => {
453
+ const dir = mkdtempSync(join(tmpdir(), "plan-refresh-error-"));
454
+ const path = join(dir, "ledger.md");
455
+ const valid = ledger(["1 | open | 0 |"]);
456
+ const h = harness();
457
+ const initial = await h.call({ action: "init", tasks: [{ name: "G1: closed", status: "complete" }, "G2: pending"] });
458
+ const widget = h.widget();
459
+ const cases = [
460
+ { name: "missing path", path: undefined, text: valid, error: /refresh: cannot read .*path required/ },
461
+ { name: "unreadable path", path: join(dir, "absent.md"), text: valid, error: /refresh: cannot read .*absent\.md:/ },
462
+ { name: "wrong discriminator", path, text: "# Plan", error: /line 1 is "# Plan", not a loop ledger/ },
463
+ { 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/ },
466
+ { name: "absent pending row", path, text: "pending: 2 | home | abc1234", error: /pending: names row 2 which is absent/ },
467
+ { name: "zero pending row", path, text: "pending: 0 | home | abc1234", error: /pending: names row 0 which is absent/ },
468
+ { name: "leading-zero absent row", path, text: "pending: 02 | home | abc1234", error: /pending: names row 2 which is absent/ },
469
+ { name: "absent mapping row", path, text: valid + "\n## Leg mapping\n| row | ac | leg |\n|---|---|---|\n| 2 | 1 | repo-a |", error: /leg mapping names row 2 which is absent/ },
470
+ ];
471
+ for (const row of cases) {
472
+ writeFileSync(path, row.text.startsWith("pending:") ? ledger(["1 | open | 0 |"], "", row.text) : row.text);
473
+ const result = await h.call({ action: "refresh", path: row.path });
474
+ assert.match(result.details.error!, row.error, row.name);
475
+ assert.equal(result.details.tasks, undefined, row.name);
476
+ assert.equal(h.widget(), widget, row.name);
477
+ assert.deepEqual((await h.call({ action: "status" })).details.tasks, initial.details.tasks, row.name);
478
+ if (row.path) assert.ok(result.content[0].text.includes(row.path), row.name);
479
+ }
480
+ });
481
+
383
482
  test("registration: sequential, tasks schema is Array<Union<String, Object>>, description carries the contract", () => {
384
483
  const { executionMode, parameters, description } = harness();
385
484
  assert.equal(executionMode(), "sequential");
@@ -6,6 +6,8 @@
6
6
  * Shows a persistent TUI widget above the editor.
7
7
  */
8
8
 
9
+ import { readFileSync } from "node:fs";
10
+ import { resolve } from "node:path";
9
11
  import { StringEnum } from "@earendil-works/pi-ai";
10
12
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
11
13
  import { Text } from "@earendil-works/pi-tui";
@@ -15,10 +17,11 @@ const TASK_STATUSES = ["pending", "in_progress", "complete", "failed", "skipped"
15
17
  type TaskStatus = (typeof TASK_STATUSES)[number];
16
18
 
17
19
  interface Task { name: string; status: TaskStatus; }
18
- interface PlanTrackerDetails { action: "init" | "add" | "update" | "status" | "clear"; tasks: Task[]; error?: string; }
20
+ interface PlanTrackerDetails { action: "init" | "add" | "update" | "status" | "clear" | "refresh"; tasks: Task[]; error?: string; }
19
21
 
20
22
  const PlanTrackerParams = Type.Object({
21
- action: StringEnum(["init", "add", "update", "status", "clear"] as const, { description: "Action to perform" }),
23
+ action: StringEnum(["init", "add", "update", "status", "clear", "refresh"] as const, { description: "Action to perform" }),
24
+ path: Type.Optional(Type.String({ description: "Artifact path for refresh; relative paths resolve against the session cwd" })),
22
25
  tasks: Type.Optional(Type.Array(Type.Union([
23
26
  Type.String(),
24
27
  Type.Object({ name: Type.String(), status: StringEnum(TASK_STATUSES, { description: "Status to recreate the task with (init only)" }) }),
@@ -40,6 +43,62 @@ export function validateSnapshot(tasks: Task[]): number[] {
40
43
  return offenders;
41
44
  }
42
45
 
46
+ function ledgerTasks(text: string, path: string): Task[] {
47
+ const lines = text.split("\n");
48
+ if (lines[0] !== "# Ledger - loop engine") {
49
+ throw new Error(`refresh: ${path} line 1 is ${JSON.stringify(lines[0])}, not a loop ledger`);
50
+ }
51
+ const malformed = (index: number): never => { throw new Error(`refresh: ${path} row table malformed at line ${index + 1}`); };
52
+ const header = lines.findIndex(line => /^\|\s*row\s*\|\s*status\s*\|\s*attempts\s*\|\s*evidence\s*\|$/.test(line));
53
+ if (header < 0) malformed(0);
54
+ if (!/^\|\s*-+\s*\|\s*-+\s*\|\s*-+\s*\|\s*-+\s*\|$/.test(lines[header + 1] ?? "")) malformed(header + 1);
55
+ const rows: { row: number; status: string }[] = [];
56
+ for (let i = header + 2; i < lines.length && lines[i].trim() && !/^#{1,2}\s/.test(lines[i]); i++) {
57
+ const cells = lines[i].split(/(?<!\\)\|/).slice(1, -1).map(cell => cell.trim());
58
+ if (!lines[i].startsWith("|") || !lines[i].endsWith("|") || cells.length !== 4 || !/^\d+$/.test(cells[0]) || !/^(open|done|failed|cut)$/.test(cells[1])) malformed(i);
59
+ rows.push({ row: Number(cells[0]), status: cells[1] });
60
+ }
61
+ if (!rows.length) malformed(header + 2);
62
+ const byRow = new Map(rows.map(row => [row.row, row]));
63
+ const pendingMatch = lines.map(line => /^pending: (seed|\d+) \| (\S+) \| ([a-f0-9]{7,40})$/.exec(line)).find(Boolean);
64
+ const pending = pendingMatch && pendingMatch[1] !== "seed" ? Number(pendingMatch[1]) : undefined;
65
+ if (pending !== undefined && !byRow.has(pending)) {
66
+ throw new Error(`refresh: ${path} pending: names row ${pending} which is absent`);
67
+ }
68
+ const dispatched = new Set<number>();
69
+ let mapping: { row: number; repo: string }[] | undefined;
70
+ let section = "";
71
+ for (const line of lines) {
72
+ if (/^#{1,2}\s/.test(line)) {
73
+ section = line;
74
+ if (section === "## Leg mapping") mapping = [];
75
+ continue;
76
+ }
77
+ if (section === "## Decisions") {
78
+ const decision = /^row (\d+) done \| .+ \| .+$/.exec(line);
79
+ if (decision) dispatched.add(Number(decision[1]));
80
+ } else if (section === "## Leg mapping") {
81
+ const entry = /^\|\s*(\d+)\s*\|\s*\d+\s*\|\s*(\S+)\s*\|$/.exec(line);
82
+ if (!entry) continue;
83
+ const row = Number(entry[1]);
84
+ if (!byRow.has(row)) throw new Error(`refresh: ${path} leg mapping names row ${row} which is absent`);
85
+ mapping!.push({ row, repo: entry[2] });
86
+ }
87
+ }
88
+ const status = (row: number): TaskStatus => {
89
+ const value = byRow.get(row)!.status;
90
+ if (value === "cut") return "skipped";
91
+ if (value === "done") return dispatched.has(row) ? "complete" : "skipped";
92
+ if (row === pending) return "in_progress";
93
+ return value === "failed" ? "failed" : "pending";
94
+ };
95
+ const entries = mapping ?? rows.map(({ row }) => ({ row, repo: undefined }));
96
+ return entries.sort((a, b) => a.row - b.row).map(({ row, repo }) => ({
97
+ name: `row ${row}${repo === undefined ? "" : ` (${repo})`}`,
98
+ status: status(row),
99
+ }));
100
+ }
101
+
43
102
  const RULE_LINE = "Rule: pending tasks must trail every started or finished task.";
44
103
  const snapshotLine = (tasks: Task[]): string => tasks.map((t, i) => `${i} ${t.name}=${t.status}`).join(", ");
45
104
  const offenderList = (tasks: Task[], indices: number[]): string => indices.map((i) => `${i} "${tasks[i].name}"`).join(", ");
@@ -115,12 +174,47 @@ export default function (pi: ExtensionAPI) {
115
174
  name: "plan_tracker",
116
175
  label: "Plan Tracker",
117
176
  description:
118
- "Track progress while EXECUTING an implementation plan (the implement phase), a verify-phase conformance fix wave, or another bounded gate checklist (e.g. pre-merge PR verification). Statuses: pending (not yet touched), in_progress (started; several at once is fine), complete, failed (terminal-negative: ran and did not pass; never counted done), skipped (terminal: not applicable in this run; counted done). Rule: pending tasks must trail every started or finished task - an update that would leave a pending task ahead of a non-pending one is rejected with the fix. update never sets pending; init and add do. Actions: init (set task list; elements are task-name strings or { name, status } to recreate a list with known statuses), add (append tasks as pending; existing statuses preserved), update (change task status), status (show current state), clear (remove plan). Do NOT use for brainstorming, research, or planning checklists: those phases are open-ended and a bounded task list misrepresents them as a fixed N-step process.",
177
+ "Track progress while EXECUTING an implementation plan (the implement phase), a verify-phase conformance fix wave, or another bounded gate checklist (e.g. pre-merge PR verification). Statuses: pending (not yet touched), in_progress (started; several at once is fine), complete, failed (terminal-negative: ran and did not pass; never counted done), skipped (terminal: not applicable in this run; counted done). Rule: pending tasks must trail every started or finished task - an update that would leave a pending task ahead of a non-pending one is rejected with the fix. update never sets pending; init and add do. Actions: init (set task list; elements are task-name strings or { name, status } to recreate a list with known statuses), add (append tasks as pending; existing statuses preserved), update (change task status), status (show current state), clear (remove plan), refresh (derive tasks from an artifact path while retaining other tasks). Do NOT use for brainstorming, research, or planning checklists: those phases are open-ended and a bounded task list misrepresents them as a fixed N-step process.",
119
178
  parameters: PlanTrackerParams,
120
179
  executionMode: "sequential",
121
180
 
122
181
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
123
182
  switch (params.action) {
183
+ case "refresh": {
184
+ let proposed: Task[];
185
+ try {
186
+ if (!params.path) throw new Error("refresh: cannot read <missing>: path required");
187
+ const path = resolve(ctx.cwd, params.path);
188
+ let text: string;
189
+ try {
190
+ text = readFileSync(path, "utf8");
191
+ } catch (error) {
192
+ throw new Error(`refresh: cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`);
193
+ }
194
+ const projected = ledgerTasks(text, path);
195
+ const kept = tasks.filter(task => !/^row \d+(?: \(\S+\))?$/.test(task.name));
196
+ proposed = [
197
+ ...projected.filter(task => task.status !== "pending"),
198
+ ...kept.filter(task => task.status !== "pending"),
199
+ ...projected.filter(task => task.status === "pending"),
200
+ ...kept.filter(task => task.status === "pending"),
201
+ ];
202
+ if (validateSnapshot(proposed).length) throw new Error(`refresh: ${path}: ${RULE_LINE}`);
203
+ } catch (error) {
204
+ const message = error instanceof Error ? error.message : String(error);
205
+ return {
206
+ content: [{ type: "text", text: `Error: ${message}` }],
207
+ details: { action: "refresh", error: message } as PlanTrackerDetails,
208
+ };
209
+ }
210
+ tasks = proposed;
211
+ updateWidget(ctx);
212
+ return {
213
+ content: [{ type: "text", text: `Plan refreshed with ${tasks.length} tasks.\n${formatStatus(tasks)}` }],
214
+ details: { action: "refresh", tasks: tasks.map(t => ({ ...t })) } as PlanTrackerDetails,
215
+ };
216
+ }
217
+
124
218
  case "init": {
125
219
  if (!params.tasks || params.tasks.length === 0) {
126
220
  return {
@@ -316,6 +410,8 @@ export default function (pi: ExtensionAPI) {
316
410
 
317
411
  const taskList = details.tasks;
318
412
  switch (details.action) {
413
+ case "refresh":
414
+ return new Text(theme.fg("success", "✓ ") + theme.fg("muted", `Plan refreshed with ${taskList.length} tasks`), 0, 0);
319
415
  case "init":
320
416
  return new Text(
321
417
  theme.fg("success", "✓ ") + theme.fg("muted", `Plan initialized with ${taskList.length} tasks`),
@@ -284,6 +284,27 @@
284
284
  assert.deepEqual([resumed.gauntletEntered, resumed.lastSpecWrite], [true, "/repo/doc/specs/a.md"]);
285
285
  });
286
286
 
287
+ test("session resume restores the last successful plan snapshot for reopen counting", async () => {
288
+ const first = await boundInPlan();
289
+ const closed = [{ name: "a", status: "complete" }];
290
+ const planResult = (tasks: { name: string; status: string }[], error?: string) => ({
291
+ type: "message", message: { role: "toolResult", toolName: "plan_tracker", details: { action: "update", tasks, error } },
292
+ });
293
+ const branch = [
294
+ { type: "message", message: { role: "toolResult", toolName: "phase_tracker", details: { action: "start", phases: P({ brainstorm: "in_progress" }) } } },
295
+ { type: "message", message: { role: "toolResult", toolName: "plan_check", details: { status: "pass", specPath: join(first.root, "doc/specs/a.md") } } },
296
+ planResult([{ name: "a", status: "pending" }]),
297
+ planResult(closed),
298
+ planResult([{ name: "a", status: "failed" }], "failed"),
299
+ ];
300
+ const second = harness({ sessionId: "s2", branch });
301
+ second.ctx.cwd = first.root;
302
+ await second.emit("session_start", { type: "session_start", reason: "resume" });
303
+ await second.emit("tool_result", { toolName: "plan_tracker", toolCallId: "pt", input: { action: "update" }, content: [], isError: false, details: { action: "update", tasks: [{ name: "a", status: "in_progress" }] } });
304
+ assert.equal(second.readRecord().derived.gates.task_reopens, 1);
305
+ assert.deepEqual(replayBranch(branch, ["doc/specs"]).lastPlanTasks, closed);
306
+ });
307
+
287
308
  test("replayBranch honors a custom spec dir list", () => {
288
309
  const branch = [
289
310
  { type: "message", message: { role: "toolResult", toolName: "phase_tracker", details: { action: "start", phases: P({ brainstorm: "in_progress" }) } } },
@@ -1340,3 +1361,85 @@ test("resume skip reads engine from bound spec live and on branch replay", async
1340
1361
  }
1341
1362
  }
1342
1363
  });
1364
+
1365
+ test("any successful tracker action counts named transitions once and ignores retries and amendments", async () => {
1366
+ const h = await boundInPlan();
1367
+ const pt = (action: string, tasks: { name: string; status: string }[], isError = false) => h.emit("tool_result", { toolName: "plan_tracker", input: { action }, details: { action, tasks }, content: [], isError });
1368
+ const closed = [{ name: "a", status: "complete" }, { name: "b", status: "complete" }];
1369
+ const reopened = [{ name: "b", status: "failed" }, { name: "a", status: "in_progress" }];
1370
+ await pt("init", closed);
1371
+ await pt("refresh", reopened, true);
1372
+ assert.equal(h.readRecord().derived.gates.task_reopens, 0);
1373
+ await pt("refresh", reopened);
1374
+ await pt("refresh", reopened);
1375
+ await pt("status", [{ name: "a", status: "in_progress" }, { name: "b", status: "in_progress" }]);
1376
+ assert.equal(h.readRecord().derived.gates.task_reopens, 2);
1377
+ assert.equal(h.readRecord().events.filter((e) => e.kind === "gate" && e.gate === "task_reopen").length, 1);
1378
+ await pt("add", closed);
1379
+ await pt("refresh", [{ name: "a", status: "skipped" }, { name: "b", status: "pending" }]);
1380
+ await pt("clear", []);
1381
+ await pt("init", reopened);
1382
+ assert.equal(h.readRecord().derived.gates.task_reopens, 2);
1383
+ });
1384
+
1385
+ test("verification calls count from implement start through seal, reload and flush", async () => {
1386
+ const h = await boundInPlan();
1387
+ await h.bash("before", "npm test");
1388
+ assert.equal(h.readRecord().derived.verification_runs, undefined);
1389
+ await h.phaseResult("start", P({ brainstorm: "complete", plan: "complete", implement: "in_progress" }));
1390
+ assert.equal(h.readRecord().derived.verification_runs, 0);
1391
+ await h.bash("implement", "npm test", true);
1392
+ assert.equal(h.readRecord().derived.verification_runs, 1);
1393
+ assert.equal(h.readRecord().derived.tests, undefined);
1394
+ await h.phaseResult("start", P({ brainstorm: "complete", plan: "complete", implement: "complete", verify: "in_progress" }));
1395
+ await h.bash("verify1", "npm test");
1396
+ await h.bash("verify2", "cd x && npm test", true);
1397
+ await h.bash("unmatched", "echo verification: custom-check");
1398
+ assert.equal(h.readRecord().derived.verification_runs, 3);
1399
+ assert.deepEqual(h.readRecord().derived.tests, { command: "npm test", result: "fail" });
1400
+ const branch = [{ type: "message", message: { role: "toolResult", toolName: "phase_tracker", isError: false, details: { action: "start", phases: P({ brainstorm: "in_progress" }) } } },
1401
+ { type: "message", message: { role: "toolResult", toolName: "phase_tracker", isError: false, details: { action: "skip", phases: { ...P({ brainstorm: "skipped", plan: "complete", implement: "complete", verify: "in_progress" }), brainstorm: { status: "skipped", reason: "resume: doc/specs/a.md" } } } } }];
1402
+ const resumed = harness({ branch, sessionId: "s2" });
1403
+ resumed.ctx.cwd = h.root;
1404
+ await resumed.emit("session_start", {});
1405
+ await resumed.bash("resumed", "npm test");
1406
+ assert.equal(resumed.readRecord().derived.verification_runs, 4);
1407
+ await resumed.phaseResult("start", P({ brainstorm: "skipped", plan: "complete", implement: "complete", verify: "complete", ship: "in_progress" }));
1408
+ await resumed.bash("ship", "npm test");
1409
+ process.env.PI_SUBAGENT_DEPTH = "1";
1410
+ try { await resumed.bash("child", "npm test"); } finally { process.env.PI_SUBAGENT_DEPTH = "0"; }
1411
+ await resumed.emit("session_shutdown", {});
1412
+ assert.equal(resumed.readRecord().derived.verification_runs, 5);
1413
+ const sealed = { ...resumed.readRecord(), status: "shipped" as const, shipped_at: "2026-09-17T18:00:00Z" };
1414
+ writeFileSync(resumed.recordPath(), serializeRecord(sealed));
1415
+ const before = readFileSync(resumed.recordPath(), "utf8");
1416
+ await resumed.bash("sealed", "npm test");
1417
+ assert.equal(readFileSync(resumed.recordPath(), "utf8"), before);
1418
+ const unbound = harness();
1419
+ await unbound.bash("unbound", "npm test");
1420
+ assert.equal(existsSync(unbound.recordPath()), false);
1421
+ });
1422
+
1423
+ test("sealed loop fixture keeps dispatch iterations, skipped plan rows and verification runs", async () => {
1424
+ const h = harness({ jjWorkspace: true, gitFail: (args) => args[0] === "rev-parse" ? { code: 1, stdout: "", stderr: "not git" } : undefined });
1425
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
1426
+ await h.writeSpec("doc/specs/a.md", "# Spec\n\n**Engine:** loop\n\n## Scope\nSynthetic run.\n");
1427
+ await h.phaseResult("complete", P({ brainstorm: "complete" }));
1428
+ await h.phaseResult("start", P({ brainstorm: "complete", plan: "skipped", implement: "in_progress" }));
1429
+ const pt = (tasks: { name: string; status: string }[]) => h.emit("tool_result", { toolName: "plan_tracker", input: { action: "refresh" }, details: { action: "refresh", tasks }, isError: false, content: [] });
1430
+ await pt([{ name: "row 1", status: "skipped" }, { name: "row 2", status: "complete" }, { name: "row 3", status: "in_progress" }]);
1431
+ await h.emit("tool_result", { toolName: "subagent", input: {}, details: { results: [{ agent: "loop-implementer", exitCode: 0 }] }, content: [], isError: false });
1432
+ await pt([{ name: "row 1", status: "skipped" }, { name: "row 2", status: "complete" }, { name: "row 3", status: "complete" }]);
1433
+ await h.phaseResult("start", P({ brainstorm: "complete", plan: "skipped", implement: "complete", verify: "in_progress" }));
1434
+ await h.bash("verify", "npm test");
1435
+ await h.phaseResult("start", P({ brainstorm: "complete", plan: "skipped", implement: "complete", verify: "complete", ship: "in_progress" }));
1436
+ await h.phaseResult("complete", P({ brainstorm: "complete", plan: "skipped", implement: "complete", verify: "complete", ship: "complete" }));
1437
+ const rec = h.readRecord();
1438
+ assert.equal(rec.status, "shipped");
1439
+ assert.equal(rec.derived.engine, "loop");
1440
+ assert.equal(rec.derived.iterations, 1);
1441
+ assert.equal(rec.events.filter((e) => e.kind === "dispatch" && e.agent === "loop-implementer").length, 1);
1442
+ assert.deepEqual(rec.derived.plan, { tasks: 3, complete: 2, failed: 0, skipped: 1 });
1443
+ assert.equal(rec.derived.verification_runs, 1);
1444
+ assert.equal(rec.derived.events_dropped, 0);
1445
+ });
@@ -21,7 +21,7 @@
21
21
  import { sha256 } from "./lib/plan-check.ts";
22
22
  import { SUPERSEDED_BY_RE, isPlanPath, isSpecPath, isSupersededByBanner, matchTestStatement, parseSpecLinks, planSpecHeader, recordPathFor, repoRelativeToolPath, toPosix, truncateCommand } from "./lib/telemetry-paths.ts";
23
23
  import { computeJjDiff } from "./lib/telemetry-diff.ts";
24
- import { COUNTED_USER_PHASES, REVIEWER_AGENTS, countFindings, countStructuredFindings, countOpenGaps, hasReopen, insertedText, planTotals, textOf, usageToTokens } from "./lib/telemetry-collect.ts";
24
+ import { COUNTED_USER_PHASES, REVIEWER_AGENTS, countFindings, countStructuredFindings, countOpenGaps, countReopens, insertedText, planTotals, textOf, usageToTokens } from "./lib/telemetry-collect.ts";
25
25
  import { addTokens, capEvents, compact, currentPhase, derive, diffPhases, emptyAccumulators, emptyPhases, foldAccumulators, implementAutoCompletes, liveShipEvent, newRecord, parseRecord, serializeRecord, type Accumulators, type BaseEvent, type PhaseAcc, type PhaseKey, type PhaseMap, type TelemetryEvent, type TelemetryRecord, type Tokens } from "./lib/telemetry-record.ts";
26
26
  import { guardReason } from "./lib/telemetry-ship.ts";
27
27
  import { buildCouncil, parseAudit, parseChairReport, resolveMembers, type Cluster, type MemberResult } from "./lib/telemetry-council.ts";
@@ -141,6 +141,7 @@
141
141
  gauntletEntered: boolean;
142
142
  planCheckSpec?: string;
143
143
  lastSpecWrite?: string;
144
+ lastPlanTasks?: { name?: string; status: string }[];
144
145
  }
145
146
 
146
147
  // gauntlet-resume's reconstruction skips brainstorm with `resume: <spec path>`; the path
@@ -179,6 +180,9 @@
179
180
  }
180
181
  const resumed = d.action === "skip" ? resumeSpecOf(d.phases, specDirs) : undefined;
181
182
  if (out.gauntletEntered && resumed) out.lastSpecWrite = resumed;
183
+ } else if (msg.toolName === "plan_tracker") {
184
+ const d = msg.details as { tasks?: { name?: string; status: string }[]; error?: string } | undefined;
185
+ if (d && !d.error && Array.isArray(d.tasks)) out.lastPlanTasks = d.tasks;
182
186
  } else if (msg.toolName === "plan_check") {
183
187
  const d = msg.details as { status?: string; specPath?: string } | undefined;
184
188
  if (out.gauntletEntered && d?.status === "pass" && typeof d.specPath === "string") out.planCheckSpec = d.specPath;
@@ -445,6 +449,7 @@
445
449
  : { kind: "phase", action: t.action, name: t.name },
446
450
  );
447
451
  if (t.action === "start" && t.name === "brainstorm") councilPending = undefined;
452
+ if (record && t.action === "start" && t.name === "implement") record.derived.verification_runs ??= 0;
448
453
  phases = details.phases;
449
454
  if (record && boundSpec && t.name === "brainstorm" && (t.action === "complete" || t.action === "skip")) {
450
455
  if (e) record.approved_at ??= e.ts;
@@ -491,6 +496,7 @@
491
496
  sessionId = ctx.sessionManager.getSessionId();
492
497
  currentDir = snap.telemetry.dir;
493
498
  const replay = replayBranch(ctx.sessionManager.getBranch(), snap.specDirs);
499
+ lastPlanTasks = replay.lastPlanTasks;
494
500
  phases = replay.phases;
495
501
  gauntletEntered = replay.gauntletEntered;
496
502
  if (!canBind()) return;
@@ -632,7 +638,7 @@
632
638
  let onOtherSpecEdit: (rel: string, event: { input: unknown }, specDirs: string[]) => void;
633
639
 
634
640
  let amendmentOpen = false;
635
- let lastPlanTasks: { status: string }[] | undefined;
641
+ let lastPlanTasks: { name?: string; status: string }[] | undefined;
636
642
  const pendingTest = new Map<string, string>(); // toolCallId -> matched statement
637
643
 
638
644
  const phaseAcc = (k: PhaseKey = phaseNow()): PhaseAcc => (live().phases[k] ??= {});
@@ -724,8 +730,9 @@
724
730
  const onPlanTracker = async (details: unknown, snap: SettingsSnapshot) => {
725
731
  const d = details as { tasks?: { name?: string; status: string }[]; error?: string; action?: string } | undefined;
726
732
  if (!d || d.error || !Array.isArray(d.tasks)) return;
727
- if (d.action === "update" && hasReopen(lastPlanTasks, d.tasks)) {
728
- live().gates.task_reopens += 1;
733
+ const reopens = countReopens(lastPlanTasks, d.tasks);
734
+ if (reopens) {
735
+ live().gates.task_reopens += reopens;
729
736
  pushEvent({ kind: "gate", gate: "task_reopen" });
730
737
  }
731
738
  lastPlanTasks = d.tasks;
@@ -771,9 +778,15 @@
771
778
  if (event.toolName === "bash") {
772
779
  const command = String((event.input as { command?: unknown }).command ?? "");
773
780
  if (phaseNow() === "brainstorm" && /\bgit\b[^\n]*\bcommit\b/.test(command)) onCouncilAudit(command);
774
- if (record && (phases.verify.status === "in_progress" || phases.ship.status === "in_progress")) {
781
+ if (record && !frozen) {
775
782
  const statement = matchTestStatement(command, snap.testCommands ?? DEFAULT_TEST_COMMANDS);
776
- if (statement) pendingTest.set(event.toolCallId, statement);
783
+ if (statement) {
784
+ if (record.events.some((e) => e.kind === "phase" && e.action === "start" && e.name === "implement")) {
785
+ record.derived.verification_runs = (record.derived.verification_runs ?? 0) + 1;
786
+ flush();
787
+ }
788
+ if (phases.verify.status === "in_progress" || phases.ship.status === "in_progress") pendingTest.set(event.toolCallId, statement);
789
+ }
777
790
  }
778
791
  }
779
792
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "8.0.1",
3
+ "version": "8.1.0",
4
4
  "description": "Opinionated, gated workflow skills, subagent personas, and runtime extensions for the pi coding agent.",
5
5
  "author": "Jacek Juraszek",
6
6
  "type": "module",
@@ -62,13 +62,14 @@
62
62
  "test": "node scripts/ci.mjs"
63
63
  },
64
64
  "peerDependencies": {
65
- "pi-cohort": ">=7.6.5"
65
+ "pi-cohort": ">=7.6.6"
66
66
  },
67
67
  "dependencies": {
68
68
  "yaml": "^2.9.0"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@anthropic-ai/claude-code": "2.1.309",
72
- "esbuild": "0.28.2"
72
+ "esbuild": "0.28.2",
73
+ "typebox": "^1.3.11"
73
74
  }
74
75
  }
@@ -40,7 +40,7 @@ When the spec file is mid-batch (uncommitted amend edits), the consent edit ride
40
40
 
41
41
  ## Implement-local maintenance boundaries
42
42
 
43
- Apply this specialization only to implement callers with a task plan; keep planning callers and ordinary attribution/restoration below unchanged. Patch only affected task entries, coverage and shifted anchors, preserving unaffected ids, titles, wave order, state and historical evidence. Capture anchored obligation text, task bodies and Tests alongside the original inventory in the saved apply report; retain it on failure and use it on idempotent retry. Interpret `tasksAnchorChanged` semantically, not as offset-only or meaning-preserving rewording changes.
43
+ Apply this specialization only to implement callers with a task plan; keep planning callers and ordinary attribution/restoration below unchanged. Patch only affected task entries, coverage and shifted anchors, preserving unaffected ids, titles, wave order, state and historical evidence. Use committed home plan/spec originals read via home-relative `git -C "<abs home worktree>" show HEAD:<path>` before edits and on every retry for original task inventory, anchored obligation text, task bodies and Tests; retain prior findings in the report, not baseline copies. Interpret `tasksAnchorChanged` semantically, not as offset-only or meaning-preserving rewording changes.
44
44
 
45
45
  For spec/plan maintenance requiring no test execution, admit declared `In-flight:` repairs and preserve staged repairs; never commit, restore or discard them merely to plan. Retain original declarations and unchanged evidence at measured revisions under `**Baseline policy:** implement-local`. Execute changed/added/missing/invalid evidence only on an identified execution tree, recording leg, checkout, revision, admitted changes, command, exit and nonzero executed-test count. Never call dirty execution clean HEAD; stop on unidentified trees, unknown dirty paths, contamination or moved test revision.
46
46
 
@@ -153,7 +153,7 @@ Reply grammar: one line per item, `<n>. <letter or free text>`; bare `1` applies
153
153
 
154
154
  Before the aftermath, `read` this section again; never run it from memory.
155
155
 
156
- **Implement callers only, with a task plan:** replace parent spec edits and the ordinary plan aftermath with a one-step chain to `plan-writer` carrying an implement-only `Amend-apply:` block, the granted items verbatim, the five absolute paths, and the parent's explicit `In-flight:` list. Authorize only those named spec sections. Require implement-local patching and historical Baseline under the boundaries above, preservation of unaffected titles and wave positions, Baseline ownership, dirty-baseline safety, and the child's self-check. Consume `{ amendedSections: string[], tasksAdded: number[], tasksAnchorChanged: number[], tasksRemoved: number[], passed: boolean, failLines?: string[] }`; use pre-apply task numbers, never tracker positions. Require the child to capture the original task numbers, titles, spec anchors, and wave positions in the existing saved apply report before the first apply and retain that original inventory on every return, including failures; add no sidecar. On `passed: false`, repeat once with `failLines` as the rule and `previousApplyReport` set to the first apply report path. Require the fresh retry worker to read that original inventory, apply granted edits idempotently without duplicating already-applied edits, and compute deltas against that original inventory rather than the already-mutated plan. Missing original inventory fails the retry; send a second failure to the human with verbatim findings, including substantive baseline failures. Keep the attribution, protected-path, and dirty-baseline exits below; no delegated restoration permission is granted. On pass, print `amended the spec: <title> - <location>` for each applied item, run the parent's `plan_check` for stamping, and use its fresh manifest with the returned task numbers without reading the plan or spec. Add `tasksAdded`, reopen completed `tasksAnchorChanged` as `in_progress`, and handle `tasksRemoved` through the removal rules below. Require parent PASS before tracker changes. Skip the ordinary parent edit, `Amend:` dispatch, and amended-plan read; preserve companion aftermath, audit, and the single batch commit below. Planning callers keep the ordinary apply and aftermath below; ledger and no-plan routes stay unchanged.
156
+ **Implement callers only, with a task plan:** replace parent spec edits and the ordinary plan aftermath with a one-step chain to `plan-writer` carrying an implement-only `Amend-apply:` block, the granted items verbatim, the five absolute paths, and the parent's explicit `In-flight:` list. Authorize only those named spec sections. Require implement-local patching and historical Baseline under the boundaries above, preservation of unaffected titles and wave positions, Baseline ownership, dirty-baseline safety, and the child's self-check. Consume `{ amendedSections: string[], tasksAdded: number[], tasksAnchorChanged: number[], tasksRemoved: number[], passed: boolean, failLines?: string[] }`; use pre-apply task numbers, never tracker positions. Require both committed home plan/spec originals before the first edit and on every retry, with home-relative HEAD paths; add no sidecar or report-carried inventory. When structured apply JSON is suppressed, read the sole capture at `<Artifacts>/structured-output/*/output.json` with the existing `read` bound `limit: 300`, no offset or pagination for that one-task dispatch and require a success envelope satisfying unchanged `APPLY_SCHEMA`. Stop on zero or two files naming paths, or malformed values; never recover from prose. Route captured `passed`/`failLines` like printed JSON. Only a failLines entry beginning exactly `baseline read failed: git -C ` bypasses retry: stop quoting the exact command and actual stderr. For every other `passed: false`, retry once with `failLines` as the rule and `previousApplyReport` set to the first apply report path for prior findings only. Require the fresh retry worker to reread both committed originals, apply granted edits idempotently without duplicating already-applied edits, and compute semantic deltas against those originals rather than the already-mutated plan/spec. Send a second failure to the human with verbatim findings, including substantive baseline failures. Keep the attribution, protected-path, and dirty-baseline exits below; no delegated restoration permission is granted. On pass, print `amended the spec: <title> - <location>` for each applied item, run the parent's `plan_check` for stamping, and use its fresh manifest with the returned task numbers without reading the plan or spec. Add `tasksAdded`, reopen completed `tasksAnchorChanged` as `in_progress`, and handle `tasksRemoved` through the removal rules below. Require parent PASS before tracker changes. Skip the ordinary parent edit, `Amend:` dispatch, and amended-plan read; preserve companion aftermath, audit, and the single batch commit below. Planning callers keep the ordinary apply and aftermath below; ledger and no-plan routes stay unchanged.
157
157
 
158
158
  Apply accepted items only: reviewer clears, `accept`, `alt-n`, and state-changing `custom`. Print one line per applied item: `amended the spec: <title> - <what>`.
159
159
 
@@ -169,7 +169,7 @@ reread rule; amend the block before using a changed contract. Legacy specs
169
169
  without that section retain finish-time rereading. A one-member run has no
170
170
  companion artifact or companion aftermath.
171
171
 
172
- Aftermath, once per batch, only when at least one item applied (nothing applied -> skip to the commit below). Ledger exists (line 1 `# Ledger - loop engine` in the resolved plan dir) -> skip `plan-writer`, `plan_check`, and `plan_tracker`. Reconcile the ledger against the approved spec dispositions: `deviates:`, `deferred:`, or `elsewhere:` -> `cut`, evidence = the disposition; factual or requirement-preserving amendment -> preserve rows; re-bounded `done` row -> `open`, evidence = `reopened by amendment`. Preserve every row's attempts and the ledger's iterations and cap. Apply evidenced drafted-row text corrections as amend items while preserving row identity; retain the cut/reopen rules above. Ticket text and row add/remove changes remain redraws. Commit spec + ledger together with subject `loop: cut row <n>` for a batch containing only one cut, otherwise `loop: amend` (including mixed batches), carrying the amendment audit body below (including each `granted` record and granting sentence); continue without task-plan aftermath. No plan yet -> commit the spec; continue. Plan exists -> read `git -C "<abs worktree path>" status --porcelain` (plain jj: `jj -R "<abs worktree path>" diff --summary`); collect the parent's in-flight workflow edits as worktree-relative paths, including the amended spec and staged in-place wave-repair deliverables. Dispatch `plan-writer` per `writing-plans` `## Author the plan` step 3 with an `Amend:` block naming the amended spec sections, an `In-flight:` list of those paths (the parent supplies the set; the child never infers it), and the rule "preserve every task title and wave position the amendment does not touch; edit only affected anchors and tasks", then run that section's steps 5-6 (the parent `plan_check` and its branches).
172
+ Aftermath, once per batch, only when at least one item applied (nothing applied -> skip to the commit below). Ledger exists (line 1 `# Ledger - loop engine` in the resolved plan dir) -> skip `plan-writer` and `plan_check`. Reconcile the ledger against the approved spec dispositions: `deviates:`, `deferred:`, or `elsewhere:` -> `cut`, evidence = the disposition; factual or requirement-preserving amendment -> preserve rows; re-bounded `done` row -> `open`, evidence = `reopened by amendment`. Preserve every row's attempts and the ledger's iterations and cap. Apply evidenced drafted-row text corrections as amend items while preserving row identity; retain the cut/reopen rules above. Ticket text and row add/remove changes remain redraws. Commit spec + ledger together with subject `loop: cut row <n>` for a batch containing only one cut, otherwise `loop: amend` (including mixed batches), carrying the amendment audit body below (including each `granted` record and granting sentence); after the commit, use the driver's next ledger-path refresh to project the new statuses and continue without task-plan aftermath. Keep task-plan `add`/`update`/`init` out of this route. Project `done -> cut` as `complete -> skipped` and a re-bound `done -> open` as `complete -> pending`; count neither as a reopen. During verify, stop on a re-bound row with `Stopped: row <n> reopened by amendment during verify`; keep verify active and offer authorization to return that row to ledger implementation or stop. On authorization, invoke `loop-implementer` through the driver's `## Dispatch` and `## Validate the return` (tuple, refresh, admission, refresh), with no phase reset; close the row on disk, then rerun the step 1 gate before verify continues. Never close it with `plan_tracker update`. No plan yet -> commit the spec; continue. Plan exists -> read `git -C "<abs worktree path>" status --porcelain` (plain jj: `jj -R "<abs worktree path>" diff --summary`); collect the parent's in-flight workflow edits as worktree-relative paths, including the amended spec and staged in-place wave-repair deliverables. Dispatch `plan-writer` per `writing-plans` `## Author the plan` step 3 with an `Amend:` block naming the amended spec sections, an `In-flight:` list of those paths (the parent supplies the set; the child never infers it), and the rule "preserve every task title and wave position the amendment does not touch; edit only affected anchors and tasks", then run that section's steps 5-6 (the parent `plan_check` and its branches).
173
173
 
174
174
  At that return point, classify human-facing outcomes with this table; let writing-plans' first `Findings:` repair run as written.
175
175