pi-gauntlet 7.0.1 → 7.0.3

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## v7.0.3 - 2026-10-02
4
+
5
+ ### Fixed
6
+
7
+ - **Amendment aftermath keeps the phase; reopened tasks close in place.** A spec amendment approved during `verify` or `ship` reopens its plan tasks inside that phase; `amendment-surface.md` no longer prescribes `skip <phase>` + `start implement force: true`, and the batch card's phase slot is the constant `no phase change`. Each reopened task is repaired through the current phase's fix loop and closed with `plan_tracker update ... complete` once its re-review or re-audit accepts. Supersedes the phase-restart clauses of the 2026-09-10 gh-27 spec and the 2026-09-22 batch-card spec.
8
+ - **Skill resources load with `read`, never a shell command.** Brainstorming's amend section states the rule (pi-condense's `**/skills/**/*.md` protection keys on `read`'s `path`, so a `bash sed` load was pruned mid-flow); `amendment-surface.md`, SDD, and finishing name `read` at their amendment entry points, and section 5 re-reads itself before the aftermath.
9
+ - **phase-tracker: open-task nudge after a verify-phase repair wave.** Under `flowGuards.enforce`, every non-error implementer-wave result while `verify` is in progress is prefixed with the latest `plan_tracker` snapshot's `pending`/`in_progress` rows and the `update ... complete` call that closes them. Advisory only - no block, no fix-round or phase change - and it reads session entries, so context pruning cannot hide it. The completion backstop's branch scan is shared with it (`latestPlanSnapshot`/`unfinishedTaskLines`). Documented in `doc/configuration.md`.
10
+
11
+ ## v7.0.2 - 2026-10-02
12
+
13
+ ### Added
14
+
15
+ - `gauntlet-telemetry-seal` seals records in a plain jj workspace (no `.git`): all git/jj dispatch centralizes in a new `extensions/lib/vcs.ts` (`vcsFor` over the shared `checkoutOfSync`), the ship diff comes from `computeJjDiff` (fork-point base against `trunk()`/`main`/`master`, `--to @`), `--base` is validated as a jj revset, and the record is tracked with `jj file track --include-ignored` instead of a `telemetry:` commit, so a gitignored telemetry dir is snapshotted like `git add -f` stages it. The jj runner reads up to 64 MiB of patch output. `/skill:finishing-a-development-branch` Step 5 strips the plan with `jj restore --from <base-branch> -- root:"<plan>"` there (anchored at the workspace root, since jj resolves bare paths against cwd), and a checkout with neither git nor jj now errors `not a git or jj checkout`.
16
+
3
17
  ## v7.0.1 - 2026-10-02
4
18
 
5
19
  ### Fixed
package/README.md CHANGED
@@ -71,7 +71,7 @@ pi-gauntlet ships three kinds of pieces, layered on top of pi-cohort's dispatch:
71
71
 
72
72
  - **20 skills** - the workflow logic. Twelve activate automatically when pi sees the matching kind of task, and the workflow skills chain through their gates: `writing-plans`, `roasting-the-spec`, `test-driven-development`, `subagent-driven-development`, `dispatching-parallel-agents`, `verification-before-completion`, `requesting-code-review`, `receiving-code-review`, `using-git-worktrees`, `finishing-a-development-branch`, `forge-skill`, `linear` (reads/searches/comments on/manages Linear tickets via the `linearis` CLI; owns all linearis mechanics and the `## Issue tracker` overrides schema; tracker-facing skills route to it). Eight more require explicit entry (`disable-model-invocation: true`): `brainstorming` starts the gated workflow on explicit consent - use `/skill:brainstorming` for reliable discovery, or opt in through reachable explicit prose or a human-selected handoff; after spec approval, downstream planning and implementation still chain automatically. `shape-ticket` creates or repairs one tracker issue per run against a Context/Problem/Idea/Acceptance-Criteria template, gated by an AC integrity check, a cheap council roast, and a single human-confirmed write - run it with `/skill:shape-ticket`. `gatekeep-pr` is consent-gated pre-merge verification of a PR against its issue's acceptance criteria - read-only gathering, verification evidence resolved CI-first (green checks on the exact assessed head count as evidence; the project's verification command runs only as fallback), a rubric-based review, a compact bottom-up report (what the PR delivers, ticket changes, PR comments, nits, blockers, then a `mergeable` / `fixable` verdict) and a verb menu that always offers `fix` whoever authored the PR; nothing mutates (fixes, pushes, reviews, merges, tracker comments) until you pick a row - run it with `/skill:gatekeep-pr <pr>`. `check-delivery` is a post-merge detective control: proves an issue actually shipped (default-branch landing, delivery target, per-AC evidence) before its tracker status advances; it never writes a terminal status - run it with `/skill:check-delivery <ref>`. `chase-bug` is human-only bug triage: read-only root-cause discovery to an evidenced verdict menu (real bug -> ticket/brainstorm/hotfix/respond; five negative verdicts), then a gated response to the reporter for addressable origins (GitHub issue / tracker ticket) and a rendered verdict summary otherwise - it never fixes during triage; the hotfix row hands off to `skills/chase-bug/hotfix.md` after the menu - run it with `/skill:chase-bug`. `gauntlet-performance` reads the committed run telemetry (current repo by default, `--dir <path>` adds others) through the parse-only `gauntlet-performance` CLI and answers with one example-led recommendation, 3-5 cornerstone numbers, a council assessment when available, and a menu of at most three actions (render a report file, open the recommendation as a ticket via `shape-ticket`, drill into one run); it writes nothing unless you pick render - run it with `/skill:gauntlet-performance [--dir <path>]... [--since <version>]`. `gauntlet-handoff` ends a session whose flow a fresh session will continue: it invokes pi-cohort's `handoff` skill (`--out <path>` or `--key <name>`, default key = the run worktree's branch with `/` flattened to `-`, default file `<tmpdir>/pi-handoff/<key>.md`), then appends the gauntlet process-state section (phase/plan tracker status) per the shared contract `skills/gauntlet-resume/reference/brief-contract.md` - run it with `/skill:gauntlet-handoff [--out <path> | --key <name>]`. `gauntlet-resume` is the only way back into an interrupted flow from a fresh session: it takes a `gauntlet-handoff` brief (a file path, pasted text, or - with no arguments - a pick from the briefs under `<tmpdir>/pi-handoff/`), a bare worktree that already holds a spec, or a spec tracked on main under a configured spec dir (a spec seed: it creates or reuses the worktree through `using-git-worktrees` and pins the spec), restores phase/plan tracker state through the legal arming sequence (`start brainstorm`, `skip` with `resume:` reasons, `plan_check` before implement-or-later), and never infers approval from artifacts - run it with `/skill:gauntlet-resume [<brief-file> | <spec>.md | <worktree-name-or-path>]`.
73
73
  - **7 subagent personas** - the specialized child agents the skills dispatch via pi-cohort: `implementer`, `code-reviewer`, `spec-reviewer`, `conformance-reviewer`, `spec-summarizer`, `spec-council-member`, `spec-council-synthesizer`. See [doc/personas.md](./doc/personas.md) for what each one does and why its permissions are scoped the way they are.
74
- - **4 runtime extensions** - the enforcement layer. `plan-tracker` and `phase-tracker` are tools skills call to track progress (with a TUI widget); `verify-before-ship` is a hook that warns if you push or open a PR without a passing test run since your last edit; a phase-tracker flow guard reminds on implement-phase commits missing spec/code review. In a brainstorming-entered flow, phase-tracker rejects `implement` or `verify` completion while tracker tasks remain pending or in progress; see [its configuration reference](./doc/configuration.md#phase-tracker). phase-tracker also registers `plan_check`, which verifies a plan against its spec and against the grammar in [skills/writing-plans/reference/plan-contract.md](./skills/writing-plans/reference/plan-contract.md), including that each task's `Tests:` commands are selective and never the full suite; a pass stamps the plan for implementation. `telemetry` records one YAML record per gauntlet run (phase timing, models, personas, gate/fix rounds, diff at ship) that ships in the squash beside the spec - it is written only inside a brainstorming-entered flow, kept untracked and git-excluded until `finishing-a-development-branch` seals and commits it once with `gauntlet-telemetry-seal` - and blocks a brainstorm `write` into an already-shipped spec; see [its configuration reference](./doc/configuration.md#telemetry). See [doc/configuration.md](./doc/configuration.md) for the settings each one reads.
74
+ - **4 runtime extensions** - the enforcement layer. `plan-tracker` and `phase-tracker` are tools skills call to track progress (with a TUI widget); `verify-before-ship` is a hook that warns if you push or open a PR without a passing test run since your last edit; a phase-tracker flow guard reminds on implement-phase commits missing spec/code review. In a brainstorming-entered flow, phase-tracker rejects `implement` or `verify` completion while tracker tasks remain pending or in progress; see [its configuration reference](./doc/configuration.md#phase-tracker). phase-tracker also registers `plan_check`, which verifies a plan against its spec and against the grammar in [skills/writing-plans/reference/plan-contract.md](./skills/writing-plans/reference/plan-contract.md), including that each task's `Tests:` commands are selective and never the full suite; a pass stamps the plan for implementation. `telemetry` records one YAML record per gauntlet run (phase timing, models, personas, gate/fix rounds, diff at ship) that ships in the squash beside the spec - it is written only inside a brainstorming-entered flow, kept out of history until `finishing-a-development-branch` seals it once with `gauntlet-telemetry-seal` (a `telemetry:` commit under git; the working-copy snapshot in a plain jj workspace) - and blocks a brainstorm `write` into an already-shipped spec; see [its configuration reference](./doc/configuration.md#telemetry). See [doc/configuration.md](./doc/configuration.md) for the settings each one reads.
75
75
 
76
76
  pi-gauntlet is **opinionated**: it recommends this pipeline for non-trivial changes, but starts `brainstorming` only after explicit user consent. 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
 
@@ -1,11 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/bins/gauntlet-telemetry-seal.mjs
4
- import { existsSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
4
+ import { existsSync as existsSync2, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
5
5
  import { homedir } from "node:os";
6
- import { isAbsolute as isAbsolute2, join, relative as relative2 } from "node:path";
7
- import { spawnSync } from "node:child_process";
8
- import process from "node:process";
6
+ import { isAbsolute as isAbsolute2, join as join2, relative as relative2 } from "node:path";
7
+ import process2 from "node:process";
9
8
 
10
9
  // extensions/lib/gauntlet-settings.ts
11
10
  import path from "node:path";
@@ -87,6 +86,105 @@ function numstatPath(raw) {
87
86
  const arrow = braced.indexOf(" => ");
88
87
  return arrow >= 0 ? braced.slice(arrow + 4) : braced;
89
88
  }
89
+ function patchBlockPath(headers) {
90
+ const plus = headers.find((l) => l.startsWith("+++ b/"));
91
+ if (plus) return plus.slice(6);
92
+ const renameTo = headers.find((l) => l.startsWith("rename to "));
93
+ if (renameTo) return renameTo.slice(10);
94
+ const minus = headers.find((l) => l.startsWith("--- a/"));
95
+ if (minus && headers.includes("+++ /dev/null")) return minus.slice(6);
96
+ if (!headers[0].startsWith("diff --git a/")) return void 0;
97
+ const rest = headers[0].slice("diff --git a/".length);
98
+ const mid = (rest.length - 3) / 2;
99
+ 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);
100
+ return void 0;
101
+ }
102
+ function parsePatchBlock(block) {
103
+ const hunk = block.findIndex((l) => l.startsWith("@@"));
104
+ const headers = hunk < 0 ? block : block.slice(0, hunk);
105
+ const path2 = patchBlockPath(headers);
106
+ if (path2 === void 0) return void 0;
107
+ let added = 0;
108
+ let removed = 0;
109
+ for (const line of hunk < 0 ? [] : block.slice(hunk)) {
110
+ if (line.startsWith("+")) added++;
111
+ else if (line.startsWith("-")) removed++;
112
+ }
113
+ return { added, removed, path: path2 };
114
+ }
115
+ function parsePatchNumstat(patch) {
116
+ if (!patch.trim()) return [];
117
+ const lines = patch.split("\n");
118
+ if (!lines.find((l) => l.trim()).startsWith("diff --git ")) return null;
119
+ const blocks = [];
120
+ for (const line of lines) {
121
+ if (line.startsWith("diff --git ")) blocks.push([line]);
122
+ else if (blocks.length) blocks[blocks.length - 1].push(line);
123
+ }
124
+ const rows = [];
125
+ for (const block of blocks) {
126
+ const row = parsePatchBlock(block);
127
+ if (!row) return null;
128
+ rows.push(row);
129
+ }
130
+ return rows;
131
+ }
132
+
133
+ // extensions/lib/vcs.ts
134
+ import { spawnSync } from "node:child_process";
135
+
136
+ // extensions/lib/checkout.ts
137
+ import { execFileSync } from "node:child_process";
138
+ import { existsSync, statSync } from "node:fs";
139
+ import { dirname, join } from "node:path";
140
+ var CHECKOUT_ARGS = ["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir"];
141
+ function parseCheckout(r) {
142
+ if (r.code !== 0) return void 0;
143
+ const lines = r.stdout.trim().split("\n").map((l) => l.trim());
144
+ if (lines.length < 3 || !lines[0]) return void 0;
145
+ return { toplevel: lines[0], isPrimary: lines[1] === lines[2] };
146
+ }
147
+ var isDir = (p) => {
148
+ try {
149
+ return statSync(p).isDirectory();
150
+ } catch {
151
+ return false;
152
+ }
153
+ };
154
+ function nearestExistingDir(absPath) {
155
+ let p = absPath;
156
+ while (!(existsSync(p) && isDir(p))) {
157
+ const parent = dirname(p);
158
+ if (parent === p) return p;
159
+ p = parent;
160
+ }
161
+ return p;
162
+ }
163
+ var JJ_ROOT_ARGS = ["root"];
164
+ function parseJjRoot(r) {
165
+ if (r.code !== 0) return void 0;
166
+ const toplevel = r.stdout.trim();
167
+ if (!toplevel) return void 0;
168
+ return { toplevel, isPrimary: isDir(join(toplevel, ".jj", "repo")) };
169
+ }
170
+ function checkoutOfSync(absPath, git = gitSync, jj = jjSync) {
171
+ const cwd = nearestExistingDir(absPath);
172
+ const g = parseCheckout(git(CHECKOUT_ARGS, cwd));
173
+ if (g) return { ...g, via: "git" };
174
+ const j = parseJjRoot(jj(JJ_ROOT_ARGS, cwd));
175
+ return j ? { ...j, via: "jj" } : void 0;
176
+ }
177
+ var commandSync = (bin) => (args, cwd) => {
178
+ try {
179
+ const stdout = execFileSync(bin, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5e3 });
180
+ return { code: 0, stdout };
181
+ } catch (e) {
182
+ const status = e.status;
183
+ return { code: typeof status === "number" ? status : 1, stdout: "" };
184
+ }
185
+ };
186
+ var gitSync = commandSync("git");
187
+ var jjSync = commandSync("jj");
90
188
 
91
189
  // extensions/lib/telemetry-ship.ts
92
190
  function modifiedFilesFrom(nameOnly, spec, dir, planDirs) {
@@ -111,7 +209,28 @@ function aggregateNumstat(numstat, files, buckets) {
111
209
  }
112
210
  return out;
113
211
  }
212
+ var JJ_MAINLINE = "coalesce(trunk() ~ root(), present(main), present(master))";
213
+ var JJ_FORK_POINT = `fork_point(${JJ_MAINLINE} | @) ~ root() & ::${JJ_MAINLINE}`;
114
214
  var firstLine = (s) => s.trim().split("\n")[0];
215
+ async function computeJjDiff(o) {
216
+ const failed = (sub, r) => ({ warning: `diff omitted: jj ${sub} failed: ${firstLine(r.stderr) || "unknown error"}` });
217
+ const baseR = await o.jj(["--color=never", "log", "-r", JJ_FORK_POINT, "--no-graph", "-T", 'commit_id ++ "\\n"'], o.cwd);
218
+ if (baseR.code !== 0) return failed("log", baseR);
219
+ const base = firstLine(baseR.stdout).trim();
220
+ if (!base) return { warning: "diff omitted: jj mainline unresolved (trunk() is root(); no main/master bookmark)" };
221
+ const patch = await o.jj(["--color=never", "--config", "diff.git.show-path-prefix=true", "diff", "--from", base, "--to", "@", "--git"], o.cwd);
222
+ if (patch.code !== 0) return failed("diff", patch);
223
+ const rows = parsePatchNumstat(patch.stdout);
224
+ if (!rows) return { warning: "diff omitted: jj diff unparseable" };
225
+ const dir = o.dir.replace(/\/+$/, "");
226
+ const count = await o.jj(["--color=never", "log", "-r", `(${base}::@ ~ ${base}) & files(~glob:"${dir}/**")`, "--count"], o.cwd);
227
+ if (count.code !== 0) return failed("log", count);
228
+ const n = count.stdout.trim();
229
+ if (!/^\d+$/.test(n)) return { warning: `diff omitted: jj log --count unparseable: ${n}` };
230
+ const files = modifiedFilesFrom(rows.map((r) => r.path).join("\n"), o.spec, o.dir, o.planDirs);
231
+ const numstat = rows.map((r) => `${r.added} ${r.removed} ${r.path}`).join("\n");
232
+ return { modified_files: files, diff: { base, commits: Number(n), buckets: aggregateNumstat(numstat, new Set(files), o.buckets) } };
233
+ }
115
234
  async function computeGitDiff(o) {
116
235
  const mb = await o.git(["merge-base", "HEAD", o.base], o.cwd);
117
236
  if (mb.code !== 0 || !mb.stdout.trim()) return { warning: `diff omitted: merge-base failed: ${firstLine(mb.stderr)}` };
@@ -123,6 +242,46 @@ async function computeGitDiff(o) {
123
242
  return { modified_files: files, diff: { base, commits: Number(count.stdout.trim()) || 0, buckets: aggregateNumstat(numstat.stdout, new Set(files), o.buckets) } };
124
243
  }
125
244
 
245
+ // extensions/lib/vcs.ts
246
+ var NO_PROMPT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true" };
247
+ var runBinary = (bin) => (args, cwd, timeoutMs = 1e4) => {
248
+ const r = spawnSync(bin, args, { cwd, encoding: "utf8", env: NO_PROMPT_ENV, timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
249
+ const err = r.error;
250
+ const timedOut = err?.code === "ETIMEDOUT";
251
+ const stderr = timedOut ? "timed out" : (r.stderr ?? "").trim().split("\n")[0] || err?.message || `${bin} exited ${r.status}`;
252
+ return { ok: !timedOut && r.status === 0, code: timedOut ? 1 : r.status ?? 1, stdout: (r.stdout ?? "").trim(), stderr };
253
+ };
254
+ function vcsFor(absPath) {
255
+ const co = checkoutOfSync(absPath);
256
+ return co && { kind: co.via, root: co.toplevel, run: runBinary(co.via) };
257
+ }
258
+ async function computeShipDiff(vcs, o) {
259
+ return vcs.kind === "jj" ? computeJjDiff({ jj: vcs.run, cwd: vcs.root, spec: o.spec, dir: o.dir, planDirs: o.planDirs, buckets: o.buckets }) : computeGitDiff({ git: vcs.run, cwd: vcs.root, spec: o.spec, dir: o.dir, planDirs: o.planDirs, buckets: o.buckets, base: o.base });
260
+ }
261
+ function validateBase(vcs, base) {
262
+ const r = vcs.kind === "jj" ? vcs.run(["--color=never", "log", "-r", base, "--limit", "1", "--no-graph", "-T", 'commit_id ++ "\\n"'], vcs.root) : vcs.run(["rev-parse", "--verify", "-q", `${base}^{commit}`], vcs.root);
263
+ return r.ok && r.stdout !== "";
264
+ }
265
+ function changedNames(vcs, base) {
266
+ if (vcs.kind === "git") return vcs.run(["diff", "--no-renames", "--name-only", `${base}...HEAD`], vcs.root);
267
+ const forkR = vcs.run(["--color=never", "log", "-r", JJ_FORK_POINT, "--no-graph", "-T", 'commit_id ++ "\\n"'], vcs.root);
268
+ if (!forkR.ok) return forkR;
269
+ const fork = forkR.stdout.split("\n")[0];
270
+ if (!fork) return { ok: false, code: 1, stdout: "", stderr: "jj mainline unresolved (trunk() is root(); no main/master bookmark)" };
271
+ return vcs.run(["--color=never", "diff", "--name-only", "--from", fork, "--to", "@"], vcs.root);
272
+ }
273
+ function recordSealed(vcs, rel) {
274
+ if (vcs.kind === "jj") return true;
275
+ return vcs.run(["ls-files", "--error-unmatch", "--", rel], vcs.root).ok && vcs.run(["diff", "--quiet", "HEAD", "--", rel], vcs.root).ok;
276
+ }
277
+ function commitRecordFile(vcs, rel, message) {
278
+ if (vcs.kind === "jj") return vcs.run(["file", "track", "--include-ignored", "--", rel], vcs.root);
279
+ const add = vcs.run(["add", "-f", "--", rel], vcs.root);
280
+ const commit = add.ok ? vcs.run(["commit", "-q", "-m", message, "--", rel], vcs.root, 3e4) : add;
281
+ if (!commit.ok) vcs.run(["reset", "-q", "--", rel], vcs.root);
282
+ return commit;
283
+ }
284
+
126
285
  // extensions/lib/telemetry-record.ts
127
286
  import { Document, isCollection, isMap, isSeq, parse as parseYaml, stringify as stringifyYaml } from "yaml";
128
287
  var PHASES = ["brainstorm", "plan", "implement", "verify", "ship"];
@@ -251,13 +410,10 @@ function parseRecord(text) {
251
410
  }
252
411
 
253
412
  // src/bins/gauntlet-telemetry-seal.mjs
254
- var GIT_TIMEOUT_MS = 1e4;
255
- var COMMIT_TIMEOUT_MS = 3e4;
256
- var GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true" };
257
413
  var OPTIONS = /* @__PURE__ */ new Set(["pr", "squash"]);
258
414
  var fail = (code, message) => {
259
- process.stderr.write(message + "\n");
260
- process.exit(code);
415
+ process2.stderr.write(message + "\n");
416
+ process2.exit(code);
261
417
  };
262
418
  var usage = () => fail(1, "usage: gauntlet-telemetry-seal --worktree <abs path> --option <pr|squash> --base <ref> [--spec <spec path inside the checkout>] [--dir <telemetry dir>]");
263
419
  function parseArgs(argv) {
@@ -279,107 +435,92 @@ function parseArgs(argv) {
279
435
  if (!opts.worktree || !isAbsolute2(opts.worktree) || !opts.base || !OPTIONS.has(opts.option)) usage();
280
436
  return opts;
281
437
  }
282
- function git(cwd, args, timeout = GIT_TIMEOUT_MS) {
283
- const r = spawnSync("git", args, { cwd, encoding: "utf8", env: GIT_ENV, timeout });
284
- const timedOut = r.error?.code === "ETIMEDOUT";
285
- const stderr = timedOut ? "timed out" : (r.stderr ?? "").trim().split("\n")[0] || r.error?.message || `git exited ${r.status}`;
286
- return { ok: !timedOut && r.status === 0, code: timedOut ? 1 : r.status ?? 1, stdout: (r.stdout ?? "").trim(), stderr };
287
- }
288
- var gitRunner = (args, cwd) => {
289
- const r = git(cwd, args);
290
- return { code: r.code, stdout: r.stdout, stderr: r.stderr };
291
- };
292
438
  function readLayer(file) {
293
- if (!existsSync(file)) return {};
439
+ if (!existsSync2(file)) return {};
294
440
  try {
295
441
  return JSON.parse(readFileSync(file, "utf8"));
296
442
  } catch (e) {
297
- process.stderr.write(`warning: ${file}: ${e.message}; using {} for this layer
443
+ process2.stderr.write(`warning: ${file}: ${e.message}; using {} for this layer
298
444
  `);
299
445
  return {};
300
446
  }
301
447
  }
302
448
  function settings(root) {
303
- const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
304
- const preset = readLayer(join(agentDir, "settings.json"));
305
- const repo = readLayer(join(root, ".pi", "settings.json"));
449
+ const agentDir = process2.env.PI_CODING_AGENT_DIR || join2(homedir(), ".pi", "agent");
450
+ const preset = readLayer(join2(agentDir, "settings.json"));
451
+ const repo = readLayer(join2(root, ".pi", "settings.json"));
306
452
  const merged = mergeGauntlet(preset?.piGauntlet, repo?.piGauntlet);
307
453
  return { telemetry: resolveTelemetry(merged), planDirs: planDirsFor(resolveFlowGuards(merged).specDirs) };
308
454
  }
309
455
  function walkRecords(absDir) {
310
- if (!existsSync(absDir)) return [];
456
+ if (!existsSync2(absDir)) return [];
311
457
  const out = [];
312
458
  for (const e of readdirSync(absDir, { withFileTypes: true })) {
313
- const p = join(absDir, e.name);
459
+ const p = join2(absDir, e.name);
314
460
  if (e.isDirectory()) out.push(...walkRecords(p));
315
461
  else if (e.isFile() && e.name.endsWith(".yaml")) out.push(p);
316
462
  }
317
463
  return out;
318
464
  }
319
465
  var isoNow = () => (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z");
320
- var trackedAndClean = (root, rec) => git(root, ["ls-files", "--error-unmatch", "--", rec]).ok && git(root, ["diff", "--quiet", "HEAD", "--", rec]).ok;
321
- async function seal(root, rec, o) {
322
- const abs = join(root, rec);
323
- if (!existsSync(abs)) fail(2, `no record at ${rec}`);
466
+ async function seal(vcs, rec, o) {
467
+ const abs = join2(vcs.root, rec);
468
+ if (!existsSync2(abs)) fail(2, `no record at ${rec}`);
324
469
  const prior = readFileSync(abs);
325
470
  const parsed = parseRecord(prior.toString("utf8"));
326
471
  if (!parsed) fail(2, `unparseable record ${rec}: schema 1 with spec and run_id required`);
327
472
  if (parsed.status !== "in_progress") {
328
- if (parsed.status !== "shipped" || trackedAndClean(root, rec)) return console.log(`already sealed ${rec}`);
473
+ if (parsed.status !== "shipped" || recordSealed(vcs, rec)) return console.log(`already sealed ${rec}`);
329
474
  } else {
330
475
  const now = isoNow();
331
476
  parsed.status = "shipped";
332
477
  parsed.shipped_at = now;
333
478
  parsed.events.push({ ts: now, session: parsed.sessions.at(-1) ?? "seal", phase: "ship", kind: "ship", option: o.option });
334
- const out = await computeGitDiff({ git: gitRunner, cwd: root, spec: parsed.spec, dir: o.dir, buckets: o.buckets, base: o.base, planDirs: o.planDirs });
479
+ const out = await computeShipDiff(vcs, { spec: parsed.spec, dir: o.dir, buckets: o.buckets, base: o.base, planDirs: o.planDirs });
335
480
  if (out.warning) parsed.events.push({ ts: now, session: parsed.sessions.at(-1) ?? "seal", phase: "ship", kind: "warning", message: out.warning });
336
481
  parsed.derived.diff = out.diff;
337
482
  parsed.derived.modified_files = out.modified_files;
338
483
  parsed.derived = derive(parsed, now);
339
484
  writeFileSync(abs, serializeRecord(parsed));
340
485
  }
341
- const rollback = (reason) => {
486
+ const commit = commitRecordFile(vcs, rec, `telemetry: ${parsed.spec}`);
487
+ if (!commit.ok) {
342
488
  writeFileSync(abs, prior);
343
- git(root, ["reset", "-q", "--", rec]);
344
- fail(1, `seal failed ${rec}: ${reason}`);
345
- };
346
- const add = git(root, ["add", "-f", "--", rec]);
347
- if (!add.ok) rollback(add.stderr);
348
- const commit = git(root, ["commit", "-q", "-m", `telemetry: ${parsed.spec}`, "--", rec], COMMIT_TIMEOUT_MS);
349
- if (!commit.ok) rollback(commit.stderr);
489
+ fail(1, `seal failed ${rec}: ${commit.stderr}`);
490
+ }
350
491
  console.log(`sealed ${rec}`);
351
492
  }
352
493
  async function main() {
353
- const opts = parseArgs(process.argv.slice(2));
494
+ const opts = parseArgs(process2.argv.slice(2));
354
495
  const override = opts.dir === void 0 ? void 0 : resolveTelemetry({ telemetry: { enabled: true, dir: opts.dir } });
355
496
  if (override?.warning) usage();
356
- const top = existsSync(opts.worktree) ? git(opts.worktree, ["rev-parse", "--show-toplevel"]) : { ok: false };
357
- if (!top.ok) fail(1, "not a git checkout");
358
- const root = top.stdout;
497
+ const vcs = existsSync2(opts.worktree) ? vcsFor(opts.worktree) : void 0;
498
+ if (!vcs) fail(1, "not a git or jj checkout");
499
+ const root = vcs.root;
359
500
  let rel;
360
501
  if (opts.spec) {
361
- const specAbs = isAbsolute2(opts.spec) && existsSync(opts.spec) ? realpathSync(opts.spec) : opts.spec;
502
+ const specAbs = isAbsolute2(opts.spec) && existsSync2(opts.spec) ? realpathSync(opts.spec) : opts.spec;
362
503
  rel = repoRelativeToolPath(root, root, specAbs);
363
504
  if (!rel) usage();
364
505
  }
365
506
  const { telemetry, planDirs } = settings(root);
366
- if (telemetry.warning) process.stderr.write(`warning: ${telemetry.warning}
507
+ if (telemetry.warning) process2.stderr.write(`warning: ${telemetry.warning}
367
508
  `);
368
509
  if (!telemetry.enabled) return console.log("telemetry disabled");
369
510
  const dir = override?.dir ?? telemetry.dir;
370
- if (!git(root, ["rev-parse", "--verify", "-q", `${opts.base}^{commit}`]).ok) fail(1, `no base ref ${opts.base}`);
511
+ if (!validateBase(vcs, opts.base)) fail(1, `no base ref ${opts.base}`);
371
512
  const o = { option: opts.option, base: opts.base, dir, buckets: telemetry.buckets, planDirs };
372
- if (opts.spec) return seal(root, recordPathFor(dir, rel), o);
373
- const diff = git(root, ["diff", "--no-renames", "--name-only", `${opts.base}...HEAD`]);
374
- if (!diff.ok) fail(1, `git diff failed: ${diff.stderr}`);
513
+ if (opts.spec) return seal(vcs, recordPathFor(dir, rel), o);
514
+ const diff = changedNames(vcs, opts.base);
515
+ if (!diff.ok) fail(1, `${vcs.kind} diff failed: ${diff.stderr}`);
375
516
  const changed = new Set(diff.stdout.split("\n").filter(Boolean));
376
- const records = walkRecords(join(root, dir)).filter((abs) => {
517
+ const records = walkRecords(join2(root, dir)).filter((abs) => {
377
518
  const parsed = parseRecord(readFileSync(abs, "utf8"));
378
- if (!parsed) process.stderr.write(`warning: unparseable record ${toPosix(relative2(root, abs))}
519
+ if (!parsed) process2.stderr.write(`warning: unparseable record ${toPosix(relative2(root, abs))}
379
520
  `);
380
521
  return parsed && changed.has(parsed.spec);
381
522
  }).map((abs) => toPosix(relative2(root, abs))).sort();
382
523
  if (records.length === 0) return console.log("no telemetry run");
383
- for (const rec of records) await seal(root, rec, o);
524
+ for (const rec of records) await seal(vcs, rec, o);
384
525
  }
385
526
  await main();
@@ -339,7 +339,7 @@ test("usage and environment errors exit 1", (t) => {
339
339
  const notGit = mkdtempSync(join(tmpdir(), "gts-nogit-")); cleanup(t, notGit);
340
340
  const ng = invoke(["--worktree", notGit, "--option", "squash", "--base", "main"]);
341
341
  assert.equal(ng.status, 1);
342
- assert.equal(ng.stderr, "not a git checkout");
342
+ assert.equal(ng.stderr, "not a git or jj checkout");
343
343
  });
344
344
 
345
345
  test("--dir overrides the telemetry dir", (t) => {
@@ -350,3 +350,167 @@ test("--dir overrides the telemetry dir", (t) => {
350
350
  assert.deepEqual(r.lines, ["sealed tele/doc/specs/x.yaml"]);
351
351
  assert.deepEqual(headFiles(root), ["tele/doc/specs/x.yaml"]);
352
352
  });
353
+
354
+ // --- Plain jj workspace (no .git). git and jj are PATH shims: git always fails
355
+ // rev-parse and only logs; jj answers from a per-test JSON script. CI has no jj.
356
+ const JJ_FORK = "f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9";
357
+ // src/a.ts: +10 -2; the spec and the record ride along to prove modified_files filters them out.
358
+ const JJ_PATCH = [
359
+ "diff --git a/src/a.ts b/src/a.ts",
360
+ "index 1111111..2222222 100644",
361
+ "--- a/src/a.ts",
362
+ "+++ b/src/a.ts",
363
+ "@@ -1,4 +1,12 @@",
364
+ ...Array.from({ length: 10 }, (_, i) => `+added ${i}`),
365
+ "-removed 1",
366
+ "-removed 2",
367
+ " context",
368
+ "diff --git a/doc/specs/x.md b/doc/specs/x.md",
369
+ "new file mode 100644",
370
+ "index 0000000..3333333",
371
+ "--- /dev/null",
372
+ "+++ b/doc/specs/x.md",
373
+ "@@ -0,0 +1,5 @@",
374
+ ...Array.from({ length: 5 }, (_, i) => `+spec ${i}`),
375
+ "diff --git a/.pi/gauntlet/telemetry/doc/specs/x.yaml b/.pi/gauntlet/telemetry/doc/specs/x.yaml",
376
+ "new file mode 100644",
377
+ "index 0000000..4444444",
378
+ "--- /dev/null",
379
+ "+++ b/.pi/gauntlet/telemetry/doc/specs/x.yaml",
380
+ "@@ -0,0 +1,3 @@",
381
+ "+schema: 1",
382
+ "+spec: doc/specs/x.md",
383
+ "+run_id: r-1",
384
+ "",
385
+ ].join("\n");
386
+ const GIT_SHIM = (log) => `#!${process.execPath}
387
+ require("node:fs").appendFileSync(${JSON.stringify(log)}, "git " + process.argv.slice(2).join(" ") + "\\n");
388
+ process.stderr.write("fatal: not a git repository (or any of the parent directories): .git\\n");
389
+ process.exit(128);
390
+ `;
391
+ const JJ_SHIM = (log, scriptPath) => `#!${process.execPath}
392
+ const fs = require("node:fs");
393
+ const a = process.argv.slice(2);
394
+ fs.appendFileSync(${JSON.stringify(log)}, "jj " + a.join(" ") + "\\n");
395
+ const script = JSON.parse(fs.readFileSync(${JSON.stringify(scriptPath)}, "utf8"));
396
+ const s = a.join(" ");
397
+ const send = (r) => { const res = r ?? { code: 1, stderr: "unscripted jj call" }; process.stdout.write(res.stdout ?? ""); process.stderr.write(res.stderr ?? ""); process.exit(res.code ?? 0); };
398
+ if (s === "root") send(script.root);
399
+ if (s.includes("--limit")) send(script.base);
400
+ if (s.includes("--count")) send(script.count);
401
+ if (s.includes("fork_point(")) send(script.fork);
402
+ if (s.includes("--name-only")) send(script.names);
403
+ if (s.startsWith("file track")) send(script.track);
404
+ send(script.patch);
405
+ `;
406
+ const jjWorkspace = (t, script, { recordText = RECORD } = {}) => {
407
+ const root = mkdtempSync(join(tmpdir(), "gts-jj-"));
408
+ write(root, SPEC, "# Spec X\n\n**Goal:** g.\n");
409
+ if (recordText !== undefined) write(root, REC, recordText);
410
+ const shims = mkdtempSync(join(tmpdir(), "gts-shim-"));
411
+ const log = join(shims, "calls.log");
412
+ writeFileSync(log, "");
413
+ const scriptPath = join(shims, "script.json");
414
+ writeFileSync(scriptPath, JSON.stringify({ root: { code: 0, stdout: root + "\n" }, ...script }));
415
+ for (const [name, body] of [["git", GIT_SHIM(log)], ["jj", JJ_SHIM(log, scriptPath)]]) {
416
+ const p = join(shims, name);
417
+ writeFileSync(p, body);
418
+ chmodSync(p, 0o755);
419
+ }
420
+ cleanup(t, root, shims);
421
+ return { root, log, env: { PATH: `${shims}:${process.env.PATH}` } };
422
+ };
423
+ const happyJj = {
424
+ base: { code: 0, stdout: JJ_FORK + "\n" },
425
+ fork: { code: 0, stdout: JJ_FORK + "\n" },
426
+ count: { code: 0, stdout: "2\n" },
427
+ patch: { code: 0, stdout: JJ_PATCH },
428
+ track: { code: 0, stdout: "" },
429
+ };
430
+
431
+ test("jj workspace: record stamped shipped from the jj diff, one git probe, no git writes, file track instead of a commit", (t) => {
432
+ const { root, log, env } = jjWorkspace(t, happyJj);
433
+ const r = run(root, ["--spec", SPEC], env);
434
+ assert.equal(r.status, 0, r.stderr);
435
+ assert.deepEqual(r.lines, [`sealed ${REC}`]);
436
+ const rec = record(root);
437
+ assert.equal(rec.status, "shipped");
438
+ assert.equal(rec.events.at(-1).kind, "ship");
439
+ assert.equal(rec.events.at(-1).option, "squash");
440
+ assert.deepEqual(rec.derived.modified_files, ["src/a.ts"], "spec and telemetry-dir paths filtered");
441
+ assert.equal(rec.derived.diff.base, JJ_FORK);
442
+ assert.equal(rec.derived.diff.commits, 2);
443
+ assert.deepEqual(rec.derived.diff.buckets.code, { files: 1, insertions: 10, deletions: 2 });
444
+ const calls = readFileSync(log, "utf8").split("\n").filter(Boolean);
445
+ assert.deepEqual(calls.filter((c) => c.startsWith("git ")).map((c) => c.split(" ")[1]), ["rev-parse"], "git is only the failed checkout probe");
446
+ assert.ok(calls.some((c) => c === "jj root"));
447
+ assert.ok(calls.includes(`jj file track --include-ignored -- ${REC}`), "the record is tracked even under a gitignored dir");
448
+ });
449
+
450
+ test("jj workspace: a failing file track restores the pre-seal bytes and exits 1", (t) => {
451
+ const { root, env } = jjWorkspace(t, { ...happyJj, track: { code: 1, stderr: "Error: Path is not in the workspace\n" } });
452
+ const r = run(root, ["--spec", SPEC], env);
453
+ assert.equal(r.status, 1);
454
+ assert.equal(r.stderr, `seal failed ${REC}: Error: Path is not in the workspace`);
455
+ assert.equal(record(root).status, "in_progress");
456
+ });
457
+
458
+ test("jj workspace with unresolved mainline: warning recorded, diff fields absent, record still sealed", (t) => {
459
+ const { root, env } = jjWorkspace(t, { ...happyJj, fork: { code: 0, stdout: "" } });
460
+ const r = run(root, ["--spec", SPEC], env);
461
+ assert.equal(r.status, 0, r.stderr);
462
+ assert.deepEqual(r.lines, [`sealed ${REC}`]);
463
+ const rec = record(root);
464
+ assert.equal(rec.status, "shipped");
465
+ assert.ok(!("diff" in rec.derived) && !("modified_files" in rec.derived));
466
+ assert.equal(rec.events.find((e) => e.kind === "warning")?.message, "diff omitted: jj mainline unresolved (trunk() is root(); no main/master bookmark)");
467
+ });
468
+
469
+ test("jj diff failure and unparseable patch each seal with a warning and no diff fields", (t) => {
470
+ const failing = jjWorkspace(t, { ...happyJj, patch: { code: 1, stderr: "fatal: diff exploded\n" } });
471
+ const r1 = run(failing.root, ["--spec", SPEC], failing.env);
472
+ assert.equal(r1.status, 0, r1.stderr);
473
+ let rec = record(failing.root);
474
+ assert.equal(rec.status, "shipped");
475
+ assert.ok(!("diff" in rec.derived) && !("modified_files" in rec.derived));
476
+ assert.equal(rec.events.find((e) => e.kind === "warning")?.message, "diff omitted: jj diff failed: fatal: diff exploded");
477
+
478
+ const garbage = jjWorkspace(t, { ...happyJj, patch: { code: 0, stdout: "not a git patch\n" } });
479
+ const r2 = run(garbage.root, ["--spec", SPEC], garbage.env);
480
+ assert.equal(r2.status, 0, r2.stderr);
481
+ rec = record(garbage.root);
482
+ assert.equal(rec.events.find((e) => e.kind === "warning")?.message, "diff omitted: jj diff unparseable");
483
+ });
484
+
485
+ test("jj workspace: an unresolvable or empty --base revset fails no base ref, exit 1", (t) => {
486
+ const unknown = jjWorkspace(t, { ...happyJj, base: { code: 1, stderr: "Error: No such bookmark: nope\n" } });
487
+ const r1 = invoke(["--worktree", unknown.root, "--option", "squash", "--base", "nope", "--spec", SPEC], unknown.env);
488
+ assert.equal(r1.status, 1);
489
+ assert.equal(r1.stderr, "no base ref nope");
490
+ const empty = jjWorkspace(t, { ...happyJj, base: { code: 0, stdout: "" } });
491
+ const r2 = invoke(["--worktree", empty.root, "--option", "squash", "--base", "none()"], empty.env);
492
+ assert.equal(r2.status, 1);
493
+ assert.equal(r2.stderr, "no base ref none()");
494
+ });
495
+
496
+ test("jj workspace without --spec discovers records from the jj fork-point diff", (t) => {
497
+ const found = jjWorkspace(t, { ...happyJj, names: { code: 0, stdout: `${SPEC}\nsrc/a.ts\n` } });
498
+ const r1 = run(found.root, [], found.env);
499
+ assert.equal(r1.status, 0, r1.stderr);
500
+ assert.deepEqual(r1.lines, [`sealed ${REC}`]);
501
+ assert.ok(readFileSync(found.log, "utf8").includes(`jj --color=never diff --name-only --from ${JJ_FORK} --to @`));
502
+ const none = jjWorkspace(t, { ...happyJj, names: { code: 0, stdout: "src/a.ts\n" } });
503
+ const r2 = run(none.root, [], none.env);
504
+ assert.equal(r2.status, 0, r2.stderr);
505
+ assert.deepEqual(r2.lines, ["no telemetry run"]);
506
+ });
507
+
508
+ test("jj workspace: a shipped record is already sealed without any git checks", (t) => {
509
+ const { root, log, env } = jjWorkspace(t, happyJj, { recordText: SEALED });
510
+ const r = run(root, ["--spec", SPEC], env);
511
+ assert.equal(r.status, 0, r.stderr);
512
+ assert.deepEqual(r.lines, [`already sealed ${REC}`]);
513
+ assert.equal(record(root).shipped_at, "2026-09-17T18:00:00Z");
514
+ const calls = readFileSync(log, "utf8").split("\n").filter(Boolean);
515
+ assert.deepEqual(calls.filter((c) => c.startsWith("git ")).map((c) => c.split(" ")[1]), ["rev-parse"]);
516
+ });
@@ -25,12 +25,13 @@ export function aggregateNumstat(numstat: string, files: Set<string>, buckets: B
25
25
  }
26
26
 
27
27
  export const JJ_MAINLINE = "coalesce(trunk() ~ root(), present(main), present(master))";
28
+ // Without the ::M guard an empty mainline resolves to @ and counts the full history.
29
+ export const JJ_FORK_POINT = `fork_point(${JJ_MAINLINE} | @) ~ root() & ::${JJ_MAINLINE}`;
28
30
  const firstLine = (s: string): string => s.trim().split("\n")[0];
29
31
 
30
32
  export async function computeJjDiff(o: { jj: Runner; cwd: string; spec: string; dir: string; planDirs: readonly string[]; buckets: Buckets }): Promise<DiffOutcome> {
31
33
  const failed = (sub: string, r: RunResult): DiffOutcome => ({ warning: `diff omitted: jj ${sub} failed: ${firstLine(r.stderr) || "unknown error"}` });
32
- // Without the ::M guard an empty mainline resolves to @ and counts the full history.
33
- const baseR = await o.jj(["--color=never", "log", "-r", `fork_point(${JJ_MAINLINE} | @) ~ root() & ::${JJ_MAINLINE}`, "--no-graph", "-T", 'commit_id ++ "\\n"'], o.cwd);
34
+ const baseR = await o.jj(["--color=never", "log", "-r", JJ_FORK_POINT, "--no-graph", "-T", 'commit_id ++ "\\n"'], o.cwd);
34
35
  if (baseR.code !== 0) return failed("log", baseR);
35
36
  const base = firstLine(baseR.stdout).trim();
36
37
  if (!base) return { warning: "diff omitted: jj mainline unresolved (trunk() is root(); no main/master bookmark)" };
@@ -0,0 +1,74 @@
1
+ // VCS-flavor dispatch for gauntlet tools: the one module that knows both git and
2
+ // jj, so callers (the telemetry seal bin today) stay flavor-free. Checkout
3
+ // resolution delegates to checkout.ts, ship-diff derivation to telemetry-diff.ts.
4
+ import { spawnSync } from "node:child_process";
5
+ import { checkoutOfSync } from "./checkout.ts";
6
+ import { computeGitDiff, computeJjDiff, JJ_FORK_POINT, type Buckets, type DiffOutcome, type RunResult } from "./telemetry-diff.ts";
7
+
8
+ export interface VcsResult extends RunResult { ok: boolean }
9
+ export type VcsRun = (args: string[], cwd: string, timeoutMs?: number) => VcsResult;
10
+ export interface Vcs { kind: "git" | "jj"; root: string; run: VcsRun }
11
+
12
+ // Prompts off so a credential or editor request cannot hang a non-interactive caller.
13
+ const NO_PROMPT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true" };
14
+
15
+ const runBinary = (bin: "git" | "jj"): VcsRun => (args, cwd, timeoutMs = 10_000) => {
16
+ // jj diff --git returns full patches; spawnSync's 1 MiB default buffer truncates them (ENOBUFS).
17
+ const r = spawnSync(bin, args, { cwd, encoding: "utf8", env: NO_PROMPT_ENV, timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
18
+ const err = r.error as NodeJS.ErrnoException | undefined;
19
+ const timedOut = err?.code === "ETIMEDOUT";
20
+ const stderr = timedOut ? "timed out" : (r.stderr ?? "").trim().split("\n")[0] || err?.message || `${bin} exited ${r.status}`;
21
+ return { ok: !timedOut && r.status === 0, code: timedOut ? 1 : (r.status ?? 1), stdout: (r.stdout ?? "").trim(), stderr };
22
+ };
23
+
24
+ export function vcsFor(absPath: string): Vcs | undefined {
25
+ const co = checkoutOfSync(absPath);
26
+ return co && { kind: co.via, root: co.toplevel, run: runBinary(co.via) };
27
+ }
28
+
29
+ // The ship diff for a sealed record. git diffs o.base's merge-base against HEAD;
30
+ // jj derives its own fork-point base (o.base is validation-only there).
31
+ export async function computeShipDiff(vcs: Vcs, o: { spec: string; dir: string; planDirs: readonly string[]; buckets: Buckets; base: string }): Promise<DiffOutcome> {
32
+ return vcs.kind === "jj"
33
+ ? computeJjDiff({ jj: vcs.run, cwd: vcs.root, spec: o.spec, dir: o.dir, planDirs: o.planDirs, buckets: o.buckets })
34
+ : computeGitDiff({ git: vcs.run, cwd: vcs.root, spec: o.spec, dir: o.dir, planDirs: o.planDirs, buckets: o.buckets, base: o.base });
35
+ }
36
+
37
+ // git: any committish. jj: a revset (local bookmark, main@origin, trunk(), ...)
38
+ // resolving to at least one commit; the ship diff still derives its own base.
39
+ export function validateBase(vcs: Vcs, base: string): boolean {
40
+ const r = vcs.kind === "jj"
41
+ ? vcs.run(["--color=never", "log", "-r", base, "--limit", "1", "--no-graph", "-T", 'commit_id ++ "\\n"'], vcs.root)
42
+ : vcs.run(["rev-parse", "--verify", "-q", `${base}^{commit}`], vcs.root);
43
+ return r.ok && r.stdout !== "";
44
+ }
45
+
46
+ // Name-only working diff for record discovery. git diffs base...HEAD; jj diffs
47
+ // the fork point computeJjDiff derives against @ (--base is validation-only on jj).
48
+ export function changedNames(vcs: Vcs, base: string): VcsResult {
49
+ if (vcs.kind === "git") return vcs.run(["diff", "--no-renames", "--name-only", `${base}...HEAD`], vcs.root);
50
+ const forkR = vcs.run(["--color=never", "log", "-r", JJ_FORK_POINT, "--no-graph", "-T", 'commit_id ++ "\\n"'], vcs.root);
51
+ if (!forkR.ok) return forkR;
52
+ const fork = forkR.stdout.split("\n")[0];
53
+ if (!fork) return { ok: false, code: 1, stdout: "", stderr: "jj mainline unresolved (trunk() is root(); no main/master bookmark)" };
54
+ return vcs.run(["--color=never", "diff", "--name-only", "--from", fork, "--to", "@"], vcs.root);
55
+ }
56
+
57
+ // git: sealed == tracked and clean (ls-files --error-unmatch, diff --quiet HEAD).
58
+ // jj: no commit step, so a shipped record is always sealed.
59
+ export function recordSealed(vcs: Vcs, rel: string): boolean {
60
+ if (vcs.kind === "jj") return true;
61
+ return vcs.run(["ls-files", "--error-unmatch", "--", rel], vcs.root).ok
62
+ && vcs.run(["diff", "--quiet", "HEAD", "--", rel], vcs.root).ok;
63
+ }
64
+
65
+ // jj has no commit step: `file track --include-ignored` is the `git add -f` equivalent - it
66
+ // snapshots the stamped record into the working-copy change even under a gitignored dir.
67
+ export function commitRecordFile(vcs: Vcs, rel: string, message: string): VcsResult {
68
+ if (vcs.kind === "jj") return vcs.run(["file", "track", "--include-ignored", "--", rel], vcs.root);
69
+ const add = vcs.run(["add", "-f", "--", rel], vcs.root);
70
+ const commit = add.ok ? vcs.run(["commit", "-q", "-m", message, "--", rel], vcs.root, 30_000) : add;
71
+ // Unstage the -f add so a retry sees the same state.
72
+ if (!commit.ok) vcs.run(["reset", "-q", "--", rel], vcs.root);
73
+ return commit;
74
+ }
@@ -1814,3 +1814,111 @@ test("grant: accepts MAX_SAFE_INTEGER and current on-disk cap controls whether t
1814
1814
  writeFileSync(join(changed.ctx.cwd, ".pi", "settings.json"), JSON.stringify({ piGauntlet: { closureReview: { maxFixRounds: 1 } } }));
1815
1815
  assert.match((await grant(changed, "g", { rounds: 1, reason: "ok" })).details.error ?? "", /0 used, cap 1/);
1816
1816
  });
1817
+
1818
+ const eightTasks = (open: number[]) =>
1819
+ Array.from({ length: 8 }, (_, i) => ({ name: `T${i + 1} task`, status: open.includes(i) ? "in_progress" : "complete" }));
1820
+
1821
+ const resultText = async (h: ReturnType<typeof harness>, id: string, isError = false) => {
1822
+ const [res] = (await h.emitEvent("tool_result", waveResult(id, [{ agent: "implementer", exitCode: 0 }], isError))) as (
1823
+ | { content: { text: string }[] }
1824
+ | undefined
1825
+ )[];
1826
+ return res?.content[0]?.text;
1827
+ };
1828
+
1829
+ test("open-task nudge: verify-phase implementer wave lists mid-list open rows with the closure instruction, before any audit", async () => {
1830
+ const h = harness({ branch: verifyBranch([taskSnapshot(eightTasks([3]))]) });
1831
+ await h.emit("session_start");
1832
+ const text = await resultText(h, "w1");
1833
+ assert.ok(text, "nudge present");
1834
+ assert.match(text, /^Plan tasks still open after this repair wave:\n3: T4 task \(in_progress\)\nOnce this wave's repair is integrated and its re-review or re-audit accepts it, close that task: plan_tracker\(\{ action: "update", index: N, status: "complete" \}\)\. Leave unaccepted repairs in_progress\. A pending row is a task still to run, not to close\. Open tasks block complete verify\.$/);
1835
+ assert.doesNotMatch(text, /0: T1 task/);
1836
+ });
1837
+
1838
+ // fixRounds is observed through the cap guard: one nudged wave against maxFixRounds 1
1839
+ // blocks the next dispatch exactly as an un-nudged wave would.
1840
+ test("open-task nudge: fires on every qualifying wave and leaves fix-round counting unchanged", async () => {
1841
+ const h = guardedHarness({ piGauntlet: { closureReview: { enforce: true, maxFixRounds: 1 } } }, [taskSnapshot(eightTasks([0, 3]))]);
1842
+ await h.emit("session_start");
1843
+ assert.equal(await firstCallResult(h, "c1", implementerWave(1)), undefined);
1844
+ assert.match((await resultText(h, "c1")) ?? "", /0: T1 task \(in_progress\)\n3: T4 task \(in_progress\)/);
1845
+ const blocked = await firstCallResult(h, "c2", implementerWave(1));
1846
+ assert.equal(blocked?.block, true, "one wave counted, cap 1 reached");
1847
+ const h2 = guardedHarness({ piGauntlet: { closureReview: { enforce: true } } }, [taskSnapshot(eightTasks([3]))]);
1848
+ await h2.emit("session_start");
1849
+ assert.match((await resultText(h2, "a")) ?? "", /^Plan tasks still open/);
1850
+ assert.match((await resultText(h2, "b")) ?? "", /^Plan tasks still open/);
1851
+ });
1852
+
1853
+ // The closure-model mismatch warning is stashed at tool_call; it must render first.
1854
+ test("open-task nudge: a stashed tool_call warning stays ahead of the nudge on the same result", async () => {
1855
+ const h = harness({
1856
+ cwd: tempCwd({ piGauntlet: { closureReview: { enforce: true, model: "p/configured" } } }),
1857
+ branch: verifyBranch([taskSnapshot(eightTasks([3]))]),
1858
+ });
1859
+ await h.emit("session_start");
1860
+ const call = { tasks: [{ agent: "conformance-reviewer", model: "p/other", task: "audit" }, { agent: "implementer", task: "fix", worktree: true }] };
1861
+ assert.equal(await firstCallResult(h, "m1", call), undefined);
1862
+ const text = (await resultText(h, "m1")) ?? "";
1863
+ const warningAt = text.indexOf("p/other");
1864
+ const nudgeAt = text.indexOf("Plan tasks still open after this repair wave:");
1865
+ assert.ok(warningAt >= 0 && nudgeAt > warningAt, `warning first, nudge second: ${text}`);
1866
+ });
1867
+
1868
+ test("open-task nudge: silent outside the verify window and without open rows", async () => {
1869
+ const cases: [string, ReturnType<typeof harness>][] = [
1870
+ ["implement in progress", harness({ branch: implementBranch([taskSnapshot(eightTasks([3]))]) })],
1871
+ [
1872
+ "ship in progress",
1873
+ harness({
1874
+ branch: verifyBranch([
1875
+ phaseResult("complete", phases({ brainstorm: "complete", plan: "complete", implement: "complete", verify: "complete" })),
1876
+ phaseResult("start", phases({ brainstorm: "complete", plan: "complete", implement: "complete", verify: "complete", ship: "in_progress" })),
1877
+ taskSnapshot(eightTasks([3])),
1878
+ ]),
1879
+ }),
1880
+ ],
1881
+ ["all tasks complete or skipped", harness({ branch: verifyBranch([taskSnapshot([...eightTasks([]).slice(0, 7), { name: "T8 task", status: "skipped" }])]) })],
1882
+ ["no snapshot on the branch", harness({ branch: verifyBranch() })],
1883
+ ["latest snapshot is a clear", harness({ branch: verifyBranch([taskSnapshot(eightTasks([3])), taskSnapshot([])]) })],
1884
+ ["rejected update is skipped, prior all-complete snapshot used", harness({ branch: verifyBranch([taskSnapshot(eightTasks([])), { type: "message", message: { role: "toolResult", toolName: "plan_tracker", details: { tasks: eightTasks([3]), error: "rejected" } } }]) })],
1885
+ ["flowGuards.enforce: false", harness({ cwd: tempCwd({ piGauntlet: { flowGuards: { enforce: false } } }), branch: verifyBranch([taskSnapshot(eightTasks([3]))]) })],
1886
+ ["async handle results: []", harness({ branch: verifyBranch([taskSnapshot(eightTasks([3]))]) })],
1887
+ ];
1888
+ for (const [label, h] of cases) {
1889
+ await h.emit("session_start");
1890
+ if (label === "async handle results: []") {
1891
+ assert.deepEqual(await h.emitEvent("tool_result", waveResult("x", [])), [undefined], label);
1892
+ continue;
1893
+ }
1894
+ assert.equal(await resultText(h, "x"), undefined, label);
1895
+ }
1896
+ });
1897
+
1898
+ test("open-task nudge: closureReview.enforce: false does not silence it", async () => {
1899
+ const h = harness({ cwd: tempCwd({ piGauntlet: { closureReview: { enforce: false } } }), branch: verifyBranch([taskSnapshot(eightTasks([3]))]) });
1900
+ await h.emit("session_start");
1901
+ assert.match((await resultText(h, "k")) ?? "", /^Plan tasks still open/);
1902
+ });
1903
+
1904
+ test("open-task nudge: silent on an isError result, a degraded settings read, and in a subagent child", async () => {
1905
+ const errored = harness({ branch: verifyBranch([taskSnapshot(eightTasks([3]))]) });
1906
+ await errored.emit("session_start");
1907
+ assert.equal(await resultText(errored, "e", true), undefined, "isError result");
1908
+
1909
+ const malformed = tempCwd();
1910
+ mkdirSync(join(malformed, ".pi"), { recursive: true });
1911
+ writeFileSync(join(malformed, ".pi", "settings.json"), "{ not json");
1912
+ const degraded = harness({ cwd: malformed, branch: verifyBranch([taskSnapshot(eightTasks([3]))]) });
1913
+ await degraded.emit("session_start");
1914
+ assert.equal(await resultText(degraded, "d"), undefined, "settings errors");
1915
+
1916
+ process.env.PI_SUBAGENT_DEPTH = "1";
1917
+ try {
1918
+ const child = harness({ branch: verifyBranch([taskSnapshot(eightTasks([3]))]) });
1919
+ await child.emit("session_start");
1920
+ assert.equal(await resultText(child, "c"), undefined, "subagent child");
1921
+ } finally {
1922
+ process.env.PI_SUBAGENT_DEPTH = "0";
1923
+ }
1924
+ });
@@ -101,6 +101,33 @@ const isImplementerWave = (details: unknown, isError: boolean | undefined): bool
101
101
  return d.results.some((r) => r?.agent === "implementer");
102
102
  };
103
103
 
104
+ type Task = { name: string; status: string };
105
+
106
+ // Latest successful plan_tracker snapshot on the current branch. Shared by the
107
+ // completion backstop and the open-task nudge so both reason about one state;
108
+ // read from session entries, so context pruning cannot hide it.
109
+ const latestPlanSnapshot = (ctx: ExtensionContext): Task[] => {
110
+ for (const entry of [...ctx.sessionManager.getBranch()].reverse()) {
111
+ if (
112
+ entry.type !== "message" ||
113
+ entry.message.role !== "toolResult" ||
114
+ entry.message.toolName !== "plan_tracker" ||
115
+ entry.message.isError
116
+ ) {
117
+ continue;
118
+ }
119
+ const details = entry.message.details as { tasks?: Task[]; error?: string } | undefined;
120
+ if (!details || details.error || !details.tasks) continue;
121
+ return details.tasks;
122
+ }
123
+ return [];
124
+ };
125
+
126
+ const unfinishedTaskLines = (tasks: Task[]): string[] =>
127
+ tasks.flatMap((task, index) =>
128
+ task.status === "pending" || task.status === "in_progress" ? [`${index}: ${task.name} (${task.status})`] : [],
129
+ );
130
+
104
131
  // Review-cadence guard (spec 2026-08-12-execution-fidelity-hardening): presence-only
105
132
  // advisory ledger of the most recent completed implementer / spec-reviewer /
106
133
  // code-reviewer dispatch. Agents completing in the SAME dispatch share a sequence
@@ -210,6 +237,13 @@ const loneImplementerBlockReason =
210
237
  "a lone agent call runs unisolated and produces no worktree diff. " +
211
238
  "To disable this gate, set piGauntlet.closureReview.enforce: false.";
212
239
 
240
+ // Preview the completion backstop so reopened tasks close at acceptance,
241
+ // not before the repair is integrated and reviewed.
242
+ const openTaskNudge = (rows: string[]): string =>
243
+ `Plan tasks still open after this repair wave:\n${rows.join("\n")}\n` +
244
+ 'Once this wave\'s repair is integrated and its re-review or re-audit accepts it, close that task: plan_tracker({ action: "update", index: N, status: "complete" }). ' +
245
+ "Leave unaccepted repairs in_progress. A pending row is a task still to run, not to close. Open tasks block complete verify.";
246
+
213
247
  const fixRoundCapBlockReason = (used: number, cap: number, settingsPath: string): string =>
214
248
  `Conformance fix loop: ${used} fix round(s) used against a cap of ${cap} (granted rounds included); ` +
215
249
  "escalate to the human with the verdict trail instead of re-looping.\n" +
@@ -382,6 +416,10 @@ export default function (pi: ExtensionAPI) {
382
416
  // Warnings stashed at tool_call, prepended at tool_result (verify-before-ship pattern).
383
417
  // Relies on tool_result firing for every tool_call; reconstructState clears any stragglers.
384
418
  const pendingGuardWarnings = new Map<string, string>();
419
+ const addGuardWarning = (id: string, text: string) => {
420
+ const prior = pendingGuardWarnings.get(id);
421
+ pendingGuardWarnings.set(id, prior ? prior + "\n\n" + text : text);
422
+ };
385
423
 
386
424
  const activeGuardPhase = (): Phase | undefined =>
387
425
  GUARD_PHASES.find((p) => phases[p].status === "in_progress");
@@ -519,10 +557,6 @@ export default function (pi: ExtensionAPI) {
519
557
 
520
558
  pi.on("tool_call", async (event, ctx) => {
521
559
  // D3.b: one fresh settings read per event, lazily, only if a guard needs it.
522
- const addGuardWarning = (id: string, text: string) => {
523
- const prior = pendingGuardWarnings.get(id);
524
- pendingGuardWarnings.set(id, prior ? prior + "\n\n" + text : text);
525
- };
526
560
  let gauntletCache: ReturnType<typeof loadGauntletSettings> | undefined;
527
561
  const g = () => {
528
562
  if (!gauntletCache) {
@@ -706,6 +740,18 @@ export default function (pi: ExtensionAPI) {
706
740
  observeFixWave(event.details, event.isError, ctx);
707
741
  if (qualifiesAsClosureDispatch(event.details)) conformanceDispatched = true;
708
742
  observeCadence(event.details);
743
+ if (
744
+ !isSubagentChild &&
745
+ gauntletEntered &&
746
+ phases.verify.status === "in_progress" &&
747
+ isImplementerWave(event.details, event.isError)
748
+ ) {
749
+ const loaded = loadGauntletSettings(ctx.cwd);
750
+ if (loaded.errors.length === 0 && resolveFlowGuards(loaded.gauntlet).enforce) {
751
+ const open = unfinishedTaskLines(latestPlanSnapshot(ctx));
752
+ if (open.length) addGuardWarning(event.toolCallId, openTaskNudge(open));
753
+ }
754
+ }
709
755
  const warning = pendingGuardWarnings.get(event.toolCallId);
710
756
  if (warning) {
711
757
  pendingGuardWarnings.delete(event.toolCallId);
@@ -989,26 +1035,7 @@ export default function (pi: ExtensionAPI) {
989
1035
  (params.phase === "implement" || params.phase === "verify") &&
990
1036
  resolveFlowGuards(loadGauntletSettings(ctx.cwd).gauntlet).enforce
991
1037
  ) {
992
- let tasks: { name: string; status: string }[] = [];
993
- for (const entry of [...ctx.sessionManager.getBranch()].reverse()) {
994
- if (
995
- entry.type !== "message" ||
996
- entry.message.role !== "toolResult" ||
997
- entry.message.toolName !== "plan_tracker" ||
998
- entry.message.isError
999
- ) {
1000
- continue;
1001
- }
1002
- const details = entry.message.details as { tasks?: { name: string; status: string }[]; error?: string } | undefined;
1003
- if (!details || details.error || !details.tasks) continue;
1004
- tasks = details.tasks;
1005
- break;
1006
- }
1007
- const unfinished = tasks.flatMap((task, index) =>
1008
- task.status === "pending" || task.status === "in_progress"
1009
- ? [`${index}: ${task.name} (${task.status})`]
1010
- : [],
1011
- );
1038
+ const unfinished = unfinishedTaskLines(latestPlanSnapshot(ctx));
1012
1039
  if (unfinished.length) {
1013
1040
  return {
1014
1041
  content: [
@@ -6,13 +6,16 @@ const sources = {
6
6
  import { readFileSync } from "node:fs";
7
7
  export class SettingsManager {
8
8
  static create(cwd) { return new SettingsManager(cwd); }
9
- constructor(cwd) { this.cwd = cwd; }
9
+ constructor(cwd) { this.cwd = cwd; this.errors = []; }
10
10
  getGlobalSettings() { return {}; }
11
11
  getProjectSettings() {
12
12
  try { return JSON.parse(readFileSync(this.cwd + "/.pi/settings.json", "utf8")); }
13
- catch { return {}; }
13
+ catch (error) {
14
+ if (error?.code !== "ENOENT") this.errors.push({ scope: "project", error });
15
+ return {};
16
+ }
14
17
  }
15
- drainErrors() { return []; }
18
+ drainErrors() { const drained = this.errors; this.errors = []; return drained; }
16
19
  }
17
20
  export const getAgentDir = () => "/tmp/pi-gauntlet-test-agent";
18
21
  `,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "7.0.1",
3
+ "version": "7.0.3",
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",
@@ -67,7 +67,7 @@ The spec is the first commit in a dedicated worktree, never a separate commit on
67
67
 
68
68
  Invoke `/skill:using-git-worktrees`; use `.worktrees/` or the project-native location. Carry its `Worktree ready at <full-path>` value: spec path `<full-path>/<spec dir>/<filename>.md`; every dispatch uses that `cwd`; git uses `git -C <full-path>`. Keep the process cwd unchanged.
69
69
 
70
- Spec, plan, and implementation share this worktree. The spec and its telemetry record (`<telemetry.dir>/<spec path with .md -> .yaml>`, default `.pi/gauntlet/telemetry/doc/specs/<spec>.yaml`) are deliverables and ship in the squash; only the plan is stripped. `/skill:finishing-a-development-branch` strips the plan, then seals and commits the record before landing. Explicit trivial one-off edits outside this flow need no worktree.
70
+ Spec, plan, and implementation share this worktree. The spec and its telemetry record (`<telemetry.dir>/<spec path with .md -> .yaml>`, default `.pi/gauntlet/telemetry/doc/specs/<spec>.yaml`) are deliverables and ship in the squash; only the plan is stripped. `/skill:finishing-a-development-branch` strips the plan, then seals the record before landing (a `telemetry:` commit under git; the working-copy snapshot in a plain jj workspace). Explicit trivial one-off edits outside this flow need no worktree.
71
71
 
72
72
  ## The Process
73
73
 
@@ -146,9 +146,11 @@ Follow [User Review Gate](reference/spec-finalization.md#user-review-gate) for s
146
146
 
147
147
  Execute in place from any later phase; never invoke `/skill:brainstorming` for it (its entry resets both trackers). Worktree, spec commits, and plan survive.
148
148
 
149
+ Every skill resource in this flow (a `SKILL.md` or a `reference/*.md`) is loaded with the `read` tool at its absolute path under `skills/`, never with a shell command - pruning protection keys on `read`'s `path`.
150
+
149
151
  Classify first. Redraw test: the change alters the problem statement, adds or removes a component, or moves a component boundary -> redraw. A change inside one component (a persistence mechanism, a worker's HTTP client, dropping a fallback and its task) -> amend. State the call; the user overrides either way.
150
152
 
151
- Amend -> load `reference/amendment-surface.md` and follow it (unreadable -> stop with a blocking error; never improvise the grammar). Apply its [Standing grants](reference/amendment-surface.md#standing-grants) boundaries.
153
+ Amend -> `read` `reference/amendment-surface.md` and follow it (unreadable -> stop with a blocking error; never improvise the grammar). Apply its [Standing grants](reference/amendment-surface.md#standing-grants) boundaries.
152
154
 
153
155
  Redraw: keep the worktree and the approved spec file. `plan_tracker({ action: "clear" })`, `phase_tracker({ action: "reset" })`, `phase_tracker({ action: "start", phase: "brainstorm" })`, delete the plan file, resume at checklist step 4 with the approved spec as the draft (steps 2-3 skipped). Spec-writing overwrites it; the full gate follows.
154
156
 
@@ -1,6 +1,6 @@
1
1
  # Amendment surface
2
2
 
3
- Load from `skills/brainstorming/SKILL.md` § Amending an approved spec, from any phase, for amend-class changes; at approval, load only [Standing grants](#standing-grants), not the batch procedure. Keep redraws outside this procedure. Only the main loop holding `edit`/`write` authors amendments and human-facing amendment text. Use the human last: a fresh reviewer clears evidence-backed factual corrections; present the rest to the human once per batch plainly.
3
+ `read` this file (the `read` tool at its absolute path, never a shell command) from `skills/brainstorming/SKILL.md` § Amending an approved spec, from any phase, for amend-class changes; at approval, load only [Standing grants](#standing-grants), not the batch procedure. Keep redraws outside this procedure. Only the main loop holding `edit`/`write` authors amendments and human-facing amendment text. Use the human last: a fresh reviewer clears evidence-backed factual corrections; present the rest to the human once per batch plainly.
4
4
 
5
5
  ## Standing grants
6
6
 
@@ -77,7 +77,7 @@ Fail closed: a dispatch error, an async handle, a silence-kill, or a missing or
77
77
  Render only escalated and prefiltered items; `<N>` counts them. Nothing symbol-dense above the fold; each item's `old -> new` sits under `Details`, after the footer.
78
78
 
79
79
  ```
80
- Spec amendments: <N> need your call - from <trigger>; applying as recommended <reopens | adds | removes> <task ids | no tasks>; <phase consequence | no phase change>.
80
+ Spec amendments: <N> need your call - from <trigger>; applying as recommended <reopens | adds | removes> <task ids | no tasks>; no phase change.
81
81
 
82
82
  * <handle> - <title>: <what>. <why>.
83
83
  Example: <before -> after>
@@ -93,7 +93,7 @@ Details
93
93
  <handle>: <location> - old: <text> -> new: <text>
94
94
  ```
95
95
 
96
- `<trigger>` is the step the main loop is running when the batch forms: `the spec review`, `planning Task <n>`, `Task <n> BLOCKED`, `the verify-phase code review (FIX_FIRST <ids>)`, `the finish-gate council-edit revert`, else `the <phase> phase`; `your request` only for an amend the user raised in prose. The plan clause is the section 5 aftermath as `recommended` would land, over reviewer-cleared plus rendered items, read from the plan file and tracker state - call no `plan_tracker`/`phase_tracker` before the reply: any of `reopens <ids>`, `adds <n> task(s)`, `removes <ids>`, comma-joined (`reopens no tasks` when the plan is untouched), then `; restarts implement, then verify` when a reopen lands in `verify`/`ship`, else `; no phase change`.
96
+ `<trigger>` is the step the main loop is running when the batch forms: `the spec review`, `planning Task <n>`, `Task <n> BLOCKED`, `the verify-phase code review (FIX_FIRST <ids>)`, `the finish-gate council-edit revert`, else `the <phase> phase`; `your request` only for an amend the user raised in prose. The plan clause is the section 5 aftermath as `recommended` would land, over reviewer-cleared plus rendered items, read from the plan file and tracker state - call no `plan_tracker`/`phase_tracker` before the reply: any of `reopens <ids>`, `adds <n> task(s)`, `removes <ids>`, comma-joined (`reopens no tasks` when the plan is untouched), then `; no phase change`.
97
97
 
98
98
  `Reviewer:` quotes the `<one-line reason>` of the section 3 reply verbatim; the `probed:` half stays in the commit body; a prefiltered item carries `not reviewed - <the rule that prefiltered it>`. `Impact:` restates the design-contract shift from `location` in the reader's words, never quoted spec text, one clause per touched decision, `;`-joined; `(none) -> <new>` for a contract added, `<old> -> (removed)` for a contract removed. `Details` keeps the verbatim `old -> new`.
99
99
 
@@ -101,9 +101,11 @@ Details
101
101
 
102
102
  ## 5. Apply, aftermath, commit
103
103
 
104
+ Before the aftermath, `read` this section again; never run it from memory.
105
+
104
106
  Apply accepted items only: reviewer clears, `accept`, `alt-n`, and state-changing `custom`. Print one line per applied item: `amended the spec: <title> - <what>`.
105
107
 
106
- Aftermath, once per batch, only when at least one item applied (nothing applied -> skip to the commit below). No plan yet -> commit the spec; continue. Plan exists -> update affected anchors and tasks: `plan_tracker` `add` for new tasks; anchor-changed completed tasks are reopened as `in_progress` and re-run the task loop (`update` never sets `pending`). A removed task is deleted from the plan; then re-`init` the tracker with `{ name, status }` elements: preserved tasks keep their order and statuses, reopened tasks are `in_progress` in place, every still-`pending` task (including newly added ones, whatever wave label they carry) trails the non-pending ones, removed tasks are the only deletions (the only permitted `init` after handoff; never `clear`). Re-run `plan_check` until it passes, commit spec + plan together; continue. A task reopened while `verify` or `ship` is in progress: `phase_tracker({ action: "skip", phase: "<current>", reason: "amendment reopened Task N" })`, then `phase_tracker({ action: "start", phase: "implement", force: true })`; later phases re-enter with `force: true` and rerun in full.
108
+ Aftermath, once per batch, only when at least one item applied (nothing applied -> skip to the commit below). No plan yet -> commit the spec; continue. Plan exists -> update affected anchors and tasks: `plan_tracker` `add` for new tasks; anchor-changed completed tasks are reopened as `in_progress` and re-run the task loop (`update` never sets `pending`). A removed task is deleted from the plan; then re-`init` the tracker with `{ name, status }` elements: preserved tasks keep their order and statuses, reopened tasks are `in_progress` in place, every still-`pending` task (including newly added ones, whatever wave label they carry) trails the non-pending ones, removed tasks are the only deletions (the only permitted `init` after handoff; never `clear`). Re-run `plan_check` until it passes, commit spec + plan together; continue. A task reopened while `verify` or `ship` is in progress stays in that phase - no `phase_tracker` call. Repair it through the current phase's fix loop and `plan_tracker({ action: "update", index: N, status: "complete" })` it once that repair's review or re-audit accepts; open tasks block `complete verify`, and in `ship` nothing checks them, so close them explicitly.
107
109
 
108
110
  One commit per batch:
109
111
 
@@ -94,7 +94,7 @@ then continue directly to Step 4. No approval prompt, no menu, no shared options
94
94
 
95
95
  When the sentinel carries a `happy-path:` line, print it directly under `Closure / conformance: CONFORMS` as one informational, non-blocking line in the same shape as the `auto-applied fix commits` line: `happy-path: passed`, `happy-path: failed - unattributable`, or `happy-path: not run - <reason>` (a `failed - attributed to G<n>` value cannot reach this branch: its gaps are open).
96
96
 
97
- **Pre-menu amendment funnel (GAPS only).** Before rendering the carried-open menu, run [`amendment-surface.md` § Conformance entry](../brainstorming/reference/amendment-surface.md) over the inventory once: gaps with `recommended: accept`, verdict `DRIFTED` or `PARTIAL`, not `UNAUTHORIZED`, whose `origin` is not an acceptance criterion are drafted as `accept-into-spec` items (the edit built from `origin` + `evidence`) and sent to the reviewer in one call; cleared items apply and land as one batch commit, the spec is re-audited, the inventory regenerated. Only concerns the re-audit actually closed drop out; sibling concerns in the same gap keep their rows and dispositions. Survivors and every other gap render as ordinary rows below - one menu, never two.
97
+ **Pre-menu amendment funnel (GAPS only).** Before rendering the carried-open menu, `read` and run [`amendment-surface.md` § Conformance entry](../brainstorming/reference/amendment-surface.md) over the inventory once: gaps with `recommended: accept`, verdict `DRIFTED` or `PARTIAL`, not `UNAUTHORIZED`, whose `origin` is not an acceptance criterion are drafted as `accept-into-spec` items (the edit built from `origin` + `evidence`) and sent to the reviewer in one call; cleared items apply and land as one batch commit, the spec is re-audited, the inventory regenerated. Only concerns the re-audit actually closed drop out; sibling concerns in the same gap keep their rows and dispositions. Survivors and every other gap render as ordinary rows below - one menu, never two.
98
98
 
99
99
  **Carried-open (`status: GAPS (N open)`).** Read `reference/disposition-protocol.md` and follow it for the carried-open render (dense) grammar, the response grammar, and the 9-step execute order. Render the human decision menu in the shape below, with the sentinel's `happy-path:` line (when present) printed as one informational line directly under the `Conformance: N decisions needed before shipping.` header - a `failed - attributed to G<n>,...` value names the rows whose evidence is the transcript, a `rescope` recommendation on such a row means the failure lies in code the change never owned - drive the dispositions per that reference, then print the summary render and continue to Step 4. If that reference file cannot be read, stop and surface a blocking error — do **not** improvise the grammar from memory.
100
100
 
@@ -187,7 +187,7 @@ Rows 1-2 run the Option 1/2 blocks; row 3 runs the Keep block and row 4 the Disc
187
187
 
188
188
  #### Strip the plan, seal the record (Options 1-3)
189
189
 
190
- Run this on the feature branch before any landing path. The spec and its telemetry record (`<telemetry.dir>/<spec path with .md -> .yaml>`, default `.pi/gauntlet/telemetry/doc/specs/<spec>.yaml`) are deliverables and ship in the squash; only the plan is stripped. The record is an untracked, git-excluded file until this step commits it. `<bin>` is `<directory of this skill's SKILL.md>/../../bin`, resolved from the skill's `<location>` in the system prompt.
190
+ Run this on the feature branch before any landing path. The spec and its telemetry record (`<telemetry.dir>/<spec path with .md -> .yaml>`, default `.pi/gauntlet/telemetry/doc/specs/<spec>.yaml`) are deliverables and ship in the squash; only the plan is stripped. The record is an untracked, git-excluded file until this step commits it (in a plain jj workspace there is no commit; jj snapshots the stamped record into the working-copy change). `<bin>` is `<directory of this skill's SKILL.md>/../../bin`, resolved from the skill's `<location>` in the system prompt.
191
191
 
192
192
  Read the plan's `**Spec:**` header (repo-relative, backticks stripped) before the strip; pass that value as `--spec`. Resolve the spec dirs with `gauntlet_setting({ key: "flowGuards" })` and use their sibling `plans` dirs for plan paths.
193
193
 
@@ -196,14 +196,19 @@ Read the plan's `**Spec:**` header (repo-relative, backticks stripped) before th
196
196
  PLAN_PATH=<sibling-plans-dir>/<plan-file>.md # the sibling plans dir of the spec's dir
197
197
  if git -C "$WORKTREE" ls-files --error-unmatch "$PLAN_PATH" >/dev/null 2>&1; then
198
198
  git -C "$WORKTREE" rm "$PLAN_PATH" && git -C "$WORKTREE" commit -m "Remove ephemeral plan doc"
199
+ elif [ -f "$WORKTREE/$PLAN_PATH" ] && jj -R "$WORKTREE" root >/dev/null 2>&1; then
200
+ # Plain jj workspace (no .git): the removal lands in the working-copy change; there is no commit step.
201
+ # root: anchors the path at the workspace root - jj resolves bare paths against cwd, not -R.
202
+ jj -R "$WORKTREE" restore --from <base-branch> -- "root:\"$PLAN_PATH\""
199
203
  fi
200
204
 
201
205
  # Seal the telemetry record: stamp shipped, compute the diff, commit it once.
206
+ # The bin detects a plain jj workspace (no .git) itself: jj diff, no commit step.
202
207
  # --option is pr for Options 1-2 and squash for Option 3; --spec is the plan header's **Spec:** value (omit when no plan is known).
203
208
  node <bin>/gauntlet-telemetry-seal.mjs --worktree "$WORKTREE" --option <pr|squash> --base <base-branch> --spec <spec path>
204
209
  ```
205
210
 
206
- The seal prints one line per record (`sealed <path>`, `already sealed <path>`) or one bare outcome (`no telemetry run`, `telemetry disabled`) and exits 0; print its stdout verbatim in the ship completion message. Any nonzero exit stops the flow before the first landing command, quoting the bin's line: exit 2 (`no record at <path>`, `unparseable record <path>: <reason>`) means this gauntlet run's recorder never armed or its record is corrupt; exit 1 is a usage, environment, or git failure (`seal failed <path>: <reason>` restored the pre-seal bytes). The user decides; never land without the seal.
211
+ The seal prints one line per record (`sealed <path>`, `already sealed <path>`) or one bare outcome (`no telemetry run`, `telemetry disabled`) and exits 0; print its stdout verbatim in the ship completion message. Any nonzero exit stops the flow before the first landing command, quoting the bin's line: exit 2 (`no record at <path>`, `unparseable record <path>: <reason>`) means this gauntlet run's recorder never armed or its record is corrupt; exit 1 is a usage, environment, or git/jj failure (`seal failed <path>: <reason>` restored the pre-seal bytes). The user decides; never land without the seal.
207
212
 
208
213
  #### Option 1: Push and Create PR
209
214
 
@@ -65,7 +65,7 @@ For each task in `plan_tracker`:
65
65
  6. If the quality review is issue-bearing → re-dispatch the implementer per `/skill:requesting-code-review` Fix rounds → re-review. Loop until ✅, within [Fix-Loop Rounds](#fix-loop-rounds).
66
66
  7. After its existing reviews accept the work (and its required commit point), mark that same task index `complete` in `plan_tracker`. A subagent exit or green tests alone are not acceptance.
67
67
 
68
- The orchestrator is the spec's only writer during execution. Amendment trigger: implementer `BLOCKED` citing a spec defect, or a review finding showing the spec (not the code) is wrong -> pause the fix loop, execute brainstorming's [amendment path](../brainstorming/SKILL.md#amending-an-approved-spec) in place (it reviews first, stops only on escalation or redraw), resume. Code-vs-spec mismatch stays in the SR loop. An SR unable to read the spec at a cited anchor (missing file, unresolvable heading/range) returns a blocking finding — the contract is spec+task or stop, never a silent fallback to task-only review.
68
+ The orchestrator is the spec's only writer during execution. Amendment trigger: implementer `BLOCKED` citing a spec defect, or a review finding showing the spec (not the code) is wrong -> pause the fix loop, `read` and execute brainstorming's [amendment path](../brainstorming/SKILL.md#amending-an-approved-spec) in place (it reviews first, stops only on escalation or redraw), resume. Code-vs-spec mismatch stays in the SR loop. An SR unable to read the spec at a cited anchor (missing file, unresolvable heading/range) returns a blocking finding — the contract is spec+task or stop, never a silent fallback to task-only review.
69
69
 
70
70
  After all tasks: proceed to [After All Tasks](#after-all-tasks-complete) - it owns parent full verification, then whole-diff code review, then conformance.
71
71