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.
- package/CHANGELOG.md +6 -0
- package/README.md +1 -1
- package/bin/gauntlet-spec-index.mjs +142 -100
- package/bin/gauntlet-spec-index.test.mjs +41 -1
- package/bin/gauntlet-telemetry-seal.mjs +49 -14
- package/bin/gauntlet-telemetry-seal.test.mjs +76 -5
- package/extensions/lib/gauntlet-settings.test.ts +13 -3
- package/extensions/lib/gauntlet-settings.ts +18 -2
- package/extensions/lib/phase-tracker-helpers.test.ts +14 -0
- package/extensions/lib/phase-tracker-helpers.ts +5 -4
- package/extensions/lib/telemetry-diff.test.ts +52 -4
- package/extensions/lib/telemetry-diff.ts +4 -4
- package/extensions/lib/telemetry-paths.test.ts +17 -6
- package/extensions/lib/telemetry-paths.ts +7 -2
- package/extensions/lib/telemetry-ship.test.ts +9 -6
- package/extensions/lib/telemetry-ship.ts +4 -3
- package/extensions/phase-tracker.test.ts +24 -0
- package/extensions/phase-tracker.ts +10 -6
- package/extensions/telemetry.test.ts +32 -7
- package/extensions/telemetry.ts +30 -26
- package/package.json +1 -1
- package/skills/brainstorming/SKILL.md +5 -5
- package/skills/finishing-a-development-branch/SKILL.md +2 -2
- package/skills/gauntlet-resume/reference/reconstruction.md +1 -4
- package/skills/writing-plans/SKILL.md +4 -3
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
363
|
+
if (!rel) usage();
|
|
335
364
|
}
|
|
336
|
-
const telemetry =
|
|
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
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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(
|
|
129
|
-
: [
|
|
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
|
|
173
|
-
if (
|
|
174
|
-
dirs.add(
|
|
175
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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,
|
|
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 &&
|
|
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[] = []) => [
|