pi-gauntlet 5.8.0 → 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 CHANGED
@@ -1,5 +1,14 @@
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
+
7
+ ## v5.8.1 - 2026-09-17
8
+
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))
10
+ - `gauntlet-resume`: same-repository worktrees resume from a pi session launched in the primary checkout; cross-repository targets still stop, and reconstruction addresses the resolved worktree by path.
11
+
3
12
  ## v5.8.0 - 2026-09-17
4
13
 
5
14
  - New extension `telemetry`: records one committed YAML record per gauntlet run at `.pi/gauntlet/telemetry/<spec path>.yaml` (phase timing, model/thinking snapshots, per-persona dispatches and tokens, reviewer findings, gate and fix-round counters, plan totals, last test result, diff buckets and modified files at ship), keyed by spec path and continued across sessions; pathspec-commits the record at checkpoints; reconciles a failed ship command; freezes after squash/PR/discard. During brainstorm a `write` into a spec whose record is shipped is blocked (`edit` passes). Settings `piGauntlet.telemetry.{enabled,dir,buckets}`. (#33)
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.8.0",
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](#marking-superseded-specs)) — the `edit` prohibition at spec-writing binds the spec being written, not a predecessor file
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](#marking-superseded-specs), at the exact-order position defined in [Spec Self-Review](#spec-self-review-before-user-review-gate)
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](#marking-superseded-specs)). This position is fixed:
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](#marking-superseded-specs) 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):
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. Predecessor
50
- > check: list the project's spec directory, read titles and `**Goal:**` lines,
51
- > open at most five whose topic matches this request, and name any whose design
52
- > this request replaces or amends with the section(s) affected -
53
- > `Predecessor: <path>, <scope>` - or `Predecessor: none`. Judge by topic; shared
54
- > file paths never decide. End with an
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).
@@ -37,6 +37,7 @@ succeeds (it creates both). Every destructive command below is gated on its flag
37
37
  DEFAULT=$(git symbolic-ref --short refs/remotes/origin/HEAD) && DEFAULT=${DEFAULT#origin/}
38
38
  BASE_SHA=$(git rev-parse "$DEFAULT")
39
39
  ORIG_BRANCH=$(git branch --show-current)
40
+ ORIG_HEAD=$(git rev-parse HEAD)
40
41
  git status --porcelain > "$TMPDIR/hotfix-<slug>.baseline"
41
42
  ```
42
43
 
@@ -49,14 +50,42 @@ succeeds (it creates both). Every destructive command below is gated on its flag
49
50
  reuse, never force); `git worktree add` itself failing (creation failure).
50
51
 
51
52
  ```bash
52
- git worktree add ".worktrees/hotfix/<slug>" -b "hotfix/<slug>" "$DEFAULT"
53
+ git worktree add "$PRIMARY_ROOT/.worktrees/hotfix/<slug>" -b "hotfix/<slug>" "$DEFAULT"
54
+ WORKTREE=$(git -C "$PRIMARY_ROOT/.worktrees/hotfix/<slug>" rev-parse --show-toplevel)
53
55
  ```
54
56
 
55
- Success sets both flags. Run the project's dependency install inside the
56
- worktree. Every dispatch below: `cwd` = the worktree path, the record path in the
57
- task text, `output:` (when used) an absolute `$TMPDIR` path.
58
- 4. **Implement.** One `implementer`, fork context:
59
-
57
+ Success sets both flags; `WORKTREE` is set only after `git worktree add`
58
+ succeeds - empty `WORKTREE` -> abort (pre-land, both flags set so cleanup runs)
59
+ - and is the literal the child is compared against - both sides come from `git
60
+ rev-parse --show-toplevel`, so a symlinked `.worktrees/` cannot make them
61
+ disagree. Run the project's dependency install inside the worktree. Every
62
+ dispatch below: `cwd` = the worktree path, the record path in the task text,
63
+ `output:` (when used) an absolute `$TMPDIR` path. Every implementer round - step
64
+ 4, the step 5 retry, the step 6 gap fix, the step 7 FIX_FIRST round - is
65
+ `context: "fresh"` and reuses the full step 4 task text (binding block, record
66
+ path and inlined evidence pack, `SCOPED_TEST_COMMANDS`, commit-before-reporting,
67
+ SDD status line) with the round's failing output appended verbatim: the red test
68
+ output (step 5), the conformance gaps (step 6), the review findings (step 7).
69
+ Each round is followed by `binding_check` (see Abort, **Binding**) before
70
+ anything else. Under a fresh context a retry child sees only its task text; a
71
+ fix child not told to commit leaves its fix where step 7's `<base-sha>..HEAD`
72
+ review and step 8's `merge --squash` never see it.
73
+ 4. **Implement.** One `implementer`, `context: "fresh"` - passed explicitly,
74
+ because the persona's frontmatter defaults to fork, and a forked parent
75
+ transcript carries the parent's `cd <primary>` commands and primes the child to
76
+ leave the worktree; the record path and inlined evidence pack already make the
77
+ task self-contained. `<WORKTREE>` and `<PRIMARY_ROOT>` below are the literal
78
+ step 3 values, never a `$VAR` for the child to resolve:
79
+
80
+ > Worktree binding: your checkout is <WORKTREE>. Your first command, before any
81
+ > other, is
82
+ > actual=$(git rev-parse --show-toplevel); [ "$actual" = "<WORKTREE>" ] || echo "MISMATCH: $actual != <WORKTREE>"
83
+ > On MISMATCH run nothing else; your final message opens with the line
84
+ > `BLOCKED: toplevel <actual> != <WORKTREE>` and ends with `STATUS: BLOCKED`.
85
+ > Every mutating git command (add, commit, checkout, reset, stash) runs as
86
+ > `git -C <WORKTREE> ...`. Never `cd` out of <WORKTREE>; never run a command
87
+ > against <PRIMARY_ROOT>.
88
+ >
60
89
  > Read `$TMPDIR/hotfix-<slug>.md`; the evidence pack is also inlined here:
61
90
  > <evidence pack>. The evidence pack replaces plan and spec; do
62
91
  > not report BLOCKED for a missing plan. TDD: write the regression test, run it,
@@ -64,6 +93,14 @@ succeeds (it creates both). Every destructive command below is gated on its flag
64
93
  > re-run is the regression evidence). SCOPED_TEST_COMMANDS: <commands>. Commit on
65
94
  > `hotfix/<slug>` before reporting. End with the SDD status line verbatim.
66
95
 
96
+ The `BLOCKED:` opening line is load-bearing: pi-cohort turns a final message
97
+ whose first non-empty line starts with `BLOCKED:` into a dispatch error that
98
+ carries the full output, ahead of the implementer's completion guard that would
99
+ otherwise replace an edit-free report with a generic no-edits error.
100
+
101
+ On return, `binding_check` runs before anything else - before the status line
102
+ is read and before a dispatch error is handled; a failed check -> `binding_abort`,
103
+ the status branch is skipped. A dispatch error is routed as `BLOCKED`. Then:
67
104
  `DONE` -> step 5. `DONE_WITH_CONCERNS` -> step 5 unless a concern names a safety
68
105
  invariant -> abort. `NEEDS_CONTEXT` or `BLOCKED` -> abort. A regression command
69
106
  named in the report joins `SCOPED_TEST_COMMANDS` only if it uses the resolved
@@ -126,6 +163,8 @@ succeeds (it creates both). Every destructive command below is gated on its flag
126
163
  without the hotfix entry; `git branch --list hotfix/<slug>` empty; porcelain
127
164
  delta vs baseline none. PR exit and land-stage aborts: both present, plus
128
165
  `git worktree remove --force .worktrees/hotfix/<slug> && git branch -D hotfix/<slug>`.
166
+ Binding aborts: worktree and `hotfix/<slug>` both present; no removal command
167
+ is run.
129
168
  10. **Response.** chase-bug step 5 rules apply unchanged: addressable -> draft
130
169
  citing `fixed in <SHA>` or the PR link -> `send it`; unaddressable -> summary.
131
170
  Abort never reaches this step - it returns to the menu.
@@ -151,7 +190,7 @@ Judgment predicates (`[recommended]` only; Moderate review items):
151
190
 
152
191
  ## Abort
153
192
 
154
- Both classes end with the baseline re-check, then chase-bug step 4 re-renders.
193
+ All three classes end with the baseline re-check, then chase-bug step 4 re-renders.
155
194
 
156
195
  **Pre-land** (steps 2-7): unresolvable commands or task text; setup precondition
157
196
  unmet; `NEEDS_CONTEXT`/`BLOCKED` or a concern naming an invariant; red after retry;
@@ -175,21 +214,73 @@ exactly that); on base moved `<default>` is never touched and both SHAs are
175
214
  reported. Restore `<orig-branch>`. Preserve worktree and branch (reviewed work).
176
215
  Report path, tip SHA, closing line. The menu re-renders without the hotfix row.
177
216
 
178
- **Baseline re-check**: `git status --porcelain --untracked-files=no` matches the
179
- triage baseline; full porcelain delta reported; `<default>` == `<base-sha>` asserted
180
- only when this run touched `<default>`. Pre-existing dirt is never touched.
217
+ **Binding** (after any implementer return, steps 4-7): the parent runs
218
+ `binding_check` from `PRIMARY_ROOT` before reading the child's status line or
219
+ handling a dispatch error - a `BLOCKED` or errored child may have mutated the
220
+ primary before stopping, and the pre-land cleanup would delete the evidence.
221
+
222
+ ```bash
223
+ binding_check() {
224
+ local head branch dirt default
225
+ default=$(git -C "$PRIMARY_ROOT" rev-parse "$DEFAULT") || binding_abort read "rev-parse $DEFAULT failed"
226
+ head=$(git -C "$PRIMARY_ROOT" rev-parse HEAD) || binding_abort read "rev-parse HEAD failed"
227
+ branch=$(git -C "$PRIMARY_ROOT" branch --show-current) || binding_abort read "branch --show-current failed"
228
+ dirt=$(git -C "$PRIMARY_ROOT" status --porcelain --untracked-files=no) || binding_abort read "status failed"
229
+ local moved=""
230
+ [ "$default" = "$BASE_SHA" ] || moved="$moved <default>:$BASE_SHA..$default"
231
+ [ "$head" = "$ORIG_HEAD" ] || moved="$moved HEAD:$ORIG_HEAD..$head"
232
+ [ "$branch" = "$ORIG_BRANCH" ] || moved="$moved branch:$ORIG_BRANCH->$branch"
233
+ [ -z "$moved" ] && [ -z "$dirt" ] && return 0
234
+ binding_abort drift "$moved" "$dirt"
235
+ }
236
+ ```
237
+
238
+ Every read must succeed before any comparison: a failed read with empty stdout
239
+ would otherwise pass `-z "$dirt"` or be misread as HEAD drift. All observations
240
+ are collected first, so a stray commit plus tracked dirt is one abort. Three refs,
241
+ not one: a drifted child commits on whatever the primary has checked out, which is
242
+ `<orig-branch>`, and `<orig-branch>` may differ from `<default>`. The tracked-only
243
+ target is the empty string - the step 3 precondition already guarantees it.
244
+
245
+ `binding_abort` never touches the primary checkout: no `git reset`, no
246
+ `git checkout`, no stash - this run moved nothing, so the land-stage reset rule
247
+ already forbids it. The worktree and `hotfix/<slug>` are **preserved** as
248
+ evidence of what the child did where (a deliberate exception to pre-land
249
+ cleanup). Report, by kind: `read` - the failing command and its stderr, the
250
+ worktree path and `hotfix/<slug>` tip SHA, no drift claim; `drift` - for each
251
+ moved ref `<ref> moved from <old> to <new>` plus
252
+ `git --no-pager log --oneline <old>..<new>`
253
+ (branch switch: `checked-out branch changed from <orig-branch> to <branch>`),
254
+ for tracked dirt the path fields only (no `XY` codes), the full
255
+ `git status --porcelain` delta against the step 3 baseline file
256
+ (`$TMPDIR/hotfix-<slug>.baseline`, report-only as today), the worktree path and
257
+ `hotfix/<slug>` tip SHA. The non-executed repair line
258
+ `git checkout <ref> && git reset --hard <old-sha>` is printed only when tracked
259
+ dirt is empty -
260
+ with dirt present the parent cannot tell whose edits a reset would destroy. The
261
+ menu re-renders without the hotfix row.
262
+
263
+ **Baseline re-check**: `git status --porcelain --untracked-files=no` is empty (the
264
+ step 3 precondition; the tracked-only baseline is empty by construction); full
265
+ porcelain delta reported; `<default>` == `<base-sha>` asserted only when this run
266
+ touched `<default>`. Pre-existing dirt is never touched.
181
267
 
182
268
  ## Harness fallback
183
269
 
184
270
  No `subagent` tool and no personas (the Claude Code marketplace ships `agents: []`):
185
271
  run the duties inline, same order. Write the failing regression test, confirm red,
186
- minimal fix, confirm green, commit on `hotfix/<slug>`. Self-review the diff against
187
- the record, invariants 1-3, predicates 4-6. Run `SCOPED_TEST_COMMANDS`. Apply the
188
- abort classes as written. Finish and report per steps 8-9.
272
+ minimal fix, confirm green, commit on `hotfix/<slug>`. With no child the binding
273
+ block is moot, but `binding_check` still runs after the initial inline commit and
274
+ after each inline retry, conformance fix, or review fix, before tests continue.
275
+ Self-review the diff against the record, invariants 1-3, predicates 4-6. Run
276
+ `SCOPED_TEST_COMMANDS`. Apply the abort classes as written. Finish and report per
277
+ steps 8-9.
189
278
 
190
279
  ## Red Flags - STOP
191
280
 
192
281
  - Any mutation before every step 3 precondition passes
282
+ - Reading an implementer's status line before `binding_check`
283
+ - Resetting or checking out any primary ref from `binding_abort`
193
284
  - Reusing or force-replacing an existing `hotfix/<slug>` branch or path
194
285
  - `git reset --hard` after *proven*, on `<orig-branch>`, or inside the worktree
195
286
  - Re-running tests in the primary checkout
@@ -59,14 +59,18 @@ In order. All before any tracker mutation; entry check 1 is read-only.
59
59
  `not a git repo` and no override was given - except a `worktree: no` brief **without**
60
60
  process state, which is the brainstorming route in Dispatch, not a stop. Other
61
61
  `unavailable` fields inside `## Repo state` are legal.
62
- 3. **Session cwd binding.** `phase_tracker`, `plan_check`, and the flow guards resolve
63
- every path against the extension's session cwd; a child-shell `cd` cannot relocate it.
64
- If `realpath $(git rev-parse --show-toplevel)` in the session cwd differs from the
65
- resolved worktree, stop: "restart pi in <worktree> and re-run". Every restoration
66
- therefore runs with the session rooted in the resolved worktree.
62
+ 3. **Same-repository binding.** Settings and flow guards come from the repository in
63
+ the extension's session cwd. Compare
64
+ `realpath "$(git rev-parse --path-format=absolute --git-common-dir)"` in the session
65
+ cwd with
66
+ `realpath "$(git -C <worktree> rev-parse --path-format=absolute --git-common-dir)"`.
67
+ If they differ, stop, name both paths, and say the resolved worktree belongs to a
68
+ different repository; restart pi in that repository's primary checkout and re-run.
69
+ Same-repository worktrees proceed by path: use `git -C <worktree>` for
70
+ worktree Git commands and absolute artifact paths for `Read` and `plan_check`.
67
71
  4. **Drift notice.** Compare the brief's `HEAD` and `dirty` fields in `## Repo state`
68
- with the live worktree (`git rev-parse HEAD`, `git status --porcelain`). Announce
69
- differences. Informational, never a stop.
72
+ with the live worktree (`git -C <worktree> rev-parse HEAD`, `git -C <worktree>
73
+ status --porcelain`). Announce differences. Informational, never a stop.
70
74
  5. **Skills loaded.** For each name in `## Skills loaded` (none for
71
75
  `## Skills loaded: none`): match against the frontmatter `name` of every
72
76
  `skills/*/SKILL.md` in this package; `Read` each match's complete file into the
@@ -97,7 +97,8 @@ Per-stage call table, keyed by the brief's active phase (`→`). `R` is the reas
97
97
  (`reconstruction.md`, "Candidates", including its `flowGuards.specDirs` resolution): the
98
98
  single spec/plan pair added after base in the worktree, paired by identical basename
99
99
  (`<specDir>/<name>.md` <-> `<sibling plans dir>/<name>.md`, the writing-plans contract);
100
- zero pairs -> stop; more than one -> human picks.
100
+ resolve the selected plan to an absolute path under the worktree before `plan_check`.
101
+ Zero pairs -> stop; more than one -> human picks.
101
102
 
102
103
  "Exact" restoration binds: the active phase identity and substep, and the plan task list
103
104
  (names, order, statuses) verbatim. Prior phases show `⊘ (resume: ...)` regardless of the
@@ -13,12 +13,12 @@ available" - and never invent Intent or Decisions.
13
13
  ## Base
14
14
 
15
15
  ```bash
16
- git merge-base HEAD origin/HEAD 2>/dev/null \
17
- || git merge-base HEAD main 2>/dev/null \
18
- || git merge-base HEAD master 2>/dev/null
16
+ git -C <worktree> merge-base HEAD origin/HEAD 2>/dev/null \
17
+ || git -C <worktree> merge-base HEAD main 2>/dev/null \
18
+ || git -C <worktree> merge-base HEAD master 2>/dev/null
19
19
  ```
20
20
 
21
- Base = `git merge-base HEAD origin/HEAD`, else `main`/`master`. Empty -> ask the human
21
+ Base = `git -C <worktree> merge-base HEAD origin/HEAD`, else `main`/`master`. Empty -> ask the human
22
22
  for a base ref before reading any artifact.
23
23
 
24
24
  ## Candidates
@@ -29,15 +29,18 @@ else the active pi profile's `settings.json`, else the default `["doc/specs"]` (
29
29
  space-separated - never the literal defaults when a setting is present.
30
30
 
31
31
  ```bash
32
- git diff --diff-filter=A --name-only <base>..HEAD -- <dirs>
33
- git ls-files --others --exclude-standard -- <dirs>
32
+ git -C <worktree> diff --diff-filter=A --name-only <base>..HEAD -- <dirs>
33
+ git -C <worktree> ls-files --others --exclude-standard -- <dirs>
34
34
  ```
35
35
 
36
36
  Candidates are files under those directories added after base, plus untracked files
37
- there. A spec and a plan pair by identical basename (`<specDir>/<name>.md` <->
38
- `<sibling plans dir>/<name>.md`). The plan commit is the first post-base commit that added the
39
- plan file: `git log --diff-filter=A --format=%H --reverse <base>..HEAD -- <plan>`, first
40
- line. An uncommitted plan has no plan commit; treat every task as `pending`.
37
+ there. Paths returned by these commands are relative to `<worktree>`; resolve them to
38
+ absolute paths under `<worktree>` before reading artifacts, showing paths in prompts,
39
+ or calling `plan_check`. A spec and a plan pair by identical basename
40
+ (`<specDir>/<name>.md` <-> `<sibling plans dir>/<name>.md`). The plan commit is the first
41
+ post-base commit that added the plan file: `git -C <worktree> log --diff-filter=A
42
+ --format=%H --reverse <base>..HEAD -- <plan>`, first line. An uncommitted plan has no
43
+ plan commit; treat every task as `pending`.
41
44
 
42
45
  | Candidates | Route |
43
46
  |---|---|
@@ -63,11 +66,12 @@ Show, and ask the human to confirm or edit both in one reply:
63
66
 
64
67
  1. Per task, in plan order: the commits after the plan commit that touch any path in
65
68
  the task's declared `Files:` block. Strip a trailing `:digits[-digits]` range from
66
- each `Modify:` path before matching `git log -- <path>`:
67
- `git log --format=%h --oneline <plan-commit>..HEAD -- <path>`. Uncommitted plan (no
69
+ each `Modify:` path before matching `git -C <worktree> log -- <path>`:
70
+ `git -C <worktree> log --format=%h --oneline <plan-commit>..HEAD -- <path>`.
71
+ Uncommitted plan (no
68
72
  plan commit): skip this query entirely - there is no range to search - and show
69
73
  "plan uncommitted; no task evidence" in its place; every task is proposed `pending`.
70
- 2. Uncommitted files: `git status --porcelain`.
74
+ 2. Uncommitted files: `git -C <worktree> status --porcelain`.
71
75
  3. Proposed task statuses: `complete` iff at least one matching commit, else `pending`.
72
76
  4. Proposed stage: `implement` if any task is `pending`, else `verify`.
73
77
 
@@ -83,7 +87,7 @@ edits override proposals; never rewrite confirmed state silently.
83
87
  After confirmation:
84
88
 
85
89
  1. `start brainstorm`; `skip brainstorm resume: <spec path>`; `start plan`.
86
- 2. `plan_check` with `planPath` = the plan. FAIL -> print the findings, stop with plan
90
+ 2. `plan_check` with `planPath` = the plan's absolute path. FAIL -> print the findings, stop with plan
87
91
  in_progress, no `init`.
88
92
  3. PASS -> `skip plan` with the same `resume:` reason; for stage verify also
89
93
  `skip implement`; `start <stage>`.