pi-gauntlet 5.23.0 → 5.23.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
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v5.23.1 - 2026-09-27
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- `gauntlet-spec-index` prints a one-line stderr note (`<corpus> corpus has <n> documents - too small for the confidence rule; no rows returned`) when a query returns no rows on a corpus under 10 documents; stdout and exit code are unchanged. The brainstorming scout carries the reason as `Predecessor: none (specs corpus too small: <n> documents)` / `Docs touched: none (docs corpus too small: <n> documents)`.
|
|
8
|
+
|
|
3
9
|
## v5.23.0 - 2026-09-27
|
|
4
10
|
|
|
5
11
|
### Added
|
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 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 `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.
|
|
141
141
|
|
|
142
142
|
## Performance digest
|
|
143
143
|
|
|
@@ -10,6 +10,8 @@ const SCHEMA_VERSION = 3;
|
|
|
10
10
|
const EVIDENCE_DF_FRACTION = 0.5;
|
|
11
11
|
const MIN_EVIDENCE_TOKENS = 2;
|
|
12
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;
|
|
13
15
|
// Default banner grammar of skills/brainstorming/reference/superseding.md; a named-section
|
|
14
16
|
// scope or anything after `- fully` keeps the spec live.
|
|
15
17
|
const FULLY_BANNER = /^> \*\*Superseded by:\*\* \[.*\]\(.*\) - fully$/;
|
|
@@ -307,7 +309,7 @@ function query(db, corpus, toks, { limit, exclude }) {
|
|
|
307
309
|
if (all.size === 0 || all.size >= n * EVIDENCE_DF_FRACTION) continue;
|
|
308
310
|
evidence.push({ all, strong: rowids(corpus.strong.map((f) => `${f}:${quote(tok)}`).join(" OR ")) });
|
|
309
311
|
}
|
|
310
|
-
if (evidence.length < MIN_EVIDENCE_TOKENS) return [];
|
|
312
|
+
if (evidence.length < MIN_EVIDENCE_TOKENS) return { rows: [], n };
|
|
311
313
|
const rows = db.prepare(
|
|
312
314
|
`SELECT rowid, ${corpus.select}, bm25(${table}, ${corpus.weights.map((w) => w.toFixed(1)).join(", ")}) AS score
|
|
313
315
|
FROM ${table} WHERE ${table} MATCH ?`,
|
|
@@ -317,7 +319,7 @@ function query(db, corpus, toks, { limit, exclude }) {
|
|
|
317
319
|
kept.sort((a, b) => Number(!live(a)) - Number(!live(b)) || a.score - b.score);
|
|
318
320
|
const best = kept.find(live);
|
|
319
321
|
const cut = best ? kept.filter((r) => Math.abs(r.score) >= SCORE_RATIO * Math.abs(best.score)) : kept;
|
|
320
|
-
return cut.slice(0, limit);
|
|
322
|
+
return { rows: cut.slice(0, limit), n };
|
|
321
323
|
}
|
|
322
324
|
|
|
323
325
|
function telemetry(root, specPath) {
|
|
@@ -394,10 +396,13 @@ async function main() {
|
|
|
394
396
|
const corpus = CORPORA[name];
|
|
395
397
|
const db = await openDb(root);
|
|
396
398
|
refresh(db, root, corpus);
|
|
397
|
-
const rows = query(db, corpus, toks, { limit, exclude });
|
|
399
|
+
const { rows, n } = query(db, corpus, toks, { limit, exclude });
|
|
398
400
|
const out = [corpus.header.join("\t")];
|
|
399
401
|
for (const r of rows) out.push(corpus.format(root, r).join("\t"));
|
|
400
402
|
process.stdout.write(out.join("\n") + "\n");
|
|
403
|
+
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`);
|
|
405
|
+
}
|
|
401
406
|
db.close();
|
|
402
407
|
}
|
|
403
408
|
|
|
@@ -43,6 +43,16 @@ const commit = (root) => {
|
|
|
43
43
|
};
|
|
44
44
|
// Fillers keep the corpus large enough that two-document probe terms stay below N/2.
|
|
45
45
|
const filler = (n) => `# Filler ${n}\n\n**Goal:** filler goal ${n}.\n\n## Design\n\nnothing of note here\n`;
|
|
46
|
+
const boundaryRepo = (fillers) => {
|
|
47
|
+
const root = gitRepo();
|
|
48
|
+
const common = (n) => `# Plain spec ${n}\n\n**Goal:** plain goal ${n}.\n\nThe body mentions the common shared vocabulary.\n`;
|
|
49
|
+
write(root, "doc/specs/strong.md", "# Strong heron ibis\n\n**Goal:** heron ibis jackal.\n\nbody\n");
|
|
50
|
+
for (let n = 1; n <= 5; n++) write(root, `doc/specs/p${n}.md`, common(n));
|
|
51
|
+
for (const f of fillers) write(root, `doc/specs/filler-${f}.md`, filler(f));
|
|
52
|
+
commit(root);
|
|
53
|
+
return root;
|
|
54
|
+
};
|
|
55
|
+
const NOTE = "too small for the confidence rule; no rows returned";
|
|
46
56
|
|
|
47
57
|
const repo = () => {
|
|
48
58
|
const root = gitRepo();
|
|
@@ -357,6 +367,50 @@ test("16: punctuation splits into distinct evidence terms and query order does n
|
|
|
357
367
|
assert.deepEqual(paths(run(root, ["--query", "config config-file"])), paths(run(root, ["--query", "config-file config"])));
|
|
358
368
|
});
|
|
359
369
|
|
|
370
|
+
test("21: three-spec corpus, no match - header only plus the small-corpus note", (t) => {
|
|
371
|
+
const root = gitRepo();
|
|
372
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
373
|
+
for (const n of ["one", "two", "three"]) write(root, `doc/specs/filler-${n}.md`, filler(n));
|
|
374
|
+
commit(root);
|
|
375
|
+
const r = run(root, ["--query", "yeti unicorn"]);
|
|
376
|
+
assert.equal(r.status, 0, r.stderr);
|
|
377
|
+
assert.deepEqual(r.header, HEADER);
|
|
378
|
+
assert.deepEqual(r.rows, []);
|
|
379
|
+
assert.equal(r.stderr, `gauntlet-spec-index: specs corpus has 3 documents - ${NOTE}\n`);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("22: MIN_CORPUS boundary - note at N = 9, silent at N = 10, rows unaffected", (t) => {
|
|
383
|
+
const root = boundaryRepo(["one", "two", "three"]);
|
|
384
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
385
|
+
const nine = run(root, ["--query", "yeti unicorn"]);
|
|
386
|
+
assert.equal(nine.status, 0, nine.stderr);
|
|
387
|
+
assert.deepEqual(nine.rows, []);
|
|
388
|
+
assert.equal(nine.stderr, `gauntlet-spec-index: specs corpus has 9 documents - ${NOTE}\n`);
|
|
389
|
+
write(root, "doc/specs/filler-four.md", filler("four"));
|
|
390
|
+
commit(root);
|
|
391
|
+
for (const q of ["yeti unicorn", "common shared"]) {
|
|
392
|
+
const r = run(root, ["--query", q]);
|
|
393
|
+
assert.equal(r.status, 0, r.stderr);
|
|
394
|
+
assert.deepEqual(r.header, HEADER);
|
|
395
|
+
assert.deepEqual(r.rows, []);
|
|
396
|
+
assert.equal(r.stderr, "");
|
|
397
|
+
}
|
|
398
|
+
const strong = run(root, ["--query", "heron ibis jackal common", "--limit", "10"]);
|
|
399
|
+
assert.equal(strong.status, 0, strong.stderr);
|
|
400
|
+
assert.deepEqual(paths(strong), ["doc/specs/strong.md"]);
|
|
401
|
+
assert.equal(strong.stderr, "");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("23: n is the table count before --exclude", (t) => {
|
|
405
|
+
const root = boundaryRepo(["one", "two", "three", "four"]);
|
|
406
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
407
|
+
const r = run(root, ["--query", "yeti unicorn", "--exclude", "doc/specs/p1.md"]);
|
|
408
|
+
assert.equal(r.status, 0, r.stderr);
|
|
409
|
+
assert.deepEqual(r.header, HEADER);
|
|
410
|
+
assert.deepEqual(r.rows, []);
|
|
411
|
+
assert.equal(r.stderr, "");
|
|
412
|
+
});
|
|
413
|
+
|
|
360
414
|
test("D1: --corpus specs and omitted --corpus are byte-identical; nine columns", (t) => {
|
|
361
415
|
const root = repo();
|
|
362
416
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
@@ -420,6 +474,7 @@ test("D2: docs include defaults and fixed exclusions", (t) => {
|
|
|
420
474
|
assert.equal(tiny.status, 0, tiny.stderr);
|
|
421
475
|
assert.deepEqual(tiny.header, DOCS_HEADER);
|
|
422
476
|
assert.deepEqual(tiny.rows, []);
|
|
477
|
+
assert.equal(tiny.stderr, `gauntlet-spec-index: docs corpus has 2 documents - ${NOTE}\n`);
|
|
423
478
|
});
|
|
424
479
|
|
|
425
480
|
test("D3: shared confidence rejects common body-only hits", (t) => {
|
|
@@ -558,3 +613,15 @@ test("D10: fenced headings and shorter nested fences are ignored", (t) => {
|
|
|
558
613
|
run(root, ["--corpus", "docs", "--query", "ptarmigan gannet"]);
|
|
559
614
|
for (const p of ["doc/f.md", "doc/nested.md"]) assert.equal(withDb(root, (db) => db.prepare("SELECT headings FROM docs WHERE path = ?").get(p).headings), "");
|
|
560
615
|
});
|
|
616
|
+
|
|
617
|
+
test("D13: five-document docs corpus with rare heading terms returns rows and no note", (t) => {
|
|
618
|
+
const root = gitRepo();
|
|
619
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
620
|
+
write(root, "doc/strong.md", "# Strong doc\n\n## heron ibis\n\n## jackal\n\nbody\n");
|
|
621
|
+
for (let n = 1; n <= 4; n++) write(root, `doc/p${n}.md`, `# Plain doc ${n}\n\nThe body mentions the common shared vocabulary.\n`);
|
|
622
|
+
commit(root);
|
|
623
|
+
const r = run(root, ["--corpus", "docs", "--query", "heron ibis jackal common", "--limit", "10"]);
|
|
624
|
+
assert.equal(r.status, 0, r.stderr);
|
|
625
|
+
assert.deepEqual(paths(r), ["doc/strong.md"]);
|
|
626
|
+
assert.equal(r.stderr, "");
|
|
627
|
+
});
|
package/package.json
CHANGED
|
@@ -58,7 +58,11 @@ Scout (always dispatched):
|
|
|
58
58
|
> `node <SPEC_INDEX> --query '<keywords>' --limit 10` from the worktree root,
|
|
59
59
|
> keeping the keywords inside single quotes, and treat its rows as the candidate
|
|
60
60
|
> list; zero rows means the index found no evidence, not that no predecessor exists -
|
|
61
|
-
> judge `Predecessor: none` from the code recon.
|
|
61
|
+
> judge `Predecessor: none` from the code recon. When the command output contains a line
|
|
62
|
+
> ending "too small for the confidence rule; no rows returned" (a diagnostic, not a row) and
|
|
63
|
+
> the code recon names no predecessor, write
|
|
64
|
+
> `Predecessor: none (specs corpus too small: <n> documents)`, copying <n> from that
|
|
65
|
+
> line. If the command fails, fall back to
|
|
62
66
|
> listing the project's spec directory
|
|
63
67
|
> and reading titles and `**Goal:**` lines, and write
|
|
64
68
|
> `Spec index unavailable - predecessor check used directory listing.` in your
|
|
@@ -96,7 +100,10 @@ Scout (always dispatched):
|
|
|
96
100
|
> document (the row's `snippet` column is a hint to it), or the document title when no single
|
|
97
101
|
> section applies; or `Docs touched: none`. Judge by topic; a lexical hit alone is not
|
|
98
102
|
> coverage. Zero rows means the index found no evidence, not that no document covers the
|
|
99
|
-
> topic.
|
|
103
|
+
> topic. When the docs command output contains that same diagnostic line and the recon
|
|
104
|
+
> found no covering document, write
|
|
105
|
+
> `Docs touched: none (docs corpus too small: <n> documents)` instead. This query
|
|
106
|
+
> supplements the code and documentation recon you already perform; it
|
|
100
107
|
> never replaces it - keep reading the files the request touches. End with an
|
|
101
108
|
> "Open questions that matter for the spec"
|
|
102
109
|
> section. Compact handoff, not a dump.
|