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 +14 -0
- package/README.md +1 -1
- package/bin/gauntlet-telemetry-seal.mjs +194 -53
- package/bin/gauntlet-telemetry-seal.test.mjs +165 -1
- package/extensions/lib/telemetry-diff.ts +3 -2
- package/extensions/lib/vcs.ts +74 -0
- package/extensions/phase-tracker.test.ts +108 -0
- package/extensions/phase-tracker.ts +51 -24
- package/extensions/test-support/pi-stubs.mjs +6 -3
- package/package.json +1 -1
- package/skills/brainstorming/SKILL.md +4 -2
- package/skills/brainstorming/reference/amendment-surface.md +6 -4
- package/skills/finishing-a-development-branch/SKILL.md +8 -3
- package/skills/subagent-driven-development/SKILL.md +1 -1
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
|
|
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
|
|
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
|
-
|
|
260
|
-
|
|
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 (!
|
|
439
|
+
if (!existsSync2(file)) return {};
|
|
294
440
|
try {
|
|
295
441
|
return JSON.parse(readFileSync(file, "utf8"));
|
|
296
442
|
} catch (e) {
|
|
297
|
-
|
|
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 =
|
|
304
|
-
const preset = readLayer(
|
|
305
|
-
const repo = readLayer(
|
|
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 (!
|
|
456
|
+
if (!existsSync2(absDir)) return [];
|
|
311
457
|
const out = [];
|
|
312
458
|
for (const e of readdirSync(absDir, { withFileTypes: true })) {
|
|
313
|
-
const p =
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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" ||
|
|
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
|
|
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
|
|
486
|
+
const commit = commitRecordFile(vcs, rec, `telemetry: ${parsed.spec}`);
|
|
487
|
+
if (!commit.ok) {
|
|
342
488
|
writeFileSync(abs, prior);
|
|
343
|
-
|
|
344
|
-
|
|
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(
|
|
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
|
|
357
|
-
if (!
|
|
358
|
-
const root =
|
|
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) &&
|
|
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)
|
|
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 (!
|
|
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(
|
|
373
|
-
const diff =
|
|
374
|
-
if (!diff.ok) fail(1,
|
|
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(
|
|
517
|
+
const records = walkRecords(join2(root, dir)).filter((abs) => {
|
|
377
518
|
const parsed = parseRecord(readFileSync(abs, "utf8"));
|
|
378
|
-
if (!parsed)
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error?.code !== "ENOENT") this.errors.push({ scope: "project", error });
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
14
17
|
}
|
|
15
|
-
drainErrors() {
|
|
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
|
@@ -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
|
|
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 ->
|
|
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
|
-
|
|
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>;
|
|
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 `;
|
|
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
|
|
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
|
|