pi-gauntlet 4.6.2 → 4.8.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 +2 -2
- package/agents/implementer.md +1 -1
- package/extensions/lib/phase-tracker-helpers.test.ts +28 -0
- package/extensions/lib/phase-tracker-helpers.ts +33 -0
- package/extensions/phase-tracker.test.ts +323 -4
- package/extensions/phase-tracker.ts +86 -5
- package/extensions/test-support/pi-stubs.mjs +9 -2
- package/extensions/verify-before-ship.test.ts +92 -0
- package/extensions/verify-before-ship.ts +4 -3
- package/package.json +1 -1
- package/skills/subagent-driven-development/SKILL.md +11 -8
- package/skills/verification-before-completion/SKILL.md +5 -3
- package/skills/writing-plans/SKILL.md +70 -4
- package/skills/writing-skills/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v4.8.0 - 2026-08-12
|
|
4
|
+
|
|
5
|
+
- `writing-plans`: mandatory plan-time recon (fixed one-variable scout template writing a draft to the plan path); plan header `**Verification:**` command set (tests + style + format, header-only); per-task format-and-lint step; scoped-test and header-only-entrypoint Self-Review checks.
|
|
6
|
+
- `subagent-driven-development`: wave test gate runs the wave's declared commands only; SR-per-task + CR-per-wave stated as a commit precondition with a doc-only (SR-only) exemption; full verification set runs once at verify before the conformance dispatch; new Red Flags (SR+CR fusion; full run during implement).
|
|
7
|
+
- `phase-tracker`: advisory review-cadence flow guard - implement-phase parent commits warn when the last implementer postdates both reviewers. Presence-only, never blocks, honors `flowGuards.enforce`.
|
|
8
|
+
- **Behavior change** - `verify-before-ship` no longer watches `git commit` (still watches `git push` / `gh pr create`): local wave commits are not ship events, and warning on them trained per-wave full-CI runs.
|
|
9
|
+
- `verification-before-completion`: evidence-proportionality rule (scoped commands prove scoped claims; the full entrypoint proves phase completion, once at verify).
|
|
10
|
+
- `implementer`: REFACTOR runs task-declared tests, not "the full relevant test suite".
|
|
11
|
+
|
|
12
|
+
## v4.7.0 - 2026-08-07
|
|
13
|
+
|
|
14
|
+
Sanction the spec-in-hand resume gesture and guard the implement phase (#6).
|
|
15
|
+
Sessions that start with an approved spec from a handoff doc previously ran
|
|
16
|
+
fully unarmed - the observed incident implemented an entire plan inline in the
|
|
17
|
+
main loop with every enforcement surface dormant.
|
|
18
|
+
|
|
19
|
+
- `writing-plans` gains a "Resuming with a spec in hand" subsection: a
|
|
20
|
+
state-detected handoff entry (branch on `phase_tracker status`) that verifies
|
|
21
|
+
the spec, confirms approval, sets up the worktree, and arms `gauntletEntered`
|
|
22
|
+
via the existing `start brainstorm -> skip brainstorm -> start plan`
|
|
23
|
+
sequence, plus guidance for writing handoff docs. Prose only - no new skill,
|
|
24
|
+
no new tracker action.
|
|
25
|
+
- Fourth flow guard in `phase-tracker`: a warn-once advisory on parent
|
|
26
|
+
`write`/`edit` in the armed implement window (`implement` in progress, or
|
|
27
|
+
`plan` complete with `implement` still pending), exempting
|
|
28
|
+
`flowGuards.specDirs` plus each spec dir's sibling `plans` dir. Advisory,
|
|
29
|
+
never blocks (merge-conflict resolution between parallel waves proceeds past
|
|
30
|
+
it); subagent children (`PI_SUBAGENT_DEPTH` >= 1) never trip it; disabled
|
|
31
|
+
with the other guards via `flowGuards.enforce: false`. No new settings key.
|
|
32
|
+
- Regression coverage: resumed-session replay arming, recovery edge, and
|
|
33
|
+
closure gate; test stub `SettingsManager` now reads the repo settings layer.
|
|
34
|
+
- The 2026-07-19 sole-entry-point spec carries a supersession banner scoped to
|
|
35
|
+
its sole-arming claim.
|
|
36
|
+
|
|
3
37
|
## v4.6.2 - 2026-08-07
|
|
4
38
|
|
|
5
39
|
Trim the always-shipped tool descriptions of the gauntlet-internal tools to
|
package/README.md
CHANGED
|
@@ -66,9 +66,9 @@ pi-gauntlet ships three kinds of pieces, layered on top of pi-cohort's dispatch:
|
|
|
66
66
|
|
|
67
67
|
- **13 skills** - the workflow logic. They activate automatically when pi sees the matching kind of task, and each one gates the next: `brainstorming`, `writing-plans`, `roasting-the-spec`, `test-driven-development`, `subagent-driven-development`, `dispatching-parallel-agents`, `verification-before-completion`, `systematic-debugging`, `requesting-code-review`, `receiving-code-review`, `using-git-worktrees`, `finishing-a-development-branch`, `writing-skills`.
|
|
68
68
|
- **7 subagent personas** - the specialized child agents the skills dispatch via pi-cohort: `implementer`, `code-reviewer`, `spec-reviewer`, `conformance-reviewer`, `spec-summarizer`, `spec-council-member`, `spec-council-synthesizer`. See [doc/personas.md](./doc/personas.md) for what each one does and why its permissions are scoped the way they are.
|
|
69
|
-
- **3 runtime extensions** - the enforcement layer. `plan-tracker` and `phase-tracker` are tools skills call to track progress (with a TUI widget); `verify-before-ship` is a hook that warns if you
|
|
69
|
+
- **3 runtime extensions** - the enforcement layer. `plan-tracker` and `phase-tracker` are tools skills call to track progress (with a TUI widget); `verify-before-ship` is a hook that warns if you push or open a PR without a passing test run since your last edit; a phase-tracker flow guard reminds on implement-phase commits missing spec/code review. See [doc/configuration.md](./doc/configuration.md) for the settings each one reads.
|
|
70
70
|
|
|
71
|
-
pi-gauntlet is **opinionated**: every non-trivial change is *meant* to ride this one pipeline, entered through `brainstorming`. Enforcement is opt-in by entry, not ambient: once brainstorming starts a flow, the phase-tracker extension mechanically blocks a phase from closing before its gate runs. A change made *without* entering the flow (a typo, a formatting run, a dependency bump - see "When to use / when NOT to use") is not gated; the discipline of routing real work through the pipeline is a convention the tooling supports, not a trap it springs on every edit.
|
|
71
|
+
pi-gauntlet is **opinionated**: every non-trivial change is *meant* to ride this one pipeline, entered through `brainstorming`. Enforcement is opt-in by entry, not ambient: once brainstorming starts a flow, the phase-tracker extension mechanically blocks a phase from closing before its gate runs, and warns once if the main loop writes code during implement (subagents own implement-phase edits). A change made *without* entering the flow (a typo, a formatting run, a dependency bump - see "When to use / when NOT to use") is not gated; the discipline of routing real work through the pipeline is a convention the tooling supports, not a trap it springs on every edit.
|
|
72
72
|
|
|
73
73
|
## Key concepts
|
|
74
74
|
|
package/agents/implementer.md
CHANGED
|
@@ -16,7 +16,7 @@ You are an implementation specialist. You execute an approved plan using strict
|
|
|
16
16
|
|
|
17
17
|
1. **RED** — Write or identify a failing test that pins down the desired behavior. Run it. Confirm it fails for the right reason (not a typo, not an import error).
|
|
18
18
|
2. **GREEN** — Write the minimum code that makes the test pass. Run it.
|
|
19
|
-
3. **REFACTOR** — Clean up without changing behavior. Run the
|
|
19
|
+
3. **REFACTOR** — Clean up without changing behavior. Run the tests the task declares (its `Test:` files and stated commands).
|
|
20
20
|
|
|
21
21
|
## Three-scenario TDD
|
|
22
22
|
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
closureGateBlocks,
|
|
7
7
|
closureModelGuardApplies,
|
|
8
8
|
flowGuardApplies,
|
|
9
|
+
implementWriteGuardApplies,
|
|
10
|
+
implementExemptDirs,
|
|
9
11
|
nextGauntletEntered,
|
|
10
12
|
phaseLabel,
|
|
11
13
|
parseGitCommit,
|
|
@@ -210,3 +212,29 @@ test("closureModelGuardApplies: requires both an entered flow and closure enforc
|
|
|
210
212
|
assert.equal(closureModelGuardApplies(true, false), false); // enforce off
|
|
211
213
|
assert.equal(closureModelGuardApplies(false, false), false);
|
|
212
214
|
});
|
|
215
|
+
|
|
216
|
+
test("implementWriteGuardApplies: window is implement in_progress or plan-complete/implement-pending", () => {
|
|
217
|
+
assert.equal(implementWriteGuardApplies("complete", "in_progress", true, false), true);
|
|
218
|
+
assert.equal(implementWriteGuardApplies("complete", "pending", true, false), true); // armed post-plan gap (incident window)
|
|
219
|
+
assert.equal(implementWriteGuardApplies("skipped", "in_progress", true, false), true); // window is implement itself
|
|
220
|
+
assert.equal(implementWriteGuardApplies("in_progress", "pending", true, false), false); // still planning
|
|
221
|
+
assert.equal(implementWriteGuardApplies("skipped", "pending", true, false), false); // plan skipped: accepted residual gap (spec Edge cases)
|
|
222
|
+
assert.equal(implementWriteGuardApplies("complete", "complete", true, false), false); // implement done
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("implementWriteGuardApplies: requires an armed flow and a parent process", () => {
|
|
226
|
+
assert.equal(implementWriteGuardApplies("complete", "in_progress", false, false), false); // unarmed
|
|
227
|
+
assert.equal(implementWriteGuardApplies("complete", "in_progress", true, true), false); // subagent child
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("implementExemptDirs: adds each spec dir's sibling plans dir, deduped", () => {
|
|
231
|
+
assert.deepEqual(implementExemptDirs(["doc/specs"]), ["doc/specs", "doc/plans"]);
|
|
232
|
+
assert.deepEqual(implementExemptDirs(["specs"]), ["specs", "plans"]);
|
|
233
|
+
assert.deepEqual(implementExemptDirs(["doc/specs/"]), ["doc/specs", "doc/plans"]);
|
|
234
|
+
assert.deepEqual(implementExemptDirs(["a/plans"]), ["a/plans"]); // already a plans dir: Set dedups
|
|
235
|
+
assert.deepEqual(implementExemptDirs(["doc/specs", "svc/doc/specs"]), ["doc/specs", "doc/plans", "svc/doc/specs", "svc/doc/plans"]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("nextGauntletEntered: skip preserves the marker (resume gesture)", () => {
|
|
239
|
+
assert.equal(nextGauntletEntered(true, "skip", "skipped"), true);
|
|
240
|
+
});
|
|
@@ -145,6 +145,39 @@ export function flowGuardApplies(phaseActive: boolean, gauntletEntered: boolean)
|
|
|
145
145
|
return phaseActive && gauntletEntered;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
// Implement-write guard window (spec 2026-08-07-resume-spec-in-hand). Fires while
|
|
149
|
+
// implement runs, or in the armed post-plan gap (plan complete, implement not yet
|
|
150
|
+
// started) - the incident window where neither recoverableEdge (needs an idle
|
|
151
|
+
// session) nor an implement-scoped check could fire. Marker-first like every other
|
|
152
|
+
// surface; the enforce + path + fired conjuncts stay at the call site (settings and
|
|
153
|
+
// ledger live there). isSubagentChild: implementer forks inherit extensions and
|
|
154
|
+
// replay the parent's phase state, so without it every implementer's first write
|
|
155
|
+
// would trip a spurious advisory.
|
|
156
|
+
export function implementWriteGuardApplies(
|
|
157
|
+
planStatus: string,
|
|
158
|
+
implementStatus: string,
|
|
159
|
+
gauntletEntered: boolean,
|
|
160
|
+
isSubagentChild: boolean,
|
|
161
|
+
): boolean {
|
|
162
|
+
const windowOpen =
|
|
163
|
+
implementStatus === "in_progress" || (planStatus === "complete" && implementStatus === "pending");
|
|
164
|
+
return windowOpen && gauntletEntered && !isSubagentChild;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Exempt dirs for the implement-write guard: the configured spec dirs plus each
|
|
168
|
+
// one's sibling `plans` dir (doc/specs -> doc/plans; plans live there per
|
|
169
|
+
// writing-plans) - a routine parent plan-doc update must not burn the one-shot warning.
|
|
170
|
+
export function implementExemptDirs(specDirs: string[]): string[] {
|
|
171
|
+
const dirs = new Set<string>();
|
|
172
|
+
for (const dir of specDirs) {
|
|
173
|
+
const parts = dir.split("/").filter((c) => c.length > 0);
|
|
174
|
+
if (parts.length === 0) continue;
|
|
175
|
+
dirs.add(parts.join("/"));
|
|
176
|
+
dirs.add([...parts.slice(0, -1), "plans"].join("/"));
|
|
177
|
+
}
|
|
178
|
+
return [...dirs];
|
|
179
|
+
}
|
|
180
|
+
|
|
148
181
|
export const markerBlockReason = (file: string): string =>
|
|
149
182
|
`Blocked: ${file} still begins with the context-draft marker - the spec-writing ` +
|
|
150
183
|
`overwrite has not happened. Overwrite the draft with the real spec (write tool, ` +
|
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { test } from "node:test";
|
|
2
|
+
import { after, test } from "node:test";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
3
6
|
import registerPhaseTracker from "./phase-tracker.ts";
|
|
4
7
|
|
|
8
|
+
const tempDirs: string[] = [];
|
|
9
|
+
after(() => {
|
|
10
|
+
for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const tempCwd = (settings?: unknown) => {
|
|
14
|
+
const dir = mkdtempSync(join(tmpdir(), "phase-tracker-test-"));
|
|
15
|
+
tempDirs.push(dir);
|
|
16
|
+
if (settings !== undefined) {
|
|
17
|
+
mkdirSync(join(dir, ".pi"), { recursive: true });
|
|
18
|
+
writeFileSync(join(dir, ".pi", "settings.json"), JSON.stringify(settings));
|
|
19
|
+
}
|
|
20
|
+
return dir;
|
|
21
|
+
};
|
|
22
|
+
|
|
5
23
|
const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"] as const;
|
|
6
24
|
type Phase = (typeof PHASES)[number];
|
|
7
25
|
type Status = "pending" | "in_progress" | "complete" | "skipped";
|
|
@@ -25,13 +43,20 @@ const enteredBranch = (state: Record<Phase, { status: Status }>, extra: unknown[
|
|
|
25
43
|
...extra,
|
|
26
44
|
];
|
|
27
45
|
|
|
28
|
-
|
|
46
|
+
const resumedBranch = (rest: Partial<Record<Phase, Status>>) => [
|
|
47
|
+
phaseResult("start", phases({ brainstorm: "in_progress" })),
|
|
48
|
+
phaseResult("skip", phases({ brainstorm: "skipped" })),
|
|
49
|
+
phaseResult("start", phases({ brainstorm: "skipped", plan: "in_progress" })),
|
|
50
|
+
phaseResult("complete", phases({ brainstorm: "skipped", ...rest })),
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
function harness(options: { cwd?: string; branch?: unknown[]; idle?: boolean; beforeSettled?: (setIdle: (idle: boolean) => void) => void; sendThrows?: boolean } = {}) {
|
|
29
54
|
const handlers = new Map<string, ((event: unknown, ctx: unknown) => unknown)[]>();
|
|
30
55
|
const tools: { name: string; execute: (...args: any[]) => unknown }[] = [];
|
|
31
56
|
const sent: { message: any; options: any }[] = [];
|
|
32
57
|
let idle = options.idle ?? true;
|
|
33
58
|
const ctx = {
|
|
34
|
-
cwd:
|
|
59
|
+
cwd: options.cwd ?? tempCwd(),
|
|
35
60
|
hasUI: false,
|
|
36
61
|
isIdle: () => idle,
|
|
37
62
|
sessionManager: { getBranch: () => options.branch ?? [] },
|
|
@@ -55,7 +80,12 @@ function harness(options: { branch?: unknown[]; idle?: boolean; beforeSettled?:
|
|
|
55
80
|
const emit = async (event: string) => {
|
|
56
81
|
for (const handler of handlers.get(event) ?? []) await handler({ type: event }, ctx);
|
|
57
82
|
};
|
|
58
|
-
|
|
83
|
+
const emitEvent = async (name: string, event: unknown) => {
|
|
84
|
+
const results: unknown[] = [];
|
|
85
|
+
for (const handler of handlers.get(name) ?? []) results.push(await handler(event, ctx));
|
|
86
|
+
return results;
|
|
87
|
+
};
|
|
88
|
+
return { emit, emitEvent, sent, tools, ctx, setIdle: (next: boolean) => (idle = next) };
|
|
59
89
|
}
|
|
60
90
|
|
|
61
91
|
const settle = async (h: ReturnType<typeof harness>) => {
|
|
@@ -157,3 +187,292 @@ test("a throwing send spends the in-memory edge", async () => {
|
|
|
157
187
|
await h.emit("agent_settled");
|
|
158
188
|
assert.equal(h.sent.length, 1);
|
|
159
189
|
});
|
|
190
|
+
|
|
191
|
+
const writeCall = (id: string, path: string) => ({ toolName: "write", toolCallId: id, input: { path } });
|
|
192
|
+
const writeResult = (id: string) => ({ toolName: "write", toolCallId: id, content: [{ type: "text", text: "ok" }] });
|
|
193
|
+
|
|
194
|
+
test("implement-write guard: warns once on parent write outside exempt dirs, exempt paths silent", async () => {
|
|
195
|
+
const priorDepth = process.env.PI_SUBAGENT_DEPTH;
|
|
196
|
+
delete process.env.PI_SUBAGENT_DEPTH; // isolate from an ambient subagent depth in the test-runner's own process
|
|
197
|
+
try {
|
|
198
|
+
const h = harness({ cwd: tempCwd(), branch: resumedBranch({ plan: "complete", implement: "in_progress" }) });
|
|
199
|
+
await h.emit("session_start");
|
|
200
|
+
await h.emitEvent("tool_call", writeCall("t1", "doc/specs/x.md"));
|
|
201
|
+
assert.equal((await h.emitEvent("tool_result", writeResult("t1")))[0], undefined); // spec dir exempt
|
|
202
|
+
await h.emitEvent("tool_call", writeCall("t2", "doc/plans/x.md"));
|
|
203
|
+
assert.equal((await h.emitEvent("tool_result", writeResult("t2")))[0], undefined); // plans dir exempt
|
|
204
|
+
await h.emitEvent("tool_call", writeCall("t3", "src/x.ts"));
|
|
205
|
+
const warned = (await h.emitEvent("tool_result", writeResult("t3")))[0] as { content: { text: string }[] };
|
|
206
|
+
assert.match(warned.content[0].text, /implement/);
|
|
207
|
+
assert.match(warned.content[0].text, /subagent-driven-development/);
|
|
208
|
+
assert.match(warned.content[0].text, /merge-conflict/);
|
|
209
|
+
await h.emitEvent("tool_call", writeCall("t4", "src/y.ts"));
|
|
210
|
+
assert.equal((await h.emitEvent("tool_result", writeResult("t4")))[0], undefined); // warn-once
|
|
211
|
+
} finally {
|
|
212
|
+
if (priorDepth !== undefined) process.env.PI_SUBAGENT_DEPTH = priorDepth;
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("implement-write guard: fires in the armed post-plan gap (plan complete, implement pending)", async () => {
|
|
217
|
+
const priorDepth = process.env.PI_SUBAGENT_DEPTH;
|
|
218
|
+
delete process.env.PI_SUBAGENT_DEPTH;
|
|
219
|
+
try {
|
|
220
|
+
const h = harness({ cwd: tempCwd(), branch: resumedBranch({ plan: "complete" }) });
|
|
221
|
+
await h.emit("session_start");
|
|
222
|
+
await h.emitEvent("tool_call", writeCall("t1", "src/x.ts"));
|
|
223
|
+
const warned = (await h.emitEvent("tool_result", writeResult("t1")))[0] as { content: { text: string }[] };
|
|
224
|
+
assert.match(warned.content[0].text, /implement/);
|
|
225
|
+
} finally {
|
|
226
|
+
if (priorDepth !== undefined) process.env.PI_SUBAGENT_DEPTH = priorDepth;
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("implement-write guard: silent when unarmed, when enforce is false, and in subagent children", async () => {
|
|
231
|
+
const cold = harness({ cwd: tempCwd(), branch: [phaseResult("complete", phases({ plan: "complete", implement: "in_progress" }))] });
|
|
232
|
+
await cold.emit("session_start");
|
|
233
|
+
await cold.emitEvent("tool_call", writeCall("t1", "src/x.ts"));
|
|
234
|
+
assert.equal((await cold.emitEvent("tool_result", writeResult("t1")))[0], undefined);
|
|
235
|
+
|
|
236
|
+
const off = harness({
|
|
237
|
+
cwd: tempCwd({ piGauntlet: { flowGuards: { enforce: false } } }),
|
|
238
|
+
branch: resumedBranch({ plan: "complete", implement: "in_progress" }),
|
|
239
|
+
});
|
|
240
|
+
await off.emit("session_start");
|
|
241
|
+
await off.emitEvent("tool_call", writeCall("t1", "src/x.ts"));
|
|
242
|
+
assert.equal((await off.emitEvent("tool_result", writeResult("t1")))[0], undefined);
|
|
243
|
+
|
|
244
|
+
const priorDepth = process.env.PI_SUBAGENT_DEPTH;
|
|
245
|
+
process.env.PI_SUBAGENT_DEPTH = "1";
|
|
246
|
+
try {
|
|
247
|
+
const child = harness({ cwd: tempCwd(), branch: resumedBranch({ plan: "complete", implement: "in_progress" }) });
|
|
248
|
+
await child.emit("session_start");
|
|
249
|
+
await child.emitEvent("tool_call", writeCall("t1", "src/x.ts"));
|
|
250
|
+
assert.equal((await child.emitEvent("tool_result", writeResult("t1")))[0], undefined);
|
|
251
|
+
} finally {
|
|
252
|
+
if (priorDepth === undefined) delete process.env.PI_SUBAGENT_DEPTH;
|
|
253
|
+
else process.env.PI_SUBAGENT_DEPTH = priorDepth;
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("brainstorm write guard is unchanged by the implement guard", async () => {
|
|
258
|
+
const priorDepth = process.env.PI_SUBAGENT_DEPTH;
|
|
259
|
+
delete process.env.PI_SUBAGENT_DEPTH;
|
|
260
|
+
try {
|
|
261
|
+
const h = harness({ cwd: tempCwd(), branch: [phaseResult("start", phases({ brainstorm: "in_progress" }))] });
|
|
262
|
+
await h.emit("session_start");
|
|
263
|
+
await h.emitEvent("tool_call", writeCall("t1", "src/x.ts"));
|
|
264
|
+
const warned = (await h.emitEvent("tool_result", writeResult("t1")))[0] as { content: { text: string }[] };
|
|
265
|
+
assert.match(warned.content[0].text, /brainstorm/);
|
|
266
|
+
} finally {
|
|
267
|
+
if (priorDepth !== undefined) process.env.PI_SUBAGENT_DEPTH = priorDepth;
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("resumed session: plan-implement recovery edge fires (AC 3)", async () => {
|
|
272
|
+
const h = harness({ cwd: tempCwd(), branch: [...resumedBranch({ plan: "complete" }), assistant()] });
|
|
273
|
+
await settle(h);
|
|
274
|
+
assert.equal(h.sent.length, 1);
|
|
275
|
+
assert.deepEqual(h.sent[0].message.details, { piGauntletRecoveryEdge: "plan-implement" });
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("resumed session: closure gate blocks complete verify without a conformance dispatch (AC 3)", async () => {
|
|
279
|
+
const h = harness({
|
|
280
|
+
cwd: tempCwd(),
|
|
281
|
+
branch: resumedBranch({ plan: "complete", implement: "complete", verify: "in_progress" }),
|
|
282
|
+
});
|
|
283
|
+
await h.emit("session_start");
|
|
284
|
+
const tool = h.tools.find((t) => t.name === "phase_tracker")!;
|
|
285
|
+
const res = (await tool.execute("t1", { action: "complete", phase: "verify" }, undefined, undefined, h.ctx)) as {
|
|
286
|
+
details: { error?: string };
|
|
287
|
+
};
|
|
288
|
+
assert.equal(res.details.error, "no conformance-reviewer dispatch observed");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const subagentResult = (agents: string[]) => ({
|
|
292
|
+
type: "message",
|
|
293
|
+
message: {
|
|
294
|
+
role: "toolResult",
|
|
295
|
+
toolName: "subagent",
|
|
296
|
+
details: { results: agents.map((agent) => ({ agent, exitCode: 0 })) },
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const implementBranch = (extra: unknown[] = []) => [
|
|
301
|
+
phaseResult("start", phases({ brainstorm: "in_progress" })),
|
|
302
|
+
phaseResult("complete", phases({ brainstorm: "complete" })),
|
|
303
|
+
phaseResult("start", phases({ brainstorm: "complete", plan: "in_progress" })),
|
|
304
|
+
phaseResult("complete", phases({ brainstorm: "complete", plan: "complete" })),
|
|
305
|
+
phaseResult("start", phases({ brainstorm: "complete", plan: "complete", implement: "in_progress" })),
|
|
306
|
+
...extra,
|
|
307
|
+
];
|
|
308
|
+
|
|
309
|
+
const commitCall = (id: string) => ({
|
|
310
|
+
toolName: "bash",
|
|
311
|
+
toolCallId: id,
|
|
312
|
+
input: { command: "git commit -m 'integrate wave'" },
|
|
313
|
+
});
|
|
314
|
+
const commitResult = (id: string) => ({
|
|
315
|
+
toolName: "bash",
|
|
316
|
+
toolCallId: id,
|
|
317
|
+
isError: false,
|
|
318
|
+
content: [{ type: "text", text: "ok" }],
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("implement-phase commit with implementer newer than both reviewers warns", async () => {
|
|
322
|
+
const priorDepth = process.env.PI_SUBAGENT_DEPTH;
|
|
323
|
+
delete process.env.PI_SUBAGENT_DEPTH; // isolate from an ambient subagent depth in the test-runner's own process
|
|
324
|
+
try {
|
|
325
|
+
const h = harness({ branch: implementBranch([subagentResult(["implementer"])]) });
|
|
326
|
+
await h.emit("session_start");
|
|
327
|
+
await h.emitEvent("tool_call", commitCall("c1"));
|
|
328
|
+
const warned = (await h.emitEvent("tool_result", commitResult("c1")))[0] as { content: { text: string }[] };
|
|
329
|
+
assert.match(warned.content[0].text, /no spec-reviewer or code-reviewer observed/);
|
|
330
|
+
} finally {
|
|
331
|
+
if (priorDepth !== undefined) process.env.PI_SUBAGENT_DEPTH = priorDepth;
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("cadence guard silent in subagent children", async () => {
|
|
336
|
+
const priorDepth = process.env.PI_SUBAGENT_DEPTH;
|
|
337
|
+
process.env.PI_SUBAGENT_DEPTH = "1";
|
|
338
|
+
try {
|
|
339
|
+
const branch = implementBranch([subagentResult(["implementer"])]);
|
|
340
|
+
const h = harness({ branch });
|
|
341
|
+
await h.emit("session_start");
|
|
342
|
+
await h.emitEvent("tool_call", commitCall("c1"));
|
|
343
|
+
assert.equal((await h.emitEvent("tool_result", commitResult("c1")))[0], undefined);
|
|
344
|
+
} finally {
|
|
345
|
+
if (priorDepth === undefined) delete process.env.PI_SUBAGENT_DEPTH;
|
|
346
|
+
else process.env.PI_SUBAGENT_DEPTH = priorDepth;
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("fresh SR and CR after the implementer keep the commit silent", async () => {
|
|
351
|
+
const priorDepth = process.env.PI_SUBAGENT_DEPTH;
|
|
352
|
+
delete process.env.PI_SUBAGENT_DEPTH;
|
|
353
|
+
try {
|
|
354
|
+
const h = harness({
|
|
355
|
+
branch: implementBranch([
|
|
356
|
+
subagentResult(["implementer"]),
|
|
357
|
+
subagentResult(["spec-reviewer"]),
|
|
358
|
+
subagentResult(["code-reviewer"]),
|
|
359
|
+
]),
|
|
360
|
+
});
|
|
361
|
+
await h.emit("session_start");
|
|
362
|
+
await h.emitEvent("tool_call", commitCall("c1"));
|
|
363
|
+
assert.equal((await h.emitEvent("tool_result", commitResult("c1")))[0], undefined);
|
|
364
|
+
} finally {
|
|
365
|
+
if (priorDepth !== undefined) process.env.PI_SUBAGENT_DEPTH = priorDepth;
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("AND-logic: fresh SR with stale CR stays silent (doc-only wave shape)", async () => {
|
|
370
|
+
const priorDepth = process.env.PI_SUBAGENT_DEPTH;
|
|
371
|
+
delete process.env.PI_SUBAGENT_DEPTH;
|
|
372
|
+
try {
|
|
373
|
+
const h = harness({
|
|
374
|
+
branch: implementBranch([subagentResult(["implementer"]), subagentResult(["spec-reviewer"])]),
|
|
375
|
+
});
|
|
376
|
+
await h.emit("session_start");
|
|
377
|
+
await h.emitEvent("tool_call", commitCall("c1"));
|
|
378
|
+
assert.equal((await h.emitEvent("tool_result", commitResult("c1")))[0], undefined);
|
|
379
|
+
} finally {
|
|
380
|
+
if (priorDepth !== undefined) process.env.PI_SUBAGENT_DEPTH = priorDepth;
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("fused implementer+reviewer results in one dispatch stay silent", async () => {
|
|
385
|
+
const priorDepth = process.env.PI_SUBAGENT_DEPTH;
|
|
386
|
+
delete process.env.PI_SUBAGENT_DEPTH;
|
|
387
|
+
try {
|
|
388
|
+
const h = harness({
|
|
389
|
+
branch: implementBranch([subagentResult(["implementer", "spec-reviewer", "code-reviewer"])]),
|
|
390
|
+
});
|
|
391
|
+
await h.emit("session_start");
|
|
392
|
+
await h.emitEvent("tool_call", commitCall("c1"));
|
|
393
|
+
assert.equal((await h.emitEvent("tool_result", commitResult("c1")))[0], undefined);
|
|
394
|
+
} finally {
|
|
395
|
+
if (priorDepth !== undefined) process.env.PI_SUBAGENT_DEPTH = priorDepth;
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("no implementer observed: commit stays silent", async () => {
|
|
400
|
+
const priorDepth = process.env.PI_SUBAGENT_DEPTH;
|
|
401
|
+
delete process.env.PI_SUBAGENT_DEPTH;
|
|
402
|
+
try {
|
|
403
|
+
const h = harness({ branch: implementBranch() });
|
|
404
|
+
await h.emit("session_start");
|
|
405
|
+
await h.emitEvent("tool_call", commitCall("c1"));
|
|
406
|
+
assert.equal((await h.emitEvent("tool_result", commitResult("c1")))[0], undefined);
|
|
407
|
+
} finally {
|
|
408
|
+
if (priorDepth !== undefined) process.env.PI_SUBAGENT_DEPTH = priorDepth;
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("guard silent outside implement and when flowGuards.enforce is false", async () => {
|
|
413
|
+
const priorDepth = process.env.PI_SUBAGENT_DEPTH;
|
|
414
|
+
delete process.env.PI_SUBAGENT_DEPTH;
|
|
415
|
+
try {
|
|
416
|
+
const planOnly = harness({
|
|
417
|
+
branch: [
|
|
418
|
+
phaseResult("start", phases({ brainstorm: "in_progress" })),
|
|
419
|
+
phaseResult("complete", phases({ brainstorm: "complete" })),
|
|
420
|
+
phaseResult("start", phases({ brainstorm: "complete", plan: "in_progress" })),
|
|
421
|
+
subagentResult(["implementer"]),
|
|
422
|
+
],
|
|
423
|
+
});
|
|
424
|
+
await planOnly.emit("session_start");
|
|
425
|
+
await planOnly.emitEvent("tool_call", commitCall("c1"));
|
|
426
|
+
assert.equal((await planOnly.emitEvent("tool_result", commitResult("c1")))[0], undefined);
|
|
427
|
+
|
|
428
|
+
const off = harness({
|
|
429
|
+
cwd: tempCwd({ piGauntlet: { flowGuards: { enforce: false } } }),
|
|
430
|
+
branch: implementBranch([subagentResult(["implementer"])]),
|
|
431
|
+
});
|
|
432
|
+
await off.emit("session_start");
|
|
433
|
+
await off.emitEvent("tool_call", commitCall("c1"));
|
|
434
|
+
assert.equal((await off.emitEvent("tool_result", commitResult("c1")))[0], undefined);
|
|
435
|
+
} finally {
|
|
436
|
+
if (priorDepth !== undefined) process.env.PI_SUBAGENT_DEPTH = priorDepth;
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("live tool_result observation updates the ledger", async () => {
|
|
441
|
+
const priorDepth = process.env.PI_SUBAGENT_DEPTH;
|
|
442
|
+
delete process.env.PI_SUBAGENT_DEPTH;
|
|
443
|
+
try {
|
|
444
|
+
const h = harness({ branch: implementBranch([subagentResult(["implementer"])]) });
|
|
445
|
+
await h.emit("session_start");
|
|
446
|
+
await h.emitEvent("tool_result", {
|
|
447
|
+
toolName: "subagent",
|
|
448
|
+
toolCallId: "s1",
|
|
449
|
+
content: [],
|
|
450
|
+
details: { results: [{ agent: "spec-reviewer", exitCode: 0 }, { agent: "code-reviewer", exitCode: 0 }] },
|
|
451
|
+
});
|
|
452
|
+
await h.emitEvent("tool_call", commitCall("c1"));
|
|
453
|
+
assert.equal((await h.emitEvent("tool_result", commitResult("c1")))[0], undefined);
|
|
454
|
+
} finally {
|
|
455
|
+
if (priorDepth !== undefined) process.env.PI_SUBAGENT_DEPTH = priorDepth;
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("second implementer after reviews re-arms the warning", async () => {
|
|
460
|
+
const priorDepth = process.env.PI_SUBAGENT_DEPTH;
|
|
461
|
+
delete process.env.PI_SUBAGENT_DEPTH;
|
|
462
|
+
try {
|
|
463
|
+
const h = harness({
|
|
464
|
+
branch: implementBranch([
|
|
465
|
+
subagentResult(["implementer"]),
|
|
466
|
+
subagentResult(["spec-reviewer"]),
|
|
467
|
+
subagentResult(["code-reviewer"]),
|
|
468
|
+
subagentResult(["implementer"]),
|
|
469
|
+
]),
|
|
470
|
+
});
|
|
471
|
+
await h.emit("session_start");
|
|
472
|
+
await h.emitEvent("tool_call", commitCall("c1"));
|
|
473
|
+
const warned = (await h.emitEvent("tool_result", commitResult("c1")))[0] as { content: { text: string }[] };
|
|
474
|
+
assert.match(warned.content[0].text, /no spec-reviewer or code-reviewer observed/);
|
|
475
|
+
} finally {
|
|
476
|
+
if (priorDepth !== undefined) process.env.PI_SUBAGENT_DEPTH = priorDepth;
|
|
477
|
+
}
|
|
478
|
+
});
|
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
checkSubstep,
|
|
28
28
|
findMarkerFile,
|
|
29
29
|
flowGuardApplies,
|
|
30
|
+
implementExemptDirs,
|
|
31
|
+
implementWriteGuardApplies,
|
|
30
32
|
markerBlockReason,
|
|
31
33
|
nextGauntletEntered,
|
|
32
34
|
parseGitCommit,
|
|
@@ -65,6 +67,30 @@ const qualifiesAsClosureDispatch = (details: unknown): boolean => {
|
|
|
65
67
|
return d.results.some((r) => r?.agent === "conformance-reviewer" && r?.exitCode === 0);
|
|
66
68
|
};
|
|
67
69
|
|
|
70
|
+
// Review-cadence guard (spec 2026-08-12-execution-fidelity-hardening): presence-only
|
|
71
|
+
// advisory ledger of the most recent completed implementer / spec-reviewer /
|
|
72
|
+
// code-reviewer dispatch. Agents completing in the SAME dispatch share a sequence
|
|
73
|
+
// number, so a fused implementer+reviewer result never reads as reviewer-stale.
|
|
74
|
+
const CADENCE_AGENTS = ["implementer", "spec-reviewer", "code-reviewer"] as const;
|
|
75
|
+
type CadenceAgent = (typeof CADENCE_AGENTS)[number];
|
|
76
|
+
|
|
77
|
+
const completedCadenceAgents = (details: unknown): CadenceAgent[] => {
|
|
78
|
+
const d = details as { results?: { agent?: unknown; exitCode?: unknown }[] } | undefined;
|
|
79
|
+
if (!d || !Array.isArray(d.results)) return [];
|
|
80
|
+
const seen = new Set<CadenceAgent>();
|
|
81
|
+
for (const r of d.results) {
|
|
82
|
+
if (r?.exitCode === 0 && (CADENCE_AGENTS as readonly unknown[]).includes(r?.agent)) {
|
|
83
|
+
seen.add(r.agent as CadenceAgent);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return [...seen];
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const REVIEW_CADENCE_WARNING =
|
|
90
|
+
"⚠️ Reminder: no spec-reviewer or code-reviewer observed since the last implementer.\n" +
|
|
91
|
+
"SR is required per task; CR per wave for code waves (doc-only waves are SR-only).\n" +
|
|
92
|
+
"Dispatch the missing review(s) before committing integrated work.";
|
|
93
|
+
|
|
68
94
|
const CLOSURE_GATE_ERROR =
|
|
69
95
|
"Error: cannot complete 'verify': no conformance-reviewer dispatch observed.\n" +
|
|
70
96
|
"The closing loop is required before verify completes. Either:\n" +
|
|
@@ -169,6 +195,12 @@ const brainstormWriteWarning = (specDirs: string[]): string =>
|
|
|
169
195
|
`Brainstorming may only edit the spec under ${specDirs.join(", ")}. Implementation\n` +
|
|
170
196
|
"waits for the spec approval gate. If this edit IS the spec, place it under the spec dir.";
|
|
171
197
|
|
|
198
|
+
const implementWriteWarning = (): string =>
|
|
199
|
+
"⚠️ Parent write during the implement phase.\n" +
|
|
200
|
+
"During implement the main loop orchestrates; subagents write the code - dispatch\n" +
|
|
201
|
+
"this via /skill:subagent-driven-development instead of editing directly.\n" +
|
|
202
|
+
"If this edit is merge-conflict resolution between parallel waves, proceed.";
|
|
203
|
+
|
|
172
204
|
// Contiguous-subsequence match of a configured spec dir against path components.
|
|
173
205
|
const pathInSpecDirs = (rawPath: string, specDirs: string[]): boolean => {
|
|
174
206
|
const comps = rawPath.split("/").filter((c) => c.length > 0 && c !== ".");
|
|
@@ -258,6 +290,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
258
290
|
let gauntletEntered = false;
|
|
259
291
|
const attemptedRecoveryEdges = new Set<RecoveryEdge>();
|
|
260
292
|
|
|
293
|
+
let cadenceSeq = 0;
|
|
294
|
+
const cadenceLastSeen: Record<CadenceAgent, number> = {
|
|
295
|
+
implementer: 0,
|
|
296
|
+
"spec-reviewer": 0,
|
|
297
|
+
"code-reviewer": 0,
|
|
298
|
+
};
|
|
299
|
+
const observeCadence = (details: unknown) => {
|
|
300
|
+
const agents = completedCadenceAgents(details);
|
|
301
|
+
if (agents.length === 0) return;
|
|
302
|
+
cadenceSeq++;
|
|
303
|
+
for (const a of agents) cadenceLastSeen[a] = cadenceSeq;
|
|
304
|
+
};
|
|
305
|
+
|
|
261
306
|
// Warn-once-per-phase ledger; cleared on every phase transition and on reconstruct.
|
|
262
307
|
const firedGuards = new Map<string, boolean>();
|
|
263
308
|
// Warnings stashed at tool_call, prepended at tool_result (verify-before-ship pattern).
|
|
@@ -272,6 +317,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
272
317
|
// (Inside a submodule the comparison is git-version-dependent and irrelevant here -
|
|
273
318
|
// gauntlet flows do not run inside submodule git internals; whichever way it
|
|
274
319
|
// resolves, the guard merely staying off in that edge case is harmless.)
|
|
320
|
+
// pi-cohort sets PI_SUBAGENT_DEPTH >= 1 in every spawned child (getSubagentDepthEnv).
|
|
321
|
+
// Implementer forks replay the parent's phase state; without this gate every
|
|
322
|
+
// implementer's first write would trip a spurious advisory.
|
|
323
|
+
const isSubagentChild = Number(process.env.PI_SUBAGENT_DEPTH ?? "0") > 0;
|
|
324
|
+
|
|
275
325
|
const inPrimaryCheckout = (() => {
|
|
276
326
|
try {
|
|
277
327
|
const lines = execSync("git rev-parse --git-dir --git-common-dir", {
|
|
@@ -316,6 +366,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
316
366
|
attemptedRecoveryEdges.clear();
|
|
317
367
|
firedGuards.clear();
|
|
318
368
|
pendingGuardWarnings.clear();
|
|
369
|
+
cadenceSeq = 0;
|
|
370
|
+
cadenceLastSeen.implementer = 0;
|
|
371
|
+
cadenceLastSeen["spec-reviewer"] = 0;
|
|
372
|
+
cadenceLastSeen["code-reviewer"] = 0;
|
|
319
373
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
320
374
|
if (entry.type === "custom_message") {
|
|
321
375
|
if (entry.customType === RECOVERY_CUSTOM_TYPE) {
|
|
@@ -336,6 +390,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
336
390
|
}
|
|
337
391
|
} else if (msg.toolName === "subagent") {
|
|
338
392
|
if (qualifiesAsClosureDispatch(msg.details)) conformanceDispatched = true;
|
|
393
|
+
observeCadence(msg.details);
|
|
339
394
|
} else if (msg.toolName === "plan_tracker") {
|
|
340
395
|
const details = msg.details as { tasks?: { status: string }[]; error?: string } | undefined;
|
|
341
396
|
if (details && !details.error) applyPlanActivity(details.tasks);
|
|
@@ -429,7 +484,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
429
484
|
// returns here without touching disk. Only genuinely guardable events pay for g().
|
|
430
485
|
const brainstormActive = phases.brainstorm.status === "in_progress";
|
|
431
486
|
const guardableWrite =
|
|
432
|
-
(event.toolName === "write" || event.toolName === "edit") &&
|
|
487
|
+
(event.toolName === "write" || event.toolName === "edit") &&
|
|
488
|
+
(flowGuardApplies(brainstormActive, gauntletEntered) ||
|
|
489
|
+
implementWriteGuardApplies(phases.plan.status, phases.implement.status, gauntletEntered, isSubagentChild));
|
|
433
490
|
const guardableBash =
|
|
434
491
|
event.toolName === "bash" && flowGuardApplies(activeGuardPhase() !== undefined, gauntletEntered);
|
|
435
492
|
if (!guardableWrite && !guardableBash) return undefined;
|
|
@@ -437,12 +494,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
437
494
|
if (!flowGuardsEnforced()) return undefined;
|
|
438
495
|
|
|
439
496
|
// Guard 3 — write/edit outside the spec dir during brainstorm.
|
|
497
|
+
// Guard 4 — parent write/edit in the armed implement window (spec
|
|
498
|
+
// 2026-08-07-resume-spec-in-hand): advisory, warn-once, plans dir also exempt.
|
|
440
499
|
if (event.toolName === "write" || event.toolName === "edit") {
|
|
441
|
-
if (phases.brainstorm.status !== "in_progress" || firedGuards.get("brainstorm-write")) return undefined;
|
|
442
500
|
const p = (event.input as { path?: unknown } | undefined)?.path;
|
|
443
|
-
if (
|
|
444
|
-
|
|
445
|
-
|
|
501
|
+
if (phases.brainstorm.status === "in_progress") {
|
|
502
|
+
if (firedGuards.get("brainstorm-write")) return undefined;
|
|
503
|
+
if (typeof p !== "string" || pathInSpecDirs(p, specDirs())) return undefined;
|
|
504
|
+
firedGuards.set("brainstorm-write", true);
|
|
505
|
+
addGuardWarning(event.toolCallId, brainstormWriteWarning(specDirs()));
|
|
506
|
+
return undefined;
|
|
507
|
+
}
|
|
508
|
+
if (firedGuards.get("implement-write")) return undefined;
|
|
509
|
+
if (typeof p !== "string" || pathInSpecDirs(p, implementExemptDirs(specDirs()))) return undefined;
|
|
510
|
+
firedGuards.set("implement-write", true);
|
|
511
|
+
addGuardWarning(event.toolCallId, implementWriteWarning());
|
|
446
512
|
return undefined;
|
|
447
513
|
}
|
|
448
514
|
|
|
@@ -498,6 +564,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
498
564
|
}
|
|
499
565
|
}
|
|
500
566
|
|
|
567
|
+
// Guard 5 — review-cadence reminder (presence-only, advisory). Parent-session
|
|
568
|
+
// commits during implement only; child sessions (PI_SUBAGENT_DEPTH >= 1) are
|
|
569
|
+
// the implementers' own task commits and are exempt by design.
|
|
570
|
+
if (
|
|
571
|
+
!isSubagentChild &&
|
|
572
|
+
phases.implement.status === "in_progress" &&
|
|
573
|
+
parseGitCommit(command) &&
|
|
574
|
+
cadenceLastSeen.implementer > 0 &&
|
|
575
|
+
cadenceLastSeen.implementer > cadenceLastSeen["spec-reviewer"] &&
|
|
576
|
+
cadenceLastSeen.implementer > cadenceLastSeen["code-reviewer"]
|
|
577
|
+
) {
|
|
578
|
+
warnings.push(REVIEW_CADENCE_WARNING);
|
|
579
|
+
}
|
|
580
|
+
|
|
501
581
|
// Guard 3 — bash mutation outside the spec dir during brainstorm.
|
|
502
582
|
if (phases.brainstorm.status === "in_progress" && !firedGuards.get("brainstorm-write")) {
|
|
503
583
|
// Redirect target is cleanly extractable: judge it directly against the spec dirs,
|
|
@@ -524,6 +604,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
524
604
|
pi.on("tool_result", async (event) => {
|
|
525
605
|
if (event.toolName === "subagent") {
|
|
526
606
|
if (qualifiesAsClosureDispatch(event.details)) conformanceDispatched = true;
|
|
607
|
+
observeCadence(event.details);
|
|
527
608
|
const warning = pendingGuardWarnings.get(event.toolCallId);
|
|
528
609
|
if (warning) {
|
|
529
610
|
pendingGuardWarnings.delete(event.toolCallId);
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
const sources = {
|
|
2
2
|
"@earendil-works/pi-ai": `export const StringEnum = (values, options = {}) => ({ values, ...options });`,
|
|
3
|
+
// Reads only the repo (project) settings layer - guard tests exercise repo-local
|
|
4
|
+
// settings via tempCwd()'s .pi/settings.json; there is no preset layer to stub.
|
|
3
5
|
"@earendil-works/pi-coding-agent": `
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
4
7
|
export class SettingsManager {
|
|
5
|
-
static create() { return new SettingsManager(); }
|
|
8
|
+
static create(cwd) { return new SettingsManager(cwd); }
|
|
9
|
+
constructor(cwd) { this.cwd = cwd; }
|
|
6
10
|
getGlobalSettings() { return {}; }
|
|
7
|
-
getProjectSettings() {
|
|
11
|
+
getProjectSettings() {
|
|
12
|
+
try { return JSON.parse(readFileSync(this.cwd + "/.pi/settings.json", "utf8")); }
|
|
13
|
+
catch { return {}; }
|
|
14
|
+
}
|
|
8
15
|
drainErrors() { return []; }
|
|
9
16
|
}
|
|
10
17
|
export const getAgentDir = () => "/tmp/pi-gauntlet-test-agent";
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, test } from "node:test";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import registerVerifyBeforeShip from "./verify-before-ship.ts";
|
|
7
|
+
|
|
8
|
+
const tempDirs: string[] = [];
|
|
9
|
+
after(() => {
|
|
10
|
+
for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function harness() {
|
|
14
|
+
const handlers = new Map<string, ((event: unknown, ctx: unknown) => unknown)[]>();
|
|
15
|
+
const dir = mkdtempSync(join(tmpdir(), "verify-before-ship-test-"));
|
|
16
|
+
tempDirs.push(dir);
|
|
17
|
+
const ctx = { cwd: dir };
|
|
18
|
+
const pi = {
|
|
19
|
+
on(event: string, handler: (event: unknown, context: unknown) => unknown) {
|
|
20
|
+
const registered = handlers.get(event) ?? [];
|
|
21
|
+
registered.push(handler);
|
|
22
|
+
handlers.set(event, registered);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
registerVerifyBeforeShip(pi as any);
|
|
26
|
+
const emitEvent = async (name: string, event: unknown) => {
|
|
27
|
+
const results: unknown[] = [];
|
|
28
|
+
for (const handler of handlers.get(name) ?? []) results.push(await handler(event, ctx));
|
|
29
|
+
return results;
|
|
30
|
+
};
|
|
31
|
+
return { emitEvent };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const bashCall = (id: string, command: string) => ({ toolName: "bash", toolCallId: id, input: { command } });
|
|
35
|
+
const bashResult = (id: string, isError = false) => ({
|
|
36
|
+
toolName: "bash",
|
|
37
|
+
toolCallId: id,
|
|
38
|
+
isError,
|
|
39
|
+
content: [{ type: "text", text: "ok" }],
|
|
40
|
+
});
|
|
41
|
+
const writeCall = (id: string, path: string) => ({ toolName: "write", toolCallId: id, input: { path } });
|
|
42
|
+
|
|
43
|
+
const editSource = async (h: ReturnType<typeof harness>) => {
|
|
44
|
+
await h.emitEvent("tool_call", writeCall("w1", "src/x.ts"));
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const warningOf = (results: unknown[]): string | undefined => {
|
|
48
|
+
const r = results[0] as { content?: { text?: string }[] } | undefined;
|
|
49
|
+
return r?.content?.[0]?.text;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
test("git commit is not watched: no warning even when unverified", async () => {
|
|
53
|
+
const h = harness();
|
|
54
|
+
await editSource(h);
|
|
55
|
+
await h.emitEvent("tool_call", bashCall("c1", "git commit -m 'wave 1'"));
|
|
56
|
+
assert.equal((await h.emitEvent("tool_result", bashResult("c1")))[0], undefined);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("git push warns when unverified", async () => {
|
|
60
|
+
const h = harness();
|
|
61
|
+
await editSource(h);
|
|
62
|
+
await h.emitEvent("tool_call", bashCall("p1", "git push origin main"));
|
|
63
|
+
const warning = warningOf(await h.emitEvent("tool_result", bashResult("p1")));
|
|
64
|
+
assert.match(warning ?? "", /ran without verification/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("gh pr create warns when unverified", async () => {
|
|
68
|
+
const h = harness();
|
|
69
|
+
await editSource(h);
|
|
70
|
+
await h.emitEvent("tool_call", bashCall("pr1", "gh pr create --fill"));
|
|
71
|
+
const warning = warningOf(await h.emitEvent("tool_result", bashResult("pr1")));
|
|
72
|
+
assert.match(warning ?? "", /ran without verification/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a passing recognised run clears the warning for push", async () => {
|
|
76
|
+
const h = harness();
|
|
77
|
+
await editSource(h);
|
|
78
|
+
await h.emitEvent("tool_call", bashCall("t1", "npm test"));
|
|
79
|
+
await h.emitEvent("tool_result", bashResult("t1"));
|
|
80
|
+
await h.emitEvent("tool_call", bashCall("p1", "git push"));
|
|
81
|
+
assert.equal((await h.emitEvent("tool_result", bashResult("p1")))[0], undefined);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a failing recognised run does not clear", async () => {
|
|
85
|
+
const h = harness();
|
|
86
|
+
await editSource(h);
|
|
87
|
+
await h.emitEvent("tool_call", bashCall("t1", "npm test"));
|
|
88
|
+
await h.emitEvent("tool_result", bashResult("t1", true));
|
|
89
|
+
await h.emitEvent("tool_call", bashCall("p1", "git push"));
|
|
90
|
+
const warning = warningOf(await h.emitEvent("tool_result", bashResult("p1")));
|
|
91
|
+
assert.match(warning ?? "", /ran without verification/);
|
|
92
|
+
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Verify-before-ship extension
|
|
3
3
|
*
|
|
4
|
-
* Single-session verification gate for shipping commands (git
|
|
5
|
-
*
|
|
4
|
+
* Single-session verification gate for shipping commands (git push / gh pr create).
|
|
5
|
+
* Local commits are not ship events; commit-time review-cadence advisories live in
|
|
6
|
+
* phase-tracker. Tracks whether a recognised verification command has succeeded
|
|
6
7
|
* since the last source-file write; injects an advisory warning into the tool
|
|
7
8
|
* result of any ship command when verification is stale.
|
|
8
9
|
*
|
|
@@ -39,7 +40,7 @@ const DEFAULT_TEST_COMMANDS = [
|
|
|
39
40
|
"go\\s+test",
|
|
40
41
|
];
|
|
41
42
|
|
|
42
|
-
const SHIP_CMD = /\b(git\s+
|
|
43
|
+
const SHIP_CMD = /\b(git\s+push|gh\s+pr\s+create)\b/;
|
|
43
44
|
|
|
44
45
|
const SOURCE_EXT = /\.(ts|tsx|js|jsx|py|rb|go|rs|java|swift|kt)$/;
|
|
45
46
|
const TEST_PATH = /(^|\/)(tests?|__tests__)\/|\.(test|spec)\.|_test\.(py|go|rb)$/;
|
package/package.json
CHANGED
|
@@ -57,7 +57,7 @@ For each task in `plan_tracker`:
|
|
|
57
57
|
2. **Handle implementer status** (see below).
|
|
58
58
|
3. **Dispatch spec reviewer.** Verify the diff matches the spec — nothing missing, nothing extra.
|
|
59
59
|
4. If spec reviewer finds gaps → re-dispatch implementer to fix → re-review. Loop until ✅.
|
|
60
|
-
5. **Dispatch code-quality reviewer.** Only after spec is ✅.
|
|
60
|
+
5. **Dispatch code-quality reviewer.** Only after spec is ✅. Skip for doc-only tasks (every file in the task's `Files:` block documentation-only) — SR-only, same exemption as doc-only waves.
|
|
61
61
|
6. If quality reviewer finds issues → re-dispatch implementer → re-review. Loop until ✅.
|
|
62
62
|
7. Mark task complete in `plan_tracker`.
|
|
63
63
|
|
|
@@ -142,13 +142,13 @@ Auto-selected at handoff by `writing-plans` (any wave with ≥2 tasks) when the
|
|
|
142
142
|
|
|
143
143
|
1. **Independence check.** Parse the wave's tasks' `Files:` blocks; assert pairwise-disjoint (mechanical). Runtime-resource disjointness (DB/schema, port, fixture, external service, shared temp path) is not machine-checkable here — trust the plan's wave grouping, which `writing-plans`' D5 contract guarantees. Either kind of overlap → the wave is mis-grouped; run those tasks as sequential single-task waves and note it.
|
|
144
144
|
2. **Fan out.** One parallel dispatch (shape below): `implementer` per task, `context: "fresh"`, `worktree: true`. Each returns a status + a patch.
|
|
145
|
-
3. **Status + spec review per task.** Parse each `DONE`/`BLOCKED`/etc. (see [Implementer Status](#implementer-status)) **first**. Then **dispatch a `spec-reviewer` per accepted patch** (`DONE`, or a `DONE_WITH_CONCERNS` you proceeded with) in one parallel fan-out — `context: "fresh"`, `cwd: <this worktree>`, **no `worktree` flag** (read-only) — each passed its task text, the returned **patch diff**, and the absolute spec path. Review is **diff-based**: the diff's hunks carry `file:line`, and test execution is not the reviewer's job here (the wave test gate in step 5 runs the
|
|
145
|
+
3. **Status + spec review per task.** Parse each `DONE`/`BLOCKED`/etc. (see [Implementer Status](#implementer-status)) **first**. Then **dispatch a `spec-reviewer` per accepted patch** (`DONE`, or a `DONE_WITH_CONCERNS` you proceeded with) in one parallel fan-out — `context: "fresh"`, `cwd: <this worktree>`, **no `worktree` flag** (read-only) — each passed its task text, the returned **patch diff**, and the absolute spec path. Review is **diff-based**: the diff's hunks carry `file:line`, and test execution is not the reviewer's job here (the wave test gate in step 5 runs the wave's declared test commands). Inline verdicts are fine at normal wave sizes; large waves use `output:` + `outputMode: "file-only"` to keep verdicts out of your context. **Re-dispatch by cause:** `BLOCKED`/`NEEDS_CONTEXT` per the [Implementer Status](#implementer-status) matrix; a **spec gap** re-dispatches the implementer (fresh, `worktree: true`) carrying the prior patch + the reviewer's findings, the new patch superseding the old at step 4. Loop until accepted + spec ✅.
|
|
146
146
|
4. **Integrate.** `git apply` each task's patch sequentially onto HEAD. Apply fails = textual conflict → drop that task, finish the rest, re-run the dropped task sequentially on the updated HEAD.
|
|
147
|
-
5. **Test gate.** Run the
|
|
148
|
-
6. **Quality review.** Code-quality review on the integrated wave diff; loop fixes to ✅.
|
|
147
|
+
5. **Test gate.** Run the union of the wave's tasks' declared test commands on the integrated tree — the full verification set is the verify phase's job, run once. Failure = semantic conflict or bug → re-run the offending task sequentially, else fix per [When a Subagent Fails](#when-a-subagent-fails).
|
|
148
|
+
6. **Quality review.** Code-quality review on the integrated wave diff; loop fixes to ✅. Skip for doc-only waves (SR-only per the commit precondition below).
|
|
149
149
|
7. **Commit the wave.** Leaves a clean tree; the next wave's children branch from this commit and so see the integrated work.
|
|
150
150
|
|
|
151
|
-
**Two-stage review is preserved:** spec review per task (pre-integration, dispatched `spec-reviewer` — not inline), quality review per wave (post-integration).
|
|
151
|
+
**Two-stage review is preserved:** spec review per task (pre-integration, dispatched `spec-reviewer` — not inline), quality review per wave (post-integration). A wave commit requires one spec-review verdict per accepted task, plus one code-review verdict on the integrated diff for waves that touch code. A doc-only wave (every task's `Files:` block documentation-only, per `writing-plans`' Wave Grouping) is SR-only — the CR gate does not apply.
|
|
152
152
|
|
|
153
153
|
**Dependent context across waves:** wave N+1 tasks branch from a HEAD containing wave N, so they see the code; still forward wave N's task summaries into wave N+1 prompts.
|
|
154
154
|
|
|
@@ -191,9 +191,10 @@ For the fan-out + worktree + patch-integration + conflict mechanics, see `dispat
|
|
|
191
191
|
|
|
192
192
|
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.)
|
|
193
193
|
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.)
|
|
194
|
-
2. **
|
|
195
|
-
3.
|
|
196
|
-
4.
|
|
194
|
+
2. **Run the full verification set — once.** Read the plan header's `**Verification:**` line and run it: tests + style + format (a single bundling entrypoint, or the listed individual commands). Green output is the fresh evidence verify requires; this is the only full run before conformance — task and wave gates ran scoped commands only. After conformance fix rounds land, re-run the set before re-dispatching the gate.
|
|
195
|
+
3. **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. Follow that reference for the partition rule, concern decomposition, and fix-loop mechanics; do not reimplement them here. The fix loop may drive `plan_tracker` to surface fix-wave progress (task name = `Gn: <short desc>`, lifecycle `pending → in_progress → complete` per gap); it never calls `phase_tracker`. Call `phase_tracker({ action: "complete", phase: "verify" })` only when the reference says the handoff is durably complete: either a current `CONFORMS` result, or a current `## Closure / conformance` inventory whose carried-open concerns all come from valid deferred gaps, including `recommended: fix` gaps carried open because a declared precondition made the fix loop unavailable (`maxFixRounds: 0`, or no eligible named-branch worktree). A started positive-cap fix loop that blocks, fails, or exhausts its rounds with an open `fix` gap is escalation, not completion; on escalation, do not complete verify, stop and report.
|
|
196
|
+
4. Summarize what was implemented (tasks completed, files changed, test counts, code-review verdict). Emit the `## Closure / conformance` block exactly as defined in `verification-before-completion/reference/conformance-check.md`: it must open with the two-line sentinel (`status: CONFORMS (0 open)` or `status: GAPS (N open)`, then `audited-base: <full HEAD SHA>`), then carry the exact durable concern schema by reference with no renamed or reformatted fields. `finishing-a-development-branch` Step 3.5 consumes that block verbatim.
|
|
197
|
+
5. **Proceed to finishing — no confirmation prompt.** Once verify is complete per step 3's criterion, 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. Carried-open concerns are resolved there per concern via the `## Closure / conformance` block from step 4. 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.
|
|
197
198
|
|
|
198
199
|
## Red Flags — STOP
|
|
199
200
|
|
|
@@ -208,6 +209,8 @@ For the fan-out + worktree + patch-integration + conflict mechanics, see `dispat
|
|
|
208
209
|
- Pausing to "check in" between tasks (continuous execution rule)
|
|
209
210
|
- Skipping the `Implementer Status` parse — treating every response as DONE
|
|
210
211
|
- Starting on main without explicit user consent
|
|
212
|
+
- Dispatching `code-reviewer` before every one of the wave's spec-review verdicts has landed (including fusing SR+CR into one parallel call)
|
|
213
|
+
- About to run the full verification entrypoint during the implement phase — task and wave gates run scoped, plan-declared commands only; the full set belongs to verify
|
|
211
214
|
|
|
212
215
|
## Integration
|
|
213
216
|
|
|
@@ -36,7 +36,7 @@ If you haven't run the verification command in this message, you cannot claim it
|
|
|
36
36
|
BEFORE claiming any status or expressing satisfaction:
|
|
37
37
|
|
|
38
38
|
1. IDENTIFY: What command proves this claim?
|
|
39
|
-
2. RUN: Execute the
|
|
39
|
+
2. RUN: Execute the full command for the claim from step 1 (fresh, complete, unabridged)
|
|
40
40
|
3. READ: Full output, check exit code, count failures
|
|
41
41
|
4. VERIFY: Does output confirm the claim?
|
|
42
42
|
- If NO: State actual status with evidence
|
|
@@ -46,6 +46,8 @@ BEFORE claiming any status or expressing satisfaction:
|
|
|
46
46
|
Skip any step = lying, not verifying
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
+
Evidence is proportionate to the claim: a task or wave claim is proven by the commands the plan declares for it — a scoped run is **complete** evidence for a scoped claim. Phase-completion and ship claims require the project's full verification entrypoint (tests + style + format), run once at verify before the conformance gate.
|
|
50
|
+
|
|
49
51
|
## Common Failures
|
|
50
52
|
|
|
51
53
|
| Claim | Requires | Not Sufficient |
|
|
@@ -69,7 +71,7 @@ Skip any step = lying, not verifying
|
|
|
69
71
|
| "Linter passed" | Linter ≠ compiler |
|
|
70
72
|
| "Agent said success" | Verify independently |
|
|
71
73
|
| "I'm tired" | Exhaustion ≠ excuse |
|
|
72
|
-
| "Partial check is enough" | Partial
|
|
74
|
+
| "Partial check is enough" | Partial = less than the claim requires; scoped-for-scoped is complete |
|
|
73
75
|
| "Different words so rule doesn't apply" | Spirit over letter |
|
|
74
76
|
|
|
75
77
|
## Red Flags - STOP
|
|
@@ -152,7 +154,7 @@ From recurring failure modes:
|
|
|
152
154
|
|
|
153
155
|
## Enforcement
|
|
154
156
|
|
|
155
|
-
The `verify-before-ship` extension shipped by pi-gauntlet watches `git
|
|
157
|
+
The `verify-before-ship` extension shipped by pi-gauntlet watches `git push` and `gh pr create`. If you have not run a passing recognised verification command since your last source-file edit in this session, an advisory warning is injected into the tool result. The warning clears automatically after a fresh passing run.
|
|
156
158
|
|
|
157
159
|
Defaults recognise `make ci`, `make test`, `npm test`, `pnpm test`, `yarn test`, `pytest`, `rspec`, `cargo test`, `go test`. Projects can override (or narrow) the list via `settings.json#piGauntlet.verifyBeforeShip.testCommands`.
|
|
158
160
|
|
|
@@ -3,7 +3,7 @@ name: writing-plans
|
|
|
3
3
|
description: Use when you have a spec or requirements for a multi-step task, before touching code
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
> **Related skills:** Reached via the auto-chain from `/skill:brainstorming
|
|
6
|
+
> **Related skills:** Reached via the auto-chain from `/skill:brainstorming`, or via the spec-in-hand handoff path (see "Resuming with a spec in hand" below) — otherwise not a direct human entry point. On completion this skill auto-invokes `/skill:subagent-driven-development`.
|
|
7
7
|
|
|
8
8
|
# Writing Plans
|
|
9
9
|
|
|
@@ -15,9 +15,9 @@ DRY. YAGNI. TDD. Frequent commits.
|
|
|
15
15
|
|
|
16
16
|
**Announce at start:** "I'm using the writing-plans skill to create the implementation plan."
|
|
17
17
|
|
|
18
|
-
Before drafting the plan, call `phase_tracker({ action: "start", phase: "plan" })
|
|
18
|
+
Before drafting the plan, call `phase_tracker({ action: "start", phase: "plan" })` (if resuming with a spec in hand, see "Resuming with a spec in hand" below first -- its arming sequence already performs this call).
|
|
19
19
|
|
|
20
|
-
**Input:** an approved spec in `<project>/doc/specs/<filename>.md`
|
|
20
|
+
**Input:** an approved spec in `<project>/doc/specs/<filename>.md` — produced by `/skill:brainstorming` in this session, or handed off from another session (see "Resuming with a spec in hand").
|
|
21
21
|
|
|
22
22
|
**Save plans to:** the sibling `doc/plans/` directory next to the spec. The plan filename matches the spec filename exactly — same date, same Linear ID (if any), same topic slug, no `-design` suffix.
|
|
23
23
|
|
|
@@ -29,6 +29,36 @@ Before drafting the plan, call `phase_tracker({ action: "start", phase: "plan" }
|
|
|
29
29
|
|
|
30
30
|
If no spec exists, send the work back to `/skill:brainstorming`. Do not invent a plan without a spec.
|
|
31
31
|
|
|
32
|
+
## Resuming with a spec in hand
|
|
33
|
+
|
|
34
|
+
A handoff path, not a shortcut: use it when an **approved spec arrives from another session** (a handoff doc, a fresh top-level session resuming ratified work). New work still enters via `/skill:brainstorming`. The trigger is **phase-tracker state, not handoff prose** — it works even when the handoff doc says nothing about arming. On invocation, check `phase_tracker({ action: "status" })` and branch on the brainstorm phase:
|
|
35
|
+
|
|
36
|
+
| brainstorm status | meaning | action |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `in_progress` or `complete` | auto-chain from brainstorming | normal flow below, unchanged |
|
|
39
|
+
| `pending`, no other phase `in_progress` | fresh resume | arm, then plan (this section) |
|
|
40
|
+
| `skipped` | already resumed in this session | do **not** re-run the sequence (`start` errors on a skipped phase without `force`); verify plan state and continue |
|
|
41
|
+
| `pending`, another phase `in_progress` | not a fresh resume (`start brainstorm` would error) | stop and ask the user; do not arm |
|
|
42
|
+
|
|
43
|
+
On a fresh resume:
|
|
44
|
+
|
|
45
|
+
1. **Verify the spec exists** at the given path. Missing → stop and ask; never arm on a missing spec.
|
|
46
|
+
2. **Confirm approval.** Any unambiguous assertion in the prompt, handoff doc, or user message ("Brainstorming is complete", "spec approved" — examples, not an allowlist) counts. No assertion → ask once; on "no", route to `/skill:brainstorming`.
|
|
47
|
+
3. **Worktree.** If not already in an isolated worktree, set one up per `/skill:using-git-worktrees` and commit the spec there (the spec must live in the worktree, same as the brainstorm path).
|
|
48
|
+
4. **Arm the flow** — run exactly:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
phase_tracker({ action: "start", phase: "brainstorm" })
|
|
52
|
+
phase_tracker({ action: "skip", phase: "brainstorm", reason: "resume: approved spec at <path>" })
|
|
53
|
+
phase_tracker({ action: "start", phase: "plan" })
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
This is the existing arming mechanism, not new mechanics: the `start` arms `gauntletEntered`, `skip` preserves it, session replay reconstructs it, `reset` disarms. The sequence already performed this skill's own `start plan` call — **do not** issue a second one (a repeat `start` on the in_progress phase is a no-op reset that re-clears the warn-once guard ledger).
|
|
57
|
+
|
|
58
|
+
Then continue with the normal flow below (Scope Check onward, including Recon).
|
|
59
|
+
|
|
60
|
+
**Writing a handoff doc** (from the producing session): name `/skill:writing-plans` as the entry point — never a phase past planning, since this gesture only arms through `start plan` — give the spec's path, and assert its approval status.
|
|
61
|
+
|
|
32
62
|
## Boundaries
|
|
33
63
|
|
|
34
64
|
- Read code and docs: yes
|
|
@@ -50,6 +80,26 @@ If yes, decompose into separate plans and call it out:
|
|
|
50
80
|
|
|
51
81
|
A single plan should land in one PR worth of work. Multi-PR sequences get separate plans.
|
|
52
82
|
|
|
83
|
+
## Recon (mandatory)
|
|
84
|
+
|
|
85
|
+
Before mapping files, dispatch a scout to build the implementation map. Foreground, no announcement, no user interaction. The task template below is fixed — fill exactly **one** variable, the absolute spec path; compose nothing else:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
subagent({ agent: "scout", context: "fresh", cwd: "<abs worktree path>",
|
|
89
|
+
phase: "plan-recon", output: "<abs plan path — same filename as the spec, per the table above>",
|
|
90
|
+
task: <the fixed template below, with the spec path filled> })
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
> Recon for implementation planning. Read the approved spec at `<abs spec path>` - it is the single source of truth for what is being built. Also read the repo's `AGENTS.md` and, if present, `.pi/gauntlet-overrides.md` for conventions. Build an implementation map for the spec: exact file paths to create/modify/delete; existing call sites and tests with line ranges; conventions and patterns the plan must match; the project's test runner and the exact scoped-invocation form for running individual test files (derived from the repo's Makefile/bin/config and the overrides file); the style/lint and auto-format commands in both scoped per-file form and repo-wide form (same sources); separately, the full-suite verification entrypoint and whether it bundles style/format checks. Flag any spec claim that contradicts the code. Read-only recon: do not edit any file except writing your report to your output path. Start your report with the line `# CONTEXT DRAFT - NOT A PLAN - fully replaced at plan-writing` verbatim. End with an "Open questions that matter for the plan" section. Compact handoff, not a dump.
|
|
94
|
+
|
|
95
|
+
Consumption:
|
|
96
|
+
|
|
97
|
+
- `Read` the draft at the plan path before mapping files — the on-disk copy is canonical (prune-proof, restart-proof).
|
|
98
|
+
- The draft is a helper, not a fence: verify load-bearing claims against real code before planning against them.
|
|
99
|
+
- Plan-writing is a **full-replacement `write`** at the same path. Re-read the draft in the same turn immediately before the overwrite. After the write, confirm line 1 is no longer the draft marker before self-review and handoff.
|
|
100
|
+
- **Degradation:** the scout failed when its task errored or the output file is missing or empty. Proceed from your own reads with a one-line note; never block. If the file is absent, no marker check applies at the overwrite.
|
|
101
|
+
- **Re-entry:** re-dispatching recon overwrites whatever the plan path holds — including a committed prior plan (recoverable from git history) or an uncommitted one (destroyed). Re-planning is a deliberate overwrite.
|
|
102
|
+
|
|
53
103
|
## File Structure
|
|
54
104
|
|
|
55
105
|
**Before drafting tasks, map the files.**
|
|
@@ -83,6 +133,8 @@ Group tasks into **waves** so the executor can parallelize independent work (see
|
|
|
83
133
|
|
|
84
134
|
**File-ownership contract.** The per-task `**Files:**` block *is* the ownership declaration — no new syntax. Rule: **within a wave, the union of every task's declared paths must be pairwise disjoint.** Globs are allowed for `Modify` when exact paths are unknown, but must not overlap another same-wave task's paths. A task that must touch another's file belongs in a later wave.
|
|
85
135
|
|
|
136
|
+
**Test-command contract.** Every code-touching wave declares at least one scoped test command across its tasks' steps. A wave with zero test commands is legal only when every task's `Files:` block is documentation-only (the trailing doc-only wave below).
|
|
137
|
+
|
|
86
138
|
**Runtime-resource disjointness.** File-disjoint is necessary but not sufficient: two tasks with disjoint files that both mutate the same DB, bind the same port, or share a fixture are **not** parallel-safe and must land in different waves. The executor auto-selects parallel for *every* multi-task wave, so this grouping is the sole parallel-safety guarantee — there is no selection-time judgment downstream. No new mandatory per-task syntax; when a shared runtime resource is the reason two file-disjoint tasks sit in different waves, record it in an inline note on the later wave.
|
|
87
139
|
|
|
88
140
|
**Doc tasks.** Doc updates are real plan tasks, not an afterthought. Task-local docs (a doc that only describes the file(s) a task already touches) ride with that task. Cross-cutting or index docs (README, `AGENTS.md`, topic guides, taxonomy indexes) sequence into a dedicated trailing doc-only wave — last wave by convention, file-disjoint from every code task so the pairwise-disjoint wave contract holds.
|
|
@@ -113,6 +165,7 @@ Each step is **one action, 2-5 minutes**:
|
|
|
113
165
|
- "Run it, confirm it fails" — step
|
|
114
166
|
- "Implement minimal code to pass" — step
|
|
115
167
|
- "Run tests, confirm green" — step
|
|
168
|
+
- "Format & lint the task's files" — step
|
|
116
169
|
- "Commit" — step
|
|
117
170
|
|
|
118
171
|
## Plan Document Header
|
|
@@ -130,11 +183,15 @@ Each step is **one action, 2-5 minutes**:
|
|
|
130
183
|
|
|
131
184
|
**Spec:** `<project>/doc/specs/<same-filename-as-this-plan>.md`
|
|
132
185
|
|
|
186
|
+
**Verification:** `<full verification command set — tests + style + format; a single bundling entrypoint, or the listed individual commands; from the recon report / project overrides>`
|
|
187
|
+
|
|
133
188
|
**Linear:** `E-XXXX` (omit if no ticket)
|
|
134
189
|
|
|
135
190
|
---
|
|
136
191
|
```
|
|
137
192
|
|
|
193
|
+
The `**Verification:**` line is the **only** place the full verification entrypoint may appear — never in any task or wave step. The verify phase reads it from the plan instead of re-deriving it; execution runs scoped commands only.
|
|
194
|
+
|
|
138
195
|
## Task Structure
|
|
139
196
|
|
|
140
197
|
Each task uses `- [ ]` checkbox steps so execution tools (and humans) can track progress.
|
|
@@ -174,7 +231,12 @@ Each task uses `- [ ]` checkbox steps so execution tools (and humans) can track
|
|
|
174
231
|
Run: `uv run pytest tests/path/test.py::test_name -v`
|
|
175
232
|
Expected: PASS
|
|
176
233
|
|
|
177
|
-
- [ ] **Step 5:
|
|
234
|
+
- [ ] **Step 5: Format & lint the task's files**
|
|
235
|
+
|
|
236
|
+
Run: `<scoped fmt/lint command from recon> exact/path/to/file.py tests/exact/path/to/test.py`
|
|
237
|
+
Expected: no diff after re-run / 0 offenses
|
|
238
|
+
|
|
239
|
+
- [ ] **Step 6: Commit**
|
|
178
240
|
|
|
179
241
|
```bash
|
|
180
242
|
git add tests/path/test.py src/path/file.py
|
|
@@ -182,6 +244,8 @@ Each task uses `- [ ]` checkbox steps so execution tools (and humans) can track
|
|
|
182
244
|
```
|
|
183
245
|
```
|
|
184
246
|
|
|
247
|
+
Every code task carries this step (red -> green -> fmt/lint -> commit). Doc-only tasks omit it unless the project formats Markdown.
|
|
248
|
+
|
|
185
249
|
## No Placeholders
|
|
186
250
|
|
|
187
251
|
Every plan failure mode:
|
|
@@ -205,6 +269,8 @@ After drafting the plan and before announcing it complete, run three checks your
|
|
|
205
269
|
- **Placeholder scan.** Grep the doc for `TODO`, `TBD`, `xxx`, `[fill in]`, `<example>`, `etc.`, "probably", "something like". Resolve or convert each into an explicit Open Question.
|
|
206
270
|
- **Type / API consistency.** Function signatures and field names that appear in multiple tasks must match exactly. The plan is its own contract — internal contradictions surface as bugs during execution.
|
|
207
271
|
- **Wave disjointness.** For every multi-task wave, confirm the tasks' `Files:` sets are pairwise disjoint **and** that no two tasks contend on a shared mutable runtime resource (DB/schema, port, fixture, external service, shared temp path). Either kind of overlap = mis-grouped wave; split or re-order before handoff.
|
|
272
|
+
- **Scoped-test coverage.** Every code-touching wave declares at least one scoped test command; only doc-only waves may have none.
|
|
273
|
+
- **Header-only entrypoint.** The full verification entrypoint appears only in the plan header's `**Verification:**` line. Grep the task body for the header's command string — expect zero hits.
|
|
208
274
|
|
|
209
275
|
Fix what this review finds before handoff.
|
|
210
276
|
|
|
@@ -183,7 +183,7 @@ If a skill leans on pi capabilities, name them explicitly:
|
|
|
183
183
|
|---|---|
|
|
184
184
|
| Plan/phase persistence | `plan_tracker` tool (provided by the `pi-gauntlet` package's `plan-tracker` extension) |
|
|
185
185
|
| Progressive disclosure | Direct `read` of `reference/<topic>.md` paths named inline in SKILL.md |
|
|
186
|
-
| Runtime enforcement | `verify-before-ship` extension from `pi-gauntlet` (advisory warning before `git
|
|
186
|
+
| Runtime enforcement | `verify-before-ship` extension from `pi-gauntlet` (advisory warning before `git push` / `gh pr create` when no canonical verification command has succeeded since the last source edit) |
|
|
187
187
|
| Subagent dispatch | `subagent` tool from `pi-cohort`; baseline subagents from `pi-gauntlet` are `implementer`, `code-reviewer`, `spec-reviewer`, `conformance-reviewer`. Consumer repos can add project-specific subagents under `.pi/agents/`. |
|
|
188
188
|
|
|
189
189
|
Don't invent capabilities. Don't reference Claude Code's `Task` tool, OpenCode hooks, or Codex `spawn_agent` unless the skill is explicitly for that harness.
|