pi-gauntlet 6.0.0 → 6.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 +1 -1
  3. package/agents/code-reviewer.md +1 -0
  4. package/agents/conformance-reviewer.md +1 -1
  5. package/bin/gauntlet-performance.test.mjs +4 -4
  6. package/bin/gauntlet-spec-index.mjs +142 -100
  7. package/bin/gauntlet-spec-index.test.mjs +41 -1
  8. package/bin/gauntlet-telemetry-seal.mjs +49 -14
  9. package/bin/gauntlet-telemetry-seal.test.mjs +76 -5
  10. package/extensions/lib/gauntlet-settings.test.ts +13 -3
  11. package/extensions/lib/gauntlet-settings.ts +18 -2
  12. package/extensions/lib/phase-tracker-helpers.test.ts +14 -0
  13. package/extensions/lib/phase-tracker-helpers.ts +5 -4
  14. package/extensions/lib/telemetry-collect.test.ts +6 -0
  15. package/extensions/lib/telemetry-collect.ts +4 -2
  16. package/extensions/lib/telemetry-diff.test.ts +52 -4
  17. package/extensions/lib/telemetry-diff.ts +4 -4
  18. package/extensions/lib/telemetry-paths.test.ts +17 -6
  19. package/extensions/lib/telemetry-paths.ts +7 -2
  20. package/extensions/lib/telemetry-ship.test.ts +9 -6
  21. package/extensions/lib/telemetry-ship.ts +4 -3
  22. package/extensions/phase-tracker.test.ts +24 -0
  23. package/extensions/phase-tracker.ts +10 -6
  24. package/extensions/telemetry.test.ts +32 -7
  25. package/extensions/telemetry.ts +30 -26
  26. package/package.json +1 -1
  27. package/skills/brainstorming/SKILL.md +5 -5
  28. package/skills/dispatching-parallel-agents/SKILL.md +1 -1
  29. package/skills/finishing-a-development-branch/SKILL.md +8 -4
  30. package/skills/gauntlet-resume/reference/reconstruction.md +1 -4
  31. package/skills/requesting-code-review/SKILL.md +4 -2
  32. package/skills/subagent-driven-development/SKILL.md +6 -6
  33. package/skills/subagent-driven-development/code-quality-reviewer-prompt.md +10 -8
  34. package/skills/verification-before-completion/reference/conformance-check.md +2 -0
  35. package/skills/verification-before-completion/reference/whole-diff-minors.md +31 -0
  36. package/skills/writing-plans/SKILL.md +4 -3
@@ -290,6 +290,19 @@ test("brainstorm write guard is unchanged by the implement guard", async () => {
290
290
  }
291
291
  });
292
292
 
293
+ test("brainstorm write confinement: a docs/specs write under the default dirs does not warn", async () => {
294
+ const priorDepth = process.env.PI_SUBAGENT_DEPTH;
295
+ delete process.env.PI_SUBAGENT_DEPTH;
296
+ try {
297
+ const h = harness({ cwd: tempCwd(), branch: [phaseResult("start", phases({ brainstorm: "in_progress" }))] });
298
+ await h.emit("session_start");
299
+ await h.emitEvent("tool_call", writeCall("t1", "docs/specs/x.md"));
300
+ assert.equal((await h.emitEvent("tool_result", writeResult("t1")))[0], undefined);
301
+ } finally {
302
+ if (priorDepth !== undefined) process.env.PI_SUBAGENT_DEPTH = priorDepth;
303
+ }
304
+ });
305
+
293
306
  test("resumed session: plan-implement recovery edge fires (AC 3)", async () => {
294
307
  const h = harness({ cwd: tempCwd(), branch: [...resumedBranch({ plan: "complete" }), assistant()] });
295
308
  await settle(h);
@@ -1244,6 +1257,17 @@ test("gauntlet_setting escalationLoop: setting absent -> ctx-derived main-loop m
1244
1257
  assert.equal(res.details.implModel, "p/strong:high");
1245
1258
  });
1246
1259
 
1260
+ test("gauntlet_setting flowGuards: default dirs + derived plan dirs; repo override replaces them", async () => {
1261
+ const h = harness({ cwd: tempCwd({ piGauntlet: {} }) });
1262
+ const tool = h.tools.find((t) => t.name === "gauntlet_setting")!;
1263
+ const def = (await tool.execute("f1", { key: "flowGuards" }, undefined, undefined, h.ctx)) as { details: unknown };
1264
+ assert.deepEqual(def.details, { key: "flowGuards", enforce: true, specDirs: ["doc/specs", "docs/specs"], planDirs: ["doc/plans", "docs/plans"], errors: [] });
1265
+ const set = harness({ cwd: tempCwd({ piGauntlet: { flowGuards: { specDirs: ["design/specs"] } } }) });
1266
+ const res = (await set.tools.find((t) => t.name === "gauntlet_setting")!.execute("f2", { key: "flowGuards" }, undefined, undefined, set.ctx)) as { details: { specDirs: string[]; planDirs: string[] } };
1267
+ assert.deepEqual(res.details.specDirs, ["design/specs"]);
1268
+ assert.deepEqual(res.details.planDirs, ["design/plans"]);
1269
+ });
1270
+
1247
1271
  // --- Conformance fix-loop dispatch guard (spec 2026-09-13-conformance-dispatch-guard) ---
1248
1272
 
1249
1273
  const verifyBranch = (extra: unknown[] = []) => [
@@ -17,6 +17,7 @@ import { Text } from "@earendil-works/pi-tui";
17
17
  import { type Static, Type } from "@sinclair/typebox";
18
18
  import {
19
19
  mainLoopModel,
20
+ planDirsFor,
20
21
  resolveClosureReview,
21
22
  resolveEscalationLoop,
22
23
  resolveFlowGuards,
@@ -734,7 +735,7 @@ export default function (pi: ExtensionAPI) {
734
735
  });
735
736
 
736
737
  const GauntletSettingParams = Type.Object({
737
- key: StringEnum(["specCouncil", "closureReview", "escalationLoop"] as const, {
738
+ key: StringEnum(["specCouncil", "closureReview", "escalationLoop", "flowGuards"] as const, {
738
739
  description: "Which gauntlet setting to resolve (merged repo-over-preset).",
739
740
  }),
740
741
  });
@@ -747,16 +748,19 @@ export default function (pi: ExtensionAPI) {
747
748
  executionMode: "sequential",
748
749
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
749
750
  const { gauntlet, errors } = loadGauntletSettings(ctx.cwd);
751
+ const fg = resolveFlowGuards(gauntlet);
750
752
  const payload =
751
753
  params.key === "specCouncil"
752
754
  ? { key: "specCouncil" as const, ...resolveSpecCouncil(gauntlet), errors }
753
755
  : params.key === "closureReview"
754
756
  ? { key: "closureReview" as const, ...resolveClosureReview(gauntlet), errors }
755
- : {
756
- key: "escalationLoop" as const,
757
- ...resolveEscalationLoop(gauntlet, mainLoopModel(ctx.model, ctx.thinkingLevel)),
758
- errors,
759
- };
757
+ : params.key === "flowGuards"
758
+ ? { key: "flowGuards" as const, ...fg, planDirs: planDirsFor(fg.specDirs), errors }
759
+ : {
760
+ key: "escalationLoop" as const,
761
+ ...resolveEscalationLoop(gauntlet, mainLoopModel(ctx.model, ctx.thinkingLevel)),
762
+ errors,
763
+ };
760
764
  return {
761
765
  content: [{ type: "text", text: "```json\n" + JSON.stringify(payload, null, 2) + "\n```" }],
762
766
  details: payload,
@@ -4,7 +4,7 @@
4
4
  import { join } from "node:path";
5
5
  import { after, test } from "node:test";
6
6
  import registerTelemetry, { realFs, replayBranch, type Deps, type GitResult } from "./telemetry.ts";
7
- import { DEFAULT_TELEMETRY_BUCKETS } from "./lib/gauntlet-settings.ts";
7
+ import { DEFAULT_TELEMETRY_BUCKETS, planDirsFor, resolveFlowGuards } from "./lib/gauntlet-settings.ts";
8
8
  import { emptyAccumulators, parseRecord, serializeRecord } from "./lib/telemetry-record.ts";
9
9
  import { guardReason } from "./lib/telemetry-ship.ts";
10
10
 
@@ -24,6 +24,7 @@
24
24
  const root = mkdtempSync(join(tmpdir(), "telemetry-test-"));
25
25
  tempDirs.push(root);
26
26
  mkdirSync(join(root, "doc/specs"), { recursive: true });
27
+ mkdirSync(join(root, "docs/specs"), { recursive: true });
27
28
  mkdirSync(join(root, "doc/plans"), { recursive: true });
28
29
  mkdirSync(join(root, ".worktrees/x/doc/specs"), { recursive: true });
29
30
  const handlers = new Map<string, Handler[]>();
@@ -31,6 +32,7 @@
31
32
  const gitCwds: string[] = [];
32
33
  const jjCalls: string[][] = [];
33
34
  const readFileCalls: string[] = [];
35
+ const specDirs = resolveFlowGuards({}).specDirs;
34
36
  let clock = Date.parse("2026-09-17T10:00:00Z");
35
37
  const deps: Deps = {
36
38
  fs: { ...realFs, readFile: (p) => { readFileCalls.push(p); return realFs.readFile(p); } },
@@ -56,7 +58,7 @@
56
58
  if (args[0] === "ls-files") return { code: 0, stdout: "", stderr: "" };
57
59
  return { code: 0, stdout: "", stderr: "" };
58
60
  },
59
- settings: () => ({ telemetry: { enabled: o.enabled ?? true, dir: o.telemetryDir ?? ".pi/gauntlet/telemetry", buckets: DEFAULT_TELEMETRY_BUCKETS, warning: o.telemetryWarning }, errors: [], agentOverrides: { implementer: { model: "p/x" } }, versions: { pi: "0.85.1" } }),
61
+ settings: () => ({ specDirs, planDirs: planDirsFor(specDirs), telemetry: { enabled: o.enabled ?? true, dir: o.telemetryDir ?? ".pi/gauntlet/telemetry", buckets: DEFAULT_TELEMETRY_BUCKETS, warning: o.telemetryWarning }, errors: [], agentOverrides: { implementer: { model: "p/x" } }, versions: { pi: "0.85.1" } }),
60
62
  // A plain jj workspace: `jj root` answers the temp root while git rev-parse fails.
61
63
  jj: async (args, cwd) => {
62
64
  jjCalls.push(args);
@@ -180,6 +182,19 @@
180
182
  assert.equal(readFileSync(h.excludeFile(), "utf8"), ".pi/gauntlet/telemetry/\n", "exclude line is written once after rebind");
181
183
  });
182
184
 
185
+ test("a docs/specs write binds under the default dirs and the record lands under telemetry.dir/docs/specs", async () => {
186
+ const h = harness();
187
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
188
+ assert.equal(existsSync(h.recordPath("docs/specs/x.md")), false);
189
+ await h.writeSpec("docs/specs/x.md");
190
+ assert.equal(h.recordPath("docs/specs/x.md"), join(h.root, ".pi/gauntlet/telemetry/docs/specs/x.yaml"));
191
+ assert.equal(h.readRecord("docs/specs/x.md").spec, "docs/specs/x.md");
192
+ await h.phaseResult("complete", P({ brainstorm: "complete" }));
193
+ const rec = h.readRecord("docs/specs/x.md");
194
+ assert.equal(rec.approved_at, rec.events.at(-1)!.ts);
195
+ assert.equal(h.gitWrites().length, 0);
196
+ });
197
+
183
198
  test("bind derives approved_at from a buffered brainstorm completion", async () => {
184
199
  const h = harness();
185
200
  await h.phaseResult("start", P({ brainstorm: "in_progress" }));
@@ -231,7 +246,7 @@
231
246
  { type: "message", message: { role: "toolResult", toolName: "write", toolCallId: "w1", isError: false } },
232
247
  { type: "message", message: { role: "toolResult", toolName: "write", toolCallId: "w2", isError: false } },
233
248
  { type: "message", message: { role: "toolResult", toolName: "write", toolCallId: "w3", isError: true } },
234
- ]);
249
+ ], ["doc/specs", "docs/specs"]);
235
250
  assert.deepEqual(replay.phases, successfulPhases);
236
251
  assert.equal(replay.gauntletEntered, true);
237
252
  assert.equal(replay.planCheckSpec, "/repo/doc/specs/a.md");
@@ -246,7 +261,7 @@
246
261
  const unarmed = replayBranch([
247
262
  ...write("w1", "doc/specs/a.md"),
248
263
  { type: "message", message: { role: "toolResult", toolName: "plan_check", details: { status: "pass", specPath: "/repo/doc/specs/a.md" } } },
249
- ]);
264
+ ], ["doc/specs", "docs/specs"]);
250
265
  assert.equal(unarmed.gauntletEntered, false);
251
266
  assert.equal(unarmed.lastSpecWrite, undefined);
252
267
  assert.equal(unarmed.planCheckSpec, undefined);
@@ -254,21 +269,31 @@
254
269
  ...write("w0", "doc/specs/before.md"),
255
270
  { type: "message", message: { role: "toolResult", toolName: "phase_tracker", details: { action: "start", phases: P({ brainstorm: "in_progress" }) } } },
256
271
  ...write("w1", "doc/specs/a.md", "edit"),
257
- ]);
272
+ ], ["doc/specs", "docs/specs"]);
258
273
  assert.deepEqual([armed.gauntletEntered, armed.lastSpecWrite], [true, "doc/specs/a.md"]);
259
274
  const reset = replayBranch([
260
275
  { type: "message", message: { role: "toolResult", toolName: "phase_tracker", details: { action: "start", phases: P({ brainstorm: "in_progress" }) } } },
261
276
  ...write("w1", "doc/specs/a.md"),
262
277
  { type: "message", message: { role: "toolResult", toolName: "phase_tracker", details: { action: "reset", phases: P() } } },
263
- ]);
278
+ ], ["doc/specs", "docs/specs"]);
264
279
  assert.deepEqual([reset.gauntletEntered, reset.lastSpecWrite], [false, undefined]);
265
280
  const resumed = replayBranch([
266
281
  { type: "message", message: { role: "toolResult", toolName: "phase_tracker", details: { action: "start", phases: P({ brainstorm: "in_progress" }) } } },
267
282
  { type: "message", message: { role: "toolResult", toolName: "phase_tracker", details: { action: "skip", phases: { ...P({ brainstorm: "skipped" }), brainstorm: { status: "skipped", reason: "resume: /repo/doc/specs/a.md" } } } } },
268
- ]);
283
+ ], ["doc/specs", "docs/specs"]);
269
284
  assert.deepEqual([resumed.gauntletEntered, resumed.lastSpecWrite], [true, "/repo/doc/specs/a.md"]);
270
285
  });
271
286
 
287
+ test("replayBranch honors a custom spec dir list", () => {
288
+ const branch = [
289
+ { type: "message", message: { role: "toolResult", toolName: "phase_tracker", details: { action: "start", phases: P({ brainstorm: "in_progress" }) } } },
290
+ { type: "message", message: { role: "assistant", content: [{ type: "toolCall", id: "w1", name: "write", arguments: { path: "design/specs/a.md" } }] } },
291
+ { type: "message", message: { role: "toolResult", toolName: "write", toolCallId: "w1", isError: false } },
292
+ ];
293
+ assert.equal(replayBranch(branch, ["design/specs"]).lastSpecWrite, "design/specs/a.md");
294
+ assert.equal(replayBranch(branch, ["doc/specs"]).lastSpecWrite, undefined);
295
+ });
296
+
272
297
  test("session_start replay binds from plan_check details with no spec write, extends sessions, preserves run_id", async () => {
273
298
  const first = harness();
274
299
  await first.phaseResult("start", P({ brainstorm: "in_progress" }));
@@ -14,7 +14,7 @@
14
14
  import * as piRuntime from "@earendil-works/pi-coding-agent";
15
15
  import { SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent";
16
16
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
- import { DEFAULT_TEST_COMMANDS, resolveTelemetry, settingsErrorWarning, type TelemetryResolved } from "./lib/gauntlet-settings.ts";
17
+ import { DEFAULT_TEST_COMMANDS, planDirsFor, resolveFlowGuards, resolveTelemetry, settingsErrorWarning, type TelemetryResolved } from "./lib/gauntlet-settings.ts";
18
18
  import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
19
19
  import { checkoutOf } from "./lib/checkout.ts";
20
20
  import { CONTEXT_DRAFT_MARKER, nextGauntletEntered } from "./lib/phase-tracker-helpers.ts";
@@ -43,6 +43,8 @@
43
43
  }
44
44
  export interface SettingsSnapshot {
45
45
  telemetry: TelemetryResolved;
46
+ specDirs: string[];
47
+ planDirs: string[];
46
48
  errors: string[];
47
49
  agentOverrides?: Record<string, unknown>;
48
50
  versions: Record<string, string>;
@@ -118,6 +120,8 @@
118
120
  function realSettings(cwd: string): SettingsSnapshot {
119
121
  const { gauntlet, errors, root } = loadGauntletSettings(cwd);
120
122
  const telemetry = resolveTelemetry(gauntlet);
123
+ const { specDirs } = resolveFlowGuards(gauntlet);
124
+ const planDirs = planDirsFor(specDirs);
121
125
  const sm = SettingsManager.create(root, getAgentDir());
122
126
  const layer = (s: unknown) => ((s as { subagents?: { agentOverrides?: unknown } })?.subagents?.agentOverrides ?? undefined) as Record<string, unknown> | undefined;
123
127
  const preset = layer(sm.getGlobalSettings());
@@ -125,7 +129,7 @@
125
129
  const agentOverrides = preset || repo ? { ...(preset ?? {}), ...(repo ?? {}) } : undefined;
126
130
  const rawCmds = gauntlet.verifyBeforeShip?.testCommands;
127
131
  const testCommands = Array.isArray(rawCmds) && rawCmds.length > 0 && rawCmds.every((c) => typeof c === "string" && c.trim()) ? (rawCmds as string[]) : undefined;
128
- return { telemetry, errors, agentOverrides, versions: realVersions(), testCommands };
132
+ return { telemetry, specDirs, planDirs, errors, agentOverrides, versions: realVersions(), testCommands };
129
133
  }
130
134
 
131
135
  export const realDeps: Deps = { fs: realFs, git: realGit, jj: realJj, now: isoNow, settings: realSettings };
@@ -142,16 +146,16 @@
142
146
  // gauntlet-resume's reconstruction skips brainstorm with `resume: <spec path>`; the path
143
147
  // is the bind candidate on that route because no spec write follows.
144
148
  const RESUME_REASON_RE = /^resume: (\S+)$/;
145
- export function resumeSpecOf(phases: PhaseMap | undefined): string | undefined {
149
+ export function resumeSpecOf(phases: PhaseMap | undefined, specDirs: readonly string[]): string | undefined {
146
150
  const b = phases?.brainstorm;
147
151
  const m = b?.status === "skipped" && typeof b.reason === "string" ? RESUME_REASON_RE.exec(b.reason) : null;
148
- return m && isSpecPath(toPosix(m[1])) ? m[1] : undefined;
152
+ return m && isSpecPath(toPosix(m[1]), specDirs) ? m[1] : undefined;
149
153
  }
150
154
 
151
155
  // Walks the session branch like phase-tracker.ts: successful phase_tracker results set
152
156
  // the phase map and the flow-entry marker; bind candidates are taken only while armed
153
157
  // and dropped on reset. Silent: no events are emitted.
154
- export function replayBranch(entries: Iterable<unknown>): ReplayResult {
158
+ export function replayBranch(entries: Iterable<unknown>, specDirs: readonly string[]): ReplayResult {
155
159
  const out: ReplayResult = { phases: emptyPhases(), gauntletEntered: false };
156
160
  const calls = new Map<string, { name: string; args: unknown }>();
157
161
  for (const raw of entries) {
@@ -173,14 +177,14 @@
173
177
  out.planCheckSpec = undefined;
174
178
  out.lastSpecWrite = undefined;
175
179
  }
176
- const resumed = d.action === "skip" ? resumeSpecOf(d.phases) : undefined;
180
+ const resumed = d.action === "skip" ? resumeSpecOf(d.phases, specDirs) : undefined;
177
181
  if (out.gauntletEntered && resumed) out.lastSpecWrite = resumed;
178
182
  } else if (msg.toolName === "plan_check") {
179
183
  const d = msg.details as { status?: string; specPath?: string } | undefined;
180
184
  if (out.gauntletEntered && d?.status === "pass" && typeof d.specPath === "string") out.planCheckSpec = d.specPath;
181
185
  } else if ((msg.toolName === "write" || msg.toolName === "edit") && !msg.isError && msg.toolCallId) {
182
186
  const p = (calls.get(msg.toolCallId)?.args as { path?: unknown } | undefined)?.path;
183
- if (out.gauntletEntered && typeof p === "string" && isSpecPath(toPosix(p))) out.lastSpecWrite = p;
187
+ if (out.gauntletEntered && typeof p === "string" && isSpecPath(toPosix(p), specDirs)) out.lastSpecWrite = p;
184
188
  }
185
189
  }
186
190
  return out;
@@ -328,7 +332,7 @@
328
332
  predecessorLinks.clear();
329
333
  record = loadOrCreate(specRel);
330
334
  for (const link of record.supersedes ?? []) {
331
- if (!isSafeSpecLink(link)) continue;
335
+ if (!isSafeSpecLink(link, snap.specDirs)) continue;
332
336
  const predecessor = deps.fs.readFile(abs(link));
333
337
  if (predecessor !== undefined && isSupersededByBanner(predecessor, record.spec)) predecessorLinks.add(link);
334
338
  }
@@ -369,9 +373,9 @@
369
373
  councilPending = undefined;
370
374
  };
371
375
 
372
- const isSafeSpecLink = (link: string): boolean => {
376
+ const isSafeSpecLink = (link: string, specDirs: readonly string[]): boolean => {
373
377
  const normalized = posix.normalize(link);
374
- return isSpecPath(link) && !posix.isAbsolute(link) && normalized !== ".." && !normalized.startsWith("../");
378
+ return isSpecPath(link, specDirs) && !posix.isAbsolute(link) && normalized !== ".." && !normalized.startsWith("../");
375
379
  };
376
380
 
377
381
  const refreshLinks = () => {
@@ -449,9 +453,9 @@
449
453
  }
450
454
  phases = details.phases;
451
455
  if (action === "skip" && !boundSpec && canBind() && lastCtx) {
452
- const resumed = resumeSpecOf(details.phases);
456
+ const resumed = resumeSpecOf(details.phases, snap.specDirs);
453
457
  const loc = resumed ? await locate(lastCtx, resumed) : undefined;
454
- if (loc && isSpecPath(loc.rel)) {
458
+ if (loc && isSpecPath(loc.rel, snap.specDirs)) {
455
459
  await bind(loc.rel, snap, loc.toplevel, loc.via);
456
460
  flush();
457
461
  }
@@ -481,13 +485,13 @@
481
485
  pendingTest.clear();
482
486
  sessionId = ctx.sessionManager.getSessionId();
483
487
  currentDir = snap.telemetry.dir;
484
- const replay = replayBranch(ctx.sessionManager.getBranch());
488
+ const replay = replayBranch(ctx.sessionManager.getBranch(), snap.specDirs);
485
489
  phases = replay.phases;
486
490
  gauntletEntered = replay.gauntletEntered;
487
491
  if (!canBind()) return;
488
492
  const candidate = replay.planCheckSpec ?? replay.lastSpecWrite;
489
493
  const loc = candidate ? await locate(ctx, candidate) : undefined;
490
- if (loc && isSpecPath(loc.rel)) {
494
+ if (loc && isSpecPath(loc.rel, snap.specDirs)) {
491
495
  await bind(loc.rel, snap, loc.toplevel, loc.via);
492
496
  flush();
493
497
  }
@@ -537,7 +541,7 @@
537
541
  const rawPath = (event.input as { path?: unknown }).path;
538
542
  const loc = typeof rawPath === "string" ? await locate(ctx, rawPath) : undefined;
539
543
  if (!loc) {
540
- if (typeof rawPath === "string" && isSpecPath(toPosix(rawPath))) warn(`spec ${rawPath} is outside a git checkout; telemetry not recorded`);
544
+ if (typeof rawPath === "string" && isSpecPath(toPosix(rawPath), snap.specDirs)) warn(`spec ${rawPath} is outside a git checkout; telemetry not recorded`);
541
545
  return undefined;
542
546
  }
543
547
  return onSpecInteraction(event.toolName, loc, event, snap);
@@ -568,13 +572,13 @@
568
572
  const onSpecInteraction = async (tool: "write" | "edit" | "read", loc: { toplevel: string; rel: string; via: "git" | "jj" }, event: { input: unknown; content: unknown[] }, snap: SettingsSnapshot): Promise<unknown> => {
569
573
  const rel = loc.rel;
570
574
  if (!boundSpec) {
571
- if ((tool === "write" || tool === "edit") && isSpecPath(rel)) {
575
+ if ((tool === "write" || tool === "edit") && isSpecPath(rel, snap.specDirs)) {
572
576
  await bind(rel, snap, loc.toplevel, loc.via);
573
577
  if (!boundSpec) return undefined;
574
578
  const patch = onBoundSpecWrite(tool, rel, event);
575
579
  flush();
576
580
  return patch;
577
- } else if (isPlanPath(rel)) {
581
+ } else if (isPlanPath(rel, snap.planDirs)) {
578
582
  const spec = planSpecHeader(deps.fs.readFile(join(loc.toplevel, rel)) ?? "");
579
583
  const specRel = spec ? repoRelativeToolPath(loc.toplevel, loc.toplevel, spec) : undefined;
580
584
  if (specRel && deps.fs.exists(join(loc.toplevel, specRel))) {
@@ -586,7 +590,7 @@
586
590
  }
587
591
  if (loc.toplevel !== toplevel) {
588
592
  // Another checkout: a fresh run, never a rename of this record.
589
- if ((tool === "write" || tool === "edit") && isSpecPath(rel) && canBind() && !sealedOnDisk(loc.toplevel, rel, snap.telemetry.dir)) {
593
+ if ((tool === "write" || tool === "edit") && isSpecPath(rel, snap.specDirs) && canBind() && !sealedOnDisk(loc.toplevel, rel, snap.telemetry.dir)) {
590
594
  unbind();
591
595
  await bind(rel, snap, loc.toplevel, loc.via);
592
596
  if (!boundSpec) return undefined;
@@ -602,13 +606,13 @@
602
606
  flush();
603
607
  return patch;
604
608
  }
605
- if (tool === "write" && isSpecPath(rel) && phases.brainstorm.status === "in_progress" && isDraftOrMissing(boundSpec)) {
609
+ if (tool === "write" && isSpecPath(rel, snap.specDirs) && phases.brainstorm.status === "in_progress" && isDraftOrMissing(boundSpec)) {
606
610
  if (await rebind(rel)) onBoundSpecWrite(tool, rel, event);
607
611
  flush();
608
612
  return undefined;
609
613
  }
610
- if (tool === "edit" && isSpecPath(rel)) {
611
- onOtherSpecEdit(rel, event);
614
+ if (tool === "edit" && isSpecPath(rel, snap.specDirs)) {
615
+ onOtherSpecEdit(rel, event, snap.specDirs);
612
616
  flush();
613
617
  }
614
618
  return undefined;
@@ -616,7 +620,7 @@
616
620
 
617
621
  // Defined in block 2.
618
622
  let onBoundSpecWrite: (tool: "write" | "edit", rel: string, event: { input: unknown; content: unknown[] }) => unknown;
619
- let onOtherSpecEdit: (rel: string, event: { input: unknown }) => void;
623
+ let onOtherSpecEdit: (rel: string, event: { input: unknown }, specDirs: string[]) => void;
620
624
 
621
625
  let amendmentOpen = false;
622
626
  let lastPlanTasks: { status: string }[] | undefined;
@@ -728,7 +732,7 @@
728
732
  if (!record || !toplevel || liveShipEvent(record.events)) return;
729
733
  record.status = "shipped";
730
734
  record.shipped_at = deps.now();
731
- const out = await computeJjDiff({ jj: deps.jj ?? realJj, cwd: toplevel, spec: record.spec, dir: currentDir, buckets: snap.telemetry.buckets });
735
+ const out = await computeJjDiff({ jj: deps.jj ?? realJj, cwd: toplevel, spec: record.spec, dir: currentDir, buckets: snap.telemetry.buckets, planDirs: snap.planDirs });
732
736
  if (out.warning) warn(out.warning);
733
737
  record.derived.modified_files = out.modified_files;
734
738
  record.derived.diff = out.diff;
@@ -743,7 +747,7 @@
743
747
  const rawPath = (event.input as { path?: unknown }).path;
744
748
  if (typeof rawPath === "string" && phases.brainstorm.status === "in_progress") {
745
749
  const loc = await locate(ctx, rawPath);
746
- if (loc && isSpecPath(loc.rel)) {
750
+ if (loc && isSpecPath(loc.rel, snap.specDirs)) {
747
751
  const recRel = recordPathFor(snap.telemetry.dir, loc.rel);
748
752
  const existing = deps.fs.readFile(join(loc.toplevel, recRel));
749
753
  const parsed = existing ? parseRecord(existing) : undefined;
@@ -832,8 +836,8 @@
832
836
  return undefined;
833
837
  };
834
838
 
835
- onOtherSpecEdit = (rel, event) => {
836
- if (!record || !boundSpec || !isSafeSpecLink(rel)) return;
839
+ onOtherSpecEdit = (rel, event, specDirs) => {
840
+ if (!record || !boundSpec || !isSafeSpecLink(rel, specDirs)) return;
837
841
  if (!isSupersededByBanner(insertedText(event.input), boundSpec)) return;
838
842
  predecessorLinks.add(rel);
839
843
  refreshLinks();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "6.0.0",
3
+ "version": "6.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",
@@ -17,9 +17,9 @@ Identify the target project → set up an isolated worktree → understand curre
17
17
 
18
18
  Do **not** implement until the design is presented and approved, regardless of simplicity. Implementation-heavy requests define spec scope; they do not lift this gate.
19
19
 
20
- You **may** read code/docs, run the existing system to observe current behavior, write under `doc/specs/`, and `edit` a predecessor spec there to add a [supersession banner](reference/superseding.md).
20
+ You **may** read code/docs, run the existing system to observe current behavior, write under a resolved spec dir (`gauntlet_setting({ key: "flowGuards" })` -> `specDirs`), and `edit` a predecessor spec there to add a [supersession banner](reference/superseding.md).
21
21
 
22
- You may **not** write outside `doc/specs/`; build, deploy, validate, or exercise the proposed change; run implementation skills; commit the spec on `main`; or start `/skill:writing-plans`.
22
+ You may **not** write outside the resolved spec dirs; build, deploy, validate, or exercise the proposed change; run implementation skills; commit the spec on `main`; or start `/skill:writing-plans`.
23
23
 
24
24
  The line: current-system observation is research; exercising the proposed change waits for approval.
25
25
 
@@ -63,7 +63,7 @@ Detection order: (1) infer from ticket labels, description, or paths; (2) use th
63
63
  The spec is the first commit in a dedicated worktree, never a separate commit on `main`.
64
64
 
65
65
  1. Invoke `/skill:using-git-worktrees`; use `.worktrees/` or the project-native location.
66
- 2. Carry its `Worktree ready at <full-path>` value: spec path `<full-path>/doc/specs/<filename>.md`; every dispatch uses that `cwd`; git uses `git -C <full-path>`. Keep the process cwd unchanged.
66
+ 2. Carry its `Worktree ready at <full-path>` value: spec path `<full-path>/<spec dir>/<filename>.md`; every dispatch uses that `cwd`; git uses `git -C <full-path>`. Keep the process cwd unchanged.
67
67
  3. Write, review, and commit there.
68
68
 
69
69
  Spec, plan, and implementation share this worktree. The spec and its telemetry record (`<telemetry.dir>/<spec path with .md -> .yaml>`, default `.pi/gauntlet/telemetry/doc/specs/<spec>.yaml`) are deliverables and ship in the squash; only the plan is stripped. `/skill:finishing-a-development-branch` strips the plan, then seals and commits the record before landing. Explicit trivial one-off edits outside this flow need no worktree.
@@ -146,7 +146,7 @@ Shared schemas, contracts, and invariants require a spec. If work is mechanical
146
146
 
147
147
  ## Filename Convention
148
148
 
149
- Write under the routed `doc/specs/`: ticketed `YYYY-MM-DD-<ticket-id>-<topic>.md`, otherwise `YYYY-MM-DD-<topic>.md`. Use a filename-safe tracker slug (`E-12345`, `gh-123`), but native references in plan headers and commits. `<topic>` is 3-6 kebab-case words without `-design` or another suffix.
149
+ Write under the routed spec dir: pick the routing prose's dir (overrides file, `AGENTS.md`) when it is one of the resolved dirs, else the one resolved dir from `gauntlet_setting({ key: "flowGuards" })` that already exists in the repo, else the first resolved dir (`doc/specs` under the default). Name ticketed files `YYYY-MM-DD-<ticket-id>-<topic>.md`, otherwise `YYYY-MM-DD-<topic>.md`. Use a filename-safe tracker slug (`E-12345`, `gh-123`), but native references in plan headers and commits. `<topic>` is 3-6 kebab-case words without `-design` or another suffix.
150
150
 
151
151
  Mint the slug once during gather and reuse it. If questionary invalidates it, write to the new path and delete the uncommitted draft.
152
152
 
@@ -293,7 +293,7 @@ One question at a time, YAGNI, 2-3 approaches, two design rounds, clarify freely
293
293
 
294
294
  ## Red Flags — STOP
295
295
 
296
- - Writes outside `doc/specs/` ([owner](#hard-constraint)).
296
+ - Writes outside the resolved spec dirs ([owner](#hard-constraint)).
297
297
  - Draft overwrite without the same-turn full read, or overwrite via `edit` ([owner](#spec-self-review-before-user-review-gate)).
298
298
  - Dispatch while line 1 is the context-draft marker ([owner](#spec-self-review-before-user-review-gate)).
299
299
  - Inline scope or ambiguity checks ([owner](#spec-council-optional)).
@@ -111,7 +111,7 @@ When agents return:
111
111
 
112
112
  Fix rounds in review loops reuse the fan-out mechanics above, keyed off the reviewer's partition certificate — the orchestrator never partitions findings itself.
113
113
 
114
- Reviewers certify fix concurrency with a `Parallel-safe:` line (see the reviewer's report contract). The fan-out trigger is a `disjoint` group naming **≥ 2 finding IDs**: that group IS the parallel wave — dispatch **one `implementer` per finding ID in the group** (`context: "fresh"`, `worktree: true`, `cwd` = the current worktree; task = that finding's block **verbatim**, including its `touched-files` line as the ownership boundary). A finding named in any `conflicts` pair runs sequentially after every finding it names has integrated (chained `conflicts` define a partial order; remaining serial findings run in the line's order). Findings outside any ≥ 2-ID `disjoint` group run sequentially. Fan out per review line only — never merge or co-schedule groups from different `Parallel-safe:` lines; run those fan-outs serially.
114
+ Reviewers certify fix concurrency with a `Parallel-safe:` line (see the reviewer's report contract). The fan-out trigger is a `disjoint` group naming **≥ 2 actionable finding IDs**: that group IS the parallel wave — dispatch **one `implementer` per actionable finding ID in the group (the probe's actionable set; the consuming loop places non-actionable findings)** (`context: "fresh"`, `worktree: true`, `cwd` = the current worktree; task = that finding's block **verbatim**, including its `touched-files` line as the ownership boundary). An actionable finding named in any `conflicts` pair runs sequentially after every finding it names has integrated (chained `conflicts` define a partial order; remaining serial findings run in the line's order). Actionable findings outside any ≥ 2-ID `disjoint` group run sequentially. Fan out per review line only — never merge or co-schedule groups from different `Parallel-safe:` lines; run those fan-outs serially.
115
115
 
116
116
  **Precondition:** a clean committed HEAD containing the code under review. When the reviewed change is an unintegrated patch (a wave-mode per-patch spec review), each fix task branches from the wave's base HEAD and carries the prior patch verbatim in its task text — the consuming loop's existing re-dispatch protocol. When the tree is dirty (e.g. post-integration, before the wave commit), the fan-out is unavailable: fix sequentially in place.
117
117
 
@@ -82,7 +82,9 @@ This is an **enforced disposition gate**, not a surface-only notice. The user is
82
82
 
83
83
  **Freshness precondition - before any verdict branch, including `CONFORMS`.** The durable block opens with a sentinel: `status: CONFORMS (0 open)` or `status: GAPS (N open)`, then `audited-base: <full HEAD SHA at audit time>`, then an optional `happy-path: <outcome>` line (present exactly when a happy-path run happened; informational, never a freshness input). Read the sentinel, then apply the reference's freshness rule (its `## Closure / conformance` block is the single source): compare `audited-base` to the current working tree; any change, doubt, missing/mismatched sentinel, legacy terse row, or malformed structured reviewer block triggers a fresh audit and replacement of the closure block. Never infer `CONFORMS` from the absence of bullets. Only a clean, valid `status: CONFORMS (0 open)` handoff enters the zero-gap fast path.
84
84
 
85
- **Zero-gap fast path:** print exactly
85
+ **Whole-diff minors (both paths).** Read the block's `whole-diff minors:` section (`conformance-check.md` "Closure / conformance") and render it as one informational, non-blocking line in the `happy-path:` position - directly under the `happy-path:` line when present, otherwise directly under `Closure / conformance: CONFORMS` or the `Conformance: N decisions needed before shipping.` header: `whole-diff minors (3, not blocking): F3, F7, F9`, `whole-diff minors: none`, or `whole-diff minors: not recorded` (section absent). Never a re-audit trigger. The reply point is the Step 4 menu.
86
+
87
+ **Zero-gap fast path:** print (plus the informational lines below)
86
88
 
87
89
  ```
88
90
  Closure / conformance: CONFORMS
@@ -177,7 +179,9 @@ Which option?
177
179
 
178
180
  Rows 1-2 run the Option 1/2 blocks; row 3 runs the Keep block and row 4 the Discard block.
179
181
 
180
- **Don't add explanation** - keep options concise.
182
+ **Don't add explanation** - keep options concise. When the minors line lists findings and the block has no `whole-diff minors fix:` line, add only this line under the menu: `Or reply "fix minors" (or "fix minors F3, F9") for one Minor fix round.` This line is the only addition to the exact numbered options.
183
+
184
+ **Standalone extra reply - `fix minors`.** When the rendered `whole-diff minors` line lists findings, `fix minors` (all) or `fix minors F3, F9` (a subset by `Fn`) is a valid reply beside the numbered options. It dispatches one `implementer` round (`cwd` = the worktree; task = the selected finding lines verbatim; `SCOPED_TEST_COMMANDS` = the plan-header verification set), commits `whole-diff minors fix`, and returns to Step 3.5, where the freshness rule re-audits conformance as for any working-tree change and the regenerated block records the spent round (`whole-diff minors fix: <full SHA>`); Step 4 then re-renders. No code review follows this round: the whole-diff review is an unscoped first review, so its Minors are graded Minors. A block that already carries `whole-diff minors fix:` refuses the reply with "minors round already spent". Any numbered choice ships without them.
181
185
 
182
186
  ### Step 5: Execute Choice
183
187
 
@@ -185,11 +189,11 @@ Rows 1-2 run the Option 1/2 blocks; row 3 runs the Keep block and row 4 the Disc
185
189
 
186
190
  Run this on the feature branch before any landing path. The spec and its telemetry record (`<telemetry.dir>/<spec path with .md -> .yaml>`, default `.pi/gauntlet/telemetry/doc/specs/<spec>.yaml`) are deliverables and ship in the squash; only the plan is stripped. The record is an untracked, git-excluded file until this step commits it. `<bin>` is `<directory of this skill's SKILL.md>/../../bin`, resolved from the skill's `<location>` in the system prompt.
187
191
 
188
- Read the plan's `**Spec:**` header (repo-relative, backticks stripped) before the strip; that value is `--spec`.
192
+ Read the plan's `**Spec:**` header (repo-relative, backticks stripped) before the strip; pass that value as `--spec`. Resolve the spec dirs with `gauntlet_setting({ key: "flowGuards" })` and use their sibling `plans` dirs for plan paths.
189
193
 
190
194
  ```bash
191
195
  # Plans are ephemeral - if one was committed on this branch, remove it before landing.
192
- PLAN_PATH=doc/plans/<plan-file>.md # or <service>/doc/plans/<plan-file>.md
196
+ PLAN_PATH=<sibling-plans-dir>/<plan-file>.md # the sibling plans dir of the spec's dir
193
197
  if git -C "$WORKTREE" ls-files --error-unmatch "$PLAN_PATH" >/dev/null 2>&1; then
194
198
  git -C "$WORKTREE" rm "$PLAN_PATH" && git -C "$WORKTREE" commit -m "Remove ephemeral plan doc"
195
199
  fi
@@ -25,10 +25,7 @@ for a base ref before reading any artifact.
25
25
 
26
26
  A pinned spec (seed route, `seed.md`) skips this section.
27
27
 
28
- Spec/plan directories: `piGauntlet.flowGuards.specDirs` plus each one's sibling `plans` directory. Resolve with the precedence `doc/configuration.md` documents for every `piGauntlet.*` key: the session cwd's `.pi/settings.json` if it defines `flowGuards`,
29
- else the active pi profile's `settings.json`, else the default `["doc/specs"]` (sibling
30
- `doc/plans`). An empty array is the default. `<dirs>` below is that resolved list,
31
- space-separated - never the literal defaults when a setting is present.
28
+ Resolve with `gauntlet_setting({ key: "flowGuards" })`; `<dirs>` below is its `specDirs` plus `planDirs` (each spec dir's sibling `plans` dir), space-separated.
32
29
 
33
30
  ```bash
34
31
  git -C <worktree> diff --diff-filter=A --name-only <base>..HEAD -- <dirs>
@@ -51,9 +51,11 @@ subagent({ agent: "code-reviewer", async: false, task: "... filled template ..."
51
51
  - `{DESCRIPTION}` - Brief summary
52
52
  - `{SCOPED_TEST_COMMANDS}` - the scoped verification commands the reviewer may run, or `none`
53
53
 
54
- **Fix rounds.** Critical and Moderate findings trigger a fix round; when dispatched from an orchestrating skill, fixes go to `implementer` subagents (per the orchestrator's no-self-coding rule), fanned out per `dispatching-parallel-agents` "Fix fan-out" when the review's `Parallel-safe:` line certifies a `disjoint` group of ≥ 2 findings. Before fanning out, validate the review's `Parallel-safe:` line with the structural probe in `dispatching-parallel-agents` § Fix fan-out (exactly-one-line grammar check, one re-ask, then explicit sequential fallback). After integration and the project's test command, re-dispatch the reviewer once on the integrated delta in the foreground with top-level `async: false`; await its terminal result. If Critical or Moderate findings remain, run one more fix round and one more foreground re-review; still failing → escalate to the user. Minor findings never trigger the fan-out.
54
+ **Fix rounds.** A `FIX_FIRST` report triggers a fix round. A `SHIP` report dispatches nothing and ends the loop, whatever Minors it lists. `REJECT` is neither: stop and report (in SDD, per its `stop-note.md`); no fix round. When dispatched from an orchestrating skill, fixes go to `implementer` subagents (per the orchestrator's no-self-coding rule), fanned out per `dispatching-parallel-agents` "Fix fan-out" when the review's `Parallel-safe:` line certifies a `disjoint` group of ≥ 2 Critical/Moderate findings. Before fanning out, validate the review's `Parallel-safe:` line with the structural probe in `dispatching-parallel-agents` § Fix fan-out (exactly-one-line grammar check, one re-ask, then explicit sequential fallback). Sequential round (one task): the task carries the whole report; every Minor rides. Fan-out: the certificate partitions the Critical and Moderate findings; each Minor attaches once to the first task, in `Fn` order, whose ownership boundary - the union of its findings' `touched-files:` - already contains the Minor's `touched-files:`; a Minor no boundary contains is not dispatched this round, and a boundary never grows for a Minor. The implementer fixes its Minors or declines one with a stated reason. After integration and the project's test command, re-dispatch the reviewer once on the integrated delta in the foreground with top-level `async: false`; await its terminal result. A `FIX_FIRST` re-review buys one more fix round and one more foreground re-review; still `FIX_FIRST` -> escalate to the user. A `SHIP` report's Minors are not carried, with one exception: the whole-diff review's Minor finding lines go verbatim into the closure block's `whole-diff minors:` section (`verification-before-completion/reference/conformance-check.md` "Closure / conformance").
55
55
 
56
- Take the recipient stance from `receiving-code-review`: verify each finding, answer a wrong finding with evidence, defer Minor items explicitly.
56
+ Worked example: a `FIX_FIRST` report with 1 Moderate and 3 Minors -> one implementer task holding all four findings (one actionable ID, no fan-out). A `SHIP` report with 4 Minors -> no dispatch; the loop ends.
57
+
58
+ Take the recipient stance from `receiving-code-review`: verify each finding, answer a wrong finding with evidence.
57
59
 
58
60
  ## Integration with Workflows
59
61
 
@@ -62,7 +62,7 @@ For each task in `plan_tracker`:
62
62
  3. **Dispatch spec reviewer.** Pass the task text, the patch diff, the absolute spec path, and the task's `**Spec:**` anchors, plus the task's `**Tests:**` block and `- Test:` paths as contract (never as commands - SR never executes) — SR reads the anchored ranges from the spec file itself; never inline spec excerpts. The spec wins every dispute; the authority hierarchy and finding labels live in `./spec-reviewer-prompt.md`. Anchor-less tasks (no `**Spec:**` line): task text alone is the contract. Verify the change satisfies the anchored spec — nothing missing, nothing extra.
63
63
  4. If spec reviewer finds gaps → re-dispatch implementer to fix → re-review. Loop until ✅, within [Fix-Loop Rounds](#fix-loop-rounds).
64
64
  5. **Dispatch code-quality reviewer.** Only after spec is ✅. Skip for doc-only tasks (every file in the task's `Files:` block documentation-only) — SR-only, same exemption as doc-only waves. Pass the task's `SCOPED_TEST_COMMANDS`.
65
- 6. If quality reviewer finds issues → re-dispatch implementer → re-review. Loop until ✅, within [Fix-Loop Rounds](#fix-loop-rounds).
65
+ 6. If the quality review is issue-bearing → re-dispatch the implementer per `/skill:requesting-code-review` Fix rounds → re-review. Loop until ✅, within [Fix-Loop Rounds](#fix-loop-rounds).
66
66
  7. After its existing reviews accept the work (and its required commit point), mark that same task index `complete` in `plan_tracker`. A subagent exit or green tests alone are not acceptance.
67
67
 
68
68
  The orchestrator is the spec's only writer during execution. Amendment trigger: implementer `BLOCKED` citing a spec defect, or a review finding showing the spec (not the code) is wrong -> pause the fix loop, execute brainstorming's [amendment path](../brainstorming/SKILL.md#amending-an-approved-spec) in place (it reviews first, stops only on escalation or redraw), resume. Code-vs-spec mismatch stays in the SR loop. An SR unable to read the spec at a cited anchor (missing file, unresolvable heading/range) returns a blocking finding — the contract is spec+task or stop, never a silent fallback to task-only review.
@@ -73,15 +73,15 @@ After all tasks: proceed to [After All Tasks](#after-all-tasks-complete) - it ow
73
73
 
74
74
  One rule governs both review loops - spec-compliance and code-quality - in sequential and parallel-wave modes alike.
75
75
 
76
- **Re-review dispatch rule:** every re-review task includes the complete prior review report verbatim under the marker `## Previous review report (re-review trigger)`, plus the trajectory block from the reviewer's prompt template. The marker's presence is what obligates the reviewer to emit the `TRAJECTORY:` line. You never select, summarize, or diff findings yourself - pattern-match the sentinel line only.
76
+ **Re-review dispatch rule:** every re-review task includes the complete prior review report verbatim under the marker `## Previous review report (re-review trigger)`, plus the trajectory block from the reviewer's prompt template. The marker's presence is what obligates the reviewer to emit the `TRAJECTORY:` line. You never select, summarize, or diff findings yourself - pattern-match the sentinel line only. Code re-reviews also carry a `## Fix delta` block holding the fix round just integrated: sequential mode, the output of `git diff <HEAD the prior review was dispatched against> HEAD`; wave mode (HEAD unmoved until step 7), the applied fix patch(es) verbatim, never a SHA range. Whole-diff re-reviews never carry the block. Omit it otherwise only when the input is unavailable (a fresh-session resume); the reviewer then treats the task's whole diff as the delta.
77
77
 
78
- **Fix fan-out.** When the triggering review's `Parallel-safe:` line certifies a `disjoint` group of ≥ 2 findings, dispatch that fix round per `dispatching-parallel-agents` "Fix fan-out"; the fan-out counts as **one** fix against this budget, its scoped test gate is the consuming task/wave's `SCOPED_TEST_COMMANDS`, and one re-review of the integrated delta follows.
78
+ **Fix fan-out.** When a fix round is fanned out per `/skill:requesting-code-review` Fix rounds, dispatch per `dispatching-parallel-agents` "Fix fan-out"; the fan-out counts as **one** fix against this budget, its scoped test gate is the consuming task/wave's `SCOPED_TEST_COMMANDS`, and one re-review of the integrated delta follows.
79
79
 
80
80
  **Behaviour-change reroute.** When the triggering CR report carries `Behaviour-change: yes`, the fix round's re-review is SR first, then CR. The SR dispatch reviews the fix diff against the task's spec anchors as a first review (no `## Previous review report` marker, so no `TRAJECTORY:` line; SR carries no test commands but does carry the task's `**Tests:**` block and `- Test:` paths), but its ordinal continues the task's SR-loop count - a task whose SR loop ended at review 2 gets review 3 here, and an issue-bearing rerouted SR after review 4 escalates. Issues follow the normal sequence: fix, then SR re-review pasting this SR's report. CR round numbering is unchanged. `Behaviour-change: no` re-reviews with CR only. A missing or malformed `Behaviour-change:` line is re-asked once like `Parallel-safe:` (see `dispatching-parallel-agents` "Structural probe"); still missing -> route through SR, never default to `no`. In wave mode the SR re-review targets the task(s) whose files the fix touched.
81
81
 
82
82
  Every fix re-dispatch (implementer) and code-review re-review carries the consuming task/wave's `SCOPED_TEST_COMMANDS`; spec-reviewer re-reviews carry the `**Tests:**` block and `- Test:` paths, no commands - SR never executes.
83
83
 
84
- **The sequence.** Each review that finds issues is a decision point: read the `TRAJECTORY:` line before dispatching anything (review 1 has no line - on issues, dispatch fix 1). Any clean review ends the loop.
84
+ **The sequence.** Each review that finds issues is a decision point: read the `TRAJECTORY:` line before dispatching anything (review 1 has no line - on issues, dispatch fix 1). Any clean review ends the loop. For code review, *issue-bearing* means verdict `FIX_FIRST` and *clean* means verdict `SHIP`, whatever Minors the report lists; spec review keeps its gaps/acceptance meaning. `REJECT` is neither: stop and report per `stop-note.md`; the loop never enters.
85
85
 
86
86
  1. **Review 1** (first review - no sentinel). Issues -> dispatch fix 1.
87
87
  2. **Review 2** (re-review). Issues -> if `STAGNANT`, escalate now - no fix 2. Otherwise dispatch fix 2.
@@ -170,7 +170,7 @@ Auto-selected at handoff by `writing-plans` (any wave with ≥2 tasks) when the
170
170
  3. **Status + spec review per task.** Parse each `DONE`/`BLOCKED`/etc. (see [Implementer Status](#implementer-status)) **first**. Then **dispatch a `spec-reviewer` per accepted patch** (`DONE`, or a `DONE_WITH_CONCERNS` you proceeded with) in one parallel fan-out — `context: "fresh"`, `cwd: <this worktree>`, **no `worktree` flag** (read-only) — each passed its task text, the returned **patch diff**, the absolute spec path, and the task's `**Spec:**` anchors, plus its `**Tests:**` block and `- Test:` paths as contract — SR reads the anchored ranges itself (never inline excerpts; authority hierarchy in `./spec-reviewer-prompt.md`; anchor-less tasks are task-text-only). Review starts from the diff (its hunks carry `file:line`) and reads each touched file in full, and test execution is never the reviewer's job - in either mode (persona rule; the wave test gate in step 5 runs the wave's `SCOPED_TEST_COMMANDS`). Inline verdicts are fine at normal wave sizes; large waves use `output:` + `outputMode: "file-only"` to keep verdicts out of your context. **Re-dispatch by cause:** `BLOCKED`/`NEEDS_CONTEXT` per the [Implementer Status](#implementer-status) matrix; a **spec gap** re-dispatches the implementer (fresh, `worktree: true`) carrying the prior patch + the reviewer's findings, the new patch superseding the old at step 4. Loop until accepted + spec ✅, within [Fix-Loop Rounds](#fix-loop-rounds), same as sequential.
171
171
  4. **Integrate.** `git -C <worktree> apply` each task's patch sequentially onto HEAD. Apply fails = textual conflict → drop that task, finish the rest, re-run the dropped task sequentially on the updated HEAD.
172
172
  5. **Test gate.** Run the wave's `SCOPED_TEST_COMMANDS` (union of its tasks' `Tests:` commands) on the integrated tree as `(cd "<worktree>" && <command>)` for each command — the full verification set is the verify phase's job, run once. Failure = semantic conflict or bug → re-run the offending task sequentially, else fix per [When a Subagent Fails](#when-a-subagent-fails).
173
- 6. **Quality review.** CR binds to the wave: exactly one **initial** code-review dispatch per code-touching wave, over the integrated wave diff - never per task within a wave, never batched across waves. Subsequent dispatches within the wave are re-reviews triggered only by findings, per Fix-Loop Rounds. Pass the wave's `SCOPED_TEST_COMMANDS`. Code-quality review on the integrated wave diff; loop fixes to ✅ within [Fix-Loop Rounds](#fix-loop-rounds), same as sequential. Skip for doc-only waves (SR-only per the commit precondition below).
173
+ 6. **Quality review.** CR binds to the wave: exactly one **initial** code-review dispatch per code-touching wave, over the integrated wave diff - never per task within a wave, never batched across waves. Subsequent dispatches within the wave are re-reviews triggered only by findings, per Fix-Loop Rounds. Pass the wave's `SCOPED_TEST_COMMANDS`. Code-quality review on the integrated wave diff; when issue-bearing, re-dispatch the implementer per `/skill:requesting-code-review` Fix rounds and re-review, to ✅ within [Fix-Loop Rounds](#fix-loop-rounds), same as sequential. Skip for doc-only waves (SR-only per the commit precondition below).
174
174
  7. **Commit and complete the wave.** After the gate passes, commit with `git -C <worktree> add ... && git -C <worktree> commit ...`, then mark all of its existing indices `complete`. Leaves a clean tree; the next wave's children branch from this commit and so see the integrated work.
175
175
 
176
176
  **Two-stage review is preserved:** spec review per task (pre-integration, dispatched `spec-reviewer` — not inline), quality review per wave (post-integration). A wave commit requires one spec-review verdict per accepted task, plus one code-review verdict on the integrated diff for waves that touch code. A doc-only wave (every task's `Files:` block documentation-only, per `writing-plans`' Wave Grouping) is SR-only — the CR gate does not apply.
@@ -221,7 +221,7 @@ For the fan-out + worktree + patch-integration + conflict mechanics, see `dispat
221
221
 
222
222
  0. Call `phase_tracker({ action: "start", phase: "verify" })`. (The `implement` phase was started at execution start and auto-completes from `plan_tracker` once all tasks are done; this flow runs its own verify gate instead of `/skill:verification-before-completion`, so it must mark verify itself.)
223
223
  1. **Parent full verification.** Run the complete plan-header `**Verification:**` command set foreground in the worktree via `(cd "<worktree>" && <command>)`: tests plus every declared lint, type, format, and build check. A failure must be repaired and the full set rerun successfully before the next step. Before dispatching a verification repair, reopen (`in_progress`) every existing plan-task index whose `Files:` ownership includes its touched files; leave unowned cross-cutting repair work in the verification report. Those indices stay `in_progress` through the successful full rerun **and** step 2's whole-diff review accepting the repair — that acceptance is their completion point, not the passing rerun. Commit any verification-produced tracked changes; use the resulting `HEAD_SHA` in the review task and include the commands/results in its existing `DESCRIPTION`.
224
- 2. **Whole-diff code review.** Only after passing full verification, dispatch one foreground whole-diff `code-reviewer` per `/skill:requesting-code-review` against that committed HEAD, with `SCOPED_TEST_COMMANDS: none`; the reviewer does not repeat the full suite. Address Critical and Moderate findings. Before dispatching a review repair, reopen (`in_progress`) every existing plan-task index whose `Files:` ownership includes its touched files; leave unowned cross-cutting repair work in the review report. Mark each reopened index `complete` only once the repair is re-verified and the re-review accepts it — this is the same completion point step 1's reopened indices wait for, not an extra gate, and the gate order stays full verification -> whole-diff CR -> conformance. Any repair invalidates prior full verification, so rerun the full set successfully before the next gate.
224
+ 2. **Whole-diff code review.** Only after passing full verification, dispatch one foreground whole-diff `code-reviewer` per `/skill:requesting-code-review` against that committed HEAD, with `SCOPED_TEST_COMMANDS: none`; the reviewer does not repeat the full suite. Act on the verdict per `/skill:requesting-code-review` Fix rounds; carry the final `SHIP` report's Minor finding lines verbatim into the closure block's `whole-diff minors:` section (`verification-before-completion/reference/conformance-check.md` "Closure / conformance"), or `whole-diff minors: none`. Before dispatching a review repair, reopen (`in_progress`) every existing plan-task index whose `Files:` ownership includes its touched files; leave unowned cross-cutting repair work in the review report. Mark each reopened index `complete` only once the repair is re-verified and the re-review accepts it — this is the same completion point step 1's reopened indices wait for, not an extra gate, and the gate order stays full verification -> whole-diff CR -> conformance. Any repair invalidates prior full verification, so rerun the full set successfully before the next gate.
225
225
  3. **Close the loop — conformance check.** The review in step 2 is plan-vs-code (single-step); it inherits any requirement the plan already dropped.
226
226
 
227
227
  **Happy-path run - first action of this step, only when the plan header carries a `**Happy path:**` line or the diff selects a row.** The overrides file's `## Happy path` table (schema in the README overrides contract) names path-prefixed rows; the plan header's line is the plan-time default.