pi-gauntlet 4.6.2 → 4.7.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 +25 -0
- package/README.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 +134 -4
- package/extensions/phase-tracker.ts +29 -5
- package/extensions/test-support/pi-stubs.mjs +9 -2
- package/package.json +1 -1
- package/skills/writing-plans/SKILL.md +33 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v4.7.0 - 2026-08-07
|
|
4
|
+
|
|
5
|
+
Sanction the spec-in-hand resume gesture and guard the implement phase (#6).
|
|
6
|
+
Sessions that start with an approved spec from a handoff doc previously ran
|
|
7
|
+
fully unarmed - the observed incident implemented an entire plan inline in the
|
|
8
|
+
main loop with every enforcement surface dormant.
|
|
9
|
+
|
|
10
|
+
- `writing-plans` gains a "Resuming with a spec in hand" subsection: a
|
|
11
|
+
state-detected handoff entry (branch on `phase_tracker status`) that verifies
|
|
12
|
+
the spec, confirms approval, sets up the worktree, and arms `gauntletEntered`
|
|
13
|
+
via the existing `start brainstorm -> skip brainstorm -> start plan`
|
|
14
|
+
sequence, plus guidance for writing handoff docs. Prose only - no new skill,
|
|
15
|
+
no new tracker action.
|
|
16
|
+
- Fourth flow guard in `phase-tracker`: a warn-once advisory on parent
|
|
17
|
+
`write`/`edit` in the armed implement window (`implement` in progress, or
|
|
18
|
+
`plan` complete with `implement` still pending), exempting
|
|
19
|
+
`flowGuards.specDirs` plus each spec dir's sibling `plans` dir. Advisory,
|
|
20
|
+
never blocks (merge-conflict resolution between parallel waves proceeds past
|
|
21
|
+
it); subagent children (`PI_SUBAGENT_DEPTH` >= 1) never trip it; disabled
|
|
22
|
+
with the other guards via `flowGuards.enforce: false`. No new settings key.
|
|
23
|
+
- Regression coverage: resumed-session replay arming, recovery edge, and
|
|
24
|
+
closure gate; test stub `SettingsManager` now reads the repo settings layer.
|
|
25
|
+
- The 2026-07-19 sole-entry-point spec carries a supersession banner scoped to
|
|
26
|
+
its sole-arming claim.
|
|
27
|
+
|
|
3
28
|
## v4.6.2 - 2026-08-07
|
|
4
29
|
|
|
5
30
|
Trim the always-shipped tool descriptions of the gauntlet-internal tools to
|
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ pi-gauntlet ships three kinds of pieces, layered on top of pi-cohort's dispatch:
|
|
|
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
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 commit/push without a passing test run since your last edit. 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
|
|
|
@@ -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,103 @@ 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
|
+
});
|
|
@@ -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,
|
|
@@ -169,6 +171,12 @@ const brainstormWriteWarning = (specDirs: string[]): string =>
|
|
|
169
171
|
`Brainstorming may only edit the spec under ${specDirs.join(", ")}. Implementation\n` +
|
|
170
172
|
"waits for the spec approval gate. If this edit IS the spec, place it under the spec dir.";
|
|
171
173
|
|
|
174
|
+
const implementWriteWarning = (): string =>
|
|
175
|
+
"⚠️ Parent write during the implement phase.\n" +
|
|
176
|
+
"During implement the main loop orchestrates; subagents write the code - dispatch\n" +
|
|
177
|
+
"this via /skill:subagent-driven-development instead of editing directly.\n" +
|
|
178
|
+
"If this edit is merge-conflict resolution between parallel waves, proceed.";
|
|
179
|
+
|
|
172
180
|
// Contiguous-subsequence match of a configured spec dir against path components.
|
|
173
181
|
const pathInSpecDirs = (rawPath: string, specDirs: string[]): boolean => {
|
|
174
182
|
const comps = rawPath.split("/").filter((c) => c.length > 0 && c !== ".");
|
|
@@ -272,6 +280,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
272
280
|
// (Inside a submodule the comparison is git-version-dependent and irrelevant here -
|
|
273
281
|
// gauntlet flows do not run inside submodule git internals; whichever way it
|
|
274
282
|
// resolves, the guard merely staying off in that edge case is harmless.)
|
|
283
|
+
// pi-cohort sets PI_SUBAGENT_DEPTH >= 1 in every spawned child (getSubagentDepthEnv).
|
|
284
|
+
// Implementer forks replay the parent's phase state; without this gate every
|
|
285
|
+
// implementer's first write would trip a spurious advisory.
|
|
286
|
+
const isSubagentChild = Number(process.env.PI_SUBAGENT_DEPTH ?? "0") > 0;
|
|
287
|
+
|
|
275
288
|
const inPrimaryCheckout = (() => {
|
|
276
289
|
try {
|
|
277
290
|
const lines = execSync("git rev-parse --git-dir --git-common-dir", {
|
|
@@ -429,7 +442,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
429
442
|
// returns here without touching disk. Only genuinely guardable events pay for g().
|
|
430
443
|
const brainstormActive = phases.brainstorm.status === "in_progress";
|
|
431
444
|
const guardableWrite =
|
|
432
|
-
(event.toolName === "write" || event.toolName === "edit") &&
|
|
445
|
+
(event.toolName === "write" || event.toolName === "edit") &&
|
|
446
|
+
(flowGuardApplies(brainstormActive, gauntletEntered) ||
|
|
447
|
+
implementWriteGuardApplies(phases.plan.status, phases.implement.status, gauntletEntered, isSubagentChild));
|
|
433
448
|
const guardableBash =
|
|
434
449
|
event.toolName === "bash" && flowGuardApplies(activeGuardPhase() !== undefined, gauntletEntered);
|
|
435
450
|
if (!guardableWrite && !guardableBash) return undefined;
|
|
@@ -437,12 +452,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
437
452
|
if (!flowGuardsEnforced()) return undefined;
|
|
438
453
|
|
|
439
454
|
// Guard 3 — write/edit outside the spec dir during brainstorm.
|
|
455
|
+
// Guard 4 — parent write/edit in the armed implement window (spec
|
|
456
|
+
// 2026-08-07-resume-spec-in-hand): advisory, warn-once, plans dir also exempt.
|
|
440
457
|
if (event.toolName === "write" || event.toolName === "edit") {
|
|
441
|
-
if (phases.brainstorm.status !== "in_progress" || firedGuards.get("brainstorm-write")) return undefined;
|
|
442
458
|
const p = (event.input as { path?: unknown } | undefined)?.path;
|
|
443
|
-
if (
|
|
444
|
-
|
|
445
|
-
|
|
459
|
+
if (phases.brainstorm.status === "in_progress") {
|
|
460
|
+
if (firedGuards.get("brainstorm-write")) return undefined;
|
|
461
|
+
if (typeof p !== "string" || pathInSpecDirs(p, specDirs())) return undefined;
|
|
462
|
+
firedGuards.set("brainstorm-write", true);
|
|
463
|
+
addGuardWarning(event.toolCallId, brainstormWriteWarning(specDirs()));
|
|
464
|
+
return undefined;
|
|
465
|
+
}
|
|
466
|
+
if (firedGuards.get("implement-write")) return undefined;
|
|
467
|
+
if (typeof p !== "string" || pathInSpecDirs(p, implementExemptDirs(specDirs()))) return undefined;
|
|
468
|
+
firedGuards.set("implement-write", true);
|
|
469
|
+
addGuardWarning(event.toolCallId, implementWriteWarning());
|
|
446
470
|
return undefined;
|
|
447
471
|
}
|
|
448
472
|
|
|
@@ -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";
|
package/package.json
CHANGED
|
@@ -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).
|
|
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
|