pi-gauntlet 4.0.1 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## v4.1.0 - 2026-07-02
4
+
5
+ - **`closureReview` now resolves repo-local first.** The verify-step skills
6
+ (`subagent-driven-development`, `verification-before-completion/reference/conformance-check.md`)
7
+ resolve `piGauntlet.closureReview.model`/`maxFixRounds` from a repo's
8
+ `.pi/settings.json` over the agent preset, **whole-object** - matching how the
9
+ `phase-tracker.ts` guard already reads `pi.settings` (pi replaces the whole
10
+ `closureReview` block when a repo defines it). Previously they read the preset
11
+ only, so a repo-local `closureReview.model` was silently ignored and the
12
+ conformance gate ran on the parent's model.
13
+ - **New shared reference `skills/verification-before-completion/reference/settings-precedence.md`.**
14
+ States the two-file source order and the whole-object merge rule once; `specCouncil`
15
+ and `closureReview` both resolve the same way and both point at it. `roasting-the-spec`
16
+ and `brainstorming` replace their duplicated lookup prose with a pointer.
17
+ - **Closure guard upgraded from presence-check to match-check.** A
18
+ `conformance-reviewer` dispatch that omits `model:` is still blocked; one whose
19
+ `model:` differs from the configured `closureReview.model` now gets a non-blocking
20
+ warning appended to the result. Gated by `closureReview.enforce` as before.
21
+
3
22
  ## v4.0.1 - 2026-07-02
4
23
 
5
24
  No content change. Smoke-tests the tag-triggered CI publish path (OIDC trusted publishing to npm) added in v4.0.0's release machinery commit.
package/README.md CHANGED
@@ -162,7 +162,7 @@ Unset → provider default thinking for that model. `conformance-reviewer` and t
162
162
 
163
163
  ### Conformance gate model
164
164
 
165
- `conformance-reviewer` ships without a `model:` in its frontmatter — like the spec-council personas, its model is supplied per preset so each profile points the last correctness gate at the strongest reasoning model its providers can reach. The verify-step skills read it from `piGauntlet.closureReview.model` and inject it **call-site** on the conformance dispatch (the same mechanism the spec-council chair uses). Add it to each preset's `settings.json`:
165
+ `conformance-reviewer` ships without a `model:` in its frontmatter — like the spec-council personas, its model is supplied per preset so each profile points the last correctness gate at the strongest reasoning model its providers can reach. The verify-step skills resolve `piGauntlet.closureReview.model` **repo-local first** (a repo's `.pi/settings.json` overrides the preset whole-object - defining `closureReview` there replaces the preset's entire block, so set every leaf you need together) and inject it **call-site** on the conformance dispatch (the same mechanism the spec-council chair uses). Add it to each preset's `settings.json` (or a repo's `.pi/settings.json` to override per repo):
166
166
 
167
167
  ```json
168
168
  {
@@ -174,7 +174,7 @@ Unset → provider default thinking for that model. `conformance-reviewer` and t
174
174
 
175
175
  Frontmatter pins `thinking: xhigh` and `defaultContext: fresh` (the gate always runs cold, with max reasoning) and `thinking` is not call-site overridable, so the config supplies only `model`. If `closureReview.model` is unset the dispatch omits `model:` and the gate inherits the parent's model; if the configured model is unreachable it retries once inherited.
176
176
 
177
- When `closureReview.model` **is** set, the phase-tracker enforces call-site injection: a `subagent` dispatch of `conformance-reviewer` that omits `model:` is **blocked at tool-call time** (before it runs) so the gate can never silently degrade to the parent's builder model. The documented one-retry fallback still works - pass the inherited model *explicitly* and it is allowed; only a bare omission is blocked. Disabling `closureReview.enforce` disables this guard too.
177
+ When `closureReview.model` **is** set, the phase-tracker match-checks call-site injection: a `subagent` dispatch of `conformance-reviewer` that omits `model:` is **blocked at tool-call time** (before it runs) so the gate can never silently degrade to the parent's builder model, and a dispatch whose `model:` **differs** from the configured value gets a non-blocking **warning** appended to the result (drift is surfaced, not blocked). The documented one-retry fallback still works - pass an explicit model and it runs (with a warning if it differs). Disabling `closureReview.enforce` disables this guard too.
178
178
 
179
179
  `closureReview.enforce` (default `true`) controls the phase-tracker gate that
180
180
  blocks `complete verify` until the conformance-reviewer has run; set `false` to
@@ -202,7 +202,7 @@ If you want to know what's in each persona before using it, see [`agents/`](./ag
202
202
  - `members` (required) — roster of `provider/model` strings; council size = array length, one critique per model. Empty or absent → the council never runs; brainstorming falls back to a single fresh-`worker` critique (scope + ambiguity, auto-applied).
203
203
  - `chair` (optional) — model for the consolidating synthesizer; defaults to the inherited model when omitted.
204
204
 
205
- Rosters are per-preset: each pi profile (`agent`, `agent.anthropic`, `agent.bedrock`, …) reads its own `settings.json`, so list only models that profile's providers can reach. The two personas it dispatches — `spec-council-member` and `spec-council-synthesizer` — are model-free; their model is injected per task from this config.
205
+ Rosters resolve **repo-local first**: a repo's `.pi/settings.json` overrides the preset (whole-object — the first file that defines `specCouncil` wins), otherwise each pi profile (`agent`, `agent.anthropic`, `agent.bedrock`, …) reads its own `settings.json`. List only models the resolving config's providers can reach. The two personas it dispatches — `spec-council-member` and `spec-council-synthesizer` — are model-free; their model is injected per task from this config.
206
206
 
207
207
  ## Extensions
208
208
 
@@ -98,19 +98,21 @@ const branchBlockReason = (phase: Phase): string =>
98
98
  "To override, set piGauntlet.flowGuards.enforce: false.";
99
99
 
100
100
  // Closure-review model guard: when piGauntlet.closureReview.model is configured,
101
- // a conformance-reviewer dispatch MUST inject that model call-site. The persona ships
102
- // model-free, so a bare omission silently inherits the parent session's builder model -
103
- // defeating the point of an independent closing gate on a different model. Walk the
104
- // single / tasks / chain / parallel dispatch shapes and report any conformance-reviewer
105
- // entry that carries no model. An explicit model (even a fallback) is fine; only a bare
106
- // omission is caught - that preserves the documented "retry once inherited" escape hatch,
107
- // which the orchestrator takes by passing the inherited model explicitly.
108
- const conformanceEntriesMissingModel = (input: unknown): boolean => {
109
- const entries: { agent?: unknown; model?: unknown }[] = [];
101
+ // a conformance-reviewer dispatch should inject that model call-site. The persona
102
+ // ships model-free, so a bare omission silently inherits the parent session's
103
+ // builder model - defeating the point of an independent closing gate. Walk the
104
+ // single / tasks / chain / parallel dispatch shapes and collect the model of every
105
+ // conformance-reviewer entry (undefined = absent/empty/non-string). A bare omission
106
+ // is BLOCKED; an explicit model that differs from the configured one is WARNED
107
+ // (non-blocking), preserving the documented retry-with-fallback hatch while
108
+ // surfacing drift.
109
+ const conformanceModels = (input: unknown): (string | undefined)[] => {
110
+ const models: (string | undefined)[] = [];
111
+ const norm = (m: unknown) => (typeof m === "string" && m.trim() ? m.trim() : undefined);
110
112
  const collect = (node: unknown): void => {
111
113
  if (!node || typeof node !== "object") return;
112
114
  const o = node as Record<string, unknown>;
113
- if (typeof o.agent === "string") entries.push({ agent: o.agent, model: o.model });
115
+ if (o.agent === "conformance-reviewer") models.push(norm(o.model));
114
116
  for (const key of ["tasks", "chain", "parallel"] as const) {
115
117
  const v = o[key];
116
118
  if (Array.isArray(v)) for (const item of v) collect(item);
@@ -118,19 +120,28 @@ const conformanceEntriesMissingModel = (input: unknown): boolean => {
118
120
  }
119
121
  };
120
122
  collect(input);
121
- return entries.some((e) => e.agent === "conformance-reviewer" && !e.model);
123
+ return models;
122
124
  };
123
125
 
124
- const closureModelBlockReason = (model: string): string =>
125
- `Blocked: conformance-reviewer dispatched without a model while ` +
126
- `piGauntlet.closureReview.model is set to "${model}".\n` +
126
+ const closureModelBlockReason = (model: string, missing: number, total: number): string =>
127
+ `Blocked: ${missing} of ${total} conformance-reviewer ${total === 1 ? "dispatch" : "entries"} ` +
128
+ `omitted a model while piGauntlet.closureReview.model is set to "${model}".\n` +
127
129
  `The persona ships model-free, so a bare omission silently inherits this session's ` +
128
130
  `builder model - the closing gate would then run on the same model that built the work, ` +
129
- `not the independent one the preset pins. Re-dispatch with model: "${model}" injected ` +
130
- `call-site. If that model is unreachable, pass an explicit fallback model (the documented ` +
131
- `one-retry escape hatch) - only a bare omission is blocked.\n` +
131
+ `not the independent one configured (repo .pi/settings.json, else the preset). Re-dispatch ` +
132
+ `with model: "${model}" injected call-site on every conformance-reviewer entry. If that ` +
133
+ `model is unreachable, pass an explicit fallback model (the documented one-retry escape ` +
134
+ `hatch) - only a bare omission is blocked.\n` +
132
135
  `To disable this gate, set piGauntlet.closureReview.enforce: false.`;
133
136
 
137
+ const closureModelMismatchWarning = (configured: string, mismatched: string[]): string =>
138
+ `⚠️ conformance-reviewer dispatched with ${mismatched.length === 1 ? "model" : "models"} ` +
139
+ `${mismatched.map((m) => `"${m}"`).join(", ")} while ` +
140
+ `piGauntlet.closureReview.model is set to "${configured}".\n` +
141
+ `If this is the documented one-retry fallback (the configured model was ` +
142
+ `unreachable), ignore this. Otherwise the closing gate is running on an ` +
143
+ `unintended model - re-dispatch with model: "${configured}".`;
144
+
134
145
  const brainstormWriteWarning = (specDirs: string[]): string =>
135
146
  "⚠️ Writing outside the spec directory during the brainstorm phase.\n" +
136
147
  `Brainstorming may only edit the spec under ${specDirs.join(", ")}. Implementation\n` +
@@ -317,8 +328,19 @@ export default function (pi: ExtensionAPI) {
317
328
  // Only execution-mode dispatches carry a model; management/control modes
318
329
  // (action: list/get/create/update/delete/status/...) execute nothing, so skip them.
319
330
  const hasAction = !!(event.input as { action?: unknown })?.action;
320
- if (model && !hasAction && conformanceEntriesMissingModel(event.input)) {
321
- return { block: true, reason: closureModelBlockReason(model) };
331
+ if (model && !hasAction) {
332
+ const configured = model.trim();
333
+ const models = conformanceModels(event.input);
334
+ if (models.length > 0) {
335
+ const missing = models.filter((m) => m === undefined).length;
336
+ if (missing > 0) {
337
+ return { block: true, reason: closureModelBlockReason(configured, missing, models.length) };
338
+ }
339
+ const mismatched = [...new Set((models as string[]).filter((m) => m !== configured))];
340
+ if (mismatched.length > 0) {
341
+ pendingGuardWarnings.set(event.toolCallId, closureModelMismatchWarning(configured, mismatched));
342
+ }
343
+ }
322
344
  }
323
345
  }
324
346
 
@@ -377,6 +399,11 @@ export default function (pi: ExtensionAPI) {
377
399
  pi.on("tool_result", async (event) => {
378
400
  if (event.toolName === "subagent") {
379
401
  if (qualifiesAsClosureDispatch(event.details)) conformanceDispatched = true;
402
+ const warning = pendingGuardWarnings.get(event.toolCallId);
403
+ if (warning) {
404
+ pendingGuardWarnings.delete(event.toolCallId);
405
+ return { content: [{ type: "text" as const, text: warning }, ...event.content] };
406
+ }
380
407
  return undefined;
381
408
  }
382
409
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "4.0.1",
3
+ "version": "4.1.0",
4
4
  "description": "Opinionated, gated workflow skills, subagent personas, and runtime extensions for the pi coding agent.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -215,14 +215,7 @@ After the critique pass returns, scan it for load-bearing external references be
215
215
 
216
216
  ## Spec Council (Optional)
217
217
 
218
- After the inline lint and before the user review gate, **brainstorming owns the critique-pass gate**: resolve `piGauntlet.specCouncil` from **two** settings files, repo-local first, and select the path.
219
-
220
- Lookup order (first file that **defines** `specCouncil` wins — do not merge across files):
221
-
222
- 1. `<repo-root>/.pi/settings.json` — repo root from `git rev-parse --show-toplevel` (the worktree root inside a worktree). A repo that defines `specCouncil` overrides the preset, even with empty `members` (explicit "no council here").
223
- 2. `$PI_CODING_AGENT_DIR/settings.json` — agent preset; consulted only when the repo file does not define `specCouncil`.
224
-
225
- Both files may contain comments — read them, don't strict-parse. **Expand `$PI_CODING_AGENT_DIR`; never substitute a hardcoded project path for it** (reading the repo `.pi/settings.json` as if it were the preset file is the classic miss — repo-local and preset are different files). **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.
218
+ 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.
226
219
 
227
220
  ## User Review Gate
228
221
 
@@ -24,12 +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` from **two** settings files, repo-local first:
28
-
29
- 1. `<repo-root>/.pi/settings.json` (repo root = `git rev-parse --show-toplevel`, which inside a worktree is the worktree root). **If this file defines `specCouncil`, it wins** — even an empty `members` here is an explicit "no council for this repo".
30
- 2. `$PI_CODING_AGENT_DIR/settings.json` (agent preset) — consulted **only** when the repo file does not define `specCouncil` at all.
31
-
32
- The first file that defines the key wins; do not merge member lists across files. Do not hardcode a project path in place of `$PI_CODING_AGENT_DIR` — expand the env var. The resolved config looks like:
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:
33
28
 
34
29
  ```json
35
30
  {
@@ -87,7 +87,7 @@ Pi-subagents accepts a per-task `model` override. Use it.
87
87
  | Spec review | Default | Reads diff + spec, mechanical comparison |
88
88
  | Code-quality review | Most capable | Judgment call on naming, design, complexity |
89
89
  | Final reviewer | Most capable | Whole-PR-scope review |
90
- | Conformance / closure | Most capable | Whole-deliverable-vs-origin intent gate (`conformance-reviewer`; model from `piGauntlet.closureReview.model`, injected call-site) |
90
+ | 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) |
91
91
 
92
92
  ```ts
93
93
  subagent({
@@ -112,8 +112,8 @@ subagent({ agent: "spec-reviewer", task: "<diff range + spec excerpt + ask: does
112
112
  subagent({ agent: "code-reviewer", task: "<diff range + ask: production-ready?>" })
113
113
 
114
114
  // closing-loop conformance (origin vs deliverable) — its OWN dispatch, never fused with code quality
115
- // model from piGauntlet.closureReview.model (read $PI_CODING_AGENT_DIR/settings.json); omit to inherit
116
- subagent({ agent: "conformance-reviewer", model: /* piGauntlet.closureReview.model from config, else omit to inherit */, task: "<spec path + verbatim original prompt + full diff vs main; per conformance-check.md>" })
115
+ // 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
116
+ 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>" })
117
117
  ```
118
118
 
119
119
  Prompt templates live alongside this SKILL.md:
@@ -36,14 +36,14 @@ whole-PR code-quality review. Fusing the two subordinates intent-coverage to a
36
36
  code-quality system prompt and compresses the conformance result to an
37
37
  afterthought. Code quality is one dispatch; conformance is another.
38
38
 
39
- The persona ships model-free. Read the active preset's settings at
40
- `$PI_CODING_AGENT_DIR/settings.json` for `piGauntlet.closureReview.model` and
41
- inject it **call-site** on the dispatch (`model:` if set, else omit to inherit the
42
- parent's model) — the same mechanism the spec-council chair uses. If the configured
43
- model is unreachable, retry once with the inherited model. Point it at the strongest
44
- reasoning model the preset can reach this is the last correctness gate.
45
- `thinking` stays frontmatter-pinned at `xhigh` and is not call-site overridable, so
46
- the config supplies only `model`.
39
+ The persona ships model-free. Resolve `piGauntlet.closureReview.model` **repo-local
40
+ first** — the repo's `.pi/settings.json` overrides the agent preset, whole-object (see
41
+ sibling `settings-precedence.md`) — and inject it **call-site** on the dispatch
42
+ (`model:` if set, else omit to inherit the parent's model) — the same mechanism the
43
+ spec-council chair uses. If the configured model is unreachable, retry once with the
44
+ inherited model. Point it at the strongest reasoning model the resolved config can
45
+ reach — this is the last correctness gate. `thinking` stays frontmatter-pinned at
46
+ `xhigh` and is not call-site overridable, so the config supplies only `model`.
47
47
 
48
48
  Self-checking in the main session is the fallback when delegation isn't possible.
49
49
 
@@ -197,7 +197,8 @@ still-open with its prior verdict, or introducing `Gn+1`.
197
197
 
198
198
  - The re-audit dispatch carries the **same call-site `model:` injection** as the initial
199
199
  audit (when `piGauntlet.closureReview.model` is set, the phase-tracker closure guard
200
- requires every `conformance-reviewer` dispatch to specify `model:`).
200
+ blocks a `conformance-reviewer` dispatch that omits `model:`, and warns — non-blocking —
201
+ on one whose model differs from the configured value).
201
202
  - New or still-open gaps within the cap re-enter the menu above.
202
203
  - **Cap: read `piGauntlet.closureReview.maxFixRounds`** (default `2`; missing/non-integer
203
204
  → `2`; `< 0` → `0`). `0` = audit-only: `GAPS` renders an accept/rescope-only menu and any
@@ -0,0 +1,44 @@
1
+ # Settings precedence: repo-local first
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.
6
+
7
+ ## Source order (both keys)
8
+
9
+ Read two files, **repo-local first**:
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.
14
+
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.
37
+
38
+ **Caveat - partial definitions drop siblings.** Because the replace is
39
+ whole-object, a repo file that sets only *one* leaf of a key silently drops the
40
+ preset's other leaves for that key. A repo `closureReview: { "model": "..." }` with
41
+ no `enforce`/`maxFixRounds` makes those fall back to their code defaults
42
+ (`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.)