pi-gauntlet 5.8.1 → 5.9.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.
- package/CHANGELOG.md +4 -0
- package/README.md +4 -0
- package/bin/gauntlet-spec-index.mjs +223 -0
- package/bin/gauntlet-spec-index.test.mjs +173 -0
- package/package.json +7 -1
- package/skills/brainstorming/SKILL.md +4 -23
- package/skills/brainstorming/gatherer.md +19 -6
- package/skills/brainstorming/reference/superseding.md +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v5.9.0 - 2026-09-17
|
|
4
|
+
|
|
5
|
+
- Added `gauntlet-spec-index`, a build-on-query FTS5 search over root and one-level service spec corpora with a per-worktree cache and telemetry-enriched tabular results; brainstorming uses it to find predecessor specs. Requires Node >=24.15.0. (#34)
|
|
6
|
+
|
|
3
7
|
## v5.8.1 - 2026-09-17
|
|
4
8
|
|
|
5
9
|
- chase-bug hotfix: the implementer proves its worktree binding first, addresses every mutating git command with `git -C`, runs with a fresh context, and the parent aborts non-destructively on any primary-checkout drift after each implementer return; `ci.mjs` asserts the guard text ([#36](https://github.com/jjuraszek/pi-gauntlet/issues/36))
|
package/README.md
CHANGED
|
@@ -116,6 +116,10 @@ pi install npm:pi-gauntlet
|
|
|
116
116
|
|
|
117
117
|
Pin an exact release with `npm:pi-gauntlet@X.Y.Z`. See [doc/install-internals.md](./doc/install-internals.md) for what the postinstall step actually does (symlink vs copy, `PI_GAUNTLET_AGENT_DIR`, upgrading from the pre-rename package).
|
|
118
118
|
|
|
119
|
+
## Spec search index
|
|
120
|
+
|
|
121
|
+
`gauntlet-spec-index` provides lexical search across `doc/specs/*.md` at the repository root and one service level down. From a repository worktree, run `node <pi-gauntlet-package>/bin/gauntlet-spec-index.mjs --query "<text>" [--limit N]`; it requires Node >=24.15.0, refreshes its FTS5 index on every query, and prints tab-separated `score`, `path`, `service`, `title`, `status`, `shipped_at`, `files`, and `snippet` columns. The per-worktree 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`.
|
|
122
|
+
|
|
119
123
|
For local development against a checkout instead of npm:
|
|
120
124
|
|
|
121
125
|
```bash
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Lexical search over the spec corpus. Build-on-query: refresh a per-worktree
|
|
3
|
+
// FTS5 cache by mtime+size, then rank with bm25 and join telemetry at output.
|
|
4
|
+
import { readFileSync, appendFileSync, existsSync, statSync, readdirSync, mkdirSync, rmSync, realpathSync } from "node:fs";
|
|
5
|
+
import { join, dirname, basename, isAbsolute } from "node:path";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import process from "node:process";
|
|
8
|
+
import { parse as parseYaml } from "yaml";
|
|
9
|
+
|
|
10
|
+
const SCHEMA_VERSION = 1;
|
|
11
|
+
const MIN_NODE = [24, 15, 0];
|
|
12
|
+
const DRAFT_MARKER = "# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing";
|
|
13
|
+
const EXCLUDE_LINE = "/.pi/gauntlet/index.sqlite*";
|
|
14
|
+
const SKIP_DIRS = new Set([".worktrees", "node_modules", "build"]);
|
|
15
|
+
const HEADER = ["score", "path", "service", "title", "status", "shipped_at", "files", "snippet"];
|
|
16
|
+
const SCHEMA = `
|
|
17
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
|
|
18
|
+
CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, mtime_ms INTEGER, size INTEGER);
|
|
19
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS specs USING fts5(
|
|
20
|
+
path UNINDEXED, service UNINDEXED, title, goal, headings, body,
|
|
21
|
+
tokenize = 'porter unicode61');
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
const usage = () => {
|
|
25
|
+
process.stderr.write('usage: gauntlet-spec-index --query "<text>" [--limit N]\n');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
};
|
|
28
|
+
const die = (msg) => {
|
|
29
|
+
process.stderr.write(`${msg}\n`);
|
|
30
|
+
process.exit(2);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function parseArgs(argv) {
|
|
34
|
+
let query;
|
|
35
|
+
let limit = 10;
|
|
36
|
+
for (let i = 0; i < argv.length; i++) {
|
|
37
|
+
if (argv[i] === "--query" && argv[i + 1] !== undefined) query = argv[++i];
|
|
38
|
+
else if (argv[i] === "--limit" && /^[1-9]\d*$/.test(argv[i + 1] ?? "")) limit = Number(argv[++i]);
|
|
39
|
+
else usage();
|
|
40
|
+
}
|
|
41
|
+
if (query === undefined) usage();
|
|
42
|
+
return { query, limit };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function nodeOk() {
|
|
46
|
+
const cur = process.versions.node.split(".").map(Number);
|
|
47
|
+
for (let i = 0; i < 3; i++) if (cur[i] !== MIN_NODE[i]) return cur[i] > MIN_NODE[i];
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const git = (cwd, args) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
52
|
+
|
|
53
|
+
function repoRoot() {
|
|
54
|
+
try {
|
|
55
|
+
return git(process.cwd(), ["rev-parse", "--show-toplevel"]);
|
|
56
|
+
} catch {
|
|
57
|
+
return die("gauntlet-spec-index: not inside a git repository");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function discover(root) {
|
|
62
|
+
const out = [];
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
const collect = (relDir, service) => {
|
|
65
|
+
const abs = join(root, relDir);
|
|
66
|
+
let real;
|
|
67
|
+
try {
|
|
68
|
+
real = realpathSync(abs);
|
|
69
|
+
if (!statSync(real).isDirectory()) return;
|
|
70
|
+
} catch {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (seen.has(real)) return;
|
|
74
|
+
seen.add(real);
|
|
75
|
+
for (const name of readdirSync(abs).sort()) {
|
|
76
|
+
if (!name.endsWith(".md")) continue;
|
|
77
|
+
const rel = `${relDir}/${name}`;
|
|
78
|
+
const st = statSync(join(root, rel));
|
|
79
|
+
if (st.isFile()) out.push({ path: rel, service, mtime_ms: Math.trunc(st.mtimeMs), size: st.size });
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
collect("doc/specs", "root");
|
|
83
|
+
const entries = readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
84
|
+
for (const e of entries) {
|
|
85
|
+
if (e.name.startsWith(".") || SKIP_DIRS.has(e.name)) continue;
|
|
86
|
+
if (!e.isDirectory() && !e.isSymbolicLink()) continue;
|
|
87
|
+
collect(`${e.name}/doc/specs`, e.name);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function ensureExclude(root) {
|
|
93
|
+
const rel = git(root, ["rev-parse", "--git-path", "info/exclude"]);
|
|
94
|
+
const abs = isAbsolute(rel) ? rel : join(root, rel);
|
|
95
|
+
const cur = existsSync(abs) ? readFileSync(abs, "utf8") : "";
|
|
96
|
+
if (cur.split("\n").includes(EXCLUDE_LINE)) return;
|
|
97
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
98
|
+
appendFileSync(abs, `${cur.length && !cur.endsWith("\n") ? "\n" : ""}${EXCLUDE_LINE}\n`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function openDb(root) {
|
|
102
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
103
|
+
const dbPath = join(root, ".pi/gauntlet/index.sqlite");
|
|
104
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
105
|
+
const fresh = !existsSync(dbPath);
|
|
106
|
+
const create = () => {
|
|
107
|
+
const db = new DatabaseSync(dbPath);
|
|
108
|
+
db.exec("PRAGMA busy_timeout = 2000");
|
|
109
|
+
try {
|
|
110
|
+
db.exec(SCHEMA);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
if (/fts5/i.test(e.message)) die("gauntlet-spec-index: this Node's SQLite has no FTS5 module");
|
|
113
|
+
throw e;
|
|
114
|
+
}
|
|
115
|
+
db.prepare("INSERT OR REPLACE INTO meta VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
|
|
116
|
+
return db;
|
|
117
|
+
};
|
|
118
|
+
const rebuild = (db) => {
|
|
119
|
+
try { db?.close(); } catch {}
|
|
120
|
+
for (const suffix of ["", "-journal", "-wal", "-shm"]) rmSync(dbPath + suffix, { force: true });
|
|
121
|
+
return create();
|
|
122
|
+
};
|
|
123
|
+
if (fresh) ensureExclude(root);
|
|
124
|
+
let db;
|
|
125
|
+
try {
|
|
126
|
+
db = new DatabaseSync(dbPath);
|
|
127
|
+
db.exec("PRAGMA busy_timeout = 2000");
|
|
128
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
|
|
129
|
+
if (Number(row?.value) !== SCHEMA_VERSION) return rebuild(db);
|
|
130
|
+
return db;
|
|
131
|
+
} catch (e) {
|
|
132
|
+
const missingMeta = e?.code === "ERR_SQLITE_ERROR" && e.errcode === 1 && /no such table:\s*meta/i.test(e.message);
|
|
133
|
+
const corrupt = e?.code === "ERR_SQLITE_ERROR" && (e.errcode === 11 || e.errcode === 26);
|
|
134
|
+
if (missingMeta || corrupt) return rebuild(db);
|
|
135
|
+
try { db?.close(); } catch {}
|
|
136
|
+
throw e;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function extract(text, path) {
|
|
141
|
+
const lines = text.split(/\r?\n/);
|
|
142
|
+
const title = lines.find((l) => l.startsWith("# "))?.slice(2).trim() || basename(path, ".md");
|
|
143
|
+
const goal = lines.find((l) => l.startsWith("**Goal:**"))?.slice("**Goal:**".length).trim() ?? "";
|
|
144
|
+
const headings = lines.filter((l) => /^##{1,2} /.test(l)).map((l) => l.replace(/^#+ /, "")).join("\n");
|
|
145
|
+
return { title, goal, headings };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function refresh(db, root, corpus) {
|
|
149
|
+
const known = new Map(db.prepare("SELECT path, mtime_ms, size FROM files").all().map((r) => [r.path, r]));
|
|
150
|
+
const present = new Set(corpus.map((f) => f.path));
|
|
151
|
+
const delSpec = db.prepare("DELETE FROM specs WHERE path = ?");
|
|
152
|
+
const delFile = db.prepare("DELETE FROM files WHERE path = ?");
|
|
153
|
+
const insSpec = db.prepare("INSERT INTO specs (path, service, title, goal, headings, body) VALUES (?, ?, ?, ?, ?, ?)");
|
|
154
|
+
const putFile = db.prepare("INSERT OR REPLACE INTO files (path, mtime_ms, size) VALUES (?, ?, ?)");
|
|
155
|
+
db.exec("BEGIN IMMEDIATE");
|
|
156
|
+
try {
|
|
157
|
+
for (const path of known.keys()) if (!present.has(path)) { delSpec.run(path); delFile.run(path); }
|
|
158
|
+
for (const f of corpus) {
|
|
159
|
+
const k = known.get(f.path);
|
|
160
|
+
if (k && k.mtime_ms === f.mtime_ms && k.size === f.size) continue;
|
|
161
|
+
const text = readFileSync(join(root, f.path), "utf8");
|
|
162
|
+
delSpec.run(f.path);
|
|
163
|
+
if (text.split(/\r?\n/, 1)[0] === DRAFT_MARKER) { delFile.run(f.path); continue; }
|
|
164
|
+
const x = extract(text, f.path);
|
|
165
|
+
insSpec.run(f.path, f.service, x.title, x.goal, x.headings, text);
|
|
166
|
+
putFile.run(f.path, f.mtime_ms, f.size);
|
|
167
|
+
}
|
|
168
|
+
db.exec("COMMIT");
|
|
169
|
+
} catch (e) {
|
|
170
|
+
db.exec("ROLLBACK");
|
|
171
|
+
throw e;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const toMatch = (query) =>
|
|
176
|
+
query.split(/\s+/).filter((t) => t.length >= 2).map((t) => `"${t.replaceAll('"', '""')}"`).join(" OR ");
|
|
177
|
+
|
|
178
|
+
function telemetry(root, specPath) {
|
|
179
|
+
const blank = { status: null, shipped_at: null, files: null };
|
|
180
|
+
const p = join(root, ".pi/gauntlet/telemetry", specPath.replace(/\.md$/, ".yaml"));
|
|
181
|
+
if (!existsSync(p)) return blank;
|
|
182
|
+
let rec;
|
|
183
|
+
try {
|
|
184
|
+
rec = parseYaml(readFileSync(p, "utf8"));
|
|
185
|
+
} catch {
|
|
186
|
+
process.stderr.write(`gauntlet-spec-index: warning: unreadable telemetry ${p}\n`);
|
|
187
|
+
return blank;
|
|
188
|
+
}
|
|
189
|
+
if (!rec || typeof rec !== "object") return blank;
|
|
190
|
+
const mf = rec.derived?.modified_files;
|
|
191
|
+
return { status: rec.status ?? null, shipped_at: rec.shipped_at ?? null, files: Array.isArray(mf) ? mf.length : null };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const cell = (v) => (v === null || v === undefined ? "" : String(v).replace(/\s+/g, " ").trim());
|
|
195
|
+
|
|
196
|
+
async function main() {
|
|
197
|
+
if (!nodeOk()) die(`gauntlet-spec-index needs Node >=24.15.0 (found ${process.versions.node})`);
|
|
198
|
+
const { query, limit } = parseArgs(process.argv.slice(2));
|
|
199
|
+
const match = toMatch(query);
|
|
200
|
+
if (!match) usage();
|
|
201
|
+
const root = repoRoot();
|
|
202
|
+
const db = await openDb(root);
|
|
203
|
+
refresh(db, root, discover(root));
|
|
204
|
+
const rows = db.prepare(
|
|
205
|
+
`SELECT path, service, title,
|
|
206
|
+
bm25(specs, 0, 0, 10.0, 5.0, 2.0, 1.0) AS score,
|
|
207
|
+
snippet(specs, 5, '', '', '...', 12) AS snippet
|
|
208
|
+
FROM specs WHERE specs MATCH ?
|
|
209
|
+
ORDER BY score LIMIT ?`,
|
|
210
|
+
).all(match, limit);
|
|
211
|
+
const out = [HEADER.join("\t")];
|
|
212
|
+
for (const r of rows) {
|
|
213
|
+
const t = telemetry(root, r.path);
|
|
214
|
+
out.push([r.score.toFixed(3), r.path, r.service, r.title, t.status, t.shipped_at, t.files, r.snippet].map(cell).join("\t"));
|
|
215
|
+
}
|
|
216
|
+
process.stdout.write(out.join("\n") + "\n");
|
|
217
|
+
db.close();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
main().catch((e) => {
|
|
221
|
+
if (e?.code === "ERR_SQLITE_ERROR") die(`gauntlet-spec-index: ${e.message}`);
|
|
222
|
+
throw e;
|
|
223
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, utimesSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { DatabaseSync } from "node:sqlite";
|
|
9
|
+
|
|
10
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), "gauntlet-spec-index.mjs");
|
|
11
|
+
const DRAFT = "# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing";
|
|
12
|
+
|
|
13
|
+
const write = (root, rel, text) => {
|
|
14
|
+
mkdirSync(join(root, dirname(rel)), { recursive: true });
|
|
15
|
+
writeFileSync(join(root, rel), text);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const repo = () => {
|
|
19
|
+
const root = mkdtempSync(join(tmpdir(), "gsi-"));
|
|
20
|
+
spawnSync("git", ["init", "-q"], { cwd: root });
|
|
21
|
+
write(root, "doc/specs/a.md", "# Alpha zephyr widget\n\n**Goal:** rank the widget.\n\n## Design\n\nbody text\n");
|
|
22
|
+
write(root, "svc-a/doc/specs/b.md", "# Beta service\n\n**Goal:** unrelated.\n\nDeep in the body a zephyr appears.\n");
|
|
23
|
+
write(root, ".worktrees/x/doc/specs/decoy1.md", "# zephyr decoy one\n");
|
|
24
|
+
write(root, "build/doc/specs/decoy2.md", "# zephyr decoy two\n");
|
|
25
|
+
write(root, "apps/svc/doc/specs/decoy3.md", "# zephyr decoy three\n");
|
|
26
|
+
write(root, "doc/specs/draft.md", `${DRAFT}\n\nzephyr zephyr zephyr\n`);
|
|
27
|
+
spawnSync("git", ["add", "-A"], { cwd: root });
|
|
28
|
+
spawnSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "fixture"], { cwd: root });
|
|
29
|
+
return root;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const run = (cwd, args) => {
|
|
33
|
+
const r = spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: "utf8" });
|
|
34
|
+
const lines = r.stdout.split("\n").filter(Boolean);
|
|
35
|
+
return { status: r.status, stderr: r.stderr, header: lines[0]?.split("\t"), rows: lines.slice(1).map((l) => l.split("\t")) };
|
|
36
|
+
};
|
|
37
|
+
const paths = (res) => res.rows.map((r) => r[1]);
|
|
38
|
+
const withDb = (root, fn) => {
|
|
39
|
+
const database = new DatabaseSync(join(root, ".pi/gauntlet/index.sqlite"));
|
|
40
|
+
try {
|
|
41
|
+
return fn(database);
|
|
42
|
+
} finally {
|
|
43
|
+
database.close();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
test("1: corpus boundary, ordering, draft skip, git status clean, exclude written once", (t) => {
|
|
48
|
+
const root = repo();
|
|
49
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
50
|
+
const r1 = run(root, ["--query", "zephyr"]);
|
|
51
|
+
assert.equal(r1.status, 0, r1.stderr);
|
|
52
|
+
assert.deepEqual(paths(r1), ["doc/specs/a.md", "svc-a/doc/specs/b.md"]);
|
|
53
|
+
assert.deepEqual(r1.rows.map((r) => r[2]), ["root", "svc-a"]);
|
|
54
|
+
const status = spawnSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8" }).stdout;
|
|
55
|
+
assert.equal(status, "");
|
|
56
|
+
run(root, ["--query", "zephyr"]);
|
|
57
|
+
const exclude = readFileSync(join(root, ".git/info/exclude"), "utf8");
|
|
58
|
+
assert.equal(exclude.split("\n").filter((l) => l === "/.pi/gauntlet/index.sqlite*").length, 1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("2: title outranks body; --limit 1; default up to 10", (t) => {
|
|
62
|
+
const root = repo();
|
|
63
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
64
|
+
for (let i = 0; i < 12; i++) write(root, `doc/specs/many-${i}.md`, `# Spec ${i}\n\nquokka\n`);
|
|
65
|
+
assert.deepEqual(paths(run(root, ["--query", "zephyr", "--limit", "1"])), ["doc/specs/a.md"]);
|
|
66
|
+
assert.equal(run(root, ["--query", "quokka"]).rows.length, 10);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("3: incremental refresh updates only the edited row; delete removes rows", (t) => {
|
|
70
|
+
const root = repo();
|
|
71
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
72
|
+
run(root, ["--query", "zephyr"]);
|
|
73
|
+
const before = withDb(root, (database) => Object.fromEntries(database.prepare("SELECT path, mtime_ms, size FROM files").all().map((r) => [r.path, `${r.mtime_ms}:${r.size}`])));
|
|
74
|
+
const b = join(root, "svc-a/doc/specs/b.md");
|
|
75
|
+
writeFileSync(b, readFileSync(b, "utf8") + "\nwombat\n");
|
|
76
|
+
utimesSync(b, new Date(), new Date(Date.now() + 5000));
|
|
77
|
+
assert.deepEqual(paths(run(root, ["--query", "wombat"])), ["svc-a/doc/specs/b.md"]);
|
|
78
|
+
const after = withDb(root, (database) => Object.fromEntries(database.prepare("SELECT path, mtime_ms, size FROM files").all().map((r) => [r.path, `${r.mtime_ms}:${r.size}`])));
|
|
79
|
+
assert.equal(after["doc/specs/a.md"], before["doc/specs/a.md"]);
|
|
80
|
+
assert.notEqual(after["svc-a/doc/specs/b.md"], before["svc-a/doc/specs/b.md"]);
|
|
81
|
+
const count = (root, sql, path) => withDb(root, (database) => Object.values(database.prepare(sql).get(path))[0]);
|
|
82
|
+
assert.equal(count(root, "SELECT count(*) FROM specs WHERE path = ?", "svc-a/doc/specs/b.md"), 1);
|
|
83
|
+
rmSync(b);
|
|
84
|
+
run(root, ["--query", "zephyr"]);
|
|
85
|
+
assert.equal(count(root, "SELECT count(*) FROM specs WHERE path = ?", "svc-a/doc/specs/b.md"), 0);
|
|
86
|
+
assert.equal(count(root, "SELECT count(*) FROM files WHERE path = ?", "svc-a/doc/specs/b.md"), 0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("4: schema_version mismatch rebuilds the db", (t) => {
|
|
90
|
+
const root = repo();
|
|
91
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
92
|
+
run(root, ["--query", "zephyr"]);
|
|
93
|
+
withDb(root, (database) => database.prepare("UPDATE meta SET value = '999' WHERE key = 'schema_version'").run());
|
|
94
|
+
const r = run(root, ["--query", "zephyr"]);
|
|
95
|
+
assert.equal(r.status, 0, r.stderr);
|
|
96
|
+
assert.equal(paths(r).length, 2);
|
|
97
|
+
assert.equal(withDb(root, (database) => database.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get().value), "1");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("5: a non-SQLite database is rebuilt", (t) => {
|
|
101
|
+
const root = repo();
|
|
102
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
103
|
+
write(root, ".pi/gauntlet/index.sqlite", "not a sqlite database");
|
|
104
|
+
const r = run(root, ["--query", "zephyr"]);
|
|
105
|
+
assert.equal(r.status, 0, r.stderr);
|
|
106
|
+
assert.deepEqual(paths(r), ["doc/specs/a.md", "svc-a/doc/specs/b.md"]);
|
|
107
|
+
assert.equal(withDb(root, (database) => database.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get().value), "1");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("6: telemetry join is output-only and tolerant", (t) => {
|
|
111
|
+
const root = repo();
|
|
112
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
113
|
+
run(root, ["--query", "zephyr"]);
|
|
114
|
+
const filesBefore = withDb(root, (database) => JSON.stringify(database.prepare("SELECT * FROM files ORDER BY path").all()));
|
|
115
|
+
write(root, ".pi/gauntlet/telemetry/doc/specs/a.yaml", "status: shipped\nshipped_at: 2026-09-17T10:00:00Z\nderived:\n modified_files:\n - x\n - y\n");
|
|
116
|
+
let r = run(root, ["--query", "zephyr"]);
|
|
117
|
+
assert.deepEqual(r.rows[0].slice(4, 7), ["shipped", "2026-09-17T10:00:00Z", "2"]);
|
|
118
|
+
assert.equal(withDb(root, (database) => JSON.stringify(database.prepare("SELECT * FROM files ORDER BY path").all())), filesBefore);
|
|
119
|
+
write(root, ".pi/gauntlet/telemetry/doc/specs/a.yaml", "status: in_progress\nshipped_at: 2026-09-18T10:00:00Z\n");
|
|
120
|
+
r = run(root, ["--query", "zephyr"]);
|
|
121
|
+
assert.deepEqual(r.rows[0].slice(4, 7), ["in_progress", "2026-09-18T10:00:00Z", ""]);
|
|
122
|
+
write(root, ".pi/gauntlet/telemetry/doc/specs/a.yaml", "status: [unclosed\n");
|
|
123
|
+
r = run(root, ["--query", "zephyr"]);
|
|
124
|
+
assert.equal(r.status, 0);
|
|
125
|
+
assert.deepEqual(r.rows[0].slice(4, 7), ["", "", ""]);
|
|
126
|
+
assert.match(r.stderr, /a\.yaml/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("7: query sanitising tolerates embedded quotes", (t) => {
|
|
130
|
+
const root = repo();
|
|
131
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
132
|
+
write(root, "doc/specs/q.md", '# Quotes\n\nfoo"bar and "plain" words\n');
|
|
133
|
+
let r = run(root, ["--query", 'foo"bar']);
|
|
134
|
+
assert.equal(r.status, 0, r.stderr);
|
|
135
|
+
assert.ok(paths(r).includes("doc/specs/q.md"));
|
|
136
|
+
r = run(root, ["--query", '"plain"']);
|
|
137
|
+
assert.equal(r.status, 0, r.stderr);
|
|
138
|
+
assert.ok(paths(r).includes("doc/specs/q.md"));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("8: one line per hit, eight tab-separated fields, whitespace collapsed", (t) => {
|
|
142
|
+
const root = repo();
|
|
143
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
144
|
+
write(root, "doc/specs/t.md", "# Tab\tin\ttitle narwhal\n\nbody narwhal\nline two\twith tab narwhal\r\nmore\n");
|
|
145
|
+
const r = run(root, ["--query", "narwhal"]);
|
|
146
|
+
assert.equal(r.header.length, 8);
|
|
147
|
+
assert.equal(r.rows.length, 1);
|
|
148
|
+
assert.equal(r.rows[0].length, 8);
|
|
149
|
+
assert.equal(r.rows[0][3], "Tab in title narwhal");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("9: supersession banner is plain body text", (t) => {
|
|
153
|
+
const root = repo();
|
|
154
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
155
|
+
write(root, "doc/specs/old.md", "# Old\n\n> **Superseded by:** [doc/specs/a.md](./a.md) - fully\n\nplatypus\n");
|
|
156
|
+
const r = run(root, ["--query", "Superseded"]);
|
|
157
|
+
assert.deepEqual(paths(r), ["doc/specs/old.md"]);
|
|
158
|
+
assert.deepEqual(r.rows[0].slice(4, 7), ["", "", ""]);
|
|
159
|
+
assert.deepEqual(r.header, ["score", "path", "service", "title", "status", "shipped_at", "files", "snippet"]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("10: usage errors exit 1, environment errors exit 2", (t) => {
|
|
163
|
+
const root = repo();
|
|
164
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
165
|
+
assert.equal(run(root, []).status, 1);
|
|
166
|
+
assert.equal(run(root, ["--query", "zephyr", "--limit", "0"]).status, 1);
|
|
167
|
+
assert.equal(run(root, ["--query", "zephyr", "--limit", "x"]).status, 1);
|
|
168
|
+
assert.equal(run(root, ["--query", "zephyr", "--json"]).status, 1);
|
|
169
|
+
assert.equal(run(root, ["--query", "a"]).status, 1);
|
|
170
|
+
const bare = mkdtempSync(join(tmpdir(), "gsi-bare-"));
|
|
171
|
+
t.after(() => rmSync(bare, { recursive: true, force: true }));
|
|
172
|
+
assert.equal(run(bare, ["--query", "zephyr"]).status, 2);
|
|
173
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-gauntlet",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.9.0",
|
|
4
4
|
"description": "Opinionated, gated workflow skills, subagent personas, and runtime extensions for the pi coding agent.",
|
|
5
5
|
"author": "Jacek Juraszek",
|
|
6
6
|
"type": "module",
|
|
@@ -31,6 +31,12 @@
|
|
|
31
31
|
"bin",
|
|
32
32
|
"CHANGELOG.md"
|
|
33
33
|
],
|
|
34
|
+
"bin": {
|
|
35
|
+
"gauntlet-spec-index": "bin/gauntlet-spec-index.mjs"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=24.15.0"
|
|
39
|
+
},
|
|
34
40
|
"pi": {
|
|
35
41
|
"skills": [
|
|
36
42
|
"./skills"
|
|
@@ -22,7 +22,7 @@ You **may**:
|
|
|
22
22
|
- Read code and docs
|
|
23
23
|
- Run the existing system to observe its **current** behaviour — this is research and feeds the spec (boot a local service, replay a sample request, capture a baseline classification, etc.)
|
|
24
24
|
- Write to the project's `doc/specs/` directory
|
|
25
|
-
- `edit` a predecessor spec in the project's spec directory to add a supersession banner (see [Marking superseded specs](
|
|
25
|
+
- `edit` a predecessor spec in the project's spec directory to add a supersession banner (see [Marking superseded specs](reference/superseding.md)) — the `edit` prohibition at spec-writing binds the spec being written, not a predecessor file
|
|
26
26
|
|
|
27
27
|
You may **not**:
|
|
28
28
|
|
|
@@ -65,7 +65,7 @@ Work through the items below **in order**. This is your own checklist to follow,
|
|
|
65
65
|
then state the chat premise note (section 3) before approaches
|
|
66
66
|
5. **Propose 2-3 approaches** — with trade-offs and a recommendation
|
|
67
67
|
6. **Present the design** — in two rounds, one approval each
|
|
68
|
-
7. **Write the spec** — to `doc/specs/` (see [Filename Convention](#filename-convention)); then mark any known superseded predecessor(s) per [Marking superseded specs](
|
|
68
|
+
7. **Write the spec** — to `doc/specs/` (see [Filename Convention](#filename-convention)); then mark any known superseded predecessor(s) per [Marking superseded specs](reference/superseding.md), at the exact-order position defined in [Spec Self-Review](#spec-self-review-before-user-review-gate)
|
|
69
69
|
8. **Spec self-review (lint)** — placeholder scan + internal consistency + documentation named, run inline
|
|
70
70
|
9. **Critique pass (auto-dispatched)** — scope + ambiguity; the spec council via `/skill:roasting-the-spec` when `gauntlet_setting` returns verdict `council` (it applies its apply-set, including any external-ref inlining, to the spec before returning — see [Spec Council](#spec-council-optional)), else a fresh `worker` that applies its own fixes in place
|
|
71
71
|
10. **Re-run placeholder scan** — after the critique pass returns, re-scan the **applied** spec for placeholders its edits may have introduced; if a predecessor banner exists, confirm its `<scope>` still matches the applied spec (critique edits can change what is superseded); surface any ambiguity the critique could not safely resolve at the user gate
|
|
@@ -239,25 +239,6 @@ overwrite reuses the path. If the questionary invalidated the slug, rename at
|
|
|
239
239
|
spec-writing: write the spec at the new path **and delete the old draft file**
|
|
240
240
|
(nothing was committed, so this is free).
|
|
241
241
|
|
|
242
|
-
## Marking superseded specs
|
|
243
|
-
|
|
244
|
-
When the new spec replaces a prior spec — fully or in part — (from the draft's scout recon or the request), mark the predecessor. No mechanical sweep: grep or path-overlap hits never decide supersession.
|
|
245
|
-
|
|
246
|
-
- `edit` the predecessor spec (in the project's spec directory, per [Project Routing](#project-routing)) to insert, after its title line and a blank line, one banner line per successor:
|
|
247
|
-
|
|
248
|
-
```markdown
|
|
249
|
-
> **Superseded by:** [<repo-relative path to successor>](<href relative to THIS file>) - <scope>
|
|
250
|
-
```
|
|
251
|
-
|
|
252
|
-
- The visible label is the successor's repo-relative path; the href is computed relative to the predecessor's own directory (Markdown resolves links from the containing file). Same directory: `[doc/specs/B.md](./B.md)`.
|
|
253
|
-
- `<scope>` is the value after the ` - ` separator: `fully`, or the named superseded section(s), e.g. `"Settings resolution" section only`. The scope value itself carries no leading dash — the template above already supplies the separator.
|
|
254
|
-
- Banners are **append-only**: add below any existing supersession lines, formatted or free-form prose. One old spec may accumulate banners from multiple successors. No migration, no dedup.
|
|
255
|
-
- **No transitive rewrite**: if A points at B and B is later superseded by C, A keeps pointing at B; the reader hops.
|
|
256
|
-
- **Mark, never delete.** Delete/archive policy is consumer territory via overrides.
|
|
257
|
-
- **Coverage limits**: unmarked does NOT mean current (code drift, abandoned designs, and partial ships produce no successor spec); marked does NOT mean dead (partial supersession leaves live sections).
|
|
258
|
-
- Predecessor in a **different service's spec directory**: out of scope — record it in the new spec's Open Questions instead of editing outside the write grant.
|
|
259
|
-
- **Override contract**: the gauntlet overrides file (see Project overrides) may replace the banner *syntax*; placement, append-only, no-transitive-rewrite, and mark-never-delete stay fixed. A syntax override entry must itself state the scout-citation guidance for its format (the shipped `gatherer.md` guidance names only the default banner).
|
|
260
|
-
|
|
261
242
|
## Spec Self-Review (Before User Review Gate)
|
|
262
243
|
|
|
263
244
|
Spec-writing replaces the context draft, in this exact order:
|
|
@@ -273,7 +254,7 @@ Spec-writing replaces the context draft, in this exact order:
|
|
|
273
254
|
guard is a backstop, not the primary check.
|
|
274
255
|
4. **After the line-1 check and before the inline lint**, `edit` any known
|
|
275
256
|
predecessor spec to insert its supersession banner (see
|
|
276
|
-
[Marking superseded specs](
|
|
257
|
+
[Marking superseded specs](reference/superseding.md)). This position is fixed:
|
|
277
258
|
the banner is written after any slug rename, so it always cites the final path.
|
|
278
259
|
|
|
279
260
|
After writing the spec to `<project>/doc/specs/<filename>.md` (per [Filename Convention](#filename-convention)) and before showing it to the user, run a self-review pass. **Read all five bullets first, then act:** only the **first three** run here at the main loop (the inline lint); the **last two** (scope + ambiguity) do **not** run inline — they are the dispatched critique pass (checklist item 9). Do not apply scope/ambiguity edits yourself.
|
|
@@ -328,7 +309,7 @@ subagent({ agent: "spec-summarizer", context: "fresh", async: false, cwd: "<abs
|
|
|
328
309
|
|
|
329
310
|
`<SUMMARY_PATH>` above is a placeholder in the dispatch object; it means substitute the value of the shell variable `$SUMMARY_PATH` set above. The steps below use `$SUMMARY_PATH` (the shell form) once the value is in hand.
|
|
330
311
|
|
|
331
|
-
Then commit the spec — staging any predecessor spec edited per [Marking superseded specs](
|
|
312
|
+
Then commit the spec — staging any predecessor spec edited per [Marking superseded specs](reference/superseding.md) alongside it; a change request at the gate that renames, materially revises, or drops the spec also reconciles the predecessor's banner before recommitting. This commit is **unconditional**: the summary is only a gate aid, so a degraded or missing summary never blocks it. If the council path ran, include its audit (`Coverage:` when present, then `Applied:` / `Deferred:` / `Rejected:`, verbatim from `/skill:roasting-the-spec`'s return) in the **commit message body** - this is the durable, non-contractual record a finish-time revert reads back; the audit is never a committed spec section. Evaluate the summary in two stages (the **Degrade path** referenced in each is defined just below):
|
|
332
313
|
|
|
333
314
|
1. **From the dispatch tool result, before the `Read`.** If the result is **not** an `"Output saved to: <path> (<N> KB, <M> lines)"` reference (e.g. an exit-0 save error returns the full inline output plus an "Output file error" line — the prunable shape, no file to read), or the reference reports under ~500 bytes, or a size grossly disproportionate to the spec (under ~2% of its byte size), or over ~45 KB (the `Read` truncates at 50KB / 2000 lines, so a larger file cannot render whole) — skip the `Read` and take the degrade path. Use the reference's reported figures; do not re-derive them.
|
|
334
315
|
2. **The `Read` itself, as the last content-producing tool call before composing the gate.** `Read` `$SUMMARY_PATH` and paste its contents verbatim at the top of the gate. If the `Read` fails, returns 0 bytes, or reports truncation — take the degrade path. The `Read` must be last: pi-condense does not protect a `/tmp` read, so any turn boundary between the `Read` and the render lets the ~9KB read result be pruned, reproducing the bug.
|
|
@@ -35,6 +35,10 @@ subagent({
|
|
|
35
35
|
Absolute `output:` paths are mandatory: relative paths in parallel mode resolve
|
|
36
36
|
against the worktree and would get committed.
|
|
37
37
|
|
|
38
|
+
`<SPEC_INDEX>` is `<directory of this skill's SKILL.md>/../../bin/gauntlet-spec-index.mjs`,
|
|
39
|
+
resolved to an absolute path by the main loop from the skill's `<location>` in the system prompt
|
|
40
|
+
before pasting the task.
|
|
41
|
+
|
|
38
42
|
## Task templates
|
|
39
43
|
|
|
40
44
|
Scout (always dispatched):
|
|
@@ -46,12 +50,21 @@ Scout (always dispatched):
|
|
|
46
50
|
> exact paths and line ranges. If a spec you cite carries a supersession marker
|
|
47
51
|
> (default: a `> **Superseded by:**` banner; the project's overrides may define
|
|
48
52
|
> another format), follow the successor for the superseded scope and cite it
|
|
49
|
-
> instead; cite the old spec only for its unsuperseded sections
|
|
50
|
-
> check:
|
|
51
|
-
>
|
|
52
|
-
>
|
|
53
|
-
>
|
|
54
|
-
>
|
|
53
|
+
> instead; cite the old spec only for its unsuperseded sections (banner contract:
|
|
54
|
+
> `reference/superseding.md`). Predecessor check: compose a 5-15 term keyword query
|
|
55
|
+
> from the request (topic nouns, component names, file names - not stop words; if
|
|
56
|
+
> the request is only a ticket reference, take the terms from the ticket title via
|
|
57
|
+
> the tracker CLI when one is available, otherwise use the fallback below). Run
|
|
58
|
+
> `node <SPEC_INDEX> --query '<keywords>' --limit 10` from the worktree root,
|
|
59
|
+
> keeping the keywords inside single quotes, and treat its rows as the candidate
|
|
60
|
+
> list. If the command fails, fall back to listing the project's spec directory
|
|
61
|
+
> and reading titles and `**Goal:**` lines, and write
|
|
62
|
+
> `Spec index unavailable - predecessor check used directory listing.` in your
|
|
63
|
+
> handoff. Either way open at
|
|
64
|
+
> most five candidates whose topic matches this request, and name any whose design
|
|
65
|
+
> this request replaces or amends with the section(s) affected - `Predecessor:
|
|
66
|
+
> <path>, <scope>` - or `Predecessor: none`. Judge by topic; shared file paths never
|
|
67
|
+
> decide. End with an
|
|
55
68
|
> "Open questions that matter for the spec"
|
|
56
69
|
> section. Compact handoff, not a dump.
|
|
57
70
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Marking superseded specs
|
|
2
|
+
|
|
3
|
+
When the new spec replaces a prior spec — fully or in part — (from the draft's scout recon or the request), mark the predecessor. No mechanical sweep: grep or path-overlap hits never decide supersession.
|
|
4
|
+
|
|
5
|
+
- `edit` the predecessor spec (in the project's spec directory, per [Project Routing](../SKILL.md#project-routing)) to insert, after its title line and a blank line, one banner line per successor:
|
|
6
|
+
|
|
7
|
+
```markdown
|
|
8
|
+
> **Superseded by:** [<repo-relative path to successor>](<href relative to THIS file>) - <scope>
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
- The visible label is the successor's repo-relative path; the href is computed relative to the predecessor's own directory (Markdown resolves links from the containing file). Same directory: `[doc/specs/B.md](./B.md)`.
|
|
12
|
+
- `<scope>` is the value after the ` - ` separator: `fully`, or the named superseded section(s), e.g. `"Settings resolution" section only`. The scope value itself carries no leading dash — the template above already supplies the separator.
|
|
13
|
+
- Banners are **append-only**: add below any existing supersession lines, formatted or free-form prose. One old spec may accumulate banners from multiple successors. No migration, no dedup.
|
|
14
|
+
- **No transitive rewrite**: if A points at B and B is later superseded by C, A keeps pointing at B; the reader hops.
|
|
15
|
+
- **Mark, never delete.** Delete/archive policy is consumer territory via overrides.
|
|
16
|
+
- **Coverage limits**: unmarked does NOT mean current (code drift, abandoned designs, and partial ships produce no successor spec); marked does NOT mean dead (partial supersession leaves live sections).
|
|
17
|
+
- Predecessor in a **different service's spec directory**: out of scope — record it in the new spec's Open Questions instead of editing outside the write grant.
|
|
18
|
+
- **Override contract**: the gauntlet overrides file (see `../SKILL.md#project-overrides`) may replace the banner *syntax*; placement, append-only, no-transitive-rewrite, and mark-never-delete stay fixed. A syntax override entry must itself state the scout-citation guidance for its format (the shipped `gatherer.md` guidance names only the default banner).
|