pi-gauntlet 4.2.3 → 4.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## v4.3.1 - 2026-07-06
4
+
5
+ Fix spec-summary pruning at the brainstorming user-review gate. The gate's
6
+ `spec-summarizer` dispatch now writes to a temp file via `outputMode: "file-only"`
7
+ and the main loop reads it back verbatim, so the ~9KB summary survives pi-condense
8
+ instead of being pruned to a paraphrase before render.
9
+
10
+ - **`brainstorming` gate transport:** dispatch `spec-summarizer` with an absolute
11
+ temp-dir `output:` path + `outputMode: "file-only"`, `Read` it back as the last
12
+ content-producing tool call before the gate, and degrade to a one-line note on a
13
+ stub / truncated / missing file. The temp path is outside the worktree, so it is
14
+ never committed.
15
+ - **`spec-summarizer` persona:** stays `tools: read`; a directive tells it not to
16
+ attempt the injected "write your findings" instruction (the harness persists its
17
+ final text for it), dodging the documented stub/stall failure on weaker models.
18
+ Adds a length-proportionality directive so summaries scale with spec size.
19
+ - **`AGENTS.md`:** `outputMode` added to the call-site-overridable knobs list.
20
+
21
+ Restore the per-task spec gate in `subagent-driven-development` Parallel-Wave mode.
22
+ Wave-mode step 3 reviewed spec compliance inline instead of dispatching the
23
+ `spec-reviewer` agent (sequential mode already dispatched it), silently collapsing
24
+ the gate.
25
+
26
+ - **`subagent-driven-development` wave step 3:** fan out one `spec-reviewer` per
27
+ accepted patch (fresh context, `cwd` worktree, diff-based); pin the two-stage
28
+ line to "dispatched spec-reviewer - not inline"; add a Red Flag.
29
+
30
+ ## v4.3.0 - 2026-07-06
31
+
32
+ DRY gauntlet settings resolution. All `piGauntlet.*` settings reads now route
33
+ through one shared resolver (`extensions/lib/gauntlet-settings.ts`), consumed by
34
+ a new `gauntlet_setting` tool (for skills) and by the extensions directly.
35
+
36
+ - **New `gauntlet_setting` tool** (registered by `phase-tracker`, gauntlet-internal):
37
+ returns the resolved `specCouncil` or `closureReview` value from pi's merged
38
+ settings. Five skill call sites (`brainstorming`, `roasting-the-spec`,
39
+ `subagent-driven-development`, `conformance-check`, `settings-precedence`)
40
+ replace their hand-rolled two-file bash/prose merge with this tool. This fixes
41
+ an observed failure where the repo-only read fell back to the fresh `worker`
42
+ critique and silently skipped a configured spec council.
43
+ - **Behavior change (latent-bug fix):** extensions previously read `pi.settings`,
44
+ which does not exist on the extension API - so every `closureReview`,
45
+ `flowGuards`, and `verifyBeforeShip` config read was dead (always `{}`) and
46
+ consumer overrides were silently ignored. These settings now take effect for
47
+ the first time. Most visible: `closureReview.model` now actually pins the
48
+ conformance-gate model (and the phase-tracker guard can block a mismatched
49
+ dispatch). If you set any of these keys expecting the prior no-op, review them.
50
+ - `scripts/ci.mjs` now type-checks `extensions/lib/`, runs the resolver unit
51
+ tests, asserts the lib files are packed, and enforces a no-`pi.settings`
52
+ invariant.
53
+
3
54
  ## v4.2.3 - 2026-07-05
4
55
 
5
56
  Branding, funding, and gallery preview. No behavior change.
package/README.md CHANGED
@@ -232,6 +232,8 @@ A tool, not a hook. Skills call `phase_tracker({ action: "start" | "complete" |
232
232
 
233
233
  Distinct from `plan-tracker`: `phase-tracker` answers "what stage of the workflow am I in?"; `plan-tracker` answers "which task within the current stage am I on?"
234
234
 
235
+ **`gauntlet_setting` tool.** `phase-tracker` also registers `gauntlet_setting({ key: "specCouncil" | "closureReview" })`, a gauntlet-internal tool through which skills resolve merged `piGauntlet.*` settings (repo `.pi/settings.json` over the agent preset, via pi's own `SettingsManager`). It returns the resolved value as a JSON block in the tool result — `specCouncil` yields the council-vs-worker verdict, `closureReview` yields the conformance-gate `model`/`enforce`/`maxFixRounds`. It introduces no new settings key. Every `piGauntlet.*` read — the skills via this tool, both extensions directly — routes through one shared helper (`extensions/lib/gauntlet-settings*.ts`); no code reads `pi.settings` by hand.
236
+
235
237
  **Closure-review gate.** `complete verify` is rejected unless a successful
236
238
  `conformance-reviewer` dispatch (a `subagent` result whose `results[]` contains
237
239
  `agent: "conformance-reviewer"` with `exitCode: 0`) has been observed since the
@@ -19,6 +19,7 @@ Your output is judged on whether a busy supervisor can decide from it alone. A t
19
19
 
20
20
  Optimize for "what does a supervisor need to approve **this** spec." The list below is a recommended checklist, not a rigid template:
21
21
 
22
+ - **Scale length to the spec.** The summary is proportional to the spec's size and complexity - a short or simple spec gets a short summary. Do not expand every recommended section to full depth to look thorough; the summary should be a fraction of the spec, not a near-copy of it. This is proportionality, **not** aggressive compression - never drop a decision-relevant point, rejected-alternative, risk, or gap-footer entry to hit a length target. When in doubt, keep the point and cut the words around it.
22
23
  - **Omit any section that is empty.** A bugfix has no new endpoint; a refactor has no algorithm. Write nothing for an empty section - never "N/A" or filler.
23
24
  - **Order decision-layer-first** (problem -> decisions -> scope -> risk), then the descriptive layer, so the reader can stop early once confident.
24
25
  - **Add a section the spec demands** if it carries decision-relevant content none of the below captures.
@@ -39,4 +40,4 @@ Recommended sections:
39
40
 
40
41
  Tight, human-readable, no obvious statements. If the topic is complex, an example with explanation beats prose.
41
42
 
42
- Output the summary as your final text response. Do not write any file.
43
+ Output the summary as your final text response. You have no write tool. If your task instructs you to write your findings to a file path, do **not** attempt to write, create, or edit any file and do **not** treat the inability to write as a failure - just emit the full summary as your final text response. The harness persists that response to the requested path for you.
@@ -0,0 +1,23 @@
1
+ // Isolates the pi runtime import so the pure resolver module stays importable by
2
+ // ci.mjs unit tests. Only pi-loaded extensions import this file.
3
+ import { SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import { mergeGauntlet, type PiGauntlet } from "./gauntlet-settings.ts";
5
+
6
+ export interface LoadedGauntlet {
7
+ gauntlet: PiGauntlet;
8
+ errors: string[];
9
+ }
10
+
11
+ // Reads the preset (agentDir/settings.json) and repo (cwd/.pi/settings.json)
12
+ // layers via pi's own SettingsManager and returns the whole-object second-level
13
+ // merge (repo over preset). SettingsManager never throws on a bad file - it
14
+ // substitutes {} for that layer and records the error, surfaced here via errors[]
15
+ // so callers can report a degraded read instead of failing silent.
16
+ export function loadGauntletSettings(cwd: string, agentDir: string = getAgentDir()): LoadedGauntlet {
17
+ const sm = SettingsManager.create(cwd, agentDir);
18
+ const preset = sm.getGlobalSettings() as { piGauntlet?: Record<string, unknown> };
19
+ const repo = sm.getProjectSettings() as { piGauntlet?: Record<string, unknown> };
20
+ const gauntlet = mergeGauntlet(preset?.piGauntlet, repo?.piGauntlet);
21
+ const errors = sm.drainErrors().map((e) => `${e.scope}: ${e.error.message}`);
22
+ return { gauntlet, errors };
23
+ }
@@ -0,0 +1,118 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ mergeGauntlet,
5
+ resolveSpecCouncil,
6
+ resolveClosureReview,
7
+ resolveFlowGuards,
8
+ resolveVerifyBeforeShip,
9
+ settingsErrorWarning,
10
+ type PiGauntlet,
11
+ } from "./gauntlet-settings.ts";
12
+
13
+ const DEFAULT_TEST_COMMANDS = ["make ci", "pytest"];
14
+
15
+ test("mergeGauntlet: repo key replaces preset key whole-object", () => {
16
+ const preset = { specCouncil: { members: ["a"], chair: "c" }, closureReview: { model: "m" } };
17
+ const repo = { specCouncil: { members: ["b"] } };
18
+ const merged = mergeGauntlet(preset, repo);
19
+ assert.deepEqual(merged.specCouncil, { members: ["b"] });
20
+ assert.deepEqual(merged.closureReview, { model: "m" });
21
+ });
22
+
23
+ test("mergeGauntlet: undefined layers -> {}", () => {
24
+ assert.deepEqual(mergeGauntlet(undefined, undefined), {});
25
+ });
26
+
27
+ test("specCouncil: non-empty string array -> council", () => {
28
+ const r = resolveSpecCouncil({ specCouncil: { members: ["p/m1", " p/m2 "], chair: "p/c" } });
29
+ assert.equal(r.verdict, "council");
30
+ assert.deepEqual(r.members, ["p/m1", "p/m2"]);
31
+ assert.equal(r.chair, "p/c");
32
+ assert.equal(r.malformed, false);
33
+ assert.equal(r.warning, undefined);
34
+ });
35
+
36
+ test("specCouncil: absent -> worker, not malformed", () => {
37
+ const r = resolveSpecCouncil({});
38
+ assert.equal(r.verdict, "worker");
39
+ assert.deepEqual(r.members, []);
40
+ assert.equal(r.malformed, false);
41
+ });
42
+
43
+ test("specCouncil: empty array -> worker, not malformed", () => {
44
+ const r = resolveSpecCouncil({ specCouncil: { members: [] } });
45
+ assert.equal(r.verdict, "worker");
46
+ assert.equal(r.malformed, false);
47
+ });
48
+
49
+ test("specCouncil: non-array members -> worker + malformed + warning", () => {
50
+ const r = resolveSpecCouncil({ specCouncil: { members: "p/m" as unknown as string[] } });
51
+ assert.equal(r.verdict, "worker");
52
+ assert.equal(r.malformed, true);
53
+ assert.ok(r.warning);
54
+ });
55
+
56
+ test("specCouncil: entry not a non-empty string -> worker + malformed", () => {
57
+ const r = resolveSpecCouncil({ specCouncil: { members: ["p/m", ""] } });
58
+ assert.equal(r.verdict, "worker");
59
+ assert.equal(r.malformed, true);
60
+ });
61
+
62
+ test("specCouncil: chair echoed in worker path", () => {
63
+ const r = resolveSpecCouncil({ specCouncil: { members: [], chair: "p/c" } });
64
+ assert.equal(r.verdict, "worker");
65
+ assert.equal(r.chair, "p/c");
66
+ });
67
+
68
+ test("specCouncil: non-string chair does NOT downgrade a valid members verdict", () => {
69
+ const r = resolveSpecCouncil({ specCouncil: { members: ["p/m"], chair: 5 as unknown as string } });
70
+ assert.equal(r.verdict, "council");
71
+ assert.equal(r.chair, undefined);
72
+ assert.equal(r.malformed, true);
73
+ assert.ok(r.warning);
74
+ });
75
+
76
+ test("closureReview: model present/non-string/absent", () => {
77
+ assert.equal(resolveClosureReview({ closureReview: { model: " m " } }).model, "m");
78
+ assert.equal(resolveClosureReview({ closureReview: { model: 5 as unknown as string } }).model, undefined);
79
+ assert.equal(resolveClosureReview({}).model, undefined);
80
+ });
81
+
82
+ test("closureReview: enforce default true; false only when explicitly false", () => {
83
+ assert.equal(resolveClosureReview({}).enforce, true);
84
+ assert.equal(resolveClosureReview({ closureReview: { enforce: false } }).enforce, false);
85
+ assert.equal(resolveClosureReview({ closureReview: { enforce: true } }).enforce, true);
86
+ });
87
+
88
+ test("closureReview: maxFixRounds default 2, <0 -> 0, non-int -> 2", () => {
89
+ assert.equal(resolveClosureReview({}).maxFixRounds, 2);
90
+ assert.equal(resolveClosureReview({ closureReview: { maxFixRounds: 5 } }).maxFixRounds, 5);
91
+ assert.equal(resolveClosureReview({ closureReview: { maxFixRounds: -3 } }).maxFixRounds, 0);
92
+ assert.equal(resolveClosureReview({ closureReview: { maxFixRounds: 1.5 } }).maxFixRounds, 2);
93
+ });
94
+
95
+ test("flowGuards: defaults + overrides", () => {
96
+ assert.deepEqual(resolveFlowGuards({}), { enforce: true, specDirs: ["doc/specs"] });
97
+ assert.equal(resolveFlowGuards({ flowGuards: { enforce: false } }).enforce, false);
98
+ assert.deepEqual(resolveFlowGuards({ flowGuards: { specDirs: ["a/b"] } }).specDirs, ["a/b"]);
99
+ assert.deepEqual(resolveFlowGuards({ flowGuards: { specDirs: [] } }).specDirs, ["doc/specs"]);
100
+ });
101
+
102
+ test("settingsErrorWarning: includes prefix and joined errors", () => {
103
+ const w = settingsErrorWarning(["bad json", "missing field"]);
104
+ assert.match(w, /gauntlet settings load error \(using defaults\)/);
105
+ assert.ok(w.includes("bad json; missing field"));
106
+ });
107
+
108
+ test("verifyBeforeShip: default vs override", () => {
109
+ const d = resolveVerifyBeforeShip({}, DEFAULT_TEST_COMMANDS);
110
+ assert.deepEqual(d.testCommands, DEFAULT_TEST_COMMANDS);
111
+ assert.equal(d.warningReference, undefined);
112
+ const o = resolveVerifyBeforeShip(
113
+ { verifyBeforeShip: { testCommands: ["x"], warningReference: "doc/t.md" } },
114
+ DEFAULT_TEST_COMMANDS,
115
+ );
116
+ assert.deepEqual(o.testCommands, ["x"]);
117
+ assert.equal(o.warningReference, "doc/t.md");
118
+ });
@@ -0,0 +1,124 @@
1
+ // Pure gauntlet-settings resolvers. NO pi runtime import: this module is imported
2
+ // by ci.mjs unit tests (node --test) which run outside pi, where
3
+ // @earendil-works/pi-coding-agent is unresolvable. The loader
4
+ // (gauntlet-settings-loader.ts) owns the pi import.
5
+
6
+ export interface PiGauntlet {
7
+ specCouncil?: { members?: unknown; chair?: unknown };
8
+ closureReview?: { enforce?: unknown; model?: unknown; maxFixRounds?: unknown };
9
+ flowGuards?: { enforce?: unknown; specDirs?: unknown };
10
+ verifyBeforeShip?: { testCommands?: unknown; warningReference?: unknown };
11
+ }
12
+
13
+ // Whole-object second-level merge: each piGauntlet key present in the repo layer
14
+ // replaces the preset's wholesale; keys absent from repo fall through to preset.
15
+ // Mirrors pi's own deepMergeSettings second-level spread (not exported).
16
+ export function mergeGauntlet(
17
+ preset: Record<string, unknown> | undefined,
18
+ repo: Record<string, unknown> | undefined,
19
+ ): PiGauntlet {
20
+ return { ...(preset ?? {}), ...(repo ?? {}) } as PiGauntlet;
21
+ }
22
+
23
+ const nonEmptyString = (v: unknown): v is string => typeof v === "string" && v.trim().length > 0;
24
+ const joinWarn = (ws: string[]): string | undefined => (ws.length ? ws.join("; ") : undefined);
25
+
26
+ export interface SpecCouncilResolved {
27
+ verdict: "council" | "worker";
28
+ members: string[];
29
+ chair: string | undefined;
30
+ malformed: boolean;
31
+ warning: string | undefined;
32
+ }
33
+
34
+ export function resolveSpecCouncil(g: PiGauntlet): SpecCouncilResolved {
35
+ const sc = g.specCouncil;
36
+ const warnings: string[] = [];
37
+ let malformed = false;
38
+
39
+ let chair: string | undefined;
40
+ const rawChair = sc?.chair;
41
+ if (rawChair === undefined) {
42
+ chair = undefined;
43
+ } else if (nonEmptyString(rawChair)) {
44
+ chair = rawChair.trim();
45
+ } else {
46
+ chair = undefined;
47
+ malformed = true;
48
+ warnings.push("specCouncil.chair is not a non-empty string; ignoring it");
49
+ }
50
+
51
+ const rawMembers = sc?.members;
52
+ const worker = (extra?: string): SpecCouncilResolved => {
53
+ if (extra) {
54
+ malformed = true;
55
+ warnings.push(extra);
56
+ }
57
+ return { verdict: "worker", members: [], chair, malformed, warning: joinWarn(warnings) };
58
+ };
59
+
60
+ if (rawMembers === undefined) return worker();
61
+ if (!Array.isArray(rawMembers)) return worker("specCouncil.members is not an array; using the worker critique");
62
+ if (rawMembers.length === 0) return worker();
63
+ if (!rawMembers.every(nonEmptyString))
64
+ return worker("specCouncil.members has a non-string or empty entry; using the worker critique");
65
+
66
+ return {
67
+ verdict: "council",
68
+ members: rawMembers.map((m) => (m as string).trim()),
69
+ chair,
70
+ malformed,
71
+ warning: joinWarn(warnings),
72
+ };
73
+ }
74
+
75
+ export interface ClosureReviewResolved {
76
+ model: string | undefined;
77
+ enforce: boolean;
78
+ maxFixRounds: number;
79
+ }
80
+
81
+ export function resolveClosureReview(g: PiGauntlet): ClosureReviewResolved {
82
+ const cr = g.closureReview;
83
+ const model = nonEmptyString(cr?.model) ? cr!.model.trim() : undefined;
84
+ const enforce = cr?.enforce !== false;
85
+ const raw = cr?.maxFixRounds;
86
+ const maxFixRounds = typeof raw === "number" && Number.isInteger(raw) ? (raw < 0 ? 0 : raw) : 2;
87
+ return { model, enforce, maxFixRounds };
88
+ }
89
+
90
+ export interface FlowGuardsResolved {
91
+ enforce: boolean;
92
+ specDirs: string[];
93
+ }
94
+
95
+ export function resolveFlowGuards(g: PiGauntlet): FlowGuardsResolved {
96
+ const fg = g.flowGuards;
97
+ const enforce = fg?.enforce !== false;
98
+ const rawDirs = fg?.specDirs;
99
+ const specDirs =
100
+ Array.isArray(rawDirs) && rawDirs.length > 0 && rawDirs.every(nonEmptyString)
101
+ ? (rawDirs as string[]).map((d) => d.trim())
102
+ : ["doc/specs"];
103
+ return { enforce, specDirs };
104
+ }
105
+
106
+ export interface VerifyBeforeShipResolved {
107
+ testCommands: string[];
108
+ warningReference: string | undefined;
109
+ }
110
+
111
+ export function resolveVerifyBeforeShip(g: PiGauntlet, defaultTestCommands: string[]): VerifyBeforeShipResolved {
112
+ const vbs = g.verifyBeforeShip;
113
+ const rawCmds = vbs?.testCommands;
114
+ const testCommands =
115
+ Array.isArray(rawCmds) && rawCmds.length > 0 && rawCmds.every(nonEmptyString)
116
+ ? (rawCmds as string[])
117
+ : defaultTestCommands;
118
+ const warningReference = nonEmptyString(vbs?.warningReference) ? vbs!.warningReference.trim() : undefined;
119
+ return { testCommands, warningReference };
120
+ }
121
+
122
+ export function settingsErrorWarning(errors: string[]): string {
123
+ return `\u26a0\ufe0f gauntlet settings load error (using defaults): ${errors.join("; ")}`;
124
+ }
@@ -14,6 +14,13 @@ import { StringEnum } from "@earendil-works/pi-ai";
14
14
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
15
15
  import { Text } from "@earendil-works/pi-tui";
16
16
  import { type Static, Type } from "@sinclair/typebox";
17
+ import {
18
+ resolveClosureReview,
19
+ resolveFlowGuards,
20
+ resolveSpecCouncil,
21
+ settingsErrorWarning,
22
+ } from "./lib/gauntlet-settings.ts";
23
+ import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
17
24
 
18
25
  const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"] as const;
19
26
  type Phase = (typeof PHASES)[number];
@@ -32,19 +39,6 @@ interface PhaseTrackerDetails {
32
39
  error?: string;
33
40
  }
34
41
 
35
- type Settings = {
36
- piGauntlet?: {
37
- closureReview?: {
38
- enforce?: boolean;
39
- model?: string;
40
- };
41
- flowGuards?: {
42
- enforce?: boolean;
43
- specDirs?: string[];
44
- };
45
- };
46
- };
47
-
48
42
  // Qualification per spec "Qualifying dispatch": a successful conformance-reviewer
49
43
  // child run in the result details (pi-cohort SingleResult: { agent, exitCode }).
50
44
  // Management mode and async dispatches return results: [] and fail this check.
@@ -227,14 +221,6 @@ function formatStatus(phases: PhaseMap): string {
227
221
  export default function (pi: ExtensionAPI) {
228
222
  let phases: PhaseMap = emptyPhases();
229
223
  let conformanceDispatched = false;
230
- const closureEnforced = () =>
231
- ((pi.settings ?? {}) as Settings).piGauntlet?.closureReview?.enforce !== false;
232
- const closureReviewModel = () =>
233
- ((pi.settings ?? {}) as Settings).piGauntlet?.closureReview?.model;
234
-
235
- const flowGuardsCfg = () => ((pi.settings ?? {}) as Settings).piGauntlet?.flowGuards ?? {};
236
- const flowGuardsEnforced = () => flowGuardsCfg().enforce !== false;
237
- const specDirs = () => flowGuardsCfg().specDirs ?? ["doc/specs"];
238
224
 
239
225
  // Warn-once-per-phase ledger; cleared on every phase transition and on reconstruct.
240
226
  const firedGuards = new Map<string, boolean>();
@@ -321,7 +307,25 @@ export default function (pi: ExtensionAPI) {
321
307
  });
322
308
  }
323
309
 
324
- pi.on("tool_call", async (event) => {
310
+ pi.on("tool_call", async (event, ctx) => {
311
+ // D3.b: one fresh settings read per event, lazily, only if a guard needs it.
312
+ const addGuardWarning = (id: string, text: string) => {
313
+ const prior = pendingGuardWarnings.get(id);
314
+ pendingGuardWarnings.set(id, prior ? prior + "\n\n" + text : text);
315
+ };
316
+ let gauntletCache: ReturnType<typeof loadGauntletSettings> | undefined;
317
+ const g = () => {
318
+ if (!gauntletCache) {
319
+ gauntletCache = loadGauntletSettings(ctx.cwd);
320
+ if (gauntletCache.errors.length > 0) addGuardWarning(event.toolCallId, settingsErrorWarning(gauntletCache.errors));
321
+ }
322
+ return gauntletCache.gauntlet;
323
+ };
324
+ const closureEnforced = () => resolveClosureReview(g()).enforce;
325
+ const closureReviewModel = () => resolveClosureReview(g()).model;
326
+ const flowGuardsEnforced = () => resolveFlowGuards(g()).enforce;
327
+ const specDirs = () => resolveFlowGuards(g()).specDirs;
328
+
325
329
  // Closure-review model guard - independent of flowGuards, gated by closureReview.enforce.
326
330
  if (event.toolName === "subagent" && closureEnforced()) {
327
331
  const model = closureReviewModel();
@@ -338,12 +342,21 @@ export default function (pi: ExtensionAPI) {
338
342
  }
339
343
  const mismatched = [...new Set((models as string[]).filter((m) => m !== configured))];
340
344
  if (mismatched.length > 0) {
341
- pendingGuardWarnings.set(event.toolCallId, closureModelMismatchWarning(configured, mismatched));
345
+ addGuardWarning(event.toolCallId, closureModelMismatchWarning(configured, mismatched));
342
346
  }
343
347
  }
344
348
  }
345
349
  }
346
350
 
351
+ // Flow guards apply only to write/edit/bash while a guard phase is active.
352
+ // Cheap in-memory gate BEFORE any settings load: phase state is in-memory, so an
353
+ // event no guard inspects (any read-only tool, or any tool in a dormant session)
354
+ // returns here without touching disk. Only genuinely guardable events pay for g().
355
+ const brainstormActive = phases.brainstorm.status === "in_progress";
356
+ const guardableWrite = (event.toolName === "write" || event.toolName === "edit") && brainstormActive;
357
+ const guardableBash = event.toolName === "bash" && activeGuardPhase() !== undefined;
358
+ if (!guardableWrite && !guardableBash) return undefined;
359
+
347
360
  if (!flowGuardsEnforced()) return undefined;
348
361
 
349
362
  // Guard 3 — write/edit outside the spec dir during brainstorm.
@@ -352,7 +365,7 @@ export default function (pi: ExtensionAPI) {
352
365
  const p = (event.input as { path?: unknown } | undefined)?.path;
353
366
  if (typeof p !== "string" || pathInSpecDirs(p, specDirs())) return undefined;
354
367
  firedGuards.set("brainstorm-write", true);
355
- pendingGuardWarnings.set(event.toolCallId, brainstormWriteWarning(specDirs()));
368
+ addGuardWarning(event.toolCallId, brainstormWriteWarning(specDirs()));
356
369
  return undefined;
357
370
  }
358
371
 
@@ -392,7 +405,7 @@ export default function (pi: ExtensionAPI) {
392
405
  }
393
406
  }
394
407
 
395
- if (warnings.length > 0) pendingGuardWarnings.set(event.toolCallId, warnings.join("\n\n"));
408
+ if (warnings.length > 0) addGuardWarning(event.toolCallId, warnings.join("\n\n"));
396
409
  return undefined;
397
410
  });
398
411
 
@@ -427,6 +440,34 @@ export default function (pi: ExtensionAPI) {
427
440
  updateWidget(ctx);
428
441
  });
429
442
 
443
+ const GauntletSettingParams = Type.Object({
444
+ key: StringEnum(["specCouncil", "closureReview"] as const, {
445
+ description: "Which gauntlet setting to resolve (merged repo-over-preset).",
446
+ }),
447
+ });
448
+
449
+ pi.registerTool({
450
+ name: "gauntlet_setting",
451
+ label: "Gauntlet Setting",
452
+ description:
453
+ "Gauntlet-internal, invoked by skills: resolve a merged piGauntlet.* setting " +
454
+ "(repo .pi/settings.json over the agent preset). Returns the resolved value as a " +
455
+ "JSON block in the result content - specCouncil yields the council-vs-worker verdict, " +
456
+ "closureReview yields the conformance-gate model/enforce/maxFixRounds. Not for ad-hoc use.",
457
+ parameters: GauntletSettingParams,
458
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
459
+ const { gauntlet, errors } = loadGauntletSettings(ctx.cwd);
460
+ const payload =
461
+ params.key === "specCouncil"
462
+ ? { key: "specCouncil" as const, ...resolveSpecCouncil(gauntlet), errors }
463
+ : { key: "closureReview" as const, ...resolveClosureReview(gauntlet), errors };
464
+ return {
465
+ content: [{ type: "text", text: "```json\n" + JSON.stringify(payload, null, 2) + "\n```" }],
466
+ details: payload,
467
+ };
468
+ },
469
+ });
470
+
430
471
  pi.registerTool({
431
472
  name: "phase_tracker",
432
473
  label: "Phase Tracker",
@@ -504,7 +545,11 @@ export default function (pi: ExtensionAPI) {
504
545
  } as PhaseTrackerDetails,
505
546
  };
506
547
  }
507
- if (params.phase === "verify" && closureEnforced() && !conformanceDispatched) {
548
+ if (
549
+ params.phase === "verify" &&
550
+ resolveClosureReview(loadGauntletSettings(ctx.cwd).gauntlet).enforce &&
551
+ !conformanceDispatched
552
+ ) {
508
553
  return {
509
554
  content: [{ type: "text", text: CLOSURE_GATE_ERROR }],
510
555
  details: {
@@ -24,6 +24,8 @@
24
24
  */
25
25
 
26
26
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
+ import { resolveVerifyBeforeShip, settingsErrorWarning } from "./lib/gauntlet-settings.ts";
28
+ import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
27
29
 
28
30
  // Default verification entrypoints. Override via settings.
29
31
  const DEFAULT_TEST_COMMANDS = [
@@ -38,18 +40,10 @@ const DEFAULT_TEST_COMMANDS = [
38
40
  ];
39
41
 
40
42
  const SHIP_CMD = /\b(git\s+commit|git\s+push|gh\s+pr\s+create)\b/;
43
+
41
44
  const SOURCE_EXT = /\.(ts|tsx|js|jsx|py|rb|go|rs|java|swift|kt)$/;
42
45
  const TEST_PATH = /(^|\/)(tests?|__tests__)\/|\.(test|spec)\.|_test\.(py|go|rb)$/;
43
46
 
44
- type Settings = {
45
- piGauntlet?: {
46
- verifyBeforeShip?: {
47
- testCommands?: string[];
48
- warningReference?: string;
49
- };
50
- };
51
- };
52
-
53
47
  const isSourceWrite = (filePath: string | undefined): boolean => {
54
48
  if (!filePath) return false;
55
49
  return SOURCE_EXT.test(filePath) && !TEST_PATH.test(filePath);
@@ -72,12 +66,6 @@ const formatWarning = (command: string, testCommands: string[], reference: strin
72
66
  };
73
67
 
74
68
  export default function (pi: ExtensionAPI) {
75
- const settings = (pi.settings ?? {}) as Settings;
76
- const cfg = settings.piGauntlet?.verifyBeforeShip ?? {};
77
- const testCommands = cfg.testCommands ?? DEFAULT_TEST_COMMANDS;
78
- const testRegex = buildTestCmdRegex(testCommands);
79
- const reference = cfg.warningReference;
80
-
81
69
  let verified = false;
82
70
  let pendingTestCallId: string | null = null;
83
71
  const pendingShipWarnings = new Map<string, string>();
@@ -92,7 +80,7 @@ export default function (pi: ExtensionAPI) {
92
80
  return typeof input?.path === "string" ? input.path : undefined;
93
81
  };
94
82
 
95
- pi.on("tool_call", async (event) => {
83
+ pi.on("tool_call", async (event, ctx) => {
96
84
  if (event.toolName === "write" || event.toolName === "edit") {
97
85
  if (isSourceWrite(getPath(event))) verified = false;
98
86
  return undefined;
@@ -102,13 +90,32 @@ export default function (pi: ExtensionAPI) {
102
90
  const command = getCommand(event);
103
91
  if (!command) return undefined;
104
92
 
93
+ const addShipWarning = (id: string, text: string) => {
94
+ const prior = pendingShipWarnings.get(id);
95
+ pendingShipWarnings.set(id, prior ? prior + "\n\n" + text : text);
96
+ };
97
+
98
+ const { gauntlet, errors } = loadGauntletSettings(ctx.cwd);
99
+ const { testCommands, warningReference } = resolveVerifyBeforeShip(gauntlet, DEFAULT_TEST_COMMANDS);
100
+ const settingsWarned = errors.length > 0 ? settingsErrorWarning(errors) : undefined;
101
+ let settingsErrShown = false;
102
+ const testRegex = buildTestCmdRegex(testCommands);
103
+
105
104
  if (testRegex.test(command)) {
106
105
  pendingTestCallId = event.toolCallId;
106
+ if (settingsWarned && !settingsErrShown) {
107
+ addShipWarning(event.toolCallId, settingsWarned);
108
+ settingsErrShown = true;
109
+ }
107
110
  return undefined;
108
111
  }
109
112
 
110
113
  if (SHIP_CMD.test(command) && !verified) {
111
- pendingShipWarnings.set(event.toolCallId, formatWarning(command, testCommands, reference));
114
+ if (settingsWarned && !settingsErrShown) {
115
+ addShipWarning(event.toolCallId, settingsWarned);
116
+ settingsErrShown = true;
117
+ }
118
+ addShipWarning(event.toolCallId, formatWarning(command, testCommands, warningReference));
112
119
  }
113
120
  return undefined;
114
121
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "4.2.3",
3
+ "version": "4.3.1",
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",
@@ -18,11 +18,13 @@ Identify the target project → set up an isolated worktree → understand curre
18
18
  While this skill is active, do **not** take any implementation action until you have presented a design and the user has approved it. This applies to **every** change regardless of perceived simplicity. An implementation-heavy request ("test it end-to-end", "push a request to rabbit", "run the service") does not lift this gate — it just tells you what the spec must cover.
19
19
 
20
20
  You **may**:
21
+
21
22
  - Read code and docs
22
23
  - Run the existing system to observe its **current** behaviour — this is research and feeds the spec (boot a local service, replay a sample request, capture a baseline classification, etc.)
23
24
  - Write to the project's `doc/specs/` directory
24
25
 
25
26
  You may **not**:
27
+
26
28
  - Write or edit code outside `doc/specs/`
27
29
  - Scaffold a project, or take any action that builds, deploys, or validates the **proposed change** — running new/edited code, exercising behaviour that doesn't exist yet, or "testing the fix" before there is one
28
30
  - Run implementation skills (`/skill:test-driven-development`, `/skill:subagent-driven-development`, etc.)
@@ -38,11 +40,13 @@ This skill ends with a **written, user-reviewed spec inside a worktree**. Nothin
38
40
  Work through the items below **in order**. This is your own checklist to follow, not a `plan_tracker` plan — brainstorming is open-ended exploration, and `plan_tracker` is execution-only (the implement phase). The terminal state is the user review gate; after approval the **only** next skill is `/skill:writing-plans`. Do not jump to implementation, and do not silently drop the critique pass.
39
41
 
40
42
  1. **Start brainstorm tracking (fresh epoch)** — as the **first action on entry**, before reading code, setting up the worktree, or answering the user, reset **both** trackers and then start the phase. A new brainstorm is a new flow, so it owns a clean slate — clear any stale phases *and* tasks left in the session by earlier work:
43
+
41
44
  ```
42
45
  phase_tracker({ action: "reset" }) // clears all phases
43
46
  plan_tracker({ action: "clear" }) // clears all tasks
44
47
  phase_tracker({ action: "start", phase: "brainstorm" })
45
48
  ```
49
+
46
50
  Re-entering while a brainstorm is already in progress is safe: mid-brainstorm there are no tasks or later phases to lose, so the reset just re-establishes the same clean slate.
47
51
  2. **Set up the worktree** — see [Worktree First](#worktree-first)
48
52
  3. **Explore project context** — files, docs, recent commits, current behaviour
@@ -51,9 +55,9 @@ Work through the items below **in order**. This is your own checklist to follow,
51
55
  6. **Present the design** — in sections, get approval after each
52
56
  7. **Write the spec** — to `doc/specs/` (see [Filename Convention](#filename-convention))
53
57
  8. **Spec self-review (lint)** — placeholder scan + internal consistency + documentation named, run inline
54
- 9. **Critique pass (auto-dispatched)** — scope + ambiguity; the spec council via `/skill:roasting-the-spec` when `members` is configured, else a fresh `worker` (see [Spec Council](#spec-council-optional))
58
+ 9. **Critique pass (auto-dispatched)** — scope + ambiguity; the spec council via `/skill:roasting-the-spec` when `gauntlet_setting` returns verdict `council`, else a fresh `worker` (see [Spec Council](#spec-council-optional))
55
59
  10. **Re-run placeholder scan** — after the critique pass returns, first inline any `external-ref:` flags it raised (see [Spec Self-Review](#spec-self-review-before-user-review-gate)), then re-scan for placeholders its edits may have introduced; surface any ambiguity the worker could not safely resolve at the user gate
56
- 11. **Generate spec summary** — dispatch a fresh, spec-only `spec-summarizer` and render its returned text **verbatim** at the top of the gate message — do not paraphrase, condense, re-section, or rewrite it (see [User Review Gate](#user-review-gate)); this is part of the existing gate, not a new one
60
+ 11. **Generate spec summary** — dispatch a fresh, spec-only `spec-summarizer` writing to an absolute temp-dir path via `outputMode: "file-only"`, then `Read` that file back as the **last content-producing** tool call before composing the gate and render its contents **verbatim** at the top of the gate message — do not paraphrase, condense, re-section, or rewrite it (see [User Review Gate](#user-review-gate)); this is part of the existing gate, not a new one
57
61
  12. **User review gate** — user reviews the committed spec
58
62
  13. **Transition** — only after approval, invoke `/skill:writing-plans`
59
63
 
@@ -64,6 +68,7 @@ Before brainstorming, identify the target project. The match drives the spec dir
64
68
  Read the repo's top-level `AGENTS.md` (already in pi's context) and any service-level `AGENTS.md` for the area the user mentioned. If the project documents a routing table mapping subprojects to spec directories and verification commands, follow it. Otherwise rely on the conventions you find.
65
69
 
66
70
  Detection order:
71
+
67
72
  1. Issue tracker reference (Linear, Jira, GitHub Issues) → infer from labels, description, or mentioned file paths
68
73
  2. cwd inside a service / package directory → use that area
69
74
  3. Unclear → ask the developer
@@ -135,6 +140,7 @@ When sketching the design, prefer:
135
140
  Sections of 200-300 words. Ask after each whether it looks right.
136
141
 
137
142
  Cover at minimum:
143
+
138
144
  - Architecture overview
139
145
  - Components / responsibilities
140
146
  - Data flow (or request flow)
@@ -203,7 +209,7 @@ After writing the spec to `<project>/doc/specs/<filename>.md` (per [Filename Con
203
209
 
204
210
  The first three checks — **placeholder scan**, **internal consistency**, and **documentation named** — are the inline **lint**: run them here at the main loop and fix what they surface. The last two — **scope** and **ambiguity** — are **not** run inline; they are the **critique pass**, auto-dispatched (per [Spec Council](#spec-council-optional)):
205
211
 
206
- - **Council configured** (`piGauntlet.specCouncil.members` non-empty) → invoke `/skill:roasting-the-spec`; it runs the critique and proposes dispositions.
212
+ - **Council** (`gauntlet_setting({ key: "specCouncil" })` returns verdict `council`) → invoke `/skill:roasting-the-spec`; it runs the critique and proposes dispositions.
207
213
  - **Otherwise** → dispatch one fresh `worker` that applies the scope + ambiguity checks and fixes them in place:
208
214
 
209
215
  ```
@@ -224,25 +230,39 @@ After the critique pass returns, scan it for load-bearing external references be
224
230
 
225
231
  ## Spec Council (Optional)
226
232
 
227
- After the inline lint and before the user review gate, **brainstorming owns the critique-pass gate**: resolve `piGauntlet.specCouncil` **repo-local first** the repo's `.pi/settings.json` overrides the agent preset, **whole-object** (the first file that defines `specCouncil` wins; an empty `members` there is an explicit "no council here"). Full source order and mechanics: `verification-before-completion/reference/settings-precedence.md`. **Expand `$PI_CODING_AGENT_DIR`; never substitute a hardcoded project path for it.** **When `members` is non-empty, the council *is* the critique pass invoke `/skill:roasting-the-spec` automatically (no offer, no prompt).** When it is absent or empty, run the fresh-`worker` critique instead (see [Spec Self-Review](#spec-self-review-before-user-review-gate)); if `specCouncil` is present but malformed, emit one warning line and fall back to the worker. Approved council edits (or the worker's in-place fixes) are applied to the spec and ride in the same worktree commit as the rest of this skill's output.
233
+ After the inline lint and before the user review gate, **brainstorming owns the critique-pass gate**. Resolve the council with `gauntlet_setting({ key: "specCouncil" })` - the tool returns the merged (repo-over-preset) value as `{ verdict, members, chair, malformed, warning, errors }`. **Do not** hand-roll a settings read. When `verdict` is `"council"`, the council *is* the critique pass - invoke `/skill:roasting-the-spec` automatically (no offer, no prompt), passing `members`/`chair`. When `verdict` is `"worker"`, run the fresh-`worker` critique instead (see [Spec Self-Review](#spec-self-review-before-user-review-gate)). If `malformed` is true or `errors` is non-empty, emit the `warning`/error as one line, then branch strictly on `verdict` - `malformed` can accompany *either* verdict (e.g. a bad `chair` with valid `members` still returns `council`), so never infer the worker path from `malformed` alone. If `gauntlet_setting` is unavailable, stop and report - never fall back to a manual bash/JSON settings merge. Approved council edits (or the worker's in-place fixes) ride in the same worktree commit. The conceptual precedence rule lives in `verification-before-completion/reference/settings-precedence.md`.
228
234
 
229
235
  ## User Review Gate
230
236
 
231
237
  After self-review (and council review, if configured) and after inlining any external-ref flags, dispatch the spec-only summarizer, then commit the spec on the worktree branch and stop. This is the **same** single human gate - the summary is folded into it, not a new gate.
232
238
 
233
- Dispatch the summarizer on a fresh context, reading only the spec (no `output:` path - capture the return inline; no `model:` - it inherits the main loop unless a preset sets `subagents.agentOverrides.spec-summarizer.model`):
239
+ Mint an absolute temp path outside the worktree (so it is never committed), then dispatch the summarizer on a fresh context, reading only the spec, writing to that path via file-only output (no `model:` - it inherits the main loop unless a preset sets `subagents.agentOverrides.spec-summarizer.model`):
234
240
 
241
+ ```bash
242
+ SUMMARY_PATH=$(mktemp "${TMPDIR:-/tmp}/gauntlet-spec-summary.XXXXXX") # absolute, portable across GNU/BSD mktemp
235
243
  ```
236
- subagent({ agent: "spec-summarizer", context: "fresh", cwd: "<abs worktree path, from git rev-parse --show-toplevel>", task:
244
+
245
+ ```
246
+ subagent({ agent: "spec-summarizer", context: "fresh", cwd: "<abs worktree path, from git rev-parse --show-toplevel>",
247
+ output: "<SUMMARY_PATH>", outputMode: "file-only", task:
237
248
  "Summarize the spec at <abs path to doc/specs/...> for the user review gate. Read ONLY that file." })
238
249
  ```
239
250
 
240
- If the dispatch fails, reach the gate anyway with a one-line "summary generation failed" note - the summary is an aid, not a gate.
251
+ `<SUMMARY_PATH>` above is a placeholder in the dispatch object; it means substitute the value of the shell variable `$SUMMARY_PATH` set above. The steps below use `$SUMMARY_PATH` (the shell form) once the value is in hand.
252
+
253
+ Then commit the spec — this commit is **unconditional**: the summary is only a gate aid, so a degraded or missing summary never blocks it. Evaluate the summary in two stages (the **Degrade path** referenced in each is defined just below):
254
+
255
+ 1. **From the dispatch tool result, before the `Read`.** If the result is **not** an `"Output saved to: <path> (<N> KB, <M> lines)"` reference (e.g. an exit-0 save error returns the full inline output plus an "Output file error" line — the prunable shape, no file to read), or the reference reports under ~500 bytes, or a size grossly disproportionate to the spec (under ~2% of its byte size), or over ~45 KB (the `Read` truncates at 50KB / 2000 lines, so a larger file cannot render whole) — skip the `Read` and take the degrade path. Use the reference's reported figures; do not re-derive them.
256
+ 2. **The `Read` itself, as the last content-producing tool call before composing the gate.** `Read` `$SUMMARY_PATH` and paste its contents verbatim at the top of the gate. If the `Read` fails, returns 0 bytes, or reports truncation — take the degrade path. The `Read` must be last: pi-condense does not protect a `/tmp` read, so any turn boundary between the `Read` and the render lets the ~9KB read result be pruned, reproducing the bug.
257
+
258
+ **Degrade path** — reach the gate with a one-line "summary generation failed" note; never paraphrase from the file-only reference, never render a stub as the canonical summary.
259
+
260
+ Either way — summary rendered or degraded — then `rm "$SUMMARY_PATH"` (unconditional cleanup; harmless if the file was never created, since it lives outside the worktree under the OS temp dir).
241
261
 
242
- Render the summarizer's returned text **verbatim** first — paste it as-is, do **not** paraphrase, condense, re-section, drop sections, or merge it with council output. "Fold into the gate" means *place it inside the gate message*, not *rewrite it*. After the verbatim block, append the commit confirmation, then — as their **own** adjacent lines, not edits to the summary — any council outcome, critique-pass-unresolved ambiguities, and every entry from the summarizer's gap/external-context footer (surface **all** of them, not just the top risk):
262
+ Render the temp file's contents **verbatim** first — paste it as-is, do **not** paraphrase, condense, re-section, drop sections, or merge it with council output. "Fold into the gate" means *place it inside the gate message*, not *rewrite it*. After the verbatim block, append the commit confirmation, then — as their **own** adjacent lines, not edits to the summary — any council outcome, critique-pass-unresolved ambiguities, and every entry from the summarizer's gap/external-context footer (surface **all** of them, not just the top risk):
243
263
 
244
264
  ```
245
- <spec-only summary from spec-summarizer — pasted verbatim, unedited>
265
+ <spec-only summary read back from the temp file — pasted verbatim, unedited>
246
266
 
247
267
  Spec written and committed to <project>/doc/specs/<filename>.md (worktree: <path>).
248
268
 
@@ -253,7 +273,7 @@ Please review. Approve to proceed, or tell me what to change in the spec.
253
273
 
254
274
  If you believe the summary needs correcting, do **not** silently rewrite it — re-dispatch the summarizer or note the discrepancy as an adjacent line beneath the verbatim block.
255
275
 
256
- Wait for the user. On a change request, revise and re-present. On approval, proceed immediately to `/skill:writing-plans` with no further prompt — the plan and execution mode are mechanical derivatives, so the only human gate here is spec approval itself. Don't land the spec on `main`; it stays in the worktree and ships in the same squash commit as the implementation.
276
+ Wait for the user. On a change request, revise the spec and re-present — mint a **fresh** temp path for the re-dispatched summarizer (never reuse a prior round's path, so stale content can never be mistaken for the new summary). On approval, proceed immediately to `/skill:writing-plans` with no further prompt — the plan and execution mode are mechanical derivatives, so the only human gate here is spec approval itself. Don't land the spec on `main`; it stays in the worktree and ships in the same squash commit as the implementation.
257
277
 
258
278
  After approval, mark the brainstorm phase complete:
259
279
 
@@ -278,7 +298,8 @@ phase_tracker({ action: "complete", phase: "brainstorm" })
278
298
  - About to skip the critique pass (council if configured, else fresh worker)
279
299
  - Critique dispatch (council or worker) failed to complete and you proceeded to the gate anyway
280
300
  - About to reach the user gate without re-running the placeholder scan after the critique returned
281
- - About to reach the user gate without rendering the spec-only summary (dispatch `spec-summarizer` first; a failed dispatch degrades to a one-line note, it is not silently skipped)
301
+ - About to reach the user gate without rendering the spec-only summary (dispatch `spec-summarizer` to a temp file first; a failed/stub/truncated read degrades to a one-line note, it is not silently skipped)
302
+ - About to compose the gate message when the summary `Read` was not the last content-producing tool call before it (a following `rm` of the temp file is fine) — a turn boundary between the `Read` and the render lets pi-condense prune the ~9KB read result, reproducing the original bug
282
303
  - About to present a paraphrased, condensed, or re-sectioned version of the summarizer's output instead of pasting its returned text verbatim — rewriting the summary counts as not rendering it
283
304
  - About to run the scope or ambiguity checks inline yourself instead of dispatching them (those two are the critique pass, not the inline lint)
284
305
  - About to skip the self-review pass
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: roasting-the-spec
3
- description: Use after writing a spec, when a spec council is configured (piGauntlet.specCouncil in the repo's .pi/settings.json, else the active preset's settings). Auto-dispatched by /skill:brainstorming as the critique pass when members is non-empty (no longer offered). N members on different models critique in parallel, a neutral chair consolidates and adjudicates, the parent proposes dispositions, the user approves.
3
+ description: Use after writing a spec, when a spec council is configured (the resolved piGauntlet.specCouncil council, via the gauntlet_setting tool, repo settings over the preset). Auto-dispatched by /skill:brainstorming as the critique pass when members is non-empty (no longer offered). N members on different models critique in parallel, a neutral chair consolidates and adjudicates, the parent proposes dispositions, the user approves.
4
4
  ---
5
5
 
6
6
  # Roasting the Spec (Spec Council)
@@ -24,7 +24,7 @@ This skill may read anything and edit **only** the spec under `doc/specs/`. It d
24
24
 
25
25
  ## Configuration and gating
26
26
 
27
- Resolve `piGauntlet.specCouncil` **repo-local first** the repo's `.pi/settings.json` overrides the agent preset, **whole-object** (the first file that defines `specCouncil` wins outright; an empty `members` there is an explicit "no council for this repo"). Full source order, mechanics, and the per-key granularity rule live in `verification-before-completion/reference/settings-precedence.md`. Do not hardcode a path in place of `$PI_CODING_AGENT_DIR` expand the env var. The resolved config looks like:
27
+ The caller (`/skill:brainstorming`) already resolved the council via `gauntlet_setting({ key: "specCouncil" })` and dispatched this skill only when `verdict` was `"council"`, passing the resolved `members` and `chair`. This skill therefore receives `members`/`chair` from that resolved value and does **not** read settings files itself. The resolved config looks like:
28
28
 
29
29
  ```json
30
30
  {
@@ -40,7 +40,7 @@ Resolve `piGauntlet.specCouncil` **repo-local first** — the repo's `.pi/settin
40
40
  - `members` — array of `provider/model` strings. Council size = array length.
41
41
  - `chair` — optional model for the synthesizer; if omitted, the synthesizer inherits the parent's model.
42
42
 
43
- brainstorming owns the gate: it parses this config (it may contain comments — read it, do not pipe through a strict JSON parser), emits any malformed-config warning, and decides whether to invoke this skill. This skill is dispatched **only after** brainstorming confirms `members` is non-empty, so it runs the council unconditionally on entry — no offer, no numbered choice. Absent/empty/malformed config never reaches here (brainstorming runs the fresh-`worker` critique instead). A minimal defensive re-parse is fine, but ownership of "should the council run" lives in brainstorming, not here.
43
+ brainstorming owns the gate: it resolves this config via `gauntlet_setting`, emits any malformed-config warning, and decides whether to invoke this skill. This skill is dispatched **only after** brainstorming confirms `verdict` is `council`, so it runs the council unconditionally on entry — no offer, no numbered choice. Absent/empty/malformed config never reaches here (brainstorming runs the fresh-`worker` critique instead). Ownership of "should the council run" lives in brainstorming, not here.
44
44
 
45
45
  ## The council run
46
46
 
@@ -86,7 +86,7 @@ Pi-subagents accepts a per-task `model` override. Use it.
86
86
  | Hard / novel / large surface | Most capable | New subsystem, complex algorithm, cross-service contract change |
87
87
  | Spec review | Default | Reads diff + spec, mechanical comparison |
88
88
  | Code-quality review | Most capable | Judgment call on naming, design, complexity |
89
- | Conformance / closure | Most capable | Whole-deliverable-vs-origin intent gate (`conformance-reviewer`; model from `piGauntlet.closureReview.model`, resolved repo-first whole-object, injected call-site) |
89
+ | Conformance / closure | Most capable | Whole-deliverable-vs-origin intent gate (`conformance-reviewer`; model from `gauntlet_setting({ key: "closureReview" }).model`, injected call-site) |
90
90
 
91
91
  ```ts
92
92
  subagent({
@@ -111,8 +111,9 @@ subagent({ agent: "spec-reviewer", task: "<diff range + spec excerpt + ask: does
111
111
  subagent({ agent: "code-reviewer", task: "<diff range + ask: production-ready?>" })
112
112
 
113
113
  // closing-loop conformance (origin vs deliverable) — its OWN dispatch, never fused with code quality
114
- // model from piGauntlet.closureReview.model resolve repo-local .pi/settings.json over the preset, whole-object (see verification-before-completion/reference/settings-precedence.md); omit to inherit
115
- subagent({ agent: "conformance-reviewer", model: /* piGauntlet.closureReview.model, repo-first per settings-precedence.md, else omit to inherit */, task: "<spec path + verbatim original prompt + full diff vs main; per conformance-check.md>" })
114
+ // model: call gauntlet_setting({ key: "closureReview" }) first; use the returned model (omit model: if undefined to inherit) and maxFixRounds
115
+ // If gauntlet_setting is unavailable, stop and report - never fall back to a manual bash/JSON settings merge.
116
+ subagent({ agent: "conformance-reviewer", model: /* gauntlet_setting({ key: "closureReview" }).model, else omit to inherit */, task: "<spec path + verbatim original prompt + full diff vs main; per conformance-check.md>" })
116
117
  ```
117
118
 
118
119
  Prompt templates live alongside this SKILL.md:
@@ -140,13 +141,13 @@ Auto-selected at handoff by `writing-plans` (any wave with ≥2 tasks) when the
140
141
 
141
142
  1. **Independence check.** Parse the wave's tasks' `Files:` blocks; assert pairwise-disjoint (mechanical). Runtime-resource disjointness (DB/schema, port, fixture, external service, shared temp path) is not machine-checkable here — trust the plan's wave grouping, which `writing-plans`' D5 contract guarantees. Either kind of overlap → the wave is mis-grouped; run those tasks as sequential single-task waves and note it.
142
143
  2. **Fan out.** One parallel dispatch (shape below): `implementer` per task, `context: "fresh"`, `worktree: true`. Each returns a status + a patch.
143
- 3. **Status + spec review per task.** Parse each `DONE`/`BLOCKED`/etc. (see [Implementer Status](#implementer-status)); spec-review each returned patch against its task (read-only, parallelizable). Re-dispatch incomplete tasks (fresh, `worktree: true`) until `DONE` + spec ✅.
144
+ 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**, and the absolute spec path. Review is **diff-based**: the diff's hunks carry `file:line`, and test execution is not the reviewer's job here (the wave test gate in step 5 runs the suite). 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 ✅.
144
145
  4. **Integrate.** `git 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.
145
146
  5. **Test gate.** Run the suite on the integrated tree. Failure = semantic conflict or bug → re-run the offending task sequentially, else fix per [When a Subagent Fails](#when-a-subagent-fails).
146
147
  6. **Quality review.** Code-quality review on the integrated wave diff; loop fixes to ✅.
147
148
  7. **Commit the wave.** Leaves a clean tree; the next wave's children branch from this commit and so see the integrated work.
148
149
 
149
- **Two-stage review is preserved:** spec review per task (pre-integration), quality review per wave (post-integration). Both gates required before the wave commits.
150
+ **Two-stage review is preserved:** spec review per task (pre-integration, dispatched `spec-reviewer` — not inline), quality review per wave (post-integration). Both gates required before the wave commits.
150
151
 
151
152
  **Dependent context across waves:** wave N+1 tasks branch from a HEAD containing wave N, so they see the code; still forward wave N's task summaries into wave N+1 prompts.
152
153
 
@@ -179,6 +180,7 @@ For the fan-out + worktree + patch-integration + conflict mechanics, see `dispat
179
180
  3. **After 2 failed attempts:** STOP. Report failure to the user. The task probably needs redesign.
180
181
 
181
182
  **NEVER:**
183
+
182
184
  - Write code yourself to "help" or "finish up"
183
185
  - Fix the subagent's work inline — that pollutes your context and defeats fresh-subagent isolation
184
186
  - Silently skip the failed task
@@ -188,7 +190,7 @@ For the fan-out + worktree + patch-integration + conflict mechanics, see `dispat
188
190
 
189
191
  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.)
190
192
  1. **Run the whole-diff code review.** Dispatch `/skill:requesting-code-review` against the worktree's full diff vs `main` (already covered in [The Process](#the-process) step "After all tasks"). Address Critical and Moderate findings before handoff. (Consumers wanting an in-flow project-specific audit re-add it as an explicit step in `.pi/gauntlet-overrides.md`, or run `/self-audit` manually.)
191
- 2. **Close the loop — conformance check.** The review in step 1 is plan-vs-code (single-step); it inherits any requirement the plan already dropped. Before marking verify complete, dispatch a fresh-context **`conformance-reviewer`** — its **own** dispatch, never fused into the step-1 review — to confront the deliverable (code **and** docs) against the *origin* — the spec **and** the original prompt — per `verification-before-completion/reference/conformance-check.md`. Pass the spec path, the verbatim original prompt, and the full diff. On `GAPS`, do not auto-fix or auto-proceed: run the remediation loop in `verification-before-completion/reference/conformance-check.md` "When the check finds gaps" (disposition menu → isolated fix waves → bounded delta re-audit, capped by `piGauntlet.closureReview.maxFixRounds`). Only when the verdict is `CONFORMS` (or every gap is dispositioned) call `phase_tracker({ action: "complete", phase: "verify" })`.
193
+ 2. **Close the loop — conformance check.** The review in step 1 is plan-vs-code (single-step); it inherits any requirement the plan already dropped. Before marking verify complete, dispatch a fresh-context **`conformance-reviewer`** — its **own** dispatch, never fused into the step-1 review — to confront the deliverable (code **and** docs) against the *origin* — the spec **and** the original prompt — per `verification-before-completion/reference/conformance-check.md`. Pass the spec path, the verbatim original prompt, and the full diff. On `GAPS`, do not auto-fix or auto-proceed: run the remediation loop in `verification-before-completion/reference/conformance-check.md` "When the check finds gaps" (disposition menu → isolated fix waves → bounded delta re-audit, capped by `gauntlet_setting({ key: "closureReview" }).maxFixRounds`). Only when the verdict is `CONFORMS` (or every gap is dispositioned) call `phase_tracker({ action: "complete", phase: "verify" })`.
192
194
  3. Summarize what was implemented (tasks completed, files changed, test counts, code-review verdict). Give the closing loop its **own section** — `Closure / conformance: CONFORMS` (or `GAPS` with each gap and its disposition) — so the user sees intent-fidelity as a first-class line before any finishing decision, not buried in the review verdict.
193
195
  4. **Proceed to finishing — no confirmation prompt.** When the verdict is `CONFORMS` (or every gap is dispositioned), invoke `/skill:finishing-a-development-branch` immediately. Its Step 4 menu (squash / PR / keep / discard) is the human gate; a separate "ready to finish?" prompt only stacks a second stop in front of it. Open gaps are already owned by step 2, so nothing is left to decide here. Manual testing is a follow-up after the finishing choice (on `<base-branch>` after a squash-merge, or on the PR branch), never a reason to hold this gate.
194
196
 
@@ -201,6 +203,7 @@ For the fan-out + worktree + patch-integration + conflict mechanics, see `dispat
201
203
  - Starting code-quality review before spec compliance is ✅
202
204
  - Moving to next task with either review still showing issues
203
205
  - Letting implementer self-review replace external review (both needed)
206
+ - Spec-reviewing wave patches inline instead of dispatching `spec-reviewer` per patch — sequential mode's step 3 dispatches it; wave mode must too
204
207
  - Pausing to "check in" between tasks (continuous execution rule)
205
208
  - Skipping the `Implementer Status` parse — treating every response as DONE
206
209
  - Starting on main without explicit user consent
@@ -208,12 +211,14 @@ For the fan-out + worktree + patch-integration + conflict mechanics, see `dispat
208
211
  ## Integration
209
212
 
210
213
  **Required workflow skills:**
214
+
211
215
  - `/skill:using-git-worktrees` — set up isolation first (small changes can branch in place with user approval)
212
216
  - `/skill:writing-plans` — creates the plan this skill executes
213
217
  - `/skill:requesting-code-review` — review template for reviewer subagents
214
218
  - `/skill:finishing-a-development-branch` — invoked automatically once the conformance verdict is `CONFORMS` (or all gaps dispositioned)
215
219
 
216
220
  **Subagents follow by default:**
221
+
217
222
  - TDD — runtime warnings on source-before-test. Implementer agents receive the three-scenario TDD instructions (new feature / modifying tested code / trivial) via agent profile and prompt template.
218
223
 
219
224
  ## Project overrides
@@ -41,14 +41,14 @@ whole-PR code-quality review. Fusing the two subordinates intent-coverage to a
41
41
  code-quality system prompt and compresses the conformance result to an
42
42
  afterthought. Code quality is one dispatch; conformance is another.
43
43
 
44
- The persona ships model-free. Resolve `piGauntlet.closureReview.model` **repo-local
45
- first** the repo's `.pi/settings.json` overrides the agent preset, whole-object (see
46
- sibling `settings-precedence.md`) and inject it **call-site** on the dispatch
47
- (`model:` if set, else omit to inherit the parent's model) — the same mechanism the
48
- spec-council chair uses. If the configured model is unreachable, retry once with the
49
- inherited model. Point it at the strongest reasoning model the resolved config can
50
- reach this is the last correctness gate. `thinking` stays frontmatter-pinned at
51
- `xhigh` and is not call-site overridable, so the config supplies only `model`.
44
+ The persona ships model-free. Get the model from `gauntlet_setting({ key: "closureReview" }).model`.
45
+ If `gauntlet_setting` is unavailable, stop and report - never fall back to a manual bash/JSON
46
+ settings merge. Inject the model **call-site** on the dispatch (omit `model:` when it is `undefined` to inherit
47
+ the parent's model) — the same mechanism the spec-council chair uses. If the configured model
48
+ is unreachable, retry once with the inherited model. Point it at the strongest reasoning model
49
+ the resolved config can reach this is the last correctness gate. `thinking` stays
50
+ frontmatter-pinned at `xhigh` and is not call-site overridable, so the config supplies only
51
+ `model`.
52
52
 
53
53
  Self-checking in the main session is the fallback when delegation isn't possible.
54
54
 
@@ -205,9 +205,10 @@ still-open with its prior verdict, or introducing `Gn+1`.
205
205
  blocks a `conformance-reviewer` dispatch that omits `model:`, and warns — non-blocking —
206
206
  on one whose model differs from the configured value).
207
207
  - New or still-open gaps within the cap re-enter the menu above.
208
- - **Cap: read `piGauntlet.closureReview.maxFixRounds`** (default `2`; missing/non-integer
209
- `2`; `< 0` `0`). `0` = audit-only: `GAPS` renders an accept/rescope-only menu and any
210
- unresolved gap escalates instead of dispatching a fix.
208
+ - **Cap: `gauntlet_setting({ key: "closureReview" }).maxFixRounds`** (the tool applies the
209
+ default `2`, floors negatives at `0`, and coerces non-integers to `2`). `0` = audit-only:
210
+ `GAPS` renders an accept/rescope-only menu and any unresolved gap escalates instead of
211
+ dispatching a fix.
211
212
  - **On non-convergence** (cap reached with open gaps): **escalate to human** with the
212
213
  per-gap round-by-round verdict trail. No silent re-loop, no auto-ship.
213
214
 
@@ -1,44 +1,55 @@
1
1
  # Settings precedence: repo-local first
2
2
 
3
- How to resolve a `piGauntlet.<key>` settings value that both a repo and an agent
4
- preset may define. Both the spec-critique gate (`specCouncil`) and the conformance
5
- gate (`closureReview`) use this rule; it is stated once here.
3
+ How a `piGauntlet.<key>` value is resolved when both a repo and an agent preset
4
+ define it. Both the spec-critique gate (`specCouncil`) and the conformance gate
5
+ (`closureReview`) use this rule; it is stated once here.
6
6
 
7
- ## Source order (both keys)
7
+ ## The rule
8
8
 
9
- Read two files, **repo-local first**:
9
+ Repo settings override the preset, **whole-object at the second level**:
10
10
 
11
- 1. `<repo-root>/.pi/settings.json` - repo root from `git rev-parse --show-toplevel`
12
- (inside a worktree this is the worktree root).
13
- 2. `$PI_CODING_AGENT_DIR/settings.json` - the agent preset.
11
+ - Preset: `$PI_CODING_AGENT_DIR/settings.json`.
12
+ - Repo: `<repo-root>/.pi/settings.json`.
14
13
 
15
- Mechanics for both files: they may contain comments - read them, do not pipe
16
- through a strict JSON parser. Expand `$PI_CODING_AGENT_DIR`; never substitute a
17
- hardcoded path for it (reading the repo `.pi/settings.json` as if it were the
18
- preset is the classic miss - they are different files). On a malformed repo-local
19
- file, the reading agent emits one warning line in its response and falls back to
20
- the preset.
21
-
22
- ## Merge granularity (same for both keys): whole-object
23
-
24
- Both `specCouncil` and `closureReview` resolve **whole-object**: if the repo file
25
- defines the key at all, that definition **replaces** the preset's entirely; the two
26
- are never merged leaf-by-leaf. If the repo file does not define the key, the
27
- preset's value is used unchanged.
28
-
29
- This is not a pi-gauntlet convention - it is exactly what pi's own settings merge
30
- does. `deepMergeSettings` (pi's `SettingsManager`) merges only the **top-level**
31
- Settings keys (of which `piGauntlet` is one) by a one-level spread
32
- `{ ...preset.piGauntlet, ...repo.piGauntlet }`. That spread replaces each
33
- **second-level** key (`closureReview`, `specCouncil`, `flowGuards`) wholesale when
34
- the repo defines it; it does **not** recurse into that key's leaves. So the
35
- `phase-tracker.ts` closure guard (which reads `pi.settings.piGauntlet.closureReview.model`)
36
- and the skills (which read repo-first, whole-object) resolve the same value.
14
+ If the repo file defines a `piGauntlet.<key>` at all, that definition **replaces**
15
+ the preset's for that key entirely - the two are never merged leaf-by-leaf. If the
16
+ repo file does not define the key, the preset's value is used unchanged. This is
17
+ exactly pi's own `deepMergeSettings` behaviour: it spreads the second-level keys
18
+ (`specCouncil`, `closureReview`, `flowGuards`, `verifyBeforeShip`) wholesale, and
19
+ does not recurse into their leaves.
37
20
 
38
21
  **Caveat - partial definitions drop siblings.** Because the replace is
39
22
  whole-object, a repo file that sets only *one* leaf of a key silently drops the
40
23
  preset's other leaves for that key. A repo `closureReview: { "model": "..." }` with
41
24
  no `enforce`/`maxFixRounds` makes those fall back to their code defaults
42
25
  (`enforce` true, `maxFixRounds` 2), **not** to the preset's values. Define every
43
- leaf you care about together in the file that owns the key. (Sibling keys like
44
- `specCouncil` are unaffected - only the key the repo redefines is replaced.)
26
+ leaf you care about together in the file that owns the key. (Sibling keys are
27
+ unaffected - only the key the repo redefines is replaced.)
28
+
29
+ **Settings files are strict JSON.** pi's `SettingsManager` parses them with
30
+ `JSON.parse`; comments make the file invalid to pi itself, so never add them.
31
+
32
+ ## The single operational path
33
+
34
+ Do **not** read or merge these files by hand. Resolution is centralized:
35
+
36
+ - **Skills** resolve `piGauntlet.*` via the `gauntlet_setting` tool (registered by
37
+ the `phase-tracker` extension): `gauntlet_setting({ key: "specCouncil" })` or
38
+ `gauntlet_setting({ key: "closureReview" })`. The tool returns the merged value
39
+ (repo over preset) in its result content. If the tool is unavailable, stop and
40
+ report - never fall back to a manual bash/JSON merge (that fallback is exactly
41
+ the failure mode this centralization removes).
42
+ - **Extensions** call `loadGauntletSettings(ctx.cwd)` from
43
+ `extensions/lib/gauntlet-settings-loader.ts`, then the matching resolver in
44
+ `extensions/lib/gauntlet-settings.ts`.
45
+
46
+ Both paths go through pi's own `SettingsManager`, so they resolve the same merged
47
+ value. The `phase-tracker.ts` closure guard reads via `loadGauntletSettings` +
48
+ `resolveClosureReview`, resolving the same value the tool returns.
49
+
50
+ ## Scope: launch directory
51
+
52
+ The tool and loader read relative to pi's session/launch directory (`ctx.cwd`, the
53
+ primary checkout), not a mid-session worktree. A per-branch `.pi/settings.json`
54
+ that diverges from the launch checkout is not resolved and is an unsupported
55
+ workflow - keep `piGauntlet` settings at the repo root or in the preset.