pi-gauntlet 5.23.1 → 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.
Files changed (30) hide show
  1. package/CHANGELOG.md +17 -5
  2. package/README.md +25 -18
  3. package/bin/gauntlet-spec-index.mjs +142 -100
  4. package/bin/gauntlet-spec-index.test.mjs +41 -1
  5. package/bin/gauntlet-telemetry-seal.mjs +49 -14
  6. package/bin/gauntlet-telemetry-seal.test.mjs +76 -5
  7. package/extensions/lib/gauntlet-settings.test.ts +13 -3
  8. package/extensions/lib/gauntlet-settings.ts +18 -2
  9. package/extensions/lib/phase-tracker-helpers.test.ts +14 -0
  10. package/extensions/lib/phase-tracker-helpers.ts +5 -4
  11. package/extensions/lib/telemetry-diff.test.ts +52 -4
  12. package/extensions/lib/telemetry-diff.ts +4 -4
  13. package/extensions/lib/telemetry-paths.test.ts +17 -6
  14. package/extensions/lib/telemetry-paths.ts +7 -2
  15. package/extensions/lib/telemetry-ship.test.ts +9 -6
  16. package/extensions/lib/telemetry-ship.ts +4 -3
  17. package/extensions/phase-tracker.test.ts +24 -0
  18. package/extensions/phase-tracker.ts +10 -6
  19. package/extensions/telemetry.test.ts +32 -7
  20. package/extensions/telemetry.ts +30 -26
  21. package/package.json +1 -1
  22. package/skills/brainstorming/SKILL.md +5 -5
  23. package/skills/finishing-a-development-branch/SKILL.md +2 -2
  24. package/skills/forge-skill/SKILL.md +68 -0
  25. package/skills/gauntlet-resume/reference/reconstruction.md +1 -4
  26. package/skills/writing-plans/SKILL.md +4 -3
  27. package/skills/writing-skills/SKILL.md +0 -441
  28. package/skills/writing-skills/reference/anthropic-best-practices.md +0 -1130
  29. package/skills/writing-skills/reference/persuasion.md +0 -187
  30. package/skills/writing-skills/reference/testing-skills-with-subagents.md +0 -384
@@ -1,29 +1,53 @@
1
1
  #!/usr/bin/env node
2
- // Lexical search over spec and docs corpora (FTS5, bm25, shared confidence rule); telemetry joined for specs only.
2
+
3
+ // src/bins/gauntlet-spec-index.mjs
3
4
  import { readFileSync, appendFileSync, existsSync, statSync, lstatSync, readdirSync, mkdirSync, rmSync, realpathSync, openSync, readSync, closeSync } from "node:fs";
4
5
  import { join, dirname, basename, isAbsolute, matchesGlob } from "node:path";
6
+ import { homedir } from "node:os";
5
7
  import { execFileSync } from "node:child_process";
6
8
  import process from "node:process";
7
9
  import { parse as parseYaml } from "yaml";
8
10
 
9
- const SCHEMA_VERSION = 3;
10
- const EVIDENCE_DF_FRACTION = 0.5;
11
- const MIN_EVIDENCE_TOKENS = 2;
12
- const SCORE_RATIO = 0.5;
13
- // Policy floor, not derived from the rule: below it an empty result says little about coverage.
14
- const MIN_CORPUS = 10;
15
- // Default banner grammar of skills/brainstorming/reference/superseding.md; a named-section
16
- // scope or anything after `- fully` keeps the spec live.
17
- const FULLY_BANNER = /^> \*\*Superseded by:\*\* \[.*\]\(.*\) - fully$/;
18
- const MIN_NODE = [24, 15, 0];
19
- const DRAFT_MARKER = "# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing";
20
- const EXCLUDE_LINE = "/.pi/gauntlet/index.sqlite*";
21
- const SKIP_DIRS = new Set([".worktrees", "node_modules", "build"]);
22
- const FENCE = /^\s*(`{3,}|~{3,})/;
23
- const DOC_INCLUDES = ["**/doc/**/*.md", "**/docs/**/*.md", "README.md", "AGENTS.md"];
24
- const DOC_EXCLUDES = ["**/doc/specs/**", "**/docs/specs/**", "**/doc/plans/**", "**/docs/plans/**", "**/node_modules/**", ".pi/gauntlet/**", ".worktrees/**"];
25
- const OVERRIDES_FILES = [".pi/gauntlet-overrides.md", "gauntlet-overrides.md", "doc/gauntlet-overrides.md"];
26
- const SCHEMA = `
11
+ // extensions/lib/gauntlet-settings.ts
12
+ function mergeGauntlet(preset, repo) {
13
+ return { ...preset ?? {}, ...repo ?? {} };
14
+ }
15
+ var nonEmptyString = (v) => typeof v === "string" && v.trim().length > 0;
16
+ var DEFAULT_SPEC_DIRS = ["doc/specs", "docs/specs"];
17
+ var normalizeDir = (d) => d.trim().replace(/^\.\//, "").replace(/\/+$/, "");
18
+ function resolveFlowGuards(g) {
19
+ const fg = g.flowGuards;
20
+ const enforce = fg?.enforce !== false;
21
+ const rawDirs = fg?.specDirs;
22
+ const specDirs = Array.isArray(rawDirs) && rawDirs.length > 0 && rawDirs.every(nonEmptyString) ? rawDirs.map(normalizeDir) : [...DEFAULT_SPEC_DIRS];
23
+ return { enforce, specDirs };
24
+ }
25
+ function planDirsFor(specDirs) {
26
+ const out = /* @__PURE__ */ new Set();
27
+ for (const dir of specDirs) {
28
+ const parts = dir.split("/").filter((c) => c.length > 0);
29
+ if (parts.length === 0) continue;
30
+ out.add([...parts.slice(0, -1), "plans"].join("/"));
31
+ }
32
+ return [...out];
33
+ }
34
+
35
+ // src/bins/gauntlet-spec-index.mjs
36
+ var SCHEMA_VERSION = 3;
37
+ var EVIDENCE_DF_FRACTION = 0.5;
38
+ var MIN_EVIDENCE_TOKENS = 2;
39
+ var SCORE_RATIO = 0.5;
40
+ var MIN_CORPUS = 10;
41
+ var FULLY_BANNER = /^> \*\*Superseded by:\*\* \[.*\]\(.*\) - fully$/;
42
+ var MIN_NODE = [24, 15, 0];
43
+ var DRAFT_MARKER = "# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing";
44
+ var EXCLUDE_LINE = "/.pi/gauntlet/index.sqlite*";
45
+ var SKIP_DIRS = /* @__PURE__ */ new Set([".worktrees", "node_modules", "build"]);
46
+ var FENCE = /^\s*(`{3,}|~{3,})/;
47
+ var DOC_INCLUDES = ["**/doc/**/*.md", "**/docs/**/*.md", "README.md", "AGENTS.md"];
48
+ var DOC_EXCLUDE_GLOBS = ["**/node_modules/**", ".pi/gauntlet/**", ".worktrees/**"];
49
+ var OVERRIDES_FILES = [".pi/gauntlet-overrides.md", "gauntlet-overrides.md", "doc/gauntlet-overrides.md"];
50
+ var SCHEMA = `
27
51
  CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
28
52
  CREATE TABLE IF NOT EXISTS files (corpus TEXT NOT NULL, path TEXT NOT NULL, mtime_ms INTEGER, size INTEGER, PRIMARY KEY (corpus, path));
29
53
  CREATE VIRTUAL TABLE IF NOT EXISTS specs USING fts5(
@@ -33,40 +57,36 @@ const SCHEMA = `
33
57
  path UNINDEXED, title, headings, body,
34
58
  tokenize = 'porter unicode61');
35
59
  `;
36
-
37
- const usage = () => {
60
+ var usage = () => {
38
61
  process.stderr.write('usage: gauntlet-spec-index --query "<text>" [--corpus specs|docs] [--limit N] [--exclude <repo-relative path>]...\n');
39
62
  process.exit(1);
40
63
  };
41
- const die = (msg) => {
42
- process.stderr.write(`${msg}\n`);
64
+ var die = (msg) => {
65
+ process.stderr.write(`${msg}
66
+ `);
43
67
  process.exit(2);
44
68
  };
45
-
46
69
  function parseArgs(argv) {
47
- let query;
70
+ let query2;
48
71
  let corpus = "specs";
49
72
  let limit = 10;
50
73
  const exclude = [];
51
74
  for (let i = 0; i < argv.length; i++) {
52
- if (argv[i] === "--query" && argv[i + 1] !== undefined) query = argv[++i];
75
+ if (argv[i] === "--query" && argv[i + 1] !== void 0) query2 = argv[++i];
53
76
  else if (argv[i] === "--corpus" && Object.hasOwn(CORPORA, argv[i + 1] ?? "")) corpus = argv[++i];
54
77
  else if (argv[i] === "--limit" && /^[1-9]\d*$/.test(argv[i + 1] ?? "")) limit = Number(argv[++i]);
55
- else if (argv[i] === "--exclude" && argv[i + 1] !== undefined) exclude.push(argv[++i]);
78
+ else if (argv[i] === "--exclude" && argv[i + 1] !== void 0) exclude.push(argv[++i]);
56
79
  else usage();
57
80
  }
58
- if (query === undefined) usage();
59
- return { query, corpus, limit, exclude };
81
+ if (query2 === void 0) usage();
82
+ return { query: query2, corpus, limit, exclude };
60
83
  }
61
-
62
84
  function nodeOk() {
63
85
  const cur = process.versions.node.split(".").map(Number);
64
86
  for (let i = 0; i < 3; i++) if (cur[i] !== MIN_NODE[i]) return cur[i] > MIN_NODE[i];
65
87
  return true;
66
88
  }
67
-
68
- const git = (cwd, args) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
69
-
89
+ var git = (cwd, args) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
70
90
  function repoRoot() {
71
91
  try {
72
92
  return git(process.cwd(), ["rev-parse", "--show-toplevel"]);
@@ -74,10 +94,25 @@ function repoRoot() {
74
94
  return die("gauntlet-spec-index: not inside a git repository");
75
95
  }
76
96
  }
77
-
97
+ function readLayer(file) {
98
+ if (!existsSync(file)) return {};
99
+ try {
100
+ return JSON.parse(readFileSync(file, "utf8"));
101
+ } catch (e) {
102
+ process.stderr.write(`warning: ${file}: ${e.message}; using {} for this layer
103
+ `);
104
+ return {};
105
+ }
106
+ }
107
+ function specDirsFor(root) {
108
+ const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
109
+ const merged = mergeGauntlet(readLayer(join(agentDir, "settings.json"))?.piGauntlet, readLayer(join(root, ".pi", "settings.json"))?.piGauntlet);
110
+ const specDirs = resolveFlowGuards(merged).specDirs;
111
+ return { specDirs, planDirs: planDirsFor(specDirs) };
112
+ }
78
113
  function discover(root) {
79
114
  const out = [];
80
- const seen = new Set();
115
+ const seen = /* @__PURE__ */ new Set();
81
116
  const collect = (relDir, service) => {
82
117
  const abs = join(root, relDir);
83
118
  let real;
@@ -96,18 +131,17 @@ function discover(root) {
96
131
  if (st.isFile()) out.push({ path: rel, service, mtime_ms: Math.trunc(st.mtimeMs), size: st.size });
97
132
  }
98
133
  };
99
- collect("doc/specs", "root");
100
- for (const s of services(root).sort((a, b) => a.localeCompare(b))) collect(`${s}/doc/specs`, s);
134
+ const { specDirs } = specDirsFor(root);
135
+ const svc = services(root).sort((a, b) => a.localeCompare(b));
136
+ for (const d of specDirs) {
137
+ collect(d, "root");
138
+ for (const s of svc) collect(`${s}/${d}`, s);
139
+ }
101
140
  return out;
102
141
  }
103
-
104
- // Top-level directories discover() would scan; a slash-free include also matches one level under them.
105
142
  function services(root) {
106
- return readdirSync(root, { withFileTypes: true })
107
- .filter((e) => (e.isDirectory() || e.isSymbolicLink()) && !e.name.startsWith(".") && !SKIP_DIRS.has(e.name))
108
- .map((e) => e.name);
143
+ return readdirSync(root, { withFileTypes: true }).filter((e) => (e.isDirectory() || e.isSymbolicLink()) && !e.name.startsWith(".") && !SKIP_DIRS.has(e.name)).map((e) => e.name);
109
144
  }
110
-
111
145
  function includes(root) {
112
146
  const file = OVERRIDES_FILES.map((rel) => join(root, rel)).find((abs) => existsSync(abs));
113
147
  if (!file) return DOC_INCLUDES;
@@ -120,14 +154,16 @@ function includes(root) {
120
154
  die(`gauntlet-spec-index: unreadable overrides file ${file}: ${e.message}`);
121
155
  }
122
156
  for (const line of text.split(/\r?\n/)) {
123
- if (/^## /.test(line)) { inSection = line.trim() === "## Spec index"; continue; }
157
+ if (/^## /.test(line)) {
158
+ inSection = line.trim() === "## Spec index";
159
+ continue;
160
+ }
124
161
  if (!inSection) continue;
125
162
  const m = /^-\s+docs:\s*(.+?)\s*$/.exec(line);
126
163
  if (m) globs.push(m[1].replace(/^[`"']+|[`"']+$/g, ""));
127
164
  }
128
165
  return globs.length ? globs : DOC_INCLUDES;
129
166
  }
130
-
131
167
  function firstLine(abs) {
132
168
  const fd = openSync(abs, "r");
133
169
  try {
@@ -138,15 +174,24 @@ function firstLine(abs) {
138
174
  closeSync(fd);
139
175
  }
140
176
  }
141
-
177
+ function underDirs(rel, dirs) {
178
+ const parts = rel.split("/");
179
+ return dirs.some((d) => {
180
+ const dp = d.split("/");
181
+ for (let i = 0; i + dp.length < parts.length; i++) if (dp.every((c, j) => parts[i + j] === c)) return true;
182
+ return false;
183
+ });
184
+ }
142
185
  function discoverDocs(root, globs) {
186
+ const { specDirs, planDirs } = specDirsFor(root);
187
+ const skip = [...specDirs, ...planDirs];
143
188
  const svc = services(root);
144
- const patterns = globs.flatMap((g) => (g.includes("/") ? [g] : [g, ...svc.map((s) => `${s}/${g}`)]));
189
+ const patterns = globs.flatMap((g) => g.includes("/") ? [g] : [g, ...svc.map((s) => `${s}/${g}`)]);
145
190
  const listed = execFileSync("git", ["-C", root, "ls-files", "-co", "--exclude-standard", "-z", "--", "*.md"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split("\0").filter(Boolean);
146
191
  const out = [];
147
192
  for (const rel of listed) {
148
193
  if (!patterns.some((p) => matchesGlob(rel, p))) continue;
149
- if (DOC_EXCLUDES.some((p) => matchesGlob(rel, p))) continue;
194
+ if (DOC_EXCLUDE_GLOBS.some((p) => matchesGlob(rel, p)) || underDirs(rel, skip)) continue;
150
195
  let st;
151
196
  try {
152
197
  st = lstatSync(join(root, rel));
@@ -158,35 +203,37 @@ function discoverDocs(root, globs) {
158
203
  }
159
204
  return out;
160
205
  }
161
-
162
206
  function ensureExclude(root) {
163
207
  const rel = git(root, ["rev-parse", "--git-path", "info/exclude"]);
164
208
  const abs = isAbsolute(rel) ? rel : join(root, rel);
165
209
  const cur = existsSync(abs) ? readFileSync(abs, "utf8") : "";
166
210
  if (cur.split("\n").includes(EXCLUDE_LINE)) return;
167
211
  mkdirSync(dirname(abs), { recursive: true });
168
- appendFileSync(abs, `${cur.length && !cur.endsWith("\n") ? "\n" : ""}${EXCLUDE_LINE}\n`);
212
+ appendFileSync(abs, `${cur.length && !cur.endsWith("\n") ? "\n" : ""}${EXCLUDE_LINE}
213
+ `);
169
214
  }
170
-
171
215
  async function openDb(root) {
172
216
  const { DatabaseSync } = await import("node:sqlite");
173
217
  const dbPath = join(root, ".pi/gauntlet/index.sqlite");
174
218
  mkdirSync(dirname(dbPath), { recursive: true });
175
219
  const fresh = !existsSync(dbPath);
176
220
  const create = () => {
177
- const db = new DatabaseSync(dbPath);
178
- db.exec("PRAGMA busy_timeout = 2000");
221
+ const db2 = new DatabaseSync(dbPath);
222
+ db2.exec("PRAGMA busy_timeout = 2000");
179
223
  try {
180
- db.exec(SCHEMA);
224
+ db2.exec(SCHEMA);
181
225
  } catch (e) {
182
226
  if (/fts5/i.test(e.message)) die("gauntlet-spec-index: this Node's SQLite has no FTS5 module");
183
227
  throw e;
184
228
  }
185
- db.prepare("INSERT OR REPLACE INTO meta VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
186
- return db;
229
+ db2.prepare("INSERT OR REPLACE INTO meta VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
230
+ return db2;
187
231
  };
188
- const rebuild = (db) => {
189
- try { db?.close(); } catch {}
232
+ const rebuild = (db2) => {
233
+ try {
234
+ db2?.close();
235
+ } catch {
236
+ }
190
237
  for (const suffix of ["", "-journal", "-wal", "-shm"]) rmSync(dbPath + suffix, { force: true });
191
238
  return create();
192
239
  };
@@ -202,11 +249,13 @@ async function openDb(root) {
202
249
  const missingMeta = e?.code === "ERR_SQLITE_ERROR" && e.errcode === 1 && /no such table:\s*meta/i.test(e.message);
203
250
  const corrupt = e?.code === "ERR_SQLITE_ERROR" && (e.errcode === 11 || e.errcode === 26);
204
251
  if (missingMeta || corrupt) return rebuild(db);
205
- try { db?.close(); } catch {}
252
+ try {
253
+ db?.close();
254
+ } catch {
255
+ }
206
256
  throw e;
207
257
  }
208
258
  }
209
-
210
259
  function fenceMask(lines) {
211
260
  const mask = new Array(lines.length).fill(false);
212
261
  let open = null;
@@ -222,7 +271,6 @@ function fenceMask(lines) {
222
271
  }
223
272
  return mask;
224
273
  }
225
-
226
274
  function extract(text, path) {
227
275
  const lines = text.split(/\r?\n/);
228
276
  const fenced = fenceMask(lines);
@@ -232,15 +280,16 @@ function extract(text, path) {
232
280
  const headings = lines.filter((l, i) => !fenced[i] && /^##{1,2} /.test(l)).map((l) => l.replace(/^#+ /, "")).join("\n");
233
281
  let state = "live";
234
282
  if (h1 >= 0) {
235
- // Title block: everything after the H1 up to the first H2 heading or code fence.
236
283
  for (const l of lines.slice(h1 + 1)) {
237
284
  if (/^## /.test(l) || FENCE.test(l)) break;
238
- if (FULLY_BANNER.test(l)) { state = "superseded"; break; }
285
+ if (FULLY_BANNER.test(l)) {
286
+ state = "superseded";
287
+ break;
288
+ }
239
289
  }
240
290
  }
241
291
  return { title, goal, headings, state };
242
292
  }
243
-
244
293
  function refresh(db, root, corpus) {
245
294
  const { table } = corpus;
246
295
  const found = corpus.discover(root);
@@ -252,13 +301,19 @@ function refresh(db, root, corpus) {
252
301
  const putFile = db.prepare("INSERT OR REPLACE INTO files (corpus, path, mtime_ms, size) VALUES (?, ?, ?, ?)");
253
302
  db.exec("BEGIN IMMEDIATE");
254
303
  try {
255
- for (const path of known.keys()) if (!present.has(path)) { delRow.run(path); delFile.run(table, path); }
304
+ for (const path of known.keys()) if (!present.has(path)) {
305
+ delRow.run(path);
306
+ delFile.run(table, path);
307
+ }
256
308
  for (const f of found) {
257
309
  const k = known.get(f.path);
258
310
  if (k && k.mtime_ms === f.mtime_ms && k.size === f.size) continue;
259
311
  const text = readFileSync(join(root, f.path), "utf8");
260
312
  delRow.run(f.path);
261
- if (text.split(/\r?\n/, 1)[0] === DRAFT_MARKER) { delFile.run(table, f.path); continue; }
313
+ if (text.split(/\r?\n/, 1)[0] === DRAFT_MARKER) {
314
+ delFile.run(table, f.path);
315
+ continue;
316
+ }
262
317
  insRow.run(...corpus.row(text, f));
263
318
  putFile.run(table, f.path, f.mtime_ms, f.size);
264
319
  }
@@ -268,27 +323,22 @@ function refresh(db, root, corpus) {
268
323
  throw e;
269
324
  }
270
325
  }
271
-
272
- const tokens = (query) => query.split(/[^\p{L}\p{N}\p{Co}]+/u).filter((t) => t.length >= 2);
273
- const quote = (t) => `"${t.replaceAll('"', '""')}"`;
274
- const toMatch = (toks) => toks.map(quote).join(" OR ");
275
-
276
- // Porter is not idempotent (manatee -> manate -> manat), so MATCH always receives an
277
- // original token; the vocab pass only decides which tokens are the same term.
326
+ var tokens = (query2) => query2.split(/[^\p{L}\p{N}\p{Co}]+/u).filter((t) => t.length >= 2);
327
+ var quote = (t) => `"${t.replaceAll('"', '""')}"`;
328
+ var toMatch = (toks) => toks.map(quote).join(" OR ");
278
329
  function representatives(db, toks) {
279
330
  const mem = new db.constructor(":memory:");
280
331
  try {
281
332
  mem.exec("CREATE VIRTUAL TABLE q USING fts5(t, tokenize = 'porter unicode61'); CREATE VIRTUAL TABLE qi USING fts5vocab(q, 'instance')");
282
333
  const ins = mem.prepare("INSERT INTO q (rowid, t) VALUES (?, ?)");
283
334
  toks.forEach((t, i) => ins.run(i + 1, t));
284
- const rep = new Map();
335
+ const rep = /* @__PURE__ */ new Map();
285
336
  for (const r of mem.prepare("SELECT term, doc FROM qi ORDER BY doc, offset").all()) if (!rep.has(r.term)) rep.set(r.term, toks[r.doc - 1]);
286
337
  return rep;
287
338
  } finally {
288
339
  mem.close();
289
340
  }
290
341
  }
291
-
292
342
  function confidenceFilter(rows, evidence) {
293
343
  return rows.filter((r) => {
294
344
  const hits = evidence.filter((e) => e.all.has(r.rowid)).length;
@@ -296,7 +346,6 @@ function confidenceFilter(rows, evidence) {
296
346
  return hits >= MIN_EVIDENCE_TOKENS && strong >= MIN_EVIDENCE_TOKENS;
297
347
  });
298
348
  }
299
-
300
349
  function query(db, corpus, toks, { limit, exclude }) {
301
350
  const { table } = corpus;
302
351
  const n = db.prepare(`SELECT count(*) AS n FROM ${table}`).get().n;
@@ -312,7 +361,7 @@ function query(db, corpus, toks, { limit, exclude }) {
312
361
  if (evidence.length < MIN_EVIDENCE_TOKENS) return { rows: [], n };
313
362
  const rows = db.prepare(
314
363
  `SELECT rowid, ${corpus.select}, bm25(${table}, ${corpus.weights.map((w) => w.toFixed(1)).join(", ")}) AS score
315
- FROM ${table} WHERE ${table} MATCH ?`,
364
+ FROM ${table} WHERE ${table} MATCH ?`
316
365
  ).all(toMatch(terms)).filter((r) => !exclude.includes(r.path));
317
366
  const kept = confidenceFilter(rows, evidence);
318
367
  const live = (r) => !corpus.hasState || r.state === "live";
@@ -321,7 +370,6 @@ function query(db, corpus, toks, { limit, exclude }) {
321
370
  const cut = best ? kept.filter((r) => Math.abs(r.score) >= SCORE_RATIO * Math.abs(best.score)) : kept;
322
371
  return { rows: cut.slice(0, limit), n };
323
372
  }
324
-
325
373
  function telemetry(root, specPath) {
326
374
  const blank = { status: null, shipped_at: null, files: "" };
327
375
  const p = join(root, ".pi/gauntlet/telemetry", specPath.replace(/\.md$/, ".yaml"));
@@ -330,28 +378,23 @@ function telemetry(root, specPath) {
330
378
  try {
331
379
  rec = parseYaml(readFileSync(p, "utf8"));
332
380
  } catch {
333
- process.stderr.write(`gauntlet-spec-index: warning: unreadable telemetry ${p}\n`);
381
+ process.stderr.write(`gauntlet-spec-index: warning: unreadable telemetry ${p}
382
+ `);
334
383
  return blank;
335
384
  }
336
385
  if (!rec || typeof rec !== "object" || Array.isArray(rec)) return blank;
337
386
  const mf = rec.derived?.modified_files;
338
- const files = Array.isArray(mf)
339
- ? mf.filter((f) => typeof f === "string" && existsSync(join(root, f))).join(";")
340
- : "missing";
387
+ const files = Array.isArray(mf) ? mf.filter((f) => typeof f === "string" && existsSync(join(root, f))).join(";") : "missing";
341
388
  return { status: rec.status ?? null, shipped_at: rec.shipped_at ?? null, files };
342
389
  }
343
-
344
- const cell = (v) => (v === null || v === undefined ? "" : String(v).replace(/\s+/g, " ").trim());
345
- // Paths keep their spaces; only column and row delimiters are neutralised.
346
- const filesCell = (v) => v.replace(/[\t\r\n]+/g, " ");
347
-
390
+ var cell = (v) => v === null || v === void 0 ? "" : String(v).replace(/\s+/g, " ").trim();
391
+ var filesCell = (v) => v.replace(/[\t\r\n]+/g, " ");
348
392
  function docSnippet(r) {
349
- if (!r.hsnip.includes("\u0001")) return r.bsnip;
350
- const fragment = r.hsnip.split("\n").find((line) => line.includes("\u0001")).replace(/[\u0001\u0002]/g, "");
393
+ if (!r.hsnip.includes("")) return r.bsnip;
394
+ const fragment = r.hsnip.split("\n").find((line) => line.includes("")).replace(/[\u0001\u0002]/g, "");
351
395
  return r.headings.split("\n").find((line) => line.includes(fragment));
352
396
  }
353
-
354
- const CORPORA = {
397
+ var CORPORA = {
355
398
  specs: {
356
399
  table: "specs",
357
400
  columns: ["path", "service", "title", "goal", "headings", "body", "state"],
@@ -368,7 +411,7 @@ const CORPORA = {
368
411
  format: (root, r) => {
369
412
  const t = telemetry(root, r.path);
370
413
  return [...[r.score.toFixed(3), r.path, r.service, r.title, t.status, t.shipped_at, r.state].map(cell), filesCell(t.files), cell(r.snippet)];
371
- },
414
+ }
372
415
  },
373
416
  docs: {
374
417
  table: "docs",
@@ -377,16 +420,15 @@ const CORPORA = {
377
420
  weights: [0, 10, 3, 1],
378
421
  hasState: false,
379
422
  header: ["score", "path", "title", "snippet"],
380
- select: "path, title, headings, snippet(docs, 2, '\u0001', '\u0002', '', 12) AS hsnip, snippet(docs, 3, '', '', '...', 12) AS bsnip",
423
+ select: "path, title, headings, snippet(docs, 2, '', '', '', 12) AS hsnip, snippet(docs, 3, '', '', '...', 12) AS bsnip",
381
424
  discover: (root) => discoverDocs(root, includes(root)),
382
425
  row: (text, f) => {
383
426
  const x = extract(text, f.path);
384
427
  return [f.path, x.title, x.headings, text];
385
428
  },
386
- format: (root, r) => [r.score.toFixed(3), r.path, r.title, docSnippet(r)].map(cell),
387
- },
429
+ format: (root, r) => [r.score.toFixed(3), r.path, r.title, docSnippet(r)].map(cell)
430
+ }
388
431
  };
389
-
390
432
  async function main() {
391
433
  if (!nodeOk()) die(`gauntlet-spec-index needs Node >=24.15.0 (found ${process.versions.node})`);
392
434
  const { query: text, corpus: name, limit, exclude } = parseArgs(process.argv.slice(2));
@@ -397,15 +439,15 @@ async function main() {
397
439
  const db = await openDb(root);
398
440
  refresh(db, root, corpus);
399
441
  const { rows, n } = query(db, corpus, toks, { limit, exclude });
400
- const out = [corpus.header.join("\t")];
401
- for (const r of rows) out.push(corpus.format(root, r).join("\t"));
442
+ const out = [corpus.header.join(" ")];
443
+ for (const r of rows) out.push(corpus.format(root, r).join(" "));
402
444
  process.stdout.write(out.join("\n") + "\n");
403
445
  if (rows.length === 0 && n < MIN_CORPUS) {
404
- process.stderr.write(`gauntlet-spec-index: ${name} corpus has ${n} documents - too small for the confidence rule; no rows returned\n`);
446
+ process.stderr.write(`gauntlet-spec-index: ${name} corpus has ${n} documents - too small for the confidence rule; no rows returned
447
+ `);
405
448
  }
406
449
  db.close();
407
450
  }
408
-
409
451
  main().catch((e) => {
410
452
  if (e?.code === "ERR_SQLITE_ERROR") die(`gauntlet-spec-index: ${e.message}`);
411
453
  throw e;
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { DatabaseSync } from "node:sqlite";
9
9
 
10
10
  const CLI = join(dirname(fileURLToPath(import.meta.url)), "gauntlet-spec-index.mjs");
11
+ const EMPTY_AGENT = mkdtempSync(join(tmpdir(), "gsi-empty-agent-"));
11
12
  const DRAFT = "# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing";
12
13
  const HEADER = ["score", "path", "service", "title", "status", "shipped_at", "state", "files", "snippet"];
13
14
  const DOCS_HEADER = ["score", "path", "title", "snippet"];
@@ -68,7 +69,7 @@ const repo = () => {
68
69
  };
69
70
 
70
71
  const run = (cwd, args) => {
71
- const r = spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: "utf8" });
72
+ const r = spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: "utf8", env: { ...process.env, PI_CODING_AGENT_DIR: EMPTY_AGENT } });
72
73
  const lines = r.stdout.split("\n").filter(Boolean);
73
74
  return { status: r.status, stderr: r.stderr, stdout: r.stdout, header: lines[0]?.split("\t"), rows: lines.slice(1).map((l) => l.split("\t")) };
74
75
  };
@@ -82,6 +83,45 @@ const withDb = (root, fn) => {
82
83
  }
83
84
  };
84
85
 
86
+ test("S1: a repo with only docs/specs/ is indexed under the default dirs", (t) => {
87
+ const root = gitRepo(); t.after(() => rmSync(root, { recursive: true, force: true }));
88
+ write(root, "docs/specs/a.md", "# Alpha zephyr widget\n\n**Goal:** rank the widget.\n");
89
+ for (const n of ["one", "two", "three", "four", "five", "six", "seven", "eight", "nine"]) write(root, `docs/specs/filler-${n}.md`, filler(n));
90
+ commit(root);
91
+ const r = run(root, ["--query", "zephyr widget"]);
92
+ assert.equal(r.status, 0, r.stderr);
93
+ assert.deepEqual(paths(r), ["docs/specs/a.md"]);
94
+ });
95
+
96
+ test("S2: repo flowGuards.specDirs replaces the defaults for the specs corpus", (t) => {
97
+ const root = gitRepo(); t.after(() => rmSync(root, { recursive: true, force: true }));
98
+ write(root, ".pi/settings.json", JSON.stringify({ piGauntlet: { flowGuards: { specDirs: ["design/specs"] } } }));
99
+ write(root, "design/specs/a.md", "# Alpha zephyr widget\n\n**Goal:** rank the widget.\n");
100
+ write(root, "doc/specs/decoy.md", "# zephyr widget decoy\n");
101
+ write(root, "docs/specs/decoy2.md", "# zephyr widget decoy two\n");
102
+ for (const n of ["one", "two", "three", "four", "five", "six", "seven", "eight", "nine"]) write(root, `design/specs/filler-${n}.md`, filler(n));
103
+ commit(root);
104
+ const r = run(root, ["--query", "zephyr widget"]);
105
+ assert.equal(r.status, 0, r.stderr);
106
+ assert.deepEqual(paths(r), ["design/specs/a.md"]);
107
+ });
108
+
109
+ test("S3: docs corpus excludes configured spec and plan dirs by literal path components, including glob metacharacters", (t) => {
110
+ const root = gitRepo(); t.after(() => rmSync(root, { recursive: true, force: true }));
111
+ write(root, ".pi/settings.json", JSON.stringify({ piGauntlet: { flowGuards: { specDirs: ["docs/[draft]/specs"] } } }));
112
+ write(root, "docs/[draft]/specs/s.md", decoy);
113
+ write(root, "docs/[draft]/plans/p.md", decoy);
114
+ write(root, "docs/guide.md", "# Guide\n\n## Configuring the ptarmigan gannet\n\nprose\n");
115
+ for (let n = 1; n <= 4; n++) write(root, `docs/filler-${n}.md`, docFiller(n));
116
+ commit(root);
117
+ const r = run(root, ["--corpus", "docs", "--query", "ptarmigan gannet"]);
118
+ assert.equal(r.status, 0, r.stderr);
119
+ assert.deepEqual(paths(r), ["docs/guide.md"]);
120
+ const specs = run(root, ["--query", "ptarmigan gannet"]);
121
+ assert.equal(specs.status, 0, specs.stderr);
122
+ assert.ok(withDb(root, (db) => db.prepare("SELECT path FROM specs").all().map((x) => x.path)).includes("docs/[draft]/specs/s.md"));
123
+ });
124
+
85
125
  test("1: corpus boundary, ordering, draft skip, git status clean, exclude written once", (t) => {
86
126
  const root = repo();
87
127
  t.after(() => rmSync(root, { recursive: true, force: true }));