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 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
- function harness(options: { branch?: unknown[]; idle?: boolean; beforeSettled?: (setIdle: (idle: boolean) => void) => void; sendThrows?: boolean } = {}) {
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: process.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
- return { emit, sent, tools, setIdle: (next: boolean) => (idle = next) };
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") && flowGuardApplies(brainstormActive, gauntletEntered);
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 (typeof p !== "string" || pathInSpecDirs(p, specDirs())) return undefined;
444
- firedGuards.set("brainstorm-write", true);
445
- addGuardWarning(event.toolCallId, brainstormWriteWarning(specDirs()));
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() { return {}; }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "4.6.2",
3
+ "version": "4.7.0",
4
4
  "description": "Opinionated, gated workflow skills, subagent personas, and runtime extensions for the pi coding agent.",
5
5
  "author": "Jacek Juraszek",
6
6
  "type": "module",
@@ -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` (not a direct human entry point). On completion this skill auto-invokes `/skill:subagent-driven-development`.
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` (produced by `/skill:brainstorming`).
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