pi-gauntlet 5.18.5 → 5.18.7

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## v5.18.7 - 2026-09-24
4
+
5
+ ### Added
6
+
7
+ - The telemetry recorder derives `modified_files` and diff buckets in plain jj workspaces through `jj` (remote trunk or local `main`/`master` as the mainline), with jj-named warnings and an intact record on any jj failure. (#51)
8
+
9
+ ## v5.18.6 - 2026-09-24
10
+
11
+ ### Changed
12
+
13
+ - `gauntlet-spec-index` prints the recorded modified-file paths that still exist in the repository in its `files` column, `;`-separated, with `missing` marking a telemetry record that has no list; the brainstorming scout renders them as a `Predecessor anchors` section for named predecessors. (#52)
14
+
3
15
  ## v5.18.5 - 2026-09-23
4
16
 
5
17
  - The TDD RED step directs authors to reuse existing setup helpers and consolidate near-identical cases into parametrized or table-driven examples. (#49)
package/README.md CHANGED
@@ -134,7 +134,7 @@ Pin an exact release with `npm:pi-gauntlet@X.Y.Z`. See [doc/install-internals.md
134
134
 
135
135
  ## Spec search index
136
136
 
137
- `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`.
137
+ `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 `files` column is a `;`-separated list of repo-relative paths the spec's shipped change modified and that still exist in the repository, the literal `missing` when the spec's telemetry record has no `derived.modified_files` list, or blank when there is no readable record or no recorded path remains. The 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`.
138
138
 
139
139
  ## Performance digest
140
140
 
@@ -176,7 +176,7 @@ const toMatch = (query) =>
176
176
  query.split(/\s+/).filter((t) => t.length >= 2).map((t) => `"${t.replaceAll('"', '""')}"`).join(" OR ");
177
177
 
178
178
  function telemetry(root, specPath) {
179
- const blank = { status: null, shipped_at: null, files: null };
179
+ const blank = { status: null, shipped_at: null, files: "" };
180
180
  const p = join(root, ".pi/gauntlet/telemetry", specPath.replace(/\.md$/, ".yaml"));
181
181
  if (!existsSync(p)) return blank;
182
182
  let rec;
@@ -186,12 +186,17 @@ function telemetry(root, specPath) {
186
186
  process.stderr.write(`gauntlet-spec-index: warning: unreadable telemetry ${p}\n`);
187
187
  return blank;
188
188
  }
189
- if (!rec || typeof rec !== "object") return blank;
189
+ if (!rec || typeof rec !== "object" || Array.isArray(rec)) return blank;
190
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 };
191
+ const files = Array.isArray(mf)
192
+ ? mf.filter((f) => typeof f === "string" && existsSync(join(root, f))).join(";")
193
+ : "missing";
194
+ return { status: rec.status ?? null, shipped_at: rec.shipped_at ?? null, files };
192
195
  }
193
196
 
194
197
  const cell = (v) => (v === null || v === undefined ? "" : String(v).replace(/\s+/g, " ").trim());
198
+ // Paths keep their spaces; only column and row delimiters are neutralised.
199
+ const filesCell = (v) => v.replace(/[\t\r\n]+/g, " ");
195
200
 
196
201
  async function main() {
197
202
  if (!nodeOk()) die(`gauntlet-spec-index needs Node >=24.15.0 (found ${process.versions.node})`);
@@ -211,7 +216,7 @@ async function main() {
211
216
  const out = [HEADER.join("\t")];
212
217
  for (const r of rows) {
213
218
  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"));
219
+ out.push([...[r.score.toFixed(3), r.path, r.service, r.title, t.status, t.shipped_at].map(cell), filesCell(t.files), cell(r.snippet)].join("\t"));
215
220
  }
216
221
  process.stdout.write(out.join("\n") + "\n");
217
222
  db.close();
@@ -112,13 +112,17 @@ test("6: telemetry join is output-only and tolerant", (t) => {
112
112
  t.after(() => rmSync(root, { recursive: true, force: true }));
113
113
  run(root, ["--query", "zephyr"]);
114
114
  const filesBefore = withDb(root, (database) => JSON.stringify(database.prepare("SELECT * FROM files ORDER BY path").all()));
115
+ write(root, "x", "x\n");
116
+ write(root, "y", "y\n");
115
117
  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
118
  let r = run(root, ["--query", "zephyr"]);
117
- assert.deepEqual(r.rows[0].slice(4, 7), ["shipped", "2026-09-17T10:00:00Z", "2"]);
119
+ assert.deepEqual(r.rows[0].slice(4, 7), ["shipped", "2026-09-17T10:00:00Z", "x;y"]);
120
+ assert.equal(r.rows[0].length, 8);
121
+ assert.equal(r.rows[1][6], "");
118
122
  assert.equal(withDb(root, (database) => JSON.stringify(database.prepare("SELECT * FROM files ORDER BY path").all())), filesBefore);
119
123
  write(root, ".pi/gauntlet/telemetry/doc/specs/a.yaml", "status: in_progress\nshipped_at: 2026-09-18T10:00:00Z\n");
120
124
  r = run(root, ["--query", "zephyr"]);
121
- assert.deepEqual(r.rows[0].slice(4, 7), ["in_progress", "2026-09-18T10:00:00Z", ""]);
125
+ assert.deepEqual(r.rows[0].slice(4, 7), ["in_progress", "2026-09-18T10:00:00Z", "missing"]);
122
126
  write(root, ".pi/gauntlet/telemetry/doc/specs/a.yaml", "status: [unclosed\n");
123
127
  r = run(root, ["--query", "zephyr"]);
124
128
  assert.equal(r.status, 0);
@@ -126,6 +130,51 @@ test("6: telemetry join is output-only and tolerant", (t) => {
126
130
  assert.match(r.stderr, /a\.yaml/);
127
131
  });
128
132
 
133
+ test("6b: files cell is the AC fixture - present paths kept, gone paths dropped, no list is missing", (t) => {
134
+ const root = mkdtempSync(join(tmpdir(), "gsi-"));
135
+ t.after(() => rmSync(root, { recursive: true, force: true }));
136
+ spawnSync("git", ["init", "-q"], { cwd: root });
137
+ write(root, "doc/specs/a.md", "# Alpha quokka\n\n**Goal:** quokka.\n");
138
+ write(root, "doc/specs/b.md", "# Beta quokka\n\n**Goal:** quokka too.\n");
139
+ write(root, "src/x.ts", "export {};\n");
140
+ write(root, ".pi/gauntlet/telemetry/doc/specs/a.yaml", "status: shipped\nderived:\n modified_files:\n - src/x.ts\n - src/gone.ts\n");
141
+ write(root, ".pi/gauntlet/telemetry/doc/specs/b.yaml", "status: shipped\n");
142
+ spawnSync("git", ["add", "-A"], { cwd: root });
143
+ spawnSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "fixture"], { cwd: root });
144
+ const r = run(root, ["--query", "quokka"]);
145
+ assert.equal(r.status, 0, r.stderr);
146
+ const cells = Object.fromEntries(r.rows.map((row) => [row[1], row[6]]));
147
+ assert.equal(cells["doc/specs/a.md"], "src/x.ts");
148
+ assert.equal(cells["doc/specs/b.md"], "missing");
149
+ for (const row of r.rows) assert.equal(row.length, 8);
150
+ });
151
+
152
+ test("6c: files cell edge cases - order, spaces kept, all gone, non-array, null record", (t) => {
153
+ const root = repo();
154
+ t.after(() => rmSync(root, { recursive: true, force: true }));
155
+ const yaml = ".pi/gauntlet/telemetry/doc/specs/a.yaml";
156
+ const files = () => run(root, ["--query", "zephyr"]).rows[0][6];
157
+ write(root, "b.js", "");
158
+ write(root, "a.js", "");
159
+ write(root, "sp aced.txt", "");
160
+ write(root, yaml, "status: shipped\nderived:\n modified_files:\n - a.js\n - b.js\n");
161
+ assert.equal(files(), "a.js;b.js");
162
+ write(root, "tab\there.js", "");
163
+ write(root, yaml, 'status: shipped\nderived:\n modified_files:\n - a.js\n - "tab\\there.js"\n');
164
+ assert.equal(files(), "a.js;tab here.js");
165
+ assert.equal(run(root, ["--query", "zephyr"]).rows[0].length, 8);
166
+ write(root, yaml, "status: shipped\nderived:\n modified_files:\n - 'sp aced.txt'\n - 7\n");
167
+ assert.equal(files(), "sp aced.txt");
168
+ write(root, yaml, "status: shipped\nderived:\n modified_files:\n - nope1\n - nope2\n");
169
+ assert.equal(files(), "");
170
+ write(root, yaml, 'status: shipped\nderived:\n modified_files: "x"\n');
171
+ assert.equal(files(), "missing");
172
+ write(root, yaml, "");
173
+ assert.equal(files(), "");
174
+ write(root, yaml, "- a\n- b\n");
175
+ assert.equal(files(), "");
176
+ });
177
+
129
178
  test("7: query sanitising tolerates embedded quotes", (t) => {
130
179
  const root = repo();
131
180
  t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -9,6 +9,7 @@ import {
9
9
  matchDiscardStatement,
10
10
  matchShipStatement,
11
11
  matchTestStatement,
12
+ parsePatchNumstat,
12
13
  parseSpecLinks,
13
14
  planSpecHeader,
14
15
  recordPathFor,
@@ -95,6 +96,48 @@ test("aggregateNumstat sums per bucket over the given file set; binary rows coun
95
96
  });
96
97
  });
97
98
 
99
+ const patch = (...blocks: string[][]) => blocks.map((b) => b.join("\n")).join("\n") + "\n";
100
+
101
+ test("parsePatchNumstat counts hunk lines and paths with spaces", () => {
102
+ const p = patch(
103
+ ["diff --git a/src/a.ts b/src/a.ts", "--- a/src/a.ts", "+++ b/src/a.ts", "@@ -1 +1 @@", "+a", "+b", "-c", "\"],
104
+ ["diff --git a/sp ace.txt b/sp ace.txt", "--- a/sp ace.txt", "+++ b/sp ace.txt", "@@ -1 +1 @@", "-x", "+y"],
105
+ );
106
+ assert.deepEqual(parsePatchNumstat(p), [{ added: 2, removed: 1, path: "src/a.ts" }, { added: 1, removed: 1, path: "sp ace.txt" }]);
107
+ });
108
+
109
+ test("parsePatchNumstat handles renames and delete/add pairs", () => {
110
+ assert.deepEqual(parsePatchNumstat(patch(
111
+ ["diff --git a/old.ts b/new.ts", "rename from old.ts", "rename to new.ts"],
112
+ ["diff --git a/o2.ts b/n2.ts", "rename from o2.ts", "rename to n2.ts", "--- a/o2.ts", "+++ b/n2.ts", "@@ -1 +1,2 @@", " k", "+extra"],
113
+ ["diff --git a/gone.ts b/gone.ts", "deleted file mode 100644", "--- a/gone.ts", "+++ /dev/null", "@@ -1 +0,0 @@", "-old"],
114
+ ["diff --git a/newer.ts b/newer.ts", "new file mode 100644", "--- /dev/null", "+++ b/newer.ts", "@@ -0,0 +1 @@", "+new"],
115
+ )), [
116
+ { added: 0, removed: 0, path: "new.ts" }, { added: 1, removed: 0, path: "n2.ts" },
117
+ { added: 0, removed: 1, path: "gone.ts" }, { added: 1, removed: 0, path: "newer.ts" },
118
+ ]);
119
+ });
120
+
121
+ test("parsePatchNumstat handles binary, mode-only and positional hunk headers", () => {
122
+ assert.deepEqual(parsePatchNumstat(patch(
123
+ ["diff --git a/dir b/icon.png b/dir b/icon.png", "Binary files a/dir b/icon.png and b/dir b/icon.png differ"],
124
+ ["diff --git a/run.sh b/run.sh", "old mode 100644", "new mode 100755"],
125
+ ["diff --git a/image.png b/image.png", "GIT binary patch", "literal 4"],
126
+ ["diff --git a/test/fm.md b/test/fm.md", "--- a/test/fm.md", "+++ b/test/fm.md", "@@ -1 +1 @@", "----", "++text"],
127
+ )), [
128
+ { added: 0, removed: 0, path: "dir b/icon.png" }, { added: 0, removed: 0, path: "run.sh" },
129
+ { added: 0, removed: 0, path: "image.png" }, { added: 1, removed: 1, path: "test/fm.md" },
130
+ ]);
131
+ });
132
+
133
+ test("parsePatchNumstat rejects malformed input and accepts empty patches", () => {
134
+ assert.deepEqual(parsePatchNumstat(""), []);
135
+ assert.deepEqual(parsePatchNumstat(" \n\n"), []);
136
+ assert.equal(parsePatchNumstat("garbage\n"), null);
137
+ assert.equal(parsePatchNumstat("diff --git a/x b/y\nindex 1..2\n"), null);
138
+ assert.equal(parsePatchNumstat("diff --git c/x b/x\n"), null);
139
+ });
140
+
98
141
  test("parseSpecLinks reads Supersedes/Fixes banners as paths or markdown links", () => {
99
142
  const body = "# T\n\n> **Supersedes:** [doc/specs/a.md](./a.md), doc/specs/b.md\n> **Fixes:** [doc/specs/c.md](./c.md)\n";
100
143
  assert.deepEqual(parseSpecLinks(body), { supersedes: ["doc/specs/a.md", "doc/specs/b.md"], fixes: ["doc/specs/c.md"] });
@@ -116,6 +116,60 @@ export function aggregateNumstat(numstat: string, files: Set<string>, buckets: [
116
116
  return out;
117
117
  }
118
118
 
119
+ export interface PatchRow {
120
+ added: number;
121
+ removed: number;
122
+ path: string;
123
+ }
124
+
125
+ function patchBlockPath(headers: string[]): string | undefined {
126
+ const plus = headers.find((l) => l.startsWith("+++ b/"));
127
+ if (plus) return plus.slice(6);
128
+ const renameTo = headers.find((l) => l.startsWith("rename to "));
129
+ if (renameTo) return renameTo.slice(10);
130
+ const minus = headers.find((l) => l.startsWith("--- a/"));
131
+ if (minus && headers.includes("+++ /dev/null")) return minus.slice(6);
132
+ if (!headers[0].startsWith("diff --git a/")) return undefined;
133
+ const rest = headers[0].slice("diff --git a/".length);
134
+ const mid = (rest.length - 3) / 2;
135
+ if (Number.isInteger(mid) && mid > 0 && rest.slice(mid, mid + 3) === " b/" && rest.slice(0, mid) === rest.slice(mid + 3)) return rest.slice(0, mid);
136
+ return undefined;
137
+ }
138
+
139
+ function parsePatchBlock(block: string[]): PatchRow | undefined {
140
+ const hunk = block.findIndex((l) => l.startsWith("@@"));
141
+ const headers = hunk < 0 ? block : block.slice(0, hunk);
142
+ const path = patchBlockPath(headers);
143
+ if (path === undefined) return undefined;
144
+ let added = 0;
145
+ let removed = 0;
146
+ for (const line of hunk < 0 ? [] : block.slice(hunk)) {
147
+ if (line.startsWith("+")) added++;
148
+ else if (line.startsWith("-")) removed++;
149
+ }
150
+ return { added, removed, path };
151
+ }
152
+
153
+ // jj has no numstat: --stat truncates paths and -T exposes no line counts.
154
+ // Headers end at the first @@; subsequent ---- and ++text lines count as content.
155
+ export function parsePatchNumstat(patch: string): PatchRow[] | null {
156
+ if (!patch.trim()) return [];
157
+ const lines = patch.split("\n");
158
+ if (!lines.find((l) => l.trim())!.startsWith("diff --git ")) return null;
159
+ const blocks: string[][] = [];
160
+ for (const line of lines) {
161
+ if (line.startsWith("diff --git ")) blocks.push([line]);
162
+ else if (blocks.length) blocks[blocks.length - 1].push(line);
163
+ }
164
+ const rows: PatchRow[] = [];
165
+ for (const block of blocks) {
166
+ const row = parsePatchBlock(block);
167
+ if (!row) return null;
168
+ rows.push(row);
169
+ }
170
+ return rows;
171
+ }
172
+
119
173
  // ---- spec banners --------------------------------------------------------------
120
174
 
121
175
  const LINK_BANNER_RE = /^> \*\*(Supersedes|Fixes):\*\*\s*(.+)$/gm;
@@ -20,7 +20,7 @@
20
20
 
21
21
  type Handler = (event: any, ctx: any) => unknown;
22
22
 
23
- function harness(o: { branch?: unknown[]; enabled?: boolean; gitFail?: (args: string[], cwd: string) => GitResult | undefined; jjWorkspace?: boolean; cwdSub?: string; sessionId?: string; model?: { provider: string; id: string }; thinkingLevel?: string; contextTokens?: number | null; telemetryWarning?: string; telemetryDir?: string } = {}) {
23
+ function harness(o: { branch?: unknown[]; enabled?: boolean; gitFail?: (args: string[], cwd: string) => GitResult | undefined; jjWorkspace?: boolean; jjFail?: (args: string[], cwd: string) => GitResult | undefined; cwdSub?: string; sessionId?: string; model?: { provider: string; id: string }; thinkingLevel?: string; contextTokens?: number | null; telemetryWarning?: string; telemetryDir?: string } = {}) {
24
24
  const root = mkdtempSync(join(tmpdir(), "telemetry-test-"));
25
25
  tempDirs.push(root);
26
26
  mkdirSync(join(root, "doc/specs"), { recursive: true });
@@ -29,6 +29,7 @@
29
29
  const handlers = new Map<string, Handler[]>();
30
30
  const gitCalls: string[][] = [];
31
31
  const gitCwds: string[] = [];
32
+ const jjCalls: string[][] = [];
32
33
  const readFileCalls: string[] = [];
33
34
  let clock = Date.parse("2026-09-17T10:00:00Z");
34
35
  const deps: Deps = {
@@ -53,7 +54,13 @@
53
54
  },
54
55
  settings: () => ({ telemetry: { enabled: o.enabled ?? true, dir: o.telemetryDir ?? ".pi/gauntlet/telemetry", buckets: DEFAULT_TELEMETRY_BUCKETS, warning: o.telemetryWarning }, errors: [], agentOverrides: { implementer: { model: "p/x" } }, versions: { pi: "0.85.1" } }),
55
56
  // A plain jj workspace: `jj root` answers the temp root while git rev-parse fails.
56
- ...(o.jjWorkspace ? { jj: async () => ({ code: 0, stdout: root + "\n", stderr: "" }) } : {}),
57
+ jj: async (args, cwd) => {
58
+ jjCalls.push(args);
59
+ const forced = o.jjFail?.(args, cwd);
60
+ if (forced) return forced;
61
+ if (!o.jjWorkspace) return { code: 1, stdout: "", stderr: "jj stub: not a jj workspace" };
62
+ return shipJj(args, root);
63
+ },
57
64
  };
58
65
  let branch = o.branch ?? [];
59
66
  const ctx = {
@@ -79,11 +86,39 @@
79
86
  await emit("tool_call", { toolName: "write", toolCallId: "w", input: { path: rel, content: body } });
80
87
  return emit("tool_result", { toolName: "write", toolCallId: "w", input: { path: rel, content: body }, content: [], isError: false, details: undefined });
81
88
  };
82
- return { root, handlers, emit, gitCalls, gitCwds, readFileCalls, commits, recordPath, readRecord, phaseResult, writeSpec, setBranch: (b: unknown[]) => (branch = b), ctx };
89
+ return { root, handlers, emit, gitCalls, gitCwds, jjCalls, readFileCalls, commits, recordPath, readRecord, phaseResult, writeSpec, setBranch: (b: unknown[]) => (branch = b), ctx };
83
90
  }
84
91
 
85
92
  const P = (over: Record<string, string> = {}) => Object.fromEntries(["brainstorm", "plan", "implement", "verify", "ship"].map((p) => [p, { status: over[p] ?? "pending" }]));
86
93
 
94
+ const JJ_PATCH = [
95
+ "diff --git a/src/a.ts b/src/a.ts",
96
+ "index 1111111..2222222 100644",
97
+ "--- a/src/a.ts",
98
+ "+++ b/src/a.ts",
99
+ "@@ -1,4 +1,12 @@",
100
+ ...Array.from({ length: 10 }, (_, i) => `+added ${i}`),
101
+ "-removed 1",
102
+ "-removed 2",
103
+ " context",
104
+ "diff --git a/test/a.test.ts b/test/a.test.ts",
105
+ "new file mode 100644",
106
+ "index 0000000..3333333",
107
+ "--- /dev/null",
108
+ "+++ b/test/a.test.ts",
109
+ "@@ -0,0 +1,5 @@",
110
+ ...Array.from({ length: 5 }, (_, i) => `+t ${i}`),
111
+ "",
112
+ ].join("\n");
113
+
114
+ function shipJj(args: string[], root: string): GitResult {
115
+ if (args[0] === "root") return { code: 0, stdout: root + "\n", stderr: "" };
116
+ if (args.includes("--count")) return { code: 0, stdout: args.some((a) => a.includes('files(~glob:".pi/gauntlet/telemetry/**")')) ? "2\n" : "3\n", stderr: "" };
117
+ if (args.includes("log")) return { code: 0, stdout: "jjbase0001\n", stderr: "" };
118
+ if (args.includes("diff")) return { code: 0, stdout: JJ_PATCH, stderr: "" };
119
+ return { code: 1, stdout: "", stderr: `jj stub: unexpected call ${args.join(" ")}` };
120
+ }
121
+
87
122
  test("enabled: false registers handlers that no-op", async () => {
88
123
  const h = harness({ enabled: false });
89
124
  await h.emit("session_start", { type: "session_start", reason: "startup" });
@@ -726,6 +761,100 @@
726
761
  assert.ok(rec.events.some((e) => e.kind === "warning" && /base/.test((e as any).message)));
727
762
  });
728
763
 
764
+ async function boundInJjShip(jjFail?: (args: string[]) => GitResult | undefined) {
765
+ const h = harness({
766
+ jjWorkspace: true,
767
+ jjFail: jjFail ? (args) => jjFail(args) : undefined,
768
+ gitFail: (args) => (args[0] === "rev-parse" && args.includes("--path-format=absolute") ? { code: 128, stdout: "", stderr: "fatal: not a git repository" } : undefined),
769
+ });
770
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
771
+ await h.writeSpec("doc/specs/a.md");
772
+ await h.phaseResult("complete", P({ brainstorm: "complete" }));
773
+ await h.phaseResult("start", P({ brainstorm: "complete", plan: "complete", implement: "complete", verify: "complete", ship: "in_progress" }));
774
+ await h.phaseResult("complete", P({ brainstorm: "complete", plan: "complete", implement: "complete", verify: "complete", ship: "complete" }));
775
+ return h;
776
+ }
777
+
778
+ const diffWarnings = (h: { readRecord: () => ReturnType<typeof parseRecord> }) =>
779
+ h.readRecord()!.events.filter((e) => e.kind === "warning").map((e: any) => e.message as string).filter((m) => m.startsWith("diff omitted"));
780
+
781
+ const isBaseLog = (args: string[]) => args.includes("log") && !args.includes("--count");
782
+
783
+ test("jj ship (AC 1): keep path derives modified_files and diff through jj with pinned output flags", async () => {
784
+ const h = await boundInJjShip();
785
+ const rec = h.readRecord();
786
+ assert.equal(rec.status, "shipped");
787
+ assert.equal(rec.derived.gates.ship_option, "keep");
788
+ assert.deepEqual(rec.derived.modified_files, ["src/a.ts", "test/a.test.ts"]);
789
+ assert.deepEqual(rec.derived.diff, { base: "jjbase0001", commits: 2, buckets: { code: { files: 1, insertions: 10, deletions: 2 }, test: { files: 1, insertions: 5, deletions: 0 } } });
790
+ assert.deepEqual(diffWarnings(h), []);
791
+ const diffSteps = h.jjCalls.filter((a) => a[0] !== "root");
792
+ assert.equal(diffSteps.length, 3);
793
+ assert.ok(diffSteps.every((a) => a.includes("--color=never")));
794
+ assert.deepEqual(diffSteps[1].slice(0, 4), ["--color=never", "--config", "diff.git.show-path-prefix=true", "diff"]);
795
+ assert.ok(diffSteps[0].some((a) => a.includes("fork_point(") && a.includes("~ root() & ::")));
796
+ });
797
+
798
+ test("jj ship (AC 2): empty mainline -> jj-named warning, both derived fields absent, record written", async () => {
799
+ const h = await boundInJjShip((args) => (isBaseLog(args) ? { code: 0, stdout: "", stderr: "" } : undefined));
800
+ const rec = h.readRecord();
801
+ assert.equal(rec.status, "shipped");
802
+ assert.equal(rec.derived.diff, undefined);
803
+ assert.equal(rec.derived.modified_files, undefined);
804
+ assert.deepEqual(diffWarnings(h), ["diff omitted: jj mainline unresolved (trunk() is root(); no main/master bookmark)"]);
805
+ });
806
+
807
+ test("jj ship: jj diff nonzero exit -> first stderr line in the warning", async () => {
808
+ const h = await boundInJjShip((args) => (args.includes("diff") ? { code: 1, stdout: "", stderr: "Error: Revision `jjbase0001` doesn't exist\nHint: try jj log\n" } : undefined));
809
+ assert.deepEqual(diffWarnings(h), ["diff omitted: jj diff failed: Error: Revision `jjbase0001` doesn't exist"]);
810
+ assert.equal(h.readRecord().derived.diff, undefined);
811
+ });
812
+
813
+ test("jj ship: ENOENT-shaped runner result (empty stderr) -> 'unknown error'", async () => {
814
+ const h = await boundInJjShip((args) => (isBaseLog(args) ? { code: 127, stdout: "", stderr: "" } : undefined));
815
+ assert.deepEqual(diffWarnings(h), ["diff omitted: jj log failed: unknown error"]);
816
+ assert.equal(h.readRecord().status, "shipped");
817
+ });
818
+
819
+ test("jj ship: unparseable patch -> warning, neither field set", async () => {
820
+ const h = await boundInJjShip((args) => (args.includes("diff") ? { code: 0, stdout: "not a patch\n", stderr: "" } : undefined));
821
+ assert.deepEqual(diffWarnings(h), ["diff omitted: jj diff unparseable"]);
822
+ const rec = h.readRecord();
823
+ assert.equal(rec.derived.diff, undefined);
824
+ assert.equal(rec.derived.modified_files, undefined);
825
+ });
826
+
827
+ test("jj ship: malformed patch header -> warning, neither field set, record shipped", async () => {
828
+ const h = await boundInJjShip((args) => (args.includes("diff") ? { code: 0, stdout: "diff --git c/x b/x\n", stderr: "" } : undefined));
829
+ assert.deepEqual(diffWarnings(h), ["diff omitted: jj diff unparseable"]);
830
+ const rec = h.readRecord();
831
+ assert.equal(rec.status, "shipped");
832
+ assert.equal(rec.derived.diff, undefined);
833
+ assert.equal(rec.derived.modified_files, undefined);
834
+ });
835
+
836
+ test("jj ship: --count failure -> jj log warning, no derived diff", async () => {
837
+ const h = await boundInJjShip((args) => (args.includes("--count") ? { code: 1, stdout: "", stderr: "Error: invalid revset\nHint: check the mainline\n" } : undefined));
838
+ assert.deepEqual(diffWarnings(h), ["diff omitted: jj log failed: Error: invalid revset"]);
839
+ const rec = h.readRecord();
840
+ assert.equal(rec.status, "shipped");
841
+ assert.equal(rec.derived.diff, undefined);
842
+ assert.equal(rec.derived.modified_files, undefined);
843
+ });
844
+
845
+ test("jj ship: unparseable --count -> warning with the raw output", async () => {
846
+ const h = await boundInJjShip((args) => (args.includes("--count") ? { code: 0, stdout: "abc\n", stderr: "" } : undefined));
847
+ assert.deepEqual(diffWarnings(h), ["diff omitted: jj log --count unparseable: abc"]);
848
+ assert.equal(h.readRecord().derived.diff, undefined);
849
+ });
850
+
851
+ test("git ship never calls jj", async () => {
852
+ const h = await boundInShip();
853
+ await h.bash("s1", "git push");
854
+ assert.deepEqual(h.readRecord().derived.modified_files, ["README.md", "extensions/telemetry.test.ts", "extensions/telemetry.ts"]);
855
+ assert.deepEqual(h.jjCalls, []);
856
+ });
857
+
729
858
  test("guard: write to a shipped spec during brainstorm is blocked before any state change; edit passes; other phases pass", async () => {
730
859
  const h = await boundInShip();
731
860
  await h.bash("s1", "git push");
@@ -18,7 +18,7 @@
18
18
  import { checkoutOf } from "./lib/checkout.ts";
19
19
  import { CONTEXT_DRAFT_MARKER } from "./lib/phase-tracker-helpers.ts";
20
20
  import { sha256 } from "./lib/plan-check.ts";
21
- import { SUPERSEDED_BY_RE, aggregateNumstat, isPlanPath, isSpecPath, isSupersededByBanner, matchDiscardStatement, matchShipStatement, matchTestStatement, parseSpecLinks, planSpecHeader, recordPathFor, repoRelativeToolPath, toPosix, truncateCommand } from "./lib/telemetry-paths.ts";
21
+ import { SUPERSEDED_BY_RE, aggregateNumstat, isPlanPath, isSpecPath, isSupersededByBanner, matchDiscardStatement, matchShipStatement, matchTestStatement, parsePatchNumstat, parseSpecLinks, planSpecHeader, recordPathFor, repoRelativeToolPath, toPosix, truncateCommand } from "./lib/telemetry-paths.ts";
22
22
  import { COUNTED_USER_PHASES, REVIEWER_AGENTS, countFindings, countOpenGaps, hasReopen, insertedText, planTotals, textOf, usageToTokens } from "./lib/telemetry-collect.ts";
23
23
  import { addTokens, capEvents, compact, currentPhase, derive, diffPhases, emptyAccumulators, emptyPhases, foldAccumulators, implementAutoCompletes, liveShipEvent, newRecord, parseRecord, serializeRecord, type Accumulators, type BaseEvent, type PhaseAcc, type PhaseKey, type PhaseMap, type TelemetryEvent, type TelemetryRecord, type Tokens } from "./lib/telemetry-record.ts";
24
24
  import { BASE_REFS, guardReason, modifiedFilesFrom } from "./lib/telemetry-ship.ts";
@@ -48,7 +48,7 @@
48
48
  export interface Deps {
49
49
  fs: FsPort;
50
50
  git: (args: string[], cwd: string) => Promise<GitResult>;
51
- // Optional jj override for tests; production resolves plain jj workspaces via jjSync.
51
+ // jj runner; production uses realJj for both checkout detection and the ship-time diff.
52
52
  jj?: (args: string[], cwd: string) => GitResult | Promise<GitResult>;
53
53
  now: () => string;
54
54
  settings: (cwd: string) => SettingsSnapshot;
@@ -78,6 +78,16 @@
78
78
  });
79
79
  });
80
80
 
81
+ // jj diff --git returns full patches; execFile's default buffer is too small.
82
+ // On ENOENT stderr is empty, so preserve the error message.
83
+ export const realJj = (args: string[], cwd: string): Promise<GitResult> =>
84
+ new Promise((res) => {
85
+ execFile("jj", args, { cwd, timeout: 10_000, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => {
86
+ const code = err ? (typeof (err as { code?: unknown }).code === "number" ? ((err as { code: number }).code) : 1) : 0;
87
+ res({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "").trim() || String(err?.message ?? "") });
88
+ });
89
+ });
90
+
81
91
  const isoNow = (): string => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
82
92
 
83
93
  const readPkgVersion = (p: string): string | undefined => {
@@ -115,7 +125,7 @@
115
125
  return { telemetry, errors, agentOverrides, versions: realVersions(), testCommands };
116
126
  }
117
127
 
118
- export const realDeps: Deps = { fs: realFs, git: realGit, now: isoNow, settings: realSettings };
128
+ export const realDeps: Deps = { fs: realFs, git: realGit, jj: realJj, now: isoNow, settings: realSettings };
119
129
 
120
130
  // ---- replay ------------------------------------------------------------------------
121
131
 
@@ -635,6 +645,38 @@
635
645
  let pendingShip: { id: string; option: "squash" | "pr" | "discard" } | undefined;
636
646
 
637
647
  const computeDiff = async (snap: SettingsSnapshot) => {
648
+ if (checkoutVia === "jj") await computeJjDiff(snap);
649
+ else await computeGitDiff(snap);
650
+ };
651
+
652
+ const JJ_MAINLINE = "coalesce(trunk() ~ root(), present(main), present(master))";
653
+
654
+ const computeJjDiff = async (snap: SettingsSnapshot) => {
655
+ if (!record || !toplevel) return;
656
+ const jj = deps.jj ?? realJj;
657
+ const failed = (sub: string, r: GitResult) => warn(`diff omitted: jj ${sub} failed: ${r.stderr.trim().split("\n")[0] || "unknown error"}`);
658
+ // Without the ::M guard an empty mainline resolves to @ and counts the full history.
659
+ const baseR = await jj(["--color=never", "log", "-r", `fork_point(${JJ_MAINLINE} | @) ~ root() & ::${JJ_MAINLINE}`, "--no-graph", "-T", 'commit_id ++ "\\n"'], toplevel);
660
+ if (baseR.code !== 0) return failed("log", baseR);
661
+ const base = baseR.stdout.trim().split("\n")[0].trim();
662
+ if (!base) return warn("diff omitted: jj mainline unresolved (trunk() is root(); no main/master bookmark)");
663
+ const patch = await jj(["--color=never", "--config", "diff.git.show-path-prefix=true", "diff", "--from", base, "--to", "@", "--git"], toplevel);
664
+ if (patch.code !== 0) return failed("diff", patch);
665
+ const rows = parsePatchNumstat(patch.stdout);
666
+ if (!rows) return warn("diff omitted: jj diff unparseable");
667
+ const dir = currentDir.replace(/\/+$/, "");
668
+ // Exclude record-only @ and other telemetry-only revisions from the count.
669
+ const count = await jj(["--color=never", "log", "-r", `(${base}::@ ~ ${base}) & files(~glob:"${dir}/**")`, "--count"], toplevel);
670
+ if (count.code !== 0) return failed("log", count);
671
+ const n = count.stdout.trim();
672
+ if (!/^\d+$/.test(n)) return warn(`diff omitted: jj log --count unparseable: ${n}`);
673
+ const files = modifiedFilesFrom(rows.map((r) => r.path).join("\n"), record.spec, currentDir);
674
+ const numstat = rows.map((r) => `${r.added}\t${r.removed}\t${r.path}`).join("\n");
675
+ record.derived.modified_files = files;
676
+ record.derived.diff = { base, commits: Number(n), buckets: aggregateNumstat(numstat, new Set(files), snap.telemetry.buckets) };
677
+ };
678
+
679
+ const computeGitDiff = async (snap: SettingsSnapshot) => {
638
680
  if (!record || !toplevel) return;
639
681
  let baseRef: string | undefined;
640
682
  for (const ref of BASE_REFS) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "5.18.5",
3
+ "version": "5.18.7",
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",
@@ -63,8 +63,23 @@ Scout (always dispatched):
63
63
  > handoff. Either way open at
64
64
  > most five candidates whose topic matches this request, and name any whose design
65
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
66
+ > <path>, <scope>` - or `Predecessor: none`.
67
+ > Judge by topic; shared file paths never decide.
68
+ > The `files` column of each candidate row is the `;`-separated list of repo-relative
69
+ > paths that predecessor's ship modified and that still exist, or the literal `missing`,
70
+ > or blank; do not recompute it from git or telemetry. After the `Predecessor:` line(s),
71
+ > and only when at least one predecessor is named, render a `Predecessor anchors`
72
+ > section: list every attributed path exactly once, attributed to the first named
73
+ > predecessor in index output order whose cell lists it, with no per-path commentary
74
+ > (bookkeeping paths such as `CHANGELOG.md` are listed like any other); for each named
75
+ > predecessor whose cell is `missing`, write one line
76
+ > `<spec>: modified file list missing for this spec`, where `<spec>` is the path exactly
77
+ > as written in its `Predecessor:` line. A blank cell contributes nothing for that spec;
78
+ > a named predecessor with no index row (reached through a supersession banner, or named
79
+ > from the directory-listing fallback) contributes no path and no `missing` line. When
80
+ > no path and no `missing` line results - including whenever the index was unavailable -
81
+ > omit the section entirely; `Predecessor: none` produces no anchors section. The anchors
82
+ > are a recon hint, never a selection input. End with an
68
83
  > "Open questions that matter for the spec"
69
84
  > section. Compact handoff, not a dump.
70
85