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.
- package/CHANGELOG.md +17 -5
- package/README.md +25 -18
- 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/forge-skill/SKILL.md +68 -0
- package/skills/gauntlet-resume/reference/reconstruction.md +1 -4
- package/skills/writing-plans/SKILL.md +4 -3
- package/skills/writing-skills/SKILL.md +0 -441
- package/skills/writing-skills/reference/anthropic-best-practices.md +0 -1130
- package/skills/writing-skills/reference/persuasion.md +0 -187
- package/skills/writing-skills/reference/testing-skills-with-subagents.md +0 -384
|
@@ -1,29 +1,53 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
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
|
-
|
|
42
|
-
process.stderr.write(`${msg}
|
|
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
|
|
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] !==
|
|
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] !==
|
|
78
|
+
else if (argv[i] === "--exclude" && argv[i + 1] !== void 0) exclude.push(argv[++i]);
|
|
56
79
|
else usage();
|
|
57
80
|
}
|
|
58
|
-
if (
|
|
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
|
-
|
|
100
|
-
|
|
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)) {
|
|
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) =>
|
|
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 (
|
|
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}
|
|
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
|
|
178
|
-
|
|
221
|
+
const db2 = new DatabaseSync(dbPath);
|
|
222
|
+
db2.exec("PRAGMA busy_timeout = 2000");
|
|
179
223
|
try {
|
|
180
|
-
|
|
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
|
-
|
|
186
|
-
return
|
|
229
|
+
db2.prepare("INSERT OR REPLACE INTO meta VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
|
|
230
|
+
return db2;
|
|
187
231
|
};
|
|
188
|
-
const rebuild = (
|
|
189
|
-
try {
|
|
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 {
|
|
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)) {
|
|
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)) {
|
|
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) {
|
|
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
|
-
|
|
273
|
-
|
|
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}
|
|
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
|
-
|
|
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("
|
|
350
|
-
const fragment = r.hsnip.split("\n").find((line) => line.includes("
|
|
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, '
|
|
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("
|
|
401
|
-
for (const r of rows) out.push(corpus.format(root, r).join("
|
|
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
|
|
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 }));
|