pi-gauntlet 4.2.3 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## v4.3.0 - 2026-07-06
4
+
5
+ DRY gauntlet settings resolution. All `piGauntlet.*` settings reads now route
6
+ through one shared resolver (`extensions/lib/gauntlet-settings.ts`), consumed by
7
+ a new `gauntlet_setting` tool (for skills) and by the extensions directly.
8
+
9
+ - **New `gauntlet_setting` tool** (registered by `phase-tracker`, gauntlet-internal):
10
+ returns the resolved `specCouncil` or `closureReview` value from pi's merged
11
+ settings. Five skill call sites (`brainstorming`, `roasting-the-spec`,
12
+ `subagent-driven-development`, `conformance-check`, `settings-precedence`)
13
+ replace their hand-rolled two-file bash/prose merge with this tool. This fixes
14
+ an observed failure where the repo-only read fell back to the fresh `worker`
15
+ critique and silently skipped a configured spec council.
16
+ - **Behavior change (latent-bug fix):** extensions previously read `pi.settings`,
17
+ which does not exist on the extension API - so every `closureReview`,
18
+ `flowGuards`, and `verifyBeforeShip` config read was dead (always `{}`) and
19
+ consumer overrides were silently ignored. These settings now take effect for
20
+ the first time. Most visible: `closureReview.model` now actually pins the
21
+ conformance-gate model (and the phase-tracker guard can block a mismatched
22
+ dispatch). If you set any of these keys expecting the prior no-op, review them.
23
+ - `scripts/ci.mjs` now type-checks `extensions/lib/`, runs the resolver unit
24
+ tests, asserts the lib files are packed, and enforces a no-`pi.settings`
25
+ invariant.
26
+
3
27
  ## v4.2.3 - 2026-07-05
4
28
 
5
29
  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
@@ -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.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",
@@ -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,7 +55,7 @@ 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
60
  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
57
61
  12. **User review gate** — user reviews the committed spec
@@ -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,7 +230,7 @@ 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
 
@@ -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:
@@ -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
 
@@ -208,12 +210,14 @@ For the fan-out + worktree + patch-integration + conflict mechanics, see `dispat
208
210
  ## Integration
209
211
 
210
212
  **Required workflow skills:**
213
+
211
214
  - `/skill:using-git-worktrees` — set up isolation first (small changes can branch in place with user approval)
212
215
  - `/skill:writing-plans` — creates the plan this skill executes
213
216
  - `/skill:requesting-code-review` — review template for reviewer subagents
214
217
  - `/skill:finishing-a-development-branch` — invoked automatically once the conformance verdict is `CONFORMS` (or all gaps dispositioned)
215
218
 
216
219
  **Subagents follow by default:**
220
+
217
221
  - 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
222
 
219
223
  ## 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.