pi-gauntlet 5.8.1 → 5.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +6 -0
  3. package/bin/gauntlet-spec-index.mjs +223 -0
  4. package/bin/gauntlet-spec-index.test.mjs +173 -0
  5. package/extensions/lib/checkout.test.ts +59 -0
  6. package/extensions/lib/checkout.ts +57 -0
  7. package/extensions/lib/gauntlet-settings-loader.test.ts +32 -0
  8. package/extensions/lib/gauntlet-settings-loader.ts +11 -7
  9. package/extensions/lib/phase-tracker-helpers.test.ts +17 -0
  10. package/extensions/lib/phase-tracker-helpers.ts +16 -17
  11. package/extensions/lib/telemetry-paths.test.ts +10 -0
  12. package/extensions/lib/telemetry-paths.ts +9 -5
  13. package/extensions/phase-tracker.test.ts +75 -23
  14. package/extensions/phase-tracker.ts +20 -29
  15. package/extensions/telemetry.test.ts +51 -10
  16. package/extensions/telemetry.ts +57 -41
  17. package/extensions/verify-before-ship.test.ts +16 -0
  18. package/extensions/verify-before-ship.ts +4 -4
  19. package/package.json +7 -1
  20. package/skills/brainstorming/SKILL.md +11 -28
  21. package/skills/brainstorming/gatherer.md +19 -6
  22. package/skills/brainstorming/reference/superseding.md +18 -0
  23. package/skills/finishing-a-development-branch/SKILL.md +51 -88
  24. package/skills/gauntlet-resume/SKILL.md +1 -3
  25. package/skills/gauntlet-resume/reference/brief-contract.md +1 -1
  26. package/skills/roasting-the-spec/SKILL.md +1 -1
  27. package/skills/subagent-driven-development/SKILL.md +18 -15
  28. package/skills/using-git-worktrees/SKILL.md +46 -39
  29. package/skills/verification-before-completion/reference/conformance-check.md +6 -6
  30. package/skills/verification-before-completion/reference/settings-precedence.md +8 -6
  31. package/skills/writing-plans/SKILL.md +5 -3
  32. package/skills/writing-plans/reference/plan-contract.md +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## v5.9.1 - 2026-09-17
4
+
5
+ - Process in primary, work by path: `plan_check` roots at the plan's checkout, settings load from the session cwd's checkout toplevel, the branch-switch guard evaluates the command's `-C`/`cd` target, telemetry commits on the spec's checkout, and ship detection accepts `git -C <worktree> push` / `merge --squash`. Stage skills carry the worktree path as a value (dispatch `cwd`, `git -C`, subshell) - a new CI lint keeps them that way; `finishing-a-development-branch` takes `<worktree-path>` as a mandatory argument. Requires git >= 2.31. ([#37](https://github.com/jjuraszek/pi-gauntlet/issues/37))
6
+
7
+ ## v5.9.0 - 2026-09-17
8
+
9
+ - 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)
10
+
3
11
  ## v5.8.1 - 2026-09-17
4
12
 
5
13
  - 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
@@ -75,6 +75,8 @@ pi-gauntlet ships three kinds of pieces, layered on top of pi-cohort's dispatch:
75
75
 
76
76
  pi-gauntlet is **opinionated**: every non-trivial change is *meant* to ride this one pipeline, entered through `brainstorming`. Enforcement is opt-in by entry, not ambient: once brainstorming starts a flow, the phase-tracker extension mechanically blocks a phase from closing before its gate runs, and warns once if the main loop writes code during implement (subagents own implement-phase edits). A change made *without* entering the flow (a typo, a formatting run, a dependency bump - see "When to use / when NOT to use") is not gated; the discipline of routing real work through the pipeline is a convention the tooling supports, not a trap it springs on every edit.
77
77
 
78
+ **Worktree contract.** pi runs in the primary checkout; the work happens in `.worktrees/<branch>`. Skills never `cd` there - the worktree path is a value from the `using-git-worktrees` report (or the handoff brief on `gauntlet-resume`), carried as dispatch `cwd: "<path>"`, as `git -C <path>`, or as `(cd "<path>" && <cmd>)` for other cwd-bound commands; the runtime extensions derive the checkout from the artifact path they act on. Requires git >= 2.31.
79
+
78
80
  ## Key concepts
79
81
 
80
82
  | Term | Meaning |
@@ -116,6 +118,10 @@ pi install npm:pi-gauntlet
116
118
 
117
119
  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
120
 
121
+ ## Spec search index
122
+
123
+ `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`.
124
+
119
125
  For local development against a checkout instead of npm:
120
126
 
121
127
  ```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
+ });
@@ -0,0 +1,59 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, test } from "node:test";
3
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, realpathSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { checkoutOf, parseCheckout, gitSync, type GitResult } from "./checkout.ts";
7
+
8
+ const tempDirs: string[] = [];
9
+ after(() => { for (const d of tempDirs) rmSync(d, { recursive: true, force: true }); });
10
+ const tmp = () => { const d = realpathSync(mkdtempSync(join(tmpdir(), "checkout-test-"))); tempDirs.push(d); return d; };
11
+
12
+ const out = (lines: string[]): GitResult => ({ code: 0, stdout: lines.join("\n") + "\n" });
13
+
14
+ test("parseCheckout: primary checkout when git-dir equals common-dir", () => {
15
+ assert.deepEqual(parseCheckout(out(["/repo", "/repo/.git", "/repo/.git"])), { toplevel: "/repo", isPrimary: true });
16
+ });
17
+
18
+ test("parseCheckout: linked worktree when the dirs differ", () => {
19
+ assert.deepEqual(
20
+ parseCheckout(out(["/repo/.worktrees/x", "/repo/.git/worktrees/x", "/repo/.git"])),
21
+ { toplevel: "/repo/.worktrees/x", isPrimary: false },
22
+ );
23
+ });
24
+
25
+ test("parseCheckout: nonzero code or fewer than three lines -> undefined", () => {
26
+ assert.equal(parseCheckout({ code: 128, stdout: "" }), undefined);
27
+ assert.equal(parseCheckout(out(["/repo", "/repo/.git"])), undefined);
28
+ });
29
+
30
+ test("checkoutOf: a file path runs git in its dirname", async () => {
31
+ const dir = tmp();
32
+ writeFileSync(join(dir, "spec.md"), "# s\n");
33
+ const cwds: string[] = [];
34
+ const git = (_args: string[], cwd: string): GitResult => { cwds.push(cwd); return out([dir, dir + "/.git", dir + "/.git"]); };
35
+ assert.deepEqual(await checkoutOf(join(dir, "spec.md"), git), { toplevel: dir, isPrimary: true });
36
+ assert.deepEqual(cwds, [dir]);
37
+ });
38
+
39
+ test("checkoutOf: a nonexistent leaf walks up to the nearest existing ancestor", async () => {
40
+ const dir = tmp();
41
+ mkdirSync(join(dir, "doc"));
42
+ const cwds: string[] = [];
43
+ const git = async (_args: string[], cwd: string): Promise<GitResult> => { cwds.push(cwd); return out([dir, dir + "/.git", dir + "/.git"]); };
44
+ await checkoutOf(join(dir, "doc", "specs", "new.md"), git);
45
+ assert.deepEqual(cwds, [join(dir, "doc")]);
46
+ });
47
+
48
+ test("checkoutOf: an existing directory is used as-is", async () => {
49
+ const dir = tmp();
50
+ const cwds: string[] = [];
51
+ await checkoutOf(dir, (_a, cwd) => { cwds.push(cwd); return out([dir, dir + "/.git", dir + "/.git"]); });
52
+ assert.deepEqual(cwds, [dir]);
53
+ });
54
+
55
+ test("gitSync: never throws; nonzero code outside a repo", () => {
56
+ const r = gitSync(["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir"], tmp());
57
+ assert.notEqual(r.code, 0);
58
+ assert.equal(typeof r.stdout, "string");
59
+ });
@@ -0,0 +1,57 @@
1
+ // One checkout resolver for every extension that needs "which git checkout owns this
2
+ // path" (#37): plan_check, the settings loader, Guard 2, telemetry. `--path-format=absolute`
3
+ // (git >= 2.31) makes git-dir and common-dir comparable as strings from any subdirectory.
4
+ import { execFileSync } from "node:child_process";
5
+ import { existsSync, statSync } from "node:fs";
6
+ import { dirname } from "node:path";
7
+
8
+ export type GitResult = { code: number; stdout: string };
9
+ export type Checkout = { toplevel: string; isPrimary: boolean };
10
+
11
+ export const CHECKOUT_ARGS = ["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir"];
12
+
13
+ // isPrimary = gitDir === commonDir (both absolute thanks to --path-format).
14
+ export function parseCheckout(r: GitResult): Checkout | undefined {
15
+ if (r.code !== 0) return undefined;
16
+ const lines = r.stdout.trim().split("\n").map((l) => l.trim());
17
+ if (lines.length < 3 || !lines[0]) return undefined;
18
+ return { toplevel: lines[0], isPrimary: lines[1] === lines[2] };
19
+ }
20
+
21
+ const isDir = (p: string): boolean => {
22
+ try {
23
+ return statSync(p).isDirectory();
24
+ } catch {
25
+ return false;
26
+ }
27
+ };
28
+
29
+ // Nearest existing directory at or above absPath: the path itself when it is a directory,
30
+ // else its dirname, walking up. Covers the first write into a not-yet-existing doc/specs/.
31
+ export function nearestExistingDir(absPath: string): string {
32
+ let p = absPath;
33
+ while (!(existsSync(p) && isDir(p))) {
34
+ const parent = dirname(p);
35
+ if (parent === p) return p;
36
+ p = parent;
37
+ }
38
+ return p;
39
+ }
40
+
41
+ export async function checkoutOf(
42
+ absPath: string,
43
+ git: (args: string[], cwd: string) => GitResult | Promise<GitResult>,
44
+ ): Promise<Checkout | undefined> {
45
+ return parseCheckout(await git(CHECKOUT_ARGS, nearestExistingDir(absPath)));
46
+ }
47
+
48
+ // Never throws: any failure (not a repo, missing dir, old git) is a nonzero code.
49
+ export const gitSync = (args: string[], cwd: string): GitResult => {
50
+ try {
51
+ const stdout = execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
52
+ return { code: 0, stdout };
53
+ } catch (e) {
54
+ const status = (e as { status?: unknown }).status;
55
+ return { code: typeof status === "number" ? status : 1, stdout: "" };
56
+ }
57
+ };
@@ -0,0 +1,32 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, test } from "node:test";
3
+ import { execFileSync } from "node:child_process";
4
+ import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { loadGauntletSettings } from "./gauntlet-settings-loader.ts";
8
+
9
+ const tempDirs: string[] = [];
10
+ after(() => { for (const d of tempDirs) rmSync(d, { recursive: true, force: true }); });
11
+ const tmp = () => { const d = realpathSync(mkdtempSync(join(tmpdir(), "settings-loader-test-"))); tempDirs.push(d); return d; };
12
+ const gitRepo = () => { const d = tmp(); execFileSync("git", ["init", "-q", d]); return d; };
13
+
14
+ test("settings resolve from the checkout root when cwd is a subdirectory (AC 2)", () => {
15
+ const repo = gitRepo();
16
+ mkdirSync(join(repo, ".pi"));
17
+ mkdirSync(join(repo, "doc"));
18
+ writeFileSync(join(repo, ".pi", "settings.json"), JSON.stringify({ piGauntlet: { specCouncil: { members: ["p/m"] } } }));
19
+ const loaded = loadGauntletSettings(join(repo, "doc"), "/tmp/pi-gauntlet-test-agent");
20
+ assert.equal(loaded.root, repo);
21
+ assert.deepEqual(loaded.gauntlet.specCouncil, { members: ["p/m"] });
22
+ assert.deepEqual(loaded.errors, []);
23
+ });
24
+
25
+ test("outside any checkout the loader falls back to cwd", () => {
26
+ const dir = tmp();
27
+ mkdirSync(join(dir, ".pi"));
28
+ writeFileSync(join(dir, ".pi", "settings.json"), JSON.stringify({ piGauntlet: { flowGuards: { enforce: false } } }));
29
+ const loaded = loadGauntletSettings(dir, "/tmp/pi-gauntlet-test-agent");
30
+ assert.equal(loaded.root, dir);
31
+ assert.deepEqual(loaded.gauntlet.flowGuards, { enforce: false });
32
+ });
@@ -2,22 +2,26 @@
2
2
  // ci.mjs unit tests. Only pi-loaded extensions import this file.
3
3
  import { SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
  import { mergeGauntlet, type PiGauntlet } from "./gauntlet-settings.ts";
5
+ import { gitSync, parseCheckout } from "./checkout.ts";
5
6
 
6
7
  export interface LoadedGauntlet {
7
8
  gauntlet: PiGauntlet;
8
9
  errors: string[];
10
+ // Checkout toplevel the repo layer was read from (cwd itself outside any checkout).
11
+ root: string;
9
12
  }
10
13
 
11
- // Reads the preset (agentDir/settings.json) and repo (cwd/.pi/settings.json)
12
- // layers via pi's own SettingsManager and returns the whole-object second-level
13
- // merge (repo over preset). SettingsManager never throws on a bad file - it
14
- // substitutes {} for that layer and records the error, surfaced here via errors[]
15
- // so callers can report a degraded read instead of failing silent.
14
+ // Reads the preset (agentDir/settings.json) and repo (<root>/.pi/settings.json) layers
15
+ // via pi's own SettingsManager, where <root> is the git toplevel of cwd - pi launched in a
16
+ // subdirectory or a linked worktree still finds that checkout's file (#37). SettingsManager
17
+ // never throws on a bad file - it substitutes {} for that layer and records the error,
18
+ // surfaced here via errors[] so callers can report a degraded read instead of failing silent.
16
19
  export function loadGauntletSettings(cwd: string, agentDir: string = getAgentDir()): LoadedGauntlet {
17
- const sm = SettingsManager.create(cwd, agentDir);
20
+ const root = parseCheckout(gitSync(["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir"], cwd))?.toplevel ?? cwd;
21
+ const sm = SettingsManager.create(root, agentDir);
18
22
  const preset = sm.getGlobalSettings() as { piGauntlet?: Record<string, unknown> };
19
23
  const repo = sm.getProjectSettings() as { piGauntlet?: Record<string, unknown> };
20
24
  const gauntlet = mergeGauntlet(preset?.piGauntlet, repo?.piGauntlet);
21
25
  const errors = sm.drainErrors().map((e) => `${e.scope}: ${e.error.message}`);
22
- return { gauntlet, errors };
26
+ return { gauntlet, errors, root };
23
27
  }
@@ -10,6 +10,7 @@ import {
10
10
  implementExemptDirs,
11
11
  nextGauntletEntered,
12
12
  phaseLabel,
13
+ parseGitCommand,
13
14
  parseGitCommit,
14
15
  resolveRepoDir,
15
16
  findMarkerFile,
@@ -78,6 +79,22 @@ test("phaseLabel: with and without substep", () => {
78
79
  assert.equal(phaseLabel("brainstorm", undefined), "brainstorm");
79
80
  });
80
81
 
82
+ test("parseGitCommand: branch subcommands with and without global flags", () => {
83
+ const SWITCH = /switch(?=\s|$)/;
84
+ const CHECKOUT_B = /checkout\s+-[bB](?=\s|$)/;
85
+ assert.deepEqual(parseGitCommand("git switch -c y", SWITCH), { cPath: undefined, cdPath: undefined });
86
+ assert.deepEqual(parseGitCommand("git -C .worktrees/x switch -c y", SWITCH), { cPath: ".worktrees/x", cdPath: undefined });
87
+ assert.deepEqual(parseGitCommand("git --no-pager -C p checkout -b y", CHECKOUT_B), { cPath: "p", cdPath: undefined });
88
+ assert.deepEqual(parseGitCommand("cd .worktrees/x && git checkout -b y", CHECKOUT_B), { cPath: undefined, cdPath: ".worktrees/x" });
89
+ assert.equal(parseGitCommand("git checkout file.ts", CHECKOUT_B), undefined);
90
+ assert.equal(parseGitCommand("git switcheroo", SWITCH), undefined);
91
+ assert.equal(parseGitCommand("echo git switch", SWITCH), undefined);
92
+ });
93
+
94
+ test("parseGitCommit is parseGitCommand with the commit subcommand", () => {
95
+ assert.deepEqual(parseGitCommit("git -C /wt -c user.email=x commit"), parseGitCommand("git -C /wt -c user.email=x commit", /commit(?=\s|$)/));
96
+ });
97
+
81
98
  test("parseGitCommit: plain, -am, chained after &&", () => {
82
99
  assert.ok(parseGitCommit('git commit -m "x"'));
83
100
  assert.ok(parseGitCommit("git commit -am 'x'"));