pi-gauntlet 6.0.0 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/agents/code-reviewer.md +1 -0
  4. package/agents/conformance-reviewer.md +1 -1
  5. package/bin/gauntlet-performance.test.mjs +4 -4
  6. package/bin/gauntlet-spec-index.mjs +142 -100
  7. package/bin/gauntlet-spec-index.test.mjs +41 -1
  8. package/bin/gauntlet-telemetry-seal.mjs +49 -14
  9. package/bin/gauntlet-telemetry-seal.test.mjs +76 -5
  10. package/extensions/lib/gauntlet-settings.test.ts +13 -3
  11. package/extensions/lib/gauntlet-settings.ts +18 -2
  12. package/extensions/lib/phase-tracker-helpers.test.ts +14 -0
  13. package/extensions/lib/phase-tracker-helpers.ts +5 -4
  14. package/extensions/lib/telemetry-collect.test.ts +6 -0
  15. package/extensions/lib/telemetry-collect.ts +4 -2
  16. package/extensions/lib/telemetry-diff.test.ts +52 -4
  17. package/extensions/lib/telemetry-diff.ts +4 -4
  18. package/extensions/lib/telemetry-paths.test.ts +17 -6
  19. package/extensions/lib/telemetry-paths.ts +7 -2
  20. package/extensions/lib/telemetry-ship.test.ts +9 -6
  21. package/extensions/lib/telemetry-ship.ts +4 -3
  22. package/extensions/phase-tracker.test.ts +24 -0
  23. package/extensions/phase-tracker.ts +10 -6
  24. package/extensions/telemetry.test.ts +32 -7
  25. package/extensions/telemetry.ts +30 -26
  26. package/package.json +1 -1
  27. package/skills/brainstorming/SKILL.md +5 -5
  28. package/skills/dispatching-parallel-agents/SKILL.md +1 -1
  29. package/skills/finishing-a-development-branch/SKILL.md +8 -4
  30. package/skills/gauntlet-resume/reference/reconstruction.md +1 -4
  31. package/skills/requesting-code-review/SKILL.md +4 -2
  32. package/skills/subagent-driven-development/SKILL.md +6 -6
  33. package/skills/subagent-driven-development/code-quality-reviewer-prompt.md +10 -8
  34. package/skills/verification-before-completion/reference/conformance-check.md +2 -0
  35. package/skills/verification-before-completion/reference/whole-diff-minors.md +31 -0
  36. package/skills/writing-plans/SKILL.md +4 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## v6.1.0 - 2026-09-30
4
+
5
+ ### Changed
6
+
7
+ - Minor code-review findings ride along in a `FIX_FIRST` fix round and never trigger one; a `SHIP` report ends the loop whatever Minors it lists. The whole-diff review's Minors land in the closure block as `whole-diff minors:` and the finish gate offers a one-shot `fix minors` reply. Re-reviews are scoped to a `## Fix delta` block (the whole-diff review and its re-reviews stay unscoped): a new finding outside it blocks only when Critical; `TRAJECTORY:` counts Critical and Moderate only. Telemetry counts `[Critical]`/`[Moderate]` into `blocker`/`major`. (#56)
8
+
9
+ ## v6.0.1 - 2026-09-30
10
+
11
+ ### Changed
12
+
13
+ - `flowGuards.specDirs` defaults to `["doc/specs", "docs/specs"]` and now governs telemetry spec/plan classification, the seal's plan exclusion, and the spec index; `gauntlet-telemetry-seal` selects records by their `spec:` field; `gauntlet-spec-index` ships as an esbuild bundle from `src/bins/`; `gauntlet_setting` gains the `flowGuards` key.
14
+
3
15
  ## v6.0.0 - 2026-09-30
4
16
 
5
17
  ### Changed
package/README.md CHANGED
@@ -137,7 +137,7 @@ Pin an exact release with `npm:pi-gauntlet@X.Y.Z`. See [doc/install-internals.md
137
137
 
138
138
  ## Spec search index
139
139
 
140
- `gauntlet-spec-index` provides lexical search over two corpora in one per-worktree cache. From a repository worktree, run `node <pi-gauntlet-package>/bin/gauntlet-spec-index.mjs --query "<text>" [--corpus specs|docs] [--limit N] [--exclude <repo-relative path>]...`; it requires Node >=24.15.0 and refreshes its FTS5 index on every query. `--corpus specs` (the default) searches `doc/specs/*.md` at the repository root and one service level down and prints tab-separated `score`, `path`, `service`, `title`, `status`, `shipped_at`, `state`, `files`, and `snippet` columns. `--corpus docs` searches project documentation and prints `score`, `path`, `title`, and `snippet`; the snippet is the matched `##`/`###` heading line when one holds a query term, else a body fragment. Docs candidates are the tracked and untracked, non-gitignored `*.md` files (`git ls-files -co --exclude-standard`, so nested repositories and submodules are not indexed) matching an include list - default `**/doc/**/*.md`, `**/docs/**/*.md`, `README.md`, `AGENTS.md`, where a slash-free entry also matches one service level down - minus the fixed exclusions `**/doc/specs/**`, `**/docs/specs/**`, `**/doc/plans/**`, `**/docs/plans/**`, `**/node_modules/**`, `.pi/gauntlet/**`, `.worktrees/**`, symlinks, and files whose first line is the context-draft marker; `**` never matches a dot directory, so `.pi/*.md` or `.github/*.md` is indexed only through an explicit include. A project replaces the include list with `- docs: <glob>` bullets under a `## Spec index` heading in its gauntlet overrides file (see [Project-specific overrides](#project-specific-overrides)); the exclusions are not configurable. Both corpora pass the same confidence rule before `--limit` applies: a query term is evidence when it occurs in fewer than half the rows of the queried table (stem variants of one word count once); query tokens split on word boundaries, so `gauntlet-spec-index` searches as three words; a row is returned only when it matches at least two distinct evidence terms in the corpus's strong fields - `title` or `goal` for specs, `title` or `headings` for docs - and rows scoring below half the best live row's score are dropped (every docs row is live). A query with no real match therefore prints the header only and exits 0 - and when the queried corpus has fewer than 10 documents, also writes one stderr line naming the corpus and its size (`gauntlet-spec-index: docs corpus has 5 documents - too small for the confidence rule; no rows returned`), so a small corpus and a genuine miss stay distinguishable; a corpus of two or fewer files never returns rows. `--exclude` (repeatable) removes a path before the rule runs, so the spec under review never sets the bar. `state` is `superseded` when a `> **Superseded by:** ... - fully` banner sits in the block directly under the title and `live` otherwise; named-section banners and consumer-defined banner syntaxes both read as `live`. Live rows sort before superseded rows, then by score. The `files` column is a `;`-separated list of repo-relative paths the spec's shipped change modified and that still exist in the repository, the literal `missing` when the spec's telemetry record has no `derived.modified_files` list, or blank when there is no readable record or no recorded path remains. The cache lives at `.pi/gauntlet/index.sqlite`, and its first creation adds `/.pi/gauntlet/index.sqlite*` to Git's `info/exclude` so the database and SQLite sidecars stay out of `git status`. `/skill:brainstorming` queries the specs corpus twice - the scout at gather time from the request, and the main loop at spec-writing from the finished spec's title, goal, and headings, surfacing new `live` candidates at the review gate - and the scout queries the docs corpus once to render `Docs touched:` lines that round 2 weighs as documentation-impact candidates.
140
+ `gauntlet-spec-index` provides lexical search over two corpora in one per-worktree cache. From a repository worktree, run `node <pi-gauntlet-package>/bin/gauntlet-spec-index.mjs --query "<text>" [--corpus specs|docs] [--limit N] [--exclude <repo-relative path>]...`; it requires Node >=24.15.0 and refreshes its FTS5 index on every query. `--corpus specs` (the default) searches `<spec dir>/*.md` for each resolved `flowGuards.specDirs` entry (default `doc/specs` and `docs/specs`) at the repository root and one service level down and prints tab-separated `score`, `path`, `service`, `title`, `status`, `shipped_at`, `state`, `files`, and `snippet` columns. `--corpus docs` searches project documentation and prints `score`, `path`, `title`, and `snippet`; the snippet is the matched `##`/`###` heading line when one holds a query term, else a body fragment. Docs candidates are the tracked and untracked, non-gitignored `*.md` files (`git ls-files -co --exclude-standard`, so nested repositories and submodules are not indexed) matching an include list - default `**/doc/**/*.md`, `**/docs/**/*.md`, `README.md`, `AGENTS.md`, where a slash-free entry also matches one service level down - minus the resolved spec and plan dirs (matched as literal path components), `**/node_modules/**`, `.pi/gauntlet/**`, `.worktrees/**`, symlinks, and files whose first line is the context-draft marker; `**` never matches a dot directory, so `.pi/*.md` or `.github/*.md` is indexed only through an explicit include. A project replaces the include list with `- docs: <glob>` bullets under a `## Spec index` heading in its gauntlet overrides file (see [Project-specific overrides](#project-specific-overrides)); the exclusions are not configurable through the overrides file; the spec and plan dir exclusions follow `flowGuards.specDirs`. Both corpora pass the same confidence rule before `--limit` applies: a query term is evidence when it occurs in fewer than half the rows of the queried table (stem variants of one word count once); query tokens split on word boundaries, so `gauntlet-spec-index` searches as three words; a row is returned only when it matches at least two distinct evidence terms in the corpus's strong fields - `title` or `goal` for specs, `title` or `headings` for docs - and rows scoring below half the best live row's score are dropped (every docs row is live). A query with no real match therefore prints the header only and exits 0 - and when the queried corpus has fewer than 10 documents, also writes one stderr line naming the corpus and its size (`gauntlet-spec-index: docs corpus has 5 documents - too small for the confidence rule; no rows returned`), so a small corpus and a genuine miss stay distinguishable; a corpus of two or fewer files never returns rows. `--exclude` (repeatable) removes a path before the rule runs, so the spec under review never sets the bar. `state` is `superseded` when a `> **Superseded by:** ... - fully` banner sits in the block directly under the title and `live` otherwise; named-section banners and consumer-defined banner syntaxes both read as `live`. Live rows sort before superseded rows, then by score. The `files` column is a `;`-separated list of repo-relative paths the spec's shipped change modified and that still exist in the repository, the literal `missing` when the spec's telemetry record has no `derived.modified_files` list, or blank when there is no readable record or no recorded path remains. The cache lives at `.pi/gauntlet/index.sqlite`, and its first creation adds `/.pi/gauntlet/index.sqlite*` to Git's `info/exclude` so the database and SQLite sidecars stay out of `git status`. `/skill:brainstorming` queries the specs corpus twice - the scout at gather time from the request, and the main loop at spec-writing from the finished spec's title, goal, and headings, surfacing new `live` candidates at the review gate - and the scout queries the docs corpus once to render `Docs touched:` lines that round 2 weighs as documentation-impact candidates.
141
141
 
142
142
  ## Performance digest
143
143
 
@@ -59,6 +59,7 @@ Rules:
59
59
  - Verify before praising: no "looks good" on code you did not read.
60
60
  - Report only on code you read.
61
61
  - Name the concrete change in every Fix; "improve error handling" is not a Fix.
62
+ - On a re-review (the task carries `## Previous review report (re-review trigger)`): confirm each prior finding is resolved, then review the `## Fix delta` block, reading surrounding code as needed. A new finding outside that delta is Critical, or it is `[Minor]` with the note `(outside fix delta)`. No `## Fix delta` block -> the task's whole diff is the delta. The whole-diff review and its re-reviews are never scoped.
62
63
 
63
64
  Label every finding with a globally unique `F1..Fn` ID (no restart per severity),
64
65
  and a `touched-files:`/`touched-resources:` pair (files a fix would edit, not only
@@ -147,7 +147,7 @@ Footer order: `Parallel-safe:` is the final line of the report.
147
147
  - **Read-only. Never edit.** You audit; you do not fix.
148
148
  - **Propose, do not dispose.** For each gap you may suggest a one-line remediation *direction*, but you do **not** decide the disposition - the orchestrator auto-applies `fix` gaps and defers `accept`/`rescope` gaps to the user at the finish gate (an `UNAUTHORIZED` row follows its `recommended` value like every other verdict). Never present a fix as a decision you made.
149
149
  - **Evidence or it didn't happen.** Cite a real `file:line` for every DELIVERED/PARTIAL. If you cannot, downgrade the row to MISSING.
150
- - **Origin quote or it isn't a gap.** Every non-UNAUTHORIZED gap's `origin` carries a locator AND a verbatim quote: `origin: <file/section, 'prompt', or 'ticket'> - "<quoted clause>"` (truncate long clauses with `[...]` as long as the fragment uniquely identifies the clause). No quotable origin clause = no gap. Do not derive implicit requirements. Do not flag wording preferences. A deviation recorded in the spec wins over an older origin value (Process step 2); report it only if unrecorded.
150
+ - **Origin quote or it isn't a gap.** Every non-UNAUTHORIZED gap's `origin` carries a locator AND a verbatim quote: `origin: <file/section, 'prompt', or 'ticket'> - "<quoted clause>"` (truncate long clauses with `[...]` as long as the fragment uniquely identifies the clause). No quotable origin clause = no gap. Do not derive implicit requirements. Do not flag wording preferences. A code-review finding - style, naming, comment, micro-efficiency - with no origin clause is never a gap, whatever its severity. A deviation recorded in the spec wins over an older origin value (Process step 2); report it only if unrecorded.
151
151
  - **Spec is canonical; the prompt catches what the spec dropped; the ticket is fallback only** when no spec exists.
152
152
  - **The spec's `## Acceptance criteria` section is origin, not ticket.** Its `in-scope`/`venue:` rows are `Rn`; its `deviates:`/`deferred:` rows are recorded drift (`recorded in spec? yes`).
153
153
  - **Do not absorb origin drift silently** — flag every spec↔prompt/ticket disagreement.
@@ -54,8 +54,8 @@ derived:
54
54
  worker: { dispatches: 3, models: [m/beta] }
55
55
  spec-council-member: { dispatches: 4, models: [m/alpha] }
56
56
  reviews:
57
- spec-reviewer: { dispatches: 1, nonzero_exit: 0, findings: { blocker: 1, major: 2, minor: 3 } }
58
- code-reviewer: { dispatches: 1, nonzero_exit: 0 }
57
+ spec-reviewer: { dispatches: 1, nonzero_exit: 0 }
58
+ code-reviewer: { dispatches: 1, nonzero_exit: 0, findings: { blocker: 1, major: 2, minor: 3 } }
59
59
  conformance_loops: 2
60
60
  conformance_open_gaps: 0
61
61
  gates: { spec_rounds: 1, plan_rounds: 1, fix_round_grants: 1, task_reopens: 0, ship_option: squash }
@@ -211,8 +211,8 @@ test("malformed reviewer entries and findings objects contribute null counters",
211
211
  'findings: "bad"',
212
212
  );
213
213
  const malformedReviewer = COMPLETE.replace(
214
- "spec-reviewer: { dispatches: 1, nonzero_exit: 0, findings: { blocker: 1, major: 2, minor: 3 } }",
215
- 'spec-reviewer: "bad"',
214
+ "code-reviewer: { dispatches: 1, nonzero_exit: 0, findings: { blocker: 1, major: 2, minor: 3 } }",
215
+ 'code-reviewer: "bad"',
216
216
  );
217
217
  write(root, `${TDIR}/malformed-findings-object.yaml`, malformedFindings);
218
218
  write(root, `${TDIR}/malformed-reviewer.yaml`, malformedReviewer.replace(
@@ -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 }));