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.
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/bins/gauntlet-telemetry-seal.mjs
4
- import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
4
+ import { existsSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
5
5
  import { homedir } from "node:os";
6
- import { isAbsolute as isAbsolute2, join } from "node:path";
6
+ import { isAbsolute as isAbsolute2, join, relative as relative2 } from "node:path";
7
7
  import { spawnSync } from "node:child_process";
8
8
  import process from "node:process";
9
9
 
@@ -14,6 +14,24 @@ function mergeGauntlet(preset, repo) {
14
14
  }
15
15
  var nonEmptyString = (v) => typeof v === "string" && v.trim().length > 0;
16
16
  var joinWarn = (ws) => ws.length ? ws.join("; ") : void 0;
17
+ var DEFAULT_SPEC_DIRS = ["doc/specs", "docs/specs"];
18
+ var normalizeDir = (d) => d.trim().replace(/^\.\//, "").replace(/\/+$/, "");
19
+ function resolveFlowGuards(g) {
20
+ const fg = g.flowGuards;
21
+ const enforce = fg?.enforce !== false;
22
+ const rawDirs = fg?.specDirs;
23
+ const specDirs = Array.isArray(rawDirs) && rawDirs.length > 0 && rawDirs.every(nonEmptyString) ? rawDirs.map(normalizeDir) : [...DEFAULT_SPEC_DIRS];
24
+ return { enforce, specDirs };
25
+ }
26
+ function planDirsFor(specDirs) {
27
+ const out = /* @__PURE__ */ new Set();
28
+ for (const dir of specDirs) {
29
+ const parts = dir.split("/").filter((c) => c.length > 0);
30
+ if (parts.length === 0) continue;
31
+ out.add([...parts.slice(0, -1), "plans"].join("/"));
32
+ }
33
+ return [...out];
34
+ }
17
35
  var DEFAULT_TELEMETRY_DIR = ".pi/gauntlet/telemetry";
18
36
  var DEFAULT_TELEMETRY_BUCKETS = [
19
37
  ["test", ["**/test/**", "**/tests/**", "**/__tests__/**", "**/*.test.*", "**/*.spec.*", "**/*_test.*"]],
@@ -50,7 +68,6 @@ var STMT_START = "(?:^|[\\n;&|(])\\s*";
50
68
 
51
69
  // extensions/lib/telemetry-paths.ts
52
70
  var toPosix = (p) => p.split("\\").join("/");
53
- var isSpecPath = (rel) => /(^|\/)doc\/specs\/[^/]+\.md$/.test(rel);
54
71
  var recordPathFor = (dir, specRel) => `${dir}/${specRel.replace(/\.md$/, ".yaml")}`;
55
72
  function repoRelativeToolPath(toplevel, cwd, p) {
56
73
  const abs = isAbsolute(p) ? p : resolve(cwd, p);
@@ -72,9 +89,10 @@ function numstatPath(raw) {
72
89
  }
73
90
 
74
91
  // extensions/lib/telemetry-ship.ts
75
- function modifiedFilesFrom(nameOnly, spec, dir) {
92
+ function modifiedFilesFrom(nameOnly, spec, dir, planDirs) {
76
93
  const dirPrefix = dir.replace(/\/+$/, "") + "/";
77
- return nameOnly.split("\n").filter((f) => f && f !== spec && !/(^|\/)doc\/plans\//.test(f) && !f.startsWith(dirPrefix)).sort();
94
+ const planRes = planDirs.map((d) => new RegExp(`(^|/)${d.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/`));
95
+ return nameOnly.split("\n").filter((f) => f && f !== spec && !planRes.some((re) => re.test(f)) && !f.startsWith(dirPrefix)).sort();
78
96
  }
79
97
 
80
98
  // extensions/lib/telemetry-diff.ts
@@ -99,7 +117,7 @@ async function computeGitDiff(o) {
99
117
  if (mb.code !== 0 || !mb.stdout.trim()) return { warning: `diff omitted: merge-base failed: ${firstLine(mb.stderr)}` };
100
118
  const base = mb.stdout.trim();
101
119
  const names = await o.git(["diff", "--name-only", `${base}...HEAD`], o.cwd);
102
- const files = modifiedFilesFrom(names.stdout, o.spec, o.dir);
120
+ const files = modifiedFilesFrom(names.stdout, o.spec, o.dir, o.planDirs);
103
121
  const numstat = await o.git(["diff", "--numstat", `${base}...HEAD`], o.cwd);
104
122
  const count = await o.git(["rev-list", "--count", "--invert-grep", "--grep=^telemetry: ", `${base}..HEAD`], o.cwd);
105
123
  return { modified_files: files, diff: { base, commits: Number(count.stdout.trim()) || 0, buckets: aggregateNumstat(numstat.stdout, new Set(files), o.buckets) } };
@@ -281,11 +299,22 @@ function readLayer(file) {
281
299
  return {};
282
300
  }
283
301
  }
284
- function telemetrySettings(root) {
302
+ function settings(root) {
285
303
  const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
286
304
  const preset = readLayer(join(agentDir, "settings.json"));
287
305
  const repo = readLayer(join(root, ".pi", "settings.json"));
288
- return resolveTelemetry(mergeGauntlet(preset?.piGauntlet, repo?.piGauntlet));
306
+ const merged = mergeGauntlet(preset?.piGauntlet, repo?.piGauntlet);
307
+ return { telemetry: resolveTelemetry(merged), planDirs: planDirsFor(resolveFlowGuards(merged).specDirs) };
308
+ }
309
+ function walkRecords(absDir) {
310
+ if (!existsSync(absDir)) return [];
311
+ const out = [];
312
+ for (const e of readdirSync(absDir, { withFileTypes: true })) {
313
+ const p = join(absDir, e.name);
314
+ if (e.isDirectory()) out.push(...walkRecords(p));
315
+ else if (e.isFile() && e.name.endsWith(".yaml")) out.push(p);
316
+ }
317
+ return out;
289
318
  }
290
319
  var isoNow = () => (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z");
291
320
  var trackedAndClean = (root, rec) => git(root, ["ls-files", "--error-unmatch", "--", rec]).ok && git(root, ["diff", "--quiet", "HEAD", "--", rec]).ok;
@@ -302,7 +331,7 @@ async function seal(root, rec, o) {
302
331
  parsed.status = "shipped";
303
332
  parsed.shipped_at = now;
304
333
  parsed.events.push({ ts: now, session: parsed.sessions.at(-1) ?? "seal", phase: "ship", kind: "ship", option: o.option });
305
- const out = await computeGitDiff({ git: gitRunner, cwd: root, spec: parsed.spec, dir: o.dir, buckets: o.buckets, base: o.base });
334
+ const out = await computeGitDiff({ git: gitRunner, cwd: root, spec: parsed.spec, dir: o.dir, buckets: o.buckets, base: o.base, planDirs: o.planDirs });
306
335
  if (out.warning) parsed.events.push({ ts: now, session: parsed.sessions.at(-1) ?? "seal", phase: "ship", kind: "warning", message: out.warning });
307
336
  parsed.derived.diff = out.diff;
308
337
  parsed.derived.modified_files = out.modified_files;
@@ -331,19 +360,25 @@ async function main() {
331
360
  if (opts.spec) {
332
361
  const specAbs = isAbsolute2(opts.spec) && existsSync(opts.spec) ? realpathSync(opts.spec) : opts.spec;
333
362
  rel = repoRelativeToolPath(root, root, specAbs);
334
- if (!rel || !isSpecPath(rel)) usage();
363
+ if (!rel) usage();
335
364
  }
336
- const telemetry = telemetrySettings(root);
365
+ const { telemetry, planDirs } = settings(root);
337
366
  if (telemetry.warning) process.stderr.write(`warning: ${telemetry.warning}
338
367
  `);
339
368
  if (!telemetry.enabled) return console.log("telemetry disabled");
340
369
  const dir = override?.dir ?? telemetry.dir;
341
370
  if (!git(root, ["rev-parse", "--verify", "-q", `${opts.base}^{commit}`]).ok) fail(1, `no base ref ${opts.base}`);
342
- const o = { option: opts.option, base: opts.base, dir, buckets: telemetry.buckets };
371
+ const o = { option: opts.option, base: opts.base, dir, buckets: telemetry.buckets, planDirs };
343
372
  if (opts.spec) return seal(root, recordPathFor(dir, rel), o);
344
- const diff = git(root, ["diff", "--name-only", `${opts.base}...HEAD`]);
373
+ const diff = git(root, ["diff", "--no-renames", "--name-only", `${opts.base}...HEAD`]);
345
374
  if (!diff.ok) fail(1, `git diff failed: ${diff.stderr}`);
346
- const records = diff.stdout.split("\n").filter((f) => f && isSpecPath(f)).map((f) => recordPathFor(dir, f)).filter((rec) => existsSync(join(root, rec)));
375
+ const changed = new Set(diff.stdout.split("\n").filter(Boolean));
376
+ const records = walkRecords(join(root, dir)).filter((abs) => {
377
+ const parsed = parseRecord(readFileSync(abs, "utf8"));
378
+ if (!parsed) process.stderr.write(`warning: unparseable record ${toPosix(relative2(root, abs))}
379
+ `);
380
+ return parsed && changed.has(parsed.spec);
381
+ }).map((abs) => toPosix(relative2(root, abs))).sort();
347
382
  if (records.length === 0) return console.log("no telemetry run");
348
383
  for (const rec of records) await seal(root, rec, o);
349
384
  }
@@ -10,6 +10,8 @@ import { parse as parseYaml } from "yaml";
10
10
  const CLI = join(dirname(fileURLToPath(import.meta.url)), "gauntlet-telemetry-seal.mjs");
11
11
  const SPEC = "doc/specs/x.md";
12
12
  const REC = ".pi/gauntlet/telemetry/doc/specs/x.yaml";
13
+ const SPEC2 = "docs/specs/y.md";
14
+ const REC2 = ".pi/gauntlet/telemetry/docs/specs/y.yaml";
13
15
  const RECORD = [
14
16
  "schema: 1",
15
17
  "spec: doc/specs/x.md",
@@ -60,6 +62,19 @@ const repo = (options = {}) => {
60
62
  if (record !== undefined) write(root, REC, record);
61
63
  return root;
62
64
  };
65
+ const docsRepo = () => {
66
+ const root = mkdtempSync(join(tmpdir(), "gts-"));
67
+ git(root, ["init", "-q", "-b", "main"]);
68
+ write(root, "README.md", "# fixture\n");
69
+ commit(root, "init", "README.md");
70
+ git(root, ["checkout", "-q", "-b", "feat"]);
71
+ write(root, SPEC2, "# Spec Y\n\n**Goal:** g.\n");
72
+ write(root, "src/a.ts", "export const a = 1;\n");
73
+ write(root, "docs/plans/x.md", "# Plan X\n");
74
+ commit(root, "Add spec and code", SPEC2, "src/a.ts", "docs/plans/x.md");
75
+ write(root, REC2, RECORD.replace("spec: doc/specs/x.md", `spec: ${SPEC2}`));
76
+ return root;
77
+ };
63
78
  const EMPTY_AGENT = mkdtempSync(join(tmpdir(), "gts-empty-agent-"));
64
79
  process.on("exit", () => rmSync(EMPTY_AGENT, { recursive: true, force: true }));
65
80
  const IDENTITY = { GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" };
@@ -100,6 +115,61 @@ test("seals an untracked in_progress record: stamp, ship event, diff, re-derived
100
115
  assert.equal(porcelain(root), "");
101
116
  });
102
117
 
118
+ test("docs/specs record seals with --spec (no path classification)", (t) => {
119
+ const root = docsRepo(); cleanup(t, root);
120
+ const r = run(root, ["--spec", SPEC2]);
121
+ assert.equal(r.status, 0, r.stderr);
122
+ assert.deepEqual(r.lines, [`sealed ${REC2}`]);
123
+ });
124
+
125
+ test("docs/specs record seals without --spec: candidates are records whose spec: is in the changed set", (t) => {
126
+ const root = docsRepo(); cleanup(t, root);
127
+ const r = run(root);
128
+ assert.equal(r.status, 0, r.stderr);
129
+ assert.deepEqual(r.lines, [`sealed ${REC2}`]);
130
+ const rec = parseYaml(readFileSync(join(root, REC2), "utf8"));
131
+ assert.ok(!rec.derived.modified_files.includes("docs/plans/x.md"), "docs/plans excluded from modified_files");
132
+ });
133
+
134
+ test("renamed spec still selects the record at its old path", (t) => {
135
+ const root = mkdtempSync(join(tmpdir(), "gts-")); cleanup(t, root);
136
+ git(root, ["init", "-q", "-b", "main"]);
137
+ write(root, SPEC, "# Spec X\n\n**Goal:** g.\n");
138
+ commit(root, "Add original spec", SPEC);
139
+ git(root, ["checkout", "-q", "-b", "feat"]);
140
+ assert.equal(git(root, ["mv", SPEC, "doc/specs/renamed.md"]).status, 0);
141
+ commit(root, "Rename spec", SPEC, "doc/specs/renamed.md");
142
+ write(root, REC, RECORD);
143
+ const r = run(root);
144
+ assert.equal(r.status, 0, r.stderr);
145
+ assert.deepEqual(r.lines, [`sealed ${REC}`]);
146
+ });
147
+
148
+ test("unparseable walked record warns while another candidate seals", (t) => {
149
+ const root = repo(); cleanup(t, root);
150
+ const bad = ".pi/gauntlet/telemetry/bad.yaml";
151
+ write(root, bad, "schema: 1\nspec: doc/specs/x.md\n");
152
+ const r = run(root);
153
+ assert.equal(r.status, 0, r.stderr);
154
+ assert.equal(r.stderr, `warning: unparseable record ${bad}`);
155
+ assert.deepEqual(r.lines, [`sealed ${REC}`]);
156
+ });
157
+
158
+ test("shipped-but-uncommitted docs/specs record is committed on retry without --spec", (t) => {
159
+ const root = docsRepo(); cleanup(t, root);
160
+ write(root, REC2, RECORD.replace("spec: doc/specs/x.md", `spec: ${SPEC2}`).replace("status: in_progress", "status: shipped\nshipped_at: 2026-09-17T18:00:00Z"));
161
+ const r = run(root);
162
+ assert.equal(r.status, 0, r.stderr);
163
+ assert.deepEqual(r.lines, [`sealed ${REC2}`]);
164
+ });
165
+
166
+ test("a record whose spec did not change on the branch is ignored", (t) => {
167
+ const root = repo(); cleanup(t, root);
168
+ write(root, ".pi/gauntlet/telemetry/doc/specs/other.yaml", RECORD.replace("spec: doc/specs/x.md", "spec: doc/specs/other.md"));
169
+ const r = run(root);
170
+ assert.deepEqual(r.lines, [`sealed ${REC}`]);
171
+ });
172
+
103
173
  test("seal preserves derived.council through the shipped stamp", (t) => {
104
174
  const council = { chair: { model: "p/chair:medium", dispatches: 1, clusters: 0, members_reported: 0 }, members: {} };
105
175
  const root = repo({ record: RECORD.replace(" spec_writes: {}", ` council: ${JSON.stringify(council)}\n spec_writes: {}`) }); cleanup(t, root);
@@ -256,11 +326,12 @@ test("usage and environment errors exit 1", (t) => {
256
326
  const noBase = invoke(["--worktree", root, "--option", "squash", "--base", "nope"]);
257
327
  assert.equal(noBase.status, 1);
258
328
  assert.equal(noBase.stderr, "no base ref nope");
259
- for (const spec of ["/tmp/elsewhere/doc/specs/x.md", "README.md"]) {
260
- const invalid = run(root, ["--spec", spec]);
261
- assert.equal(invalid.status, 1);
262
- assert.match(invalid.stderr, /^usage:/);
263
- }
329
+ const invalid = run(root, ["--spec", "/tmp/elsewhere/doc/specs/x.md"]);
330
+ assert.equal(invalid.status, 1);
331
+ assert.match(invalid.stderr, /^usage:/);
332
+ const noRec = run(root, ["--spec", "README.md"]);
333
+ assert.equal(noRec.status, 2);
334
+ assert.equal(noRec.stderr, "no record at .pi/gauntlet/telemetry/README.yaml");
264
335
  write(root, ".pi/settings.json", JSON.stringify({ piGauntlet: { telemetry: { enabled: false } } }));
265
336
  const disabledInvalid = run(root, ["--spec", "/tmp/elsewhere/doc/specs/x.md"]);
266
337
  assert.equal(disabledInvalid.status, 1);
@@ -7,6 +7,7 @@ import {
7
7
  resolveEscalationLoop,
8
8
  mainLoopModel,
9
9
  resolveFlowGuards,
10
+ planDirsFor,
10
11
  resolveVerifyBeforeShip,
11
12
  resolveTelemetry,
12
13
  DEFAULT_TELEMETRY_DIR,
@@ -135,11 +136,20 @@ test("closureReview: maxFixRounds default 3, <0 -> 0, non-int -> 3", () => {
135
136
  assert.equal(resolveClosureReview({ closureReview: { maxFixRounds: 1.5 } }).maxFixRounds, 3);
136
137
  });
137
138
 
138
- test("flowGuards: defaults + overrides", () => {
139
- assert.deepEqual(resolveFlowGuards({}), { enforce: true, specDirs: ["doc/specs"] });
139
+ test("flowGuards: defaults + overrides + normalization", () => {
140
+ assert.deepEqual(resolveFlowGuards({}), { enforce: true, specDirs: ["doc/specs", "docs/specs"] });
140
141
  assert.equal(resolveFlowGuards({ flowGuards: { enforce: false } }).enforce, false);
141
142
  assert.deepEqual(resolveFlowGuards({ flowGuards: { specDirs: ["a/b"] } }).specDirs, ["a/b"]);
142
- assert.deepEqual(resolveFlowGuards({ flowGuards: { specDirs: [] } }).specDirs, ["doc/specs"]);
143
+ assert.deepEqual(resolveFlowGuards({ flowGuards: { specDirs: [] } }).specDirs, ["doc/specs", "docs/specs"]);
144
+ assert.deepEqual(resolveFlowGuards({ flowGuards: { specDirs: "x" } }).specDirs, ["doc/specs", "docs/specs"]);
145
+ assert.deepEqual(resolveFlowGuards({ flowGuards: { specDirs: ["docs/specs/", "./design/specs"] } }).specDirs, ["docs/specs", "design/specs"]);
146
+ });
147
+
148
+ test("planDirsFor: sibling plans dir per spec dir, deduplicated", () => {
149
+ assert.deepEqual(planDirsFor(["doc/specs", "design/specs"]), ["doc/plans", "design/plans"]);
150
+ assert.deepEqual(planDirsFor(["doc/specs", "docs/specs"]), ["doc/plans", "docs/plans"]);
151
+ assert.deepEqual(planDirsFor(["specs"]), ["plans"]);
152
+ assert.deepEqual(planDirsFor(["a/plans", "a/specs"]), ["a/plans"]);
143
153
  });
144
154
 
145
155
  test("settingsErrorWarning: includes prefix and joined errors", () => {
@@ -119,17 +119,33 @@ export interface FlowGuardsResolved {
119
119
  specDirs: string[];
120
120
  }
121
121
 
122
+ export const DEFAULT_SPEC_DIRS = ["doc/specs", "docs/specs"];
123
+
124
+ // Strip a leading ./ and trailing slashes so "docs/specs/" and "./docs/specs" match the same paths.
125
+ const normalizeDir = (d: string): string => d.trim().replace(/^\.\//, "").replace(/\/+$/, "");
126
+
122
127
  export function resolveFlowGuards(g: PiGauntlet): FlowGuardsResolved {
123
128
  const fg = g.flowGuards;
124
129
  const enforce = fg?.enforce !== false;
125
130
  const rawDirs = fg?.specDirs;
126
131
  const specDirs =
127
132
  Array.isArray(rawDirs) && rawDirs.length > 0 && rawDirs.every(nonEmptyString)
128
- ? (rawDirs as string[]).map((d) => d.trim())
129
- : ["doc/specs"];
133
+ ? (rawDirs as string[]).map(normalizeDir)
134
+ : [...DEFAULT_SPEC_DIRS];
130
135
  return { enforce, specDirs };
131
136
  }
132
137
 
138
+ // Each spec dir's sibling `plans` dir (doc/specs -> doc/plans); plans live there per writing-plans.
139
+ export function planDirsFor(specDirs: readonly string[]): string[] {
140
+ const out = new Set<string>();
141
+ for (const dir of specDirs) {
142
+ const parts = dir.split("/").filter((c) => c.length > 0);
143
+ if (parts.length === 0) continue;
144
+ out.add([...parts.slice(0, -1), "plans"].join("/"));
145
+ }
146
+ return [...out];
147
+ }
148
+
133
149
  export interface VerifyBeforeShipResolved {
134
150
  testCommands: string[];
135
151
  warningReference: string | undefined;
@@ -1,5 +1,8 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import { spawnSync } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
3
6
  import {
4
7
  CONTEXT_DRAFT_MARKER,
5
8
  checkSubstep,
@@ -255,3 +258,14 @@ test("implementExemptDirs: adds each spec dir's sibling plans dir, deduped", ()
255
258
  test("nextGauntletEntered: skip preserves the marker (resume gesture)", () => {
256
259
  assert.equal(nextGauntletEntered(true, "skip", "skipped"), true);
257
260
  });
261
+
262
+ test("module order: telemetry-paths and phase-tracker-helpers initialize in either import order", () => {
263
+ const lib = dirname(fileURLToPath(import.meta.url));
264
+ const loader = join(lib, "..", "test-support", "pi-stubs.mjs");
265
+ const paths = join(lib, "telemetry-paths.ts");
266
+ const helpers = join(lib, "phase-tracker-helpers.ts");
267
+ for (const [a, b] of [[paths, helpers], [helpers, paths]]) {
268
+ const r = spawnSync(process.execPath, ["--experimental-loader", loader, "--input-type=module", "-e", `await import(${JSON.stringify(a)}); await import(${JSON.stringify(b)});`], { encoding: "utf8" });
269
+ assert.equal(r.status, 0, r.stderr);
270
+ }
271
+ });
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { resolve } from "node:path";
8
+ import { planDirsFor } from "./gauntlet-settings.ts";
8
9
 
9
10
  export const CONTEXT_DRAFT_MARKER = "# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing";
10
11
 
@@ -169,10 +170,10 @@ export function implementWriteGuardApplies(
169
170
  export function implementExemptDirs(specDirs: string[]): string[] {
170
171
  const dirs = new Set<string>();
171
172
  for (const dir of specDirs) {
172
- const parts = dir.split("/").filter((c) => c.length > 0);
173
- if (parts.length === 0) continue;
174
- dirs.add(parts.join("/"));
175
- dirs.add([...parts.slice(0, -1), "plans"].join("/"));
173
+ const spec = dir.split("/").filter((c) => c.length > 0).join("/");
174
+ if (!spec) continue;
175
+ dirs.add(spec);
176
+ for (const plan of planDirsFor([spec])) dirs.add(plan);
176
177
  }
177
178
  return [...dirs];
178
179
  }
@@ -5,6 +5,7 @@ import { JJ_MAINLINE, aggregateNumstat, computeGitDiff, computeJjDiff, type RunR
5
5
 
6
6
  const ok = (stdout: string): RunResult => ({ code: 0, stdout, stderr: "" });
7
7
  const DIR = ".pi/gauntlet/telemetry";
8
+ const PLAN_DIRS = ["doc/plans", "docs/plans"];
8
9
 
9
10
  test("aggregateNumstat sums per bucket over the given file set; binary rows count 0 lines", () => {
10
11
  const numstat = ["10\t2\textensions/telemetry.ts", "5\t0\textensions/lib/telemetry-paths.test.ts", "-\t-\timg.png", "3\t3\tdoc/specs/a.md", "1\t1\t{old => new}/x.ts"].join("\n");
@@ -20,12 +21,12 @@ test("computeGitDiff: merge-base, name-only minus spec/plan/dir, numstat buckets
20
21
  const git = (args: string[]) => {
21
22
  calls.push(args);
22
23
  if (args[0] === "merge-base") return ok("abc123\n");
23
- if (args[0] === "diff" && args.includes("--name-only")) return ok("extensions/telemetry.ts\nextensions/telemetry.test.ts\ndoc/specs/a.md\ndoc/plans/a.md\n.pi/gauntlet/telemetry/doc/specs/a.yaml\nREADME.md\n");
24
+ if (args[0] === "diff" && args.includes("--name-only")) return ok("extensions/telemetry.ts\nextensions/telemetry.test.ts\ndoc/specs/a.md\ndoc/plans/a.md\ndocs/plans/q.md\n.pi/gauntlet/telemetry/doc/specs/a.yaml\nREADME.md\n");
24
25
  if (args[0] === "diff" && args.includes("--numstat")) return ok("100\t5\textensions/telemetry.ts\n40\t0\textensions/telemetry.test.ts\n9\t9\tdoc/specs/a.md\n2\t1\tREADME.md\n");
25
26
  if (args[0] === "rev-list") return ok("9\n");
26
27
  return { code: 1, stdout: "", stderr: `unexpected ${args.join(" ")}` };
27
28
  };
28
- const out = await computeGitDiff({ git, cwd: "/repo", spec: "doc/specs/a.md", dir: DIR, buckets: DEFAULT_TELEMETRY_BUCKETS, base: "main" });
29
+ const out = await computeGitDiff({ git, cwd: "/repo", spec: "doc/specs/a.md", dir: DIR, planDirs: PLAN_DIRS, buckets: DEFAULT_TELEMETRY_BUCKETS, base: "main" });
29
30
  assert.deepEqual(out.modified_files, ["README.md", "extensions/telemetry.test.ts", "extensions/telemetry.ts"]);
30
31
  assert.deepEqual(out.diff, { base: "abc123", commits: 9, buckets: { code: { files: 1, insertions: 100, deletions: 5 }, test: { files: 1, insertions: 40, deletions: 0 }, docs: { files: 1, insertions: 2, deletions: 1 } } });
31
32
  assert.equal(out.warning, undefined);
@@ -33,8 +34,20 @@ test("computeGitDiff: merge-base, name-only minus spec/plan/dir, numstat buckets
33
34
  assert.deepEqual(calls.at(-1), ["rev-list", "--count", "--invert-grep", "--grep=^telemetry: ", "abc123..HEAD"]);
34
35
  });
35
36
 
37
+ test("computeGitDiff: custom plan dir excludes only configured plans", async () => {
38
+ const git = (args: string[]) => {
39
+ if (args[0] === "merge-base") return ok("abc123\n");
40
+ if (args.includes("--name-only")) return ok("design/plans/x.md\ndoc/plans/x.md\n");
41
+ if (args.includes("--numstat")) return ok("1\t0\tdesign/plans/x.md\n1\t0\tdoc/plans/x.md\n");
42
+ if (args[0] === "rev-list") return ok("1\n");
43
+ return { code: 1, stdout: "", stderr: `unexpected ${args.join(" ")}` };
44
+ };
45
+ const out = await computeGitDiff({ git, cwd: "/repo", spec: "design/specs/a.md", dir: DIR, planDirs: ["design/plans"], buckets: DEFAULT_TELEMETRY_BUCKETS, base: "main" });
46
+ assert.deepEqual(out.modified_files, ["doc/plans/x.md"]);
47
+ });
48
+
36
49
  test("computeGitDiff: merge-base failure -> warning, both fields absent", async () => {
37
- const out = await computeGitDiff({ git: () => ({ code: 128, stdout: "", stderr: "fatal: no merge base\nmore" }), cwd: "/repo", spec: "doc/specs/a.md", dir: DIR, buckets: DEFAULT_TELEMETRY_BUCKETS, base: "main" });
50
+ const out = await computeGitDiff({ git: () => ({ code: 128, stdout: "", stderr: "fatal: no merge base\nmore" }), cwd: "/repo", spec: "doc/specs/a.md", dir: DIR, planDirs: PLAN_DIRS, buckets: DEFAULT_TELEMETRY_BUCKETS, base: "main" });
38
51
  assert.deepEqual(out, { warning: "diff omitted: merge-base failed: fatal: no merge base" });
39
52
  });
40
53
 
@@ -55,6 +68,20 @@ const JJ_PATCH = [
55
68
  "+++ b/test/a.test.ts",
56
69
  "@@ -0,0 +1,5 @@",
57
70
  ...Array.from({ length: 5 }, (_, i) => `+t ${i}`),
71
+ "diff --git a/doc/plans/p.md b/doc/plans/p.md",
72
+ "new file mode 100644",
73
+ "index 0000000..4444444",
74
+ "--- /dev/null",
75
+ "+++ b/doc/plans/p.md",
76
+ "@@ -0,0 +1 @@",
77
+ "+plan",
78
+ "diff --git a/docs/plans/x.md b/docs/plans/x.md",
79
+ "new file mode 100644",
80
+ "index 0000000..4444444",
81
+ "--- /dev/null",
82
+ "+++ b/docs/plans/x.md",
83
+ "@@ -0,0 +1 @@",
84
+ "+plan",
58
85
  "",
59
86
  ].join("\n");
60
87
 
@@ -73,7 +100,7 @@ const jjStub = (over?: (args: string[]) => RunResult | undefined) => {
73
100
  };
74
101
  const runJj = (over?: (args: string[]) => RunResult | undefined) => {
75
102
  const s = jjStub(over);
76
- return computeJjDiff({ jj: s.jj, cwd: "/ws", spec: "doc/specs/a.md", dir: DIR, buckets: DEFAULT_TELEMETRY_BUCKETS }).then((out) => ({ out, calls: s.calls }));
103
+ return computeJjDiff({ jj: s.jj, cwd: "/ws", spec: "doc/specs/a.md", dir: DIR, planDirs: PLAN_DIRS, buckets: DEFAULT_TELEMETRY_BUCKETS }).then((out) => ({ out, calls: s.calls }));
77
104
  };
78
105
  const isBaseLog = (args: string[]) => args.includes("log") && !args.includes("--count");
79
106
 
@@ -88,6 +115,27 @@ test("computeJjDiff: pinned flags, mainline revset, telemetry-only revisions exc
88
115
  assert.ok(calls[0].some((a) => a.includes(`fork_point(${JJ_MAINLINE} | @) ~ root() & ::${JJ_MAINLINE}`)));
89
116
  });
90
117
 
118
+ test("computeJjDiff: custom plan dir excludes only configured plans", async () => {
119
+ const patch = [
120
+ "diff --git a/design/plans/x.md b/design/plans/x.md",
121
+ "new file mode 100644",
122
+ "--- /dev/null",
123
+ "+++ b/design/plans/x.md",
124
+ "@@ -0,0 +1 @@",
125
+ "+plan",
126
+ "diff --git a/doc/plans/x.md b/doc/plans/x.md",
127
+ "new file mode 100644",
128
+ "--- /dev/null",
129
+ "+++ b/doc/plans/x.md",
130
+ "@@ -0,0 +1 @@",
131
+ "+other plan",
132
+ "",
133
+ ].join("\n");
134
+ const { jj } = jjStub((args) => (args.includes("diff") ? ok(patch) : undefined));
135
+ const out = await computeJjDiff({ jj, cwd: "/ws", spec: "design/specs/a.md", dir: DIR, planDirs: ["design/plans"], buckets: DEFAULT_TELEMETRY_BUCKETS });
136
+ assert.deepEqual(out.modified_files, ["doc/plans/x.md"]);
137
+ });
138
+
91
139
  test("computeJjDiff: empty mainline -> jj-named warning", async () => {
92
140
  const { out } = await runJj((args) => (isBaseLog(args) ? ok("") : undefined));
93
141
  assert.deepEqual(out, { warning: "diff omitted: jj mainline unresolved (trunk() is root(); no main/master bookmark)" });
@@ -27,7 +27,7 @@ export function aggregateNumstat(numstat: string, files: Set<string>, buckets: B
27
27
  export const JJ_MAINLINE = "coalesce(trunk() ~ root(), present(main), present(master))";
28
28
  const firstLine = (s: string): string => s.trim().split("\n")[0];
29
29
 
30
- export async function computeJjDiff(o: { jj: Runner; cwd: string; spec: string; dir: string; buckets: Buckets }): Promise<DiffOutcome> {
30
+ export async function computeJjDiff(o: { jj: Runner; cwd: string; spec: string; dir: string; planDirs: readonly string[]; buckets: Buckets }): Promise<DiffOutcome> {
31
31
  const failed = (sub: string, r: RunResult): DiffOutcome => ({ warning: `diff omitted: jj ${sub} failed: ${firstLine(r.stderr) || "unknown error"}` });
32
32
  // Without the ::M guard an empty mainline resolves to @ and counts the full history.
33
33
  const baseR = await o.jj(["--color=never", "log", "-r", `fork_point(${JJ_MAINLINE} | @) ~ root() & ::${JJ_MAINLINE}`, "--no-graph", "-T", 'commit_id ++ "\\n"'], o.cwd);
@@ -44,17 +44,17 @@ export async function computeJjDiff(o: { jj: Runner; cwd: string; spec: string;
44
44
  if (count.code !== 0) return failed("log", count);
45
45
  const n = count.stdout.trim();
46
46
  if (!/^\d+$/.test(n)) return { warning: `diff omitted: jj log --count unparseable: ${n}` };
47
- const files = modifiedFilesFrom(rows.map((r) => r.path).join("\n"), o.spec, o.dir);
47
+ const files = modifiedFilesFrom(rows.map((r) => r.path).join("\n"), o.spec, o.dir, o.planDirs);
48
48
  const numstat = rows.map((r) => `${r.added}\t${r.removed}\t${r.path}`).join("\n");
49
49
  return { modified_files: files, diff: { base, commits: Number(n), buckets: aggregateNumstat(numstat, new Set(files), o.buckets) } };
50
50
  }
51
51
 
52
- export async function computeGitDiff(o: { git: Runner; cwd: string; spec: string; dir: string; buckets: Buckets; base: string }): Promise<DiffOutcome> {
52
+ export async function computeGitDiff(o: { git: Runner; cwd: string; spec: string; dir: string; planDirs: readonly string[]; buckets: Buckets; base: string }): Promise<DiffOutcome> {
53
53
  const mb = await o.git(["merge-base", "HEAD", o.base], o.cwd);
54
54
  if (mb.code !== 0 || !mb.stdout.trim()) return { warning: `diff omitted: merge-base failed: ${firstLine(mb.stderr)}` };
55
55
  const base = mb.stdout.trim();
56
56
  const names = await o.git(["diff", "--name-only", `${base}...HEAD`], o.cwd);
57
- const files = modifiedFilesFrom(names.stdout, o.spec, o.dir);
57
+ const files = modifiedFilesFrom(names.stdout, o.spec, o.dir, o.planDirs);
58
58
  const numstat = await o.git(["diff", "--numstat", `${base}...HEAD`], o.cwd);
59
59
  const count = await o.git(["rev-list", "--count", "--invert-grep", "--grep=^telemetry: ", `${base}..HEAD`], o.cwd);
60
60
  return { modified_files: files, diff: { base, commits: Number(count.stdout.trim()) || 0, buckets: aggregateNumstat(numstat.stdout, new Set(files), o.buckets) } };
@@ -28,12 +28,23 @@ test("repoRelativeToolPath resolves like pi tools: relative to cwd, then repo-re
28
28
  assert.equal(repoRelativeToolPath("/repo", "/repo", "/elsewhere/x.md"), undefined);
29
29
  });
30
30
 
31
- test("isSpecPath / isPlanPath match **/doc/specs/*.md and **/doc/plans/*.md only", () => {
32
- assert.ok(isSpecPath("doc/specs/a.md"));
33
- assert.ok(isSpecPath("svc/doc/specs/a.md"));
34
- assert.equal(isSpecPath("doc/specs/sub/a.md"), false);
35
- assert.equal(isSpecPath("doc/plans/a.md"), false);
36
- assert.ok(isPlanPath("doc/plans/a.md"));
31
+ const DEFAULT_DIRS = ["doc/specs", "docs/specs"];
32
+ const DEFAULT_PLANS = ["doc/plans", "docs/plans"];
33
+
34
+ test("isSpecPath / isPlanPath: direct child .md of any configured dir, at root or under a service prefix", () => {
35
+ for (const p of ["doc/specs/x.md", "docs/specs/x.md", "svc/doc/specs/x.md", "svc/docs/specs/x.md"]) assert.ok(isSpecPath(p, DEFAULT_DIRS), p);
36
+ for (const p of ["docs/specs/sub/x.md", "docs/spec/x.md", "doc/plans/x.md", "xdocs/specs/x.md", "docs/specs/x.txt"]) assert.equal(isSpecPath(p, DEFAULT_DIRS), false, p);
37
+ for (const p of ["doc/plans/x.md", "docs/plans/x.md", "svc/doc/plans/x.md", "svc/docs/plans/x.md"]) assert.ok(isPlanPath(p, DEFAULT_PLANS), p);
38
+ for (const p of ["docs/plans/sub/x.md", "docs/plan/x.md", "doc/specs/x.md", "xdocs/plans/x.md", "docs/plans/x.txt"]) assert.equal(isPlanPath(p, DEFAULT_PLANS), false, p);
39
+ assert.ok(isPlanPath("design/plans/x.md", ["design/plans"]));
40
+ assert.equal(isPlanPath("doc/plans/x.md", ["design/plans"]), false);
41
+ assert.equal(isPlanPath("docs/plans/x.md", ["design/plans"]), false);
42
+ assert.ok(isSpecPath("design/specs/x.md", ["design/specs"]));
43
+ assert.equal(isSpecPath("doc/specs/x.md", ["design/specs"]), false);
44
+ assert.equal(isSpecPath("docs/specs/x.md", ["design/specs"]), false);
45
+ // regex metacharacters in a configured dir are matched literally
46
+ assert.ok(isSpecPath("docs/[draft]/specs/x.md", ["docs/[draft]/specs"]));
47
+ assert.equal(isSpecPath("docs/d/specs/x.md", ["docs/[draft]/specs"]), false);
37
48
  });
38
49
 
39
50
  test("planSpecHeader extracts the **Spec:** path", () => {
@@ -16,8 +16,13 @@ import { STMT_START } from "./phase-tracker-helpers.ts";
16
16
 
17
17
  export const toPosix = (p: string): string => p.split("\\").join("/");
18
18
 
19
- export const isSpecPath = (rel: string): boolean => /(^|\/)doc\/specs\/[^/]+\.md$/.test(rel);
20
- export const isPlanPath = (rel: string): boolean => /(^|\/)doc\/plans\/[^/]+\.md$/.test(rel);
19
+ const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20
+ // Direct child .md of one configured dir, at the repo root or under any service prefix.
21
+ const inDirs = (rel: string, dirs: readonly string[]): boolean =>
22
+ dirs.some((d) => new RegExp(`(^|/)${escapeRe(d)}/[^/]+\\.md$`).test(rel));
23
+
24
+ export const isSpecPath = (rel: string, specDirs: readonly string[]): boolean => inDirs(rel, specDirs);
25
+ export const isPlanPath = (rel: string, planDirs: readonly string[]): boolean => inDirs(rel, planDirs);
21
26
 
22
27
  // <dir>/<spec path with trailing .md replaced by .yaml>, nesting preserved.
23
28
  export const recordPathFor = (dir: string, specRel: string): string => `${dir}/${specRel.replace(/\.md$/, ".yaml")}`;
@@ -9,11 +9,14 @@ test("guardReason names spec, timestamp and record path", () => {
9
9
  );
10
10
  });
11
11
 
12
- test("modifiedFilesFrom drops the spec, doc/plans/**, and the telemetry dir; sorts", () => {
13
- const names = "extensions/telemetry.ts\nextensions/telemetry.test.ts\ndoc/specs/a.md\ndoc/plans/a.md\nsvc/doc/plans/b.md\n.pi/gauntlet/telemetry/doc/specs/a.yaml\nREADME.md\n\n";
14
- assert.deepEqual(modifiedFilesFrom(names, "doc/specs/a.md", ".pi/gauntlet/telemetry"), ["README.md", "extensions/telemetry.test.ts", "extensions/telemetry.ts"]);
15
- assert.deepEqual(modifiedFilesFrom("", "doc/specs/a.md", "t/"), []);
16
- // Filenames with leading or trailing whitespace must be kept verbatim
12
+ const PLANS = ["doc/plans", "docs/plans"];
13
+
14
+ test("modifiedFilesFrom drops the spec, every configured plan dir, and the telemetry dir; sorts", () => {
15
+ const names = "extensions/telemetry.ts\nextensions/telemetry.test.ts\ndoc/specs/a.md\ndoc/plans/a.md\ndocs/plans/c.md\nsvc/doc/plans/b.md\n.pi/gauntlet/telemetry/doc/specs/a.yaml\nREADME.md\n\n";
16
+ assert.deepEqual(modifiedFilesFrom(names, "doc/specs/a.md", ".pi/gauntlet/telemetry", PLANS), ["README.md", "extensions/telemetry.test.ts", "extensions/telemetry.ts"]);
17
+ assert.deepEqual(modifiedFilesFrom("design/plans/x.md\nsrc/a.ts\n", "design/specs/a.md", "t/", ["design/plans"]), ["src/a.ts"]);
18
+ assert.deepEqual(modifiedFilesFrom("doc/plans/x.md\n", "design/specs/a.md", "t/", ["design/plans"]), ["doc/plans/x.md"]);
19
+ assert.deepEqual(modifiedFilesFrom("", "doc/specs/a.md", "t/", PLANS), []);
17
20
  const namesWithWhitespace = " lead.ts\ntrail.ts \nREADME.md\n";
18
- assert.deepEqual(modifiedFilesFrom(namesWithWhitespace, "doc/specs/a.md", "t/"), [" lead.ts", "README.md", "trail.ts "]);
21
+ assert.deepEqual(modifiedFilesFrom(namesWithWhitespace, "doc/specs/a.md", "t/", PLANS), [" lead.ts", "README.md", "trail.ts "]);
19
22
  });
@@ -4,11 +4,12 @@ export const guardReason = (spec: string, shippedAt: string, recordRel: string):
4
4
  `spec ${spec} shipped at ${shippedAt} (record ${recordRel}). Write a new date-slugged spec that supersedes it instead of reusing this file.`;
5
5
 
6
6
  // derived.modified_files: `git diff --name-only <base>...HEAD` output minus the bound
7
- // spec, doc/plans/**, and <dir>/**; sorted repo-relative paths (renames -> new path).
8
- export function modifiedFilesFrom(nameOnly: string, spec: string, dir: string): string[] {
7
+ // spec, every configured plan dir (<planDir>/** at any prefix), and <dir>/**; sorted repo-relative paths.
8
+ export function modifiedFilesFrom(nameOnly: string, spec: string, dir: string, planDirs: readonly string[]): string[] {
9
9
  const dirPrefix = dir.replace(/\/+$/, "") + "/";
10
+ const planRes = planDirs.map((d) => new RegExp(`(^|/)${d.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/`));
10
11
  return nameOnly
11
12
  .split("\n")
12
- .filter((f) => f && f !== spec && !/(^|\/)doc\/plans\//.test(f) && !f.startsWith(dirPrefix))
13
+ .filter((f) => f && f !== spec && !planRes.some((re) => re.test(f)) && !f.startsWith(dirPrefix))
13
14
  .sort();
14
15
  }
@@ -290,6 +290,19 @@ test("brainstorm write guard is unchanged by the implement guard", async () => {
290
290
  }
291
291
  });
292
292
 
293
+ test("brainstorm write confinement: a docs/specs write under the default dirs does not warn", async () => {
294
+ const priorDepth = process.env.PI_SUBAGENT_DEPTH;
295
+ delete process.env.PI_SUBAGENT_DEPTH;
296
+ try {
297
+ const h = harness({ cwd: tempCwd(), branch: [phaseResult("start", phases({ brainstorm: "in_progress" }))] });
298
+ await h.emit("session_start");
299
+ await h.emitEvent("tool_call", writeCall("t1", "docs/specs/x.md"));
300
+ assert.equal((await h.emitEvent("tool_result", writeResult("t1")))[0], undefined);
301
+ } finally {
302
+ if (priorDepth !== undefined) process.env.PI_SUBAGENT_DEPTH = priorDepth;
303
+ }
304
+ });
305
+
293
306
  test("resumed session: plan-implement recovery edge fires (AC 3)", async () => {
294
307
  const h = harness({ cwd: tempCwd(), branch: [...resumedBranch({ plan: "complete" }), assistant()] });
295
308
  await settle(h);
@@ -1244,6 +1257,17 @@ test("gauntlet_setting escalationLoop: setting absent -> ctx-derived main-loop m
1244
1257
  assert.equal(res.details.implModel, "p/strong:high");
1245
1258
  });
1246
1259
 
1260
+ test("gauntlet_setting flowGuards: default dirs + derived plan dirs; repo override replaces them", async () => {
1261
+ const h = harness({ cwd: tempCwd({ piGauntlet: {} }) });
1262
+ const tool = h.tools.find((t) => t.name === "gauntlet_setting")!;
1263
+ const def = (await tool.execute("f1", { key: "flowGuards" }, undefined, undefined, h.ctx)) as { details: unknown };
1264
+ assert.deepEqual(def.details, { key: "flowGuards", enforce: true, specDirs: ["doc/specs", "docs/specs"], planDirs: ["doc/plans", "docs/plans"], errors: [] });
1265
+ const set = harness({ cwd: tempCwd({ piGauntlet: { flowGuards: { specDirs: ["design/specs"] } } }) });
1266
+ const res = (await set.tools.find((t) => t.name === "gauntlet_setting")!.execute("f2", { key: "flowGuards" }, undefined, undefined, set.ctx)) as { details: { specDirs: string[]; planDirs: string[] } };
1267
+ assert.deepEqual(res.details.specDirs, ["design/specs"]);
1268
+ assert.deepEqual(res.details.planDirs, ["design/plans"]);
1269
+ });
1270
+
1247
1271
  // --- Conformance fix-loop dispatch guard (spec 2026-09-13-conformance-dispatch-guard) ---
1248
1272
 
1249
1273
  const verifyBranch = (extra: unknown[] = []) => [