pi-gauntlet 4.2.2 → 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 +34 -0
- package/README.md +16 -4
- package/extensions/lib/gauntlet-settings-loader.ts +23 -0
- package/extensions/lib/gauntlet-settings.test.ts +118 -0
- package/extensions/lib/gauntlet-settings.ts +124 -0
- package/extensions/phase-tracker.ts +71 -26
- package/extensions/verify-before-ship.ts +24 -17
- package/package.json +12 -3
- package/skills/brainstorming/SKILL.md +9 -3
- package/skills/roasting-the-spec/SKILL.md +3 -3
- package/skills/subagent-driven-development/SKILL.md +8 -4
- package/skills/verification-before-completion/reference/conformance-check.md +12 -11
- package/skills/verification-before-completion/reference/settings-precedence.md +43 -32
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
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
|
+
|
|
27
|
+
## v4.2.3 - 2026-07-05
|
|
28
|
+
|
|
29
|
+
Branding, funding, and gallery preview. No behavior change.
|
|
30
|
+
|
|
31
|
+
- **Logo + pi.dev gallery preview.** Repo-root `pi-gauntlet.png` (640x640), shown in the README and wired as `pi.image`.
|
|
32
|
+
- **Buy Me a Coffee funding.** `funding` in `package.json`, `.github/FUNDING.yml`, and a README badge.
|
|
33
|
+
- **`author` field** added to `package.json` (was missing).
|
|
34
|
+
- Added `subagents`, `tdd`, `code-review` keywords.
|
|
35
|
+
- README reframed product-first; obra/superpowers + coctostan attribution kept as credit (copyright preserved in LICENSE).
|
|
36
|
+
|
|
3
37
|
## v4.2.2 - 2026-07-04
|
|
4
38
|
|
|
5
39
|
Hardens the `release-notes` job so it is byte-identical across all four pi repos (pi-gauntlet, pi-cohort, pi-condense, pi-quiver).
|
package/README.md
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/jjuraszek/pi-gauntlet/main/pi-gauntlet.png" alt="pi-gauntlet" width="180">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
1
5
|
# pi-gauntlet
|
|
2
6
|
|
|
3
|
-
|
|
7
|
+
[](https://buymeacoffee.com/jjurasszek)
|
|
8
|
+
|
|
9
|
+
A workflow library for the [pi coding agent](https://github.com/earendil-works/pi): opinionated skills, ready-to-use subagent personas, and three runtime extensions that turn "run the agent in a loop" into a gated pipeline from idea to merge.
|
|
4
10
|
|
|
5
11
|
## Why
|
|
6
12
|
|
|
@@ -12,7 +18,7 @@ pi-gauntlet is the scaffolding that makes the loop hold: **brainstorm → plan
|
|
|
12
18
|
|
|
13
19
|
The spec and docs it produces get committed to the repo, so the next change starts from ground truth, not a blank slate. The loop is the easy 5%; the gated system around it is the whole job - that's what this is.
|
|
14
20
|
|
|
15
|
-
|
|
21
|
+
Rebuilt for pi with enforced gates, a spec council, conformance review, and parallel execution waves. Inspired by [obra/superpowers](https://github.com/obra/superpowers) (Claude Code), by way of [coctostan/pi-superpowers-plus](https://github.com/coctostan/pi-superpowers-plus); see [Lineage](#lineage).
|
|
16
22
|
|
|
17
23
|
## The workflow
|
|
18
24
|
|
|
@@ -63,7 +69,7 @@ Spec, plan, and implementation are all developed in the **same worktree**. The s
|
|
|
63
69
|
|
|
64
70
|
## Requirements
|
|
65
71
|
|
|
66
|
-
- [pi-coding-agent](https://github.com/
|
|
72
|
+
- [pi-coding-agent](https://github.com/earendil-works/pi) ≥ 0.1.0
|
|
67
73
|
- [pi-cohort](https://github.com/jjuraszek/pi-cohort) ≥ 1.4.5 — required peer package. Skills that dispatch agents (`requesting-code-review`, `subagent-driven-development`, `dispatching-parallel-agents`, `writing-plans`, `writing-skills`) call `subagent({})`, which is provided by pi-cohort. pi-gauntlet does not vendor the dispatch tool; without pi-cohort those skills have nothing to call.
|
|
68
74
|
|
|
69
75
|
Both packages must be listed in your `.pi/settings.json#packages` array (pi adds them automatically when you `pi install`). pi-gauntlet and pi-cohort are versioned independently but release together whenever dispatch semantics change — pin compatible versions of both.
|
|
@@ -226,6 +232,8 @@ A tool, not a hook. Skills call `phase_tracker({ action: "start" | "complete" |
|
|
|
226
232
|
|
|
227
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?"
|
|
228
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
|
+
|
|
229
237
|
**Closure-review gate.** `complete verify` is rejected unless a successful
|
|
230
238
|
`conformance-reviewer` dispatch (a `subagent` result whose `results[]` contains
|
|
231
239
|
`agent: "conformance-reviewer"` with `exitCode: 0`) has been observed since the
|
|
@@ -281,7 +289,11 @@ See [`CHANGELOG.md`](./CHANGELOG.md) for what changed in each release. Semver: m
|
|
|
281
289
|
|
|
282
290
|
## Lineage
|
|
283
291
|
|
|
284
|
-
pi-gauntlet
|
|
292
|
+
pi-gauntlet's skill methodology was inspired by [obra/superpowers](https://github.com/obra/superpowers) (MIT, Copyright (c) 2025 Jesse Vincent), by way of [coctostan/pi-superpowers-plus](https://github.com/coctostan/pi-superpowers-plus). The pi runtime integration, enforced phase gates, multi-model spec council, conformance-review gate, and parallel execution waves are pi-gauntlet's own. Thanks to the upstream authors; their copyright is preserved in [`LICENSE`](./LICENSE).
|
|
293
|
+
|
|
294
|
+
## Support
|
|
295
|
+
|
|
296
|
+
[Buy me a coffee](https://buymeacoffee.com/jjurasszek) if this saves you time.
|
|
285
297
|
|
|
286
298
|
## License
|
|
287
299
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
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 (
|
|
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
|
-
|
|
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,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-gauntlet",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"description": "Opinionated, gated workflow skills, subagent personas, and runtime extensions for the pi coding agent.",
|
|
5
|
+
"author": "Jacek Juraszek",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"license": "MIT",
|
|
7
8
|
"repository": {
|
|
@@ -18,7 +19,10 @@
|
|
|
18
19
|
"pi-coding-agent",
|
|
19
20
|
"skills",
|
|
20
21
|
"agents",
|
|
21
|
-
"
|
|
22
|
+
"subagents",
|
|
23
|
+
"workflow",
|
|
24
|
+
"tdd",
|
|
25
|
+
"code-review"
|
|
22
26
|
],
|
|
23
27
|
"files": [
|
|
24
28
|
"skills",
|
|
@@ -33,7 +37,12 @@
|
|
|
33
37
|
],
|
|
34
38
|
"extensions": [
|
|
35
39
|
"./extensions"
|
|
36
|
-
]
|
|
40
|
+
],
|
|
41
|
+
"image": "https://raw.githubusercontent.com/jjuraszek/pi-gauntlet/main/pi-gauntlet.png"
|
|
42
|
+
},
|
|
43
|
+
"funding": {
|
|
44
|
+
"type": "buymeacoffee",
|
|
45
|
+
"url": "https://buymeacoffee.com/jjurasszek"
|
|
37
46
|
},
|
|
38
47
|
"scripts": {
|
|
39
48
|
"postinstall": "node ./bin/install-agents.mjs",
|
|
@@ -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 `
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 `
|
|
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
|
|
115
|
-
|
|
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 `
|
|
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.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
`
|
|
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:
|
|
209
|
-
|
|
210
|
-
unresolved gap escalates instead of
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
##
|
|
7
|
+
## The rule
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Repo settings override the preset, **whole-object at the second level**:
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
44
|
-
|
|
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.
|