pi-gauntlet 6.0.0 → 6.0.1

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.
@@ -17,6 +17,7 @@ import { Text } from "@earendil-works/pi-tui";
17
17
  import { type Static, Type } from "@sinclair/typebox";
18
18
  import {
19
19
  mainLoopModel,
20
+ planDirsFor,
20
21
  resolveClosureReview,
21
22
  resolveEscalationLoop,
22
23
  resolveFlowGuards,
@@ -734,7 +735,7 @@ export default function (pi: ExtensionAPI) {
734
735
  });
735
736
 
736
737
  const GauntletSettingParams = Type.Object({
737
- key: StringEnum(["specCouncil", "closureReview", "escalationLoop"] as const, {
738
+ key: StringEnum(["specCouncil", "closureReview", "escalationLoop", "flowGuards"] as const, {
738
739
  description: "Which gauntlet setting to resolve (merged repo-over-preset).",
739
740
  }),
740
741
  });
@@ -747,16 +748,19 @@ export default function (pi: ExtensionAPI) {
747
748
  executionMode: "sequential",
748
749
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
749
750
  const { gauntlet, errors } = loadGauntletSettings(ctx.cwd);
751
+ const fg = resolveFlowGuards(gauntlet);
750
752
  const payload =
751
753
  params.key === "specCouncil"
752
754
  ? { key: "specCouncil" as const, ...resolveSpecCouncil(gauntlet), errors }
753
755
  : params.key === "closureReview"
754
756
  ? { key: "closureReview" as const, ...resolveClosureReview(gauntlet), errors }
755
- : {
756
- key: "escalationLoop" as const,
757
- ...resolveEscalationLoop(gauntlet, mainLoopModel(ctx.model, ctx.thinkingLevel)),
758
- errors,
759
- };
757
+ : params.key === "flowGuards"
758
+ ? { key: "flowGuards" as const, ...fg, planDirs: planDirsFor(fg.specDirs), errors }
759
+ : {
760
+ key: "escalationLoop" as const,
761
+ ...resolveEscalationLoop(gauntlet, mainLoopModel(ctx.model, ctx.thinkingLevel)),
762
+ errors,
763
+ };
760
764
  return {
761
765
  content: [{ type: "text", text: "```json\n" + JSON.stringify(payload, null, 2) + "\n```" }],
762
766
  details: payload,
@@ -4,7 +4,7 @@
4
4
  import { join } from "node:path";
5
5
  import { after, test } from "node:test";
6
6
  import registerTelemetry, { realFs, replayBranch, type Deps, type GitResult } from "./telemetry.ts";
7
- import { DEFAULT_TELEMETRY_BUCKETS } from "./lib/gauntlet-settings.ts";
7
+ import { DEFAULT_TELEMETRY_BUCKETS, planDirsFor, resolveFlowGuards } from "./lib/gauntlet-settings.ts";
8
8
  import { emptyAccumulators, parseRecord, serializeRecord } from "./lib/telemetry-record.ts";
9
9
  import { guardReason } from "./lib/telemetry-ship.ts";
10
10
 
@@ -24,6 +24,7 @@
24
24
  const root = mkdtempSync(join(tmpdir(), "telemetry-test-"));
25
25
  tempDirs.push(root);
26
26
  mkdirSync(join(root, "doc/specs"), { recursive: true });
27
+ mkdirSync(join(root, "docs/specs"), { recursive: true });
27
28
  mkdirSync(join(root, "doc/plans"), { recursive: true });
28
29
  mkdirSync(join(root, ".worktrees/x/doc/specs"), { recursive: true });
29
30
  const handlers = new Map<string, Handler[]>();
@@ -31,6 +32,7 @@
31
32
  const gitCwds: string[] = [];
32
33
  const jjCalls: string[][] = [];
33
34
  const readFileCalls: string[] = [];
35
+ const specDirs = resolveFlowGuards({}).specDirs;
34
36
  let clock = Date.parse("2026-09-17T10:00:00Z");
35
37
  const deps: Deps = {
36
38
  fs: { ...realFs, readFile: (p) => { readFileCalls.push(p); return realFs.readFile(p); } },
@@ -56,7 +58,7 @@
56
58
  if (args[0] === "ls-files") return { code: 0, stdout: "", stderr: "" };
57
59
  return { code: 0, stdout: "", stderr: "" };
58
60
  },
59
- settings: () => ({ telemetry: { enabled: o.enabled ?? true, dir: o.telemetryDir ?? ".pi/gauntlet/telemetry", buckets: DEFAULT_TELEMETRY_BUCKETS, warning: o.telemetryWarning }, errors: [], agentOverrides: { implementer: { model: "p/x" } }, versions: { pi: "0.85.1" } }),
61
+ settings: () => ({ specDirs, planDirs: planDirsFor(specDirs), telemetry: { enabled: o.enabled ?? true, dir: o.telemetryDir ?? ".pi/gauntlet/telemetry", buckets: DEFAULT_TELEMETRY_BUCKETS, warning: o.telemetryWarning }, errors: [], agentOverrides: { implementer: { model: "p/x" } }, versions: { pi: "0.85.1" } }),
60
62
  // A plain jj workspace: `jj root` answers the temp root while git rev-parse fails.
61
63
  jj: async (args, cwd) => {
62
64
  jjCalls.push(args);
@@ -180,6 +182,19 @@
180
182
  assert.equal(readFileSync(h.excludeFile(), "utf8"), ".pi/gauntlet/telemetry/\n", "exclude line is written once after rebind");
181
183
  });
182
184
 
185
+ test("a docs/specs write binds under the default dirs and the record lands under telemetry.dir/docs/specs", async () => {
186
+ const h = harness();
187
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
188
+ assert.equal(existsSync(h.recordPath("docs/specs/x.md")), false);
189
+ await h.writeSpec("docs/specs/x.md");
190
+ assert.equal(h.recordPath("docs/specs/x.md"), join(h.root, ".pi/gauntlet/telemetry/docs/specs/x.yaml"));
191
+ assert.equal(h.readRecord("docs/specs/x.md").spec, "docs/specs/x.md");
192
+ await h.phaseResult("complete", P({ brainstorm: "complete" }));
193
+ const rec = h.readRecord("docs/specs/x.md");
194
+ assert.equal(rec.approved_at, rec.events.at(-1)!.ts);
195
+ assert.equal(h.gitWrites().length, 0);
196
+ });
197
+
183
198
  test("bind derives approved_at from a buffered brainstorm completion", async () => {
184
199
  const h = harness();
185
200
  await h.phaseResult("start", P({ brainstorm: "in_progress" }));
@@ -231,7 +246,7 @@
231
246
  { type: "message", message: { role: "toolResult", toolName: "write", toolCallId: "w1", isError: false } },
232
247
  { type: "message", message: { role: "toolResult", toolName: "write", toolCallId: "w2", isError: false } },
233
248
  { type: "message", message: { role: "toolResult", toolName: "write", toolCallId: "w3", isError: true } },
234
- ]);
249
+ ], ["doc/specs", "docs/specs"]);
235
250
  assert.deepEqual(replay.phases, successfulPhases);
236
251
  assert.equal(replay.gauntletEntered, true);
237
252
  assert.equal(replay.planCheckSpec, "/repo/doc/specs/a.md");
@@ -246,7 +261,7 @@
246
261
  const unarmed = replayBranch([
247
262
  ...write("w1", "doc/specs/a.md"),
248
263
  { type: "message", message: { role: "toolResult", toolName: "plan_check", details: { status: "pass", specPath: "/repo/doc/specs/a.md" } } },
249
- ]);
264
+ ], ["doc/specs", "docs/specs"]);
250
265
  assert.equal(unarmed.gauntletEntered, false);
251
266
  assert.equal(unarmed.lastSpecWrite, undefined);
252
267
  assert.equal(unarmed.planCheckSpec, undefined);
@@ -254,21 +269,31 @@
254
269
  ...write("w0", "doc/specs/before.md"),
255
270
  { type: "message", message: { role: "toolResult", toolName: "phase_tracker", details: { action: "start", phases: P({ brainstorm: "in_progress" }) } } },
256
271
  ...write("w1", "doc/specs/a.md", "edit"),
257
- ]);
272
+ ], ["doc/specs", "docs/specs"]);
258
273
  assert.deepEqual([armed.gauntletEntered, armed.lastSpecWrite], [true, "doc/specs/a.md"]);
259
274
  const reset = replayBranch([
260
275
  { type: "message", message: { role: "toolResult", toolName: "phase_tracker", details: { action: "start", phases: P({ brainstorm: "in_progress" }) } } },
261
276
  ...write("w1", "doc/specs/a.md"),
262
277
  { type: "message", message: { role: "toolResult", toolName: "phase_tracker", details: { action: "reset", phases: P() } } },
263
- ]);
278
+ ], ["doc/specs", "docs/specs"]);
264
279
  assert.deepEqual([reset.gauntletEntered, reset.lastSpecWrite], [false, undefined]);
265
280
  const resumed = replayBranch([
266
281
  { type: "message", message: { role: "toolResult", toolName: "phase_tracker", details: { action: "start", phases: P({ brainstorm: "in_progress" }) } } },
267
282
  { type: "message", message: { role: "toolResult", toolName: "phase_tracker", details: { action: "skip", phases: { ...P({ brainstorm: "skipped" }), brainstorm: { status: "skipped", reason: "resume: /repo/doc/specs/a.md" } } } } },
268
- ]);
283
+ ], ["doc/specs", "docs/specs"]);
269
284
  assert.deepEqual([resumed.gauntletEntered, resumed.lastSpecWrite], [true, "/repo/doc/specs/a.md"]);
270
285
  });
271
286
 
287
+ test("replayBranch honors a custom spec dir list", () => {
288
+ const branch = [
289
+ { type: "message", message: { role: "toolResult", toolName: "phase_tracker", details: { action: "start", phases: P({ brainstorm: "in_progress" }) } } },
290
+ { type: "message", message: { role: "assistant", content: [{ type: "toolCall", id: "w1", name: "write", arguments: { path: "design/specs/a.md" } }] } },
291
+ { type: "message", message: { role: "toolResult", toolName: "write", toolCallId: "w1", isError: false } },
292
+ ];
293
+ assert.equal(replayBranch(branch, ["design/specs"]).lastSpecWrite, "design/specs/a.md");
294
+ assert.equal(replayBranch(branch, ["doc/specs"]).lastSpecWrite, undefined);
295
+ });
296
+
272
297
  test("session_start replay binds from plan_check details with no spec write, extends sessions, preserves run_id", async () => {
273
298
  const first = harness();
274
299
  await first.phaseResult("start", P({ brainstorm: "in_progress" }));
@@ -14,7 +14,7 @@
14
14
  import * as piRuntime from "@earendil-works/pi-coding-agent";
15
15
  import { SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent";
16
16
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
- import { DEFAULT_TEST_COMMANDS, resolveTelemetry, settingsErrorWarning, type TelemetryResolved } from "./lib/gauntlet-settings.ts";
17
+ import { DEFAULT_TEST_COMMANDS, planDirsFor, resolveFlowGuards, resolveTelemetry, settingsErrorWarning, type TelemetryResolved } from "./lib/gauntlet-settings.ts";
18
18
  import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
19
19
  import { checkoutOf } from "./lib/checkout.ts";
20
20
  import { CONTEXT_DRAFT_MARKER, nextGauntletEntered } from "./lib/phase-tracker-helpers.ts";
@@ -43,6 +43,8 @@
43
43
  }
44
44
  export interface SettingsSnapshot {
45
45
  telemetry: TelemetryResolved;
46
+ specDirs: string[];
47
+ planDirs: string[];
46
48
  errors: string[];
47
49
  agentOverrides?: Record<string, unknown>;
48
50
  versions: Record<string, string>;
@@ -118,6 +120,8 @@
118
120
  function realSettings(cwd: string): SettingsSnapshot {
119
121
  const { gauntlet, errors, root } = loadGauntletSettings(cwd);
120
122
  const telemetry = resolveTelemetry(gauntlet);
123
+ const { specDirs } = resolveFlowGuards(gauntlet);
124
+ const planDirs = planDirsFor(specDirs);
121
125
  const sm = SettingsManager.create(root, getAgentDir());
122
126
  const layer = (s: unknown) => ((s as { subagents?: { agentOverrides?: unknown } })?.subagents?.agentOverrides ?? undefined) as Record<string, unknown> | undefined;
123
127
  const preset = layer(sm.getGlobalSettings());
@@ -125,7 +129,7 @@
125
129
  const agentOverrides = preset || repo ? { ...(preset ?? {}), ...(repo ?? {}) } : undefined;
126
130
  const rawCmds = gauntlet.verifyBeforeShip?.testCommands;
127
131
  const testCommands = Array.isArray(rawCmds) && rawCmds.length > 0 && rawCmds.every((c) => typeof c === "string" && c.trim()) ? (rawCmds as string[]) : undefined;
128
- return { telemetry, errors, agentOverrides, versions: realVersions(), testCommands };
132
+ return { telemetry, specDirs, planDirs, errors, agentOverrides, versions: realVersions(), testCommands };
129
133
  }
130
134
 
131
135
  export const realDeps: Deps = { fs: realFs, git: realGit, jj: realJj, now: isoNow, settings: realSettings };
@@ -142,16 +146,16 @@
142
146
  // gauntlet-resume's reconstruction skips brainstorm with `resume: <spec path>`; the path
143
147
  // is the bind candidate on that route because no spec write follows.
144
148
  const RESUME_REASON_RE = /^resume: (\S+)$/;
145
- export function resumeSpecOf(phases: PhaseMap | undefined): string | undefined {
149
+ export function resumeSpecOf(phases: PhaseMap | undefined, specDirs: readonly string[]): string | undefined {
146
150
  const b = phases?.brainstorm;
147
151
  const m = b?.status === "skipped" && typeof b.reason === "string" ? RESUME_REASON_RE.exec(b.reason) : null;
148
- return m && isSpecPath(toPosix(m[1])) ? m[1] : undefined;
152
+ return m && isSpecPath(toPosix(m[1]), specDirs) ? m[1] : undefined;
149
153
  }
150
154
 
151
155
  // Walks the session branch like phase-tracker.ts: successful phase_tracker results set
152
156
  // the phase map and the flow-entry marker; bind candidates are taken only while armed
153
157
  // and dropped on reset. Silent: no events are emitted.
154
- export function replayBranch(entries: Iterable<unknown>): ReplayResult {
158
+ export function replayBranch(entries: Iterable<unknown>, specDirs: readonly string[]): ReplayResult {
155
159
  const out: ReplayResult = { phases: emptyPhases(), gauntletEntered: false };
156
160
  const calls = new Map<string, { name: string; args: unknown }>();
157
161
  for (const raw of entries) {
@@ -173,14 +177,14 @@
173
177
  out.planCheckSpec = undefined;
174
178
  out.lastSpecWrite = undefined;
175
179
  }
176
- const resumed = d.action === "skip" ? resumeSpecOf(d.phases) : undefined;
180
+ const resumed = d.action === "skip" ? resumeSpecOf(d.phases, specDirs) : undefined;
177
181
  if (out.gauntletEntered && resumed) out.lastSpecWrite = resumed;
178
182
  } else if (msg.toolName === "plan_check") {
179
183
  const d = msg.details as { status?: string; specPath?: string } | undefined;
180
184
  if (out.gauntletEntered && d?.status === "pass" && typeof d.specPath === "string") out.planCheckSpec = d.specPath;
181
185
  } else if ((msg.toolName === "write" || msg.toolName === "edit") && !msg.isError && msg.toolCallId) {
182
186
  const p = (calls.get(msg.toolCallId)?.args as { path?: unknown } | undefined)?.path;
183
- if (out.gauntletEntered && typeof p === "string" && isSpecPath(toPosix(p))) out.lastSpecWrite = p;
187
+ if (out.gauntletEntered && typeof p === "string" && isSpecPath(toPosix(p), specDirs)) out.lastSpecWrite = p;
184
188
  }
185
189
  }
186
190
  return out;
@@ -328,7 +332,7 @@
328
332
  predecessorLinks.clear();
329
333
  record = loadOrCreate(specRel);
330
334
  for (const link of record.supersedes ?? []) {
331
- if (!isSafeSpecLink(link)) continue;
335
+ if (!isSafeSpecLink(link, snap.specDirs)) continue;
332
336
  const predecessor = deps.fs.readFile(abs(link));
333
337
  if (predecessor !== undefined && isSupersededByBanner(predecessor, record.spec)) predecessorLinks.add(link);
334
338
  }
@@ -369,9 +373,9 @@
369
373
  councilPending = undefined;
370
374
  };
371
375
 
372
- const isSafeSpecLink = (link: string): boolean => {
376
+ const isSafeSpecLink = (link: string, specDirs: readonly string[]): boolean => {
373
377
  const normalized = posix.normalize(link);
374
- return isSpecPath(link) && !posix.isAbsolute(link) && normalized !== ".." && !normalized.startsWith("../");
378
+ return isSpecPath(link, specDirs) && !posix.isAbsolute(link) && normalized !== ".." && !normalized.startsWith("../");
375
379
  };
376
380
 
377
381
  const refreshLinks = () => {
@@ -449,9 +453,9 @@
449
453
  }
450
454
  phases = details.phases;
451
455
  if (action === "skip" && !boundSpec && canBind() && lastCtx) {
452
- const resumed = resumeSpecOf(details.phases);
456
+ const resumed = resumeSpecOf(details.phases, snap.specDirs);
453
457
  const loc = resumed ? await locate(lastCtx, resumed) : undefined;
454
- if (loc && isSpecPath(loc.rel)) {
458
+ if (loc && isSpecPath(loc.rel, snap.specDirs)) {
455
459
  await bind(loc.rel, snap, loc.toplevel, loc.via);
456
460
  flush();
457
461
  }
@@ -481,13 +485,13 @@
481
485
  pendingTest.clear();
482
486
  sessionId = ctx.sessionManager.getSessionId();
483
487
  currentDir = snap.telemetry.dir;
484
- const replay = replayBranch(ctx.sessionManager.getBranch());
488
+ const replay = replayBranch(ctx.sessionManager.getBranch(), snap.specDirs);
485
489
  phases = replay.phases;
486
490
  gauntletEntered = replay.gauntletEntered;
487
491
  if (!canBind()) return;
488
492
  const candidate = replay.planCheckSpec ?? replay.lastSpecWrite;
489
493
  const loc = candidate ? await locate(ctx, candidate) : undefined;
490
- if (loc && isSpecPath(loc.rel)) {
494
+ if (loc && isSpecPath(loc.rel, snap.specDirs)) {
491
495
  await bind(loc.rel, snap, loc.toplevel, loc.via);
492
496
  flush();
493
497
  }
@@ -537,7 +541,7 @@
537
541
  const rawPath = (event.input as { path?: unknown }).path;
538
542
  const loc = typeof rawPath === "string" ? await locate(ctx, rawPath) : undefined;
539
543
  if (!loc) {
540
- if (typeof rawPath === "string" && isSpecPath(toPosix(rawPath))) warn(`spec ${rawPath} is outside a git checkout; telemetry not recorded`);
544
+ if (typeof rawPath === "string" && isSpecPath(toPosix(rawPath), snap.specDirs)) warn(`spec ${rawPath} is outside a git checkout; telemetry not recorded`);
541
545
  return undefined;
542
546
  }
543
547
  return onSpecInteraction(event.toolName, loc, event, snap);
@@ -568,13 +572,13 @@
568
572
  const onSpecInteraction = async (tool: "write" | "edit" | "read", loc: { toplevel: string; rel: string; via: "git" | "jj" }, event: { input: unknown; content: unknown[] }, snap: SettingsSnapshot): Promise<unknown> => {
569
573
  const rel = loc.rel;
570
574
  if (!boundSpec) {
571
- if ((tool === "write" || tool === "edit") && isSpecPath(rel)) {
575
+ if ((tool === "write" || tool === "edit") && isSpecPath(rel, snap.specDirs)) {
572
576
  await bind(rel, snap, loc.toplevel, loc.via);
573
577
  if (!boundSpec) return undefined;
574
578
  const patch = onBoundSpecWrite(tool, rel, event);
575
579
  flush();
576
580
  return patch;
577
- } else if (isPlanPath(rel)) {
581
+ } else if (isPlanPath(rel, snap.planDirs)) {
578
582
  const spec = planSpecHeader(deps.fs.readFile(join(loc.toplevel, rel)) ?? "");
579
583
  const specRel = spec ? repoRelativeToolPath(loc.toplevel, loc.toplevel, spec) : undefined;
580
584
  if (specRel && deps.fs.exists(join(loc.toplevel, specRel))) {
@@ -586,7 +590,7 @@
586
590
  }
587
591
  if (loc.toplevel !== toplevel) {
588
592
  // Another checkout: a fresh run, never a rename of this record.
589
- if ((tool === "write" || tool === "edit") && isSpecPath(rel) && canBind() && !sealedOnDisk(loc.toplevel, rel, snap.telemetry.dir)) {
593
+ if ((tool === "write" || tool === "edit") && isSpecPath(rel, snap.specDirs) && canBind() && !sealedOnDisk(loc.toplevel, rel, snap.telemetry.dir)) {
590
594
  unbind();
591
595
  await bind(rel, snap, loc.toplevel, loc.via);
592
596
  if (!boundSpec) return undefined;
@@ -602,13 +606,13 @@
602
606
  flush();
603
607
  return patch;
604
608
  }
605
- if (tool === "write" && isSpecPath(rel) && phases.brainstorm.status === "in_progress" && isDraftOrMissing(boundSpec)) {
609
+ if (tool === "write" && isSpecPath(rel, snap.specDirs) && phases.brainstorm.status === "in_progress" && isDraftOrMissing(boundSpec)) {
606
610
  if (await rebind(rel)) onBoundSpecWrite(tool, rel, event);
607
611
  flush();
608
612
  return undefined;
609
613
  }
610
- if (tool === "edit" && isSpecPath(rel)) {
611
- onOtherSpecEdit(rel, event);
614
+ if (tool === "edit" && isSpecPath(rel, snap.specDirs)) {
615
+ onOtherSpecEdit(rel, event, snap.specDirs);
612
616
  flush();
613
617
  }
614
618
  return undefined;
@@ -616,7 +620,7 @@
616
620
 
617
621
  // Defined in block 2.
618
622
  let onBoundSpecWrite: (tool: "write" | "edit", rel: string, event: { input: unknown; content: unknown[] }) => unknown;
619
- let onOtherSpecEdit: (rel: string, event: { input: unknown }) => void;
623
+ let onOtherSpecEdit: (rel: string, event: { input: unknown }, specDirs: string[]) => void;
620
624
 
621
625
  let amendmentOpen = false;
622
626
  let lastPlanTasks: { status: string }[] | undefined;
@@ -728,7 +732,7 @@
728
732
  if (!record || !toplevel || liveShipEvent(record.events)) return;
729
733
  record.status = "shipped";
730
734
  record.shipped_at = deps.now();
731
- const out = await computeJjDiff({ jj: deps.jj ?? realJj, cwd: toplevel, spec: record.spec, dir: currentDir, buckets: snap.telemetry.buckets });
735
+ const out = await computeJjDiff({ jj: deps.jj ?? realJj, cwd: toplevel, spec: record.spec, dir: currentDir, buckets: snap.telemetry.buckets, planDirs: snap.planDirs });
732
736
  if (out.warning) warn(out.warning);
733
737
  record.derived.modified_files = out.modified_files;
734
738
  record.derived.diff = out.diff;
@@ -743,7 +747,7 @@
743
747
  const rawPath = (event.input as { path?: unknown }).path;
744
748
  if (typeof rawPath === "string" && phases.brainstorm.status === "in_progress") {
745
749
  const loc = await locate(ctx, rawPath);
746
- if (loc && isSpecPath(loc.rel)) {
750
+ if (loc && isSpecPath(loc.rel, snap.specDirs)) {
747
751
  const recRel = recordPathFor(snap.telemetry.dir, loc.rel);
748
752
  const existing = deps.fs.readFile(join(loc.toplevel, recRel));
749
753
  const parsed = existing ? parseRecord(existing) : undefined;
@@ -832,8 +836,8 @@
832
836
  return undefined;
833
837
  };
834
838
 
835
- onOtherSpecEdit = (rel, event) => {
836
- if (!record || !boundSpec || !isSafeSpecLink(rel)) return;
839
+ onOtherSpecEdit = (rel, event, specDirs) => {
840
+ if (!record || !boundSpec || !isSafeSpecLink(rel, specDirs)) return;
837
841
  if (!isSupersededByBanner(insertedText(event.input), boundSpec)) return;
838
842
  predecessorLinks.add(rel);
839
843
  refreshLinks();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "6.0.0",
3
+ "version": "6.0.1",
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",
@@ -17,9 +17,9 @@ Identify the target project → set up an isolated worktree → understand curre
17
17
 
18
18
  Do **not** implement until the design is presented and approved, regardless of simplicity. Implementation-heavy requests define spec scope; they do not lift this gate.
19
19
 
20
- You **may** read code/docs, run the existing system to observe current behavior, write under `doc/specs/`, and `edit` a predecessor spec there to add a [supersession banner](reference/superseding.md).
20
+ You **may** read code/docs, run the existing system to observe current behavior, write under a resolved spec dir (`gauntlet_setting({ key: "flowGuards" })` -> `specDirs`), and `edit` a predecessor spec there to add a [supersession banner](reference/superseding.md).
21
21
 
22
- You may **not** write outside `doc/specs/`; build, deploy, validate, or exercise the proposed change; run implementation skills; commit the spec on `main`; or start `/skill:writing-plans`.
22
+ You may **not** write outside the resolved spec dirs; build, deploy, validate, or exercise the proposed change; run implementation skills; commit the spec on `main`; or start `/skill:writing-plans`.
23
23
 
24
24
  The line: current-system observation is research; exercising the proposed change waits for approval.
25
25
 
@@ -63,7 +63,7 @@ Detection order: (1) infer from ticket labels, description, or paths; (2) use th
63
63
  The spec is the first commit in a dedicated worktree, never a separate commit on `main`.
64
64
 
65
65
  1. Invoke `/skill:using-git-worktrees`; use `.worktrees/` or the project-native location.
66
- 2. Carry its `Worktree ready at <full-path>` value: spec path `<full-path>/doc/specs/<filename>.md`; every dispatch uses that `cwd`; git uses `git -C <full-path>`. Keep the process cwd unchanged.
66
+ 2. Carry its `Worktree ready at <full-path>` value: spec path `<full-path>/<spec dir>/<filename>.md`; every dispatch uses that `cwd`; git uses `git -C <full-path>`. Keep the process cwd unchanged.
67
67
  3. Write, review, and commit there.
68
68
 
69
69
  Spec, plan, and implementation share this worktree. The spec and its telemetry record (`<telemetry.dir>/<spec path with .md -> .yaml>`, default `.pi/gauntlet/telemetry/doc/specs/<spec>.yaml`) are deliverables and ship in the squash; only the plan is stripped. `/skill:finishing-a-development-branch` strips the plan, then seals and commits the record before landing. Explicit trivial one-off edits outside this flow need no worktree.
@@ -146,7 +146,7 @@ Shared schemas, contracts, and invariants require a spec. If work is mechanical
146
146
 
147
147
  ## Filename Convention
148
148
 
149
- Write under the routed `doc/specs/`: ticketed `YYYY-MM-DD-<ticket-id>-<topic>.md`, otherwise `YYYY-MM-DD-<topic>.md`. Use a filename-safe tracker slug (`E-12345`, `gh-123`), but native references in plan headers and commits. `<topic>` is 3-6 kebab-case words without `-design` or another suffix.
149
+ Write under the routed spec dir: pick the routing prose's dir (overrides file, `AGENTS.md`) when it is one of the resolved dirs, else the one resolved dir from `gauntlet_setting({ key: "flowGuards" })` that already exists in the repo, else the first resolved dir (`doc/specs` under the default). Name ticketed files `YYYY-MM-DD-<ticket-id>-<topic>.md`, otherwise `YYYY-MM-DD-<topic>.md`. Use a filename-safe tracker slug (`E-12345`, `gh-123`), but native references in plan headers and commits. `<topic>` is 3-6 kebab-case words without `-design` or another suffix.
150
150
 
151
151
  Mint the slug once during gather and reuse it. If questionary invalidates it, write to the new path and delete the uncommitted draft.
152
152
 
@@ -293,7 +293,7 @@ One question at a time, YAGNI, 2-3 approaches, two design rounds, clarify freely
293
293
 
294
294
  ## Red Flags — STOP
295
295
 
296
- - Writes outside `doc/specs/` ([owner](#hard-constraint)).
296
+ - Writes outside the resolved spec dirs ([owner](#hard-constraint)).
297
297
  - Draft overwrite without the same-turn full read, or overwrite via `edit` ([owner](#spec-self-review-before-user-review-gate)).
298
298
  - Dispatch while line 1 is the context-draft marker ([owner](#spec-self-review-before-user-review-gate)).
299
299
  - Inline scope or ambiguity checks ([owner](#spec-council-optional)).
@@ -185,11 +185,11 @@ Rows 1-2 run the Option 1/2 blocks; row 3 runs the Keep block and row 4 the Disc
185
185
 
186
186
  Run this on the feature branch before any landing path. The spec and its telemetry record (`<telemetry.dir>/<spec path with .md -> .yaml>`, default `.pi/gauntlet/telemetry/doc/specs/<spec>.yaml`) are deliverables and ship in the squash; only the plan is stripped. The record is an untracked, git-excluded file until this step commits it. `<bin>` is `<directory of this skill's SKILL.md>/../../bin`, resolved from the skill's `<location>` in the system prompt.
187
187
 
188
- Read the plan's `**Spec:**` header (repo-relative, backticks stripped) before the strip; that value is `--spec`.
188
+ Read the plan's `**Spec:**` header (repo-relative, backticks stripped) before the strip; pass that value as `--spec`. Resolve the spec dirs with `gauntlet_setting({ key: "flowGuards" })` and use their sibling `plans` dirs for plan paths.
189
189
 
190
190
  ```bash
191
191
  # Plans are ephemeral - if one was committed on this branch, remove it before landing.
192
- PLAN_PATH=doc/plans/<plan-file>.md # or <service>/doc/plans/<plan-file>.md
192
+ PLAN_PATH=<sibling-plans-dir>/<plan-file>.md # the sibling plans dir of the spec's dir
193
193
  if git -C "$WORKTREE" ls-files --error-unmatch "$PLAN_PATH" >/dev/null 2>&1; then
194
194
  git -C "$WORKTREE" rm "$PLAN_PATH" && git -C "$WORKTREE" commit -m "Remove ephemeral plan doc"
195
195
  fi
@@ -25,10 +25,7 @@ for a base ref before reading any artifact.
25
25
 
26
26
  A pinned spec (seed route, `seed.md`) skips this section.
27
27
 
28
- Spec/plan directories: `piGauntlet.flowGuards.specDirs` plus each one's sibling `plans` directory. Resolve with the precedence `doc/configuration.md` documents for every `piGauntlet.*` key: the session cwd's `.pi/settings.json` if it defines `flowGuards`,
29
- else the active pi profile's `settings.json`, else the default `["doc/specs"]` (sibling
30
- `doc/plans`). An empty array is the default. `<dirs>` below is that resolved list,
31
- space-separated - never the literal defaults when a setting is present.
28
+ Resolve with `gauntlet_setting({ key: "flowGuards" })`; `<dirs>` below is its `specDirs` plus `planDirs` (each spec dir's sibling `plans` dir), space-separated.
32
29
 
33
30
  ```bash
34
31
  git -C <worktree> diff --diff-filter=A --name-only <base>..HEAD -- <dirs>
@@ -17,13 +17,14 @@ DRY. YAGNI. TDD. Frequent commits.
17
17
 
18
18
  Before drafting the plan, check `phase_tracker({ action: "status" })`. If `plan` is already `in_progress` (a flow restored by `/skill:gauntlet-resume` arrives this way), do **not** call `start` again - a repeat `start` on the in_progress phase resets its guard ledger. Otherwise call `phase_tracker({ action: "start", phase: "plan" })`.
19
19
 
20
- **Input:** an approved spec in `<project>/doc/specs/<filename>.md` — produced by `/skill:brainstorming` in this session, or restored from another session by `/skill:gauntlet-resume`. Those two are the only entry points.
20
+ **Input:** an approved spec in a resolved spec dir from `gauntlet_setting({ key: "flowGuards" })` - produced by `/skill:brainstorming` in this session, or restored from another session by `/skill:gauntlet-resume`. Those two are the only entry points.
21
21
 
22
- **Save plans to:** the sibling `doc/plans/` directory next to the spec. The plan filename matches the spec filename exactly — same date, same ticket ID (if any), same topic slug, no `-design` suffix. The plan path is absolute under the worktree (`<abs worktree path>/doc/plans/<filename>.md`, the path reported by `using-git-worktrees`), and `plan_check({ planPath })` receives that absolute path - the tool roots its checks at the plan's own checkout, not at the session cwd.
22
+ **Save plans to:** the sibling `plans/` directory next to the spec's resolved dir (`doc/specs` -> `doc/plans`, `docs/specs` -> `docs/plans`). The plan filename matches the spec filename exactly - same date, same ticket ID (if any), same topic slug, no `-design` suffix. Use the absolute plan path under the worktree (`<abs worktree path>/<sibling plans dir>/<filename>.md`, using the path reported by `using-git-worktrees`), and `plan_check({ planPath })` receives that absolute path - the tool roots its checks at the plan's own checkout, not at the session cwd.
23
23
 
24
24
  | Spec path | Plan path |
25
25
  |---|---|
26
26
  | `doc/specs/2025-05-26-foo.md` | `doc/plans/2025-05-26-foo.md` |
27
+ | `docs/specs/2025-05-26-foo.md` | `docs/plans/2025-05-26-foo.md` |
27
28
  | `doc/specs/2025-05-26-PROJ-1234-foo.md` | `doc/plans/2025-05-26-PROJ-1234-foo.md` |
28
29
  | `<service>/doc/specs/<name>.md` | `<service>/doc/plans/<name>.md` |
29
30
 
@@ -32,7 +33,7 @@ If no spec exists, send the work back to `/skill:brainstorming`. Do not invent a
32
33
  ## Boundaries
33
34
 
34
35
  - Read code and docs: yes
35
- - Write the plan to the sibling `doc/plans/` of the spec: yes
36
+ - Write the plan to the sibling `plans/` dir of the spec: yes
36
37
  - Edit or create any other files: no
37
38
  - Write implementation code: never inside this skill. After Self-Review + `phase_tracker` complete, auto-invoke `/skill:subagent-driven-development` to execute.
38
39
  - Land the plan on `main`: no — the plan commit goes on the worktree branch (same branch as the spec)