pi-gauntlet 7.0.0 → 7.0.2
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/package.json +1 -1
- package/skills/brainstorming/SKILL.md +1 -1
- package/skills/finishing-a-development-branch/SKILL.md +7 -2
- package/skills/gatekeep-pr/reference/assessment.md +1 -1
- package/skills/gatekeep-pr/reference/decision-menu.md +1 -1
- package/skills/gatekeep-pr/reference/findings.md +4 -2
- package/skills/gatekeep-pr/reference/report.md +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v7.0.2 - 2026-10-02
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `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`.
|
|
8
|
+
|
|
9
|
+
## v7.0.1 - 2026-10-02
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **gatekeep-pr: cross-repo split is explicit.** `reference/findings.md` "Whole or part": a PR in another repository, named in the ticket body or a human ticket comment, waives exactly the rows whose mechanism lives there; a PR named only in this PR's body stays a lead. The `gap` table row carries the same exception. Before, a two-repo ticket produced an unfixable blocker per row owned by the other repo.
|
|
14
|
+
- **gatekeep-pr: one defect, one blocker.** A missing mechanism that surfaces as reviewer finding, AC `gap`, doc drift, and `contradicted` claim at once renders as one blocker with every locator (`reference/findings.md` "Namespaces").
|
|
15
|
+
- **gatekeep-pr: `Delivers` composes.** The coverage, split, and observation clauses are optional and joined with semicolons, so "covers none of the ACs" and "whole + observation half" both render (`reference/report.md`). The compose hint lists only compositions whose verbs are rendered rows (`reference/decision-menu.md`). `## PR gate` `verification command` accepts one command per subproject, resolved by the diff's paths (`reference/assessment.md`).
|
|
16
|
+
|
|
3
17
|
## v7.0.0 - 2026-10-01
|
|
4
18
|
|
|
5
19
|
### Changed
|
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
|
+
}
|
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
|
|
|
@@ -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
|
|
|
@@ -25,7 +25,7 @@ Resolve per concern, first match wins, evaluated unconditionally - never delegat
|
|
|
25
25
|
|
|
26
26
|
```markdown
|
|
27
27
|
## PR gate
|
|
28
|
-
- verification command: <command> # required unless documented elsewhere
|
|
28
|
+
- verification command: <command> # required unless documented elsewhere; one command per subproject ("<dir>: <command>") resolves by the diff's paths, every touched subproject runs
|
|
29
29
|
- timeout minutes: 15 # optional; default 15
|
|
30
30
|
- requires credentials: false # optional; true => the gate reports "not run" as missing evidence
|
|
31
31
|
- local verification: always # optional; default (absent) = CI-first; "always" forces the local run even when exact-head CI is green
|
|
@@ -21,7 +21,7 @@ Rows are `<n>. <verb> - <consequence>`; every row, `stop` and `(not available: .
|
|
|
21
21
|
- `wait` - poll the reviewer run, the pending required checks, and the comment set, then re-render
|
|
22
22
|
- `stop` - leave the PR as-is
|
|
23
23
|
|
|
24
|
-
Compose hint: `Type a number, or compose: "fix 2", "fix nits", "fix + nits", "fix 1 + reply".` A composed line never bundles a push-producing verb (`fix`, `push`) with `merge`. When a failing check withholds `merge`, the hint line also offers `check <name> flaky | real | ci-broken`, which records the disposition on that check (`findings.md` `## Dispositions`): `flaky` lifts the blocker, `real` keeps it until green, `ci-broken` triggers the fallback local run (`ci-broken` records `ci-infrastructure-broken`).
|
|
24
|
+
Compose hint: `Type a number, or compose: "fix 2", "fix nits", "fix + nits", "fix 1 + reply".` The hint lists only compositions whose every verb is a rendered row: no `Nits` section, no `fix nits`; no `reply` row, no `fix 1 + reply`; no composable row, `Type a number.` alone. A composed line never bundles a push-producing verb (`fix`, `push`) with `merge`. When a failing check withholds `merge`, the hint line also offers `check <name> flaky | real | ci-broken`, which records the disposition on that check (`findings.md` `## Dispositions`): `flaky` lifts the blocker, `real` keeps it until green, `ci-broken` triggers the fallback local run (`ci-broken` records `ci-infrastructure-broken`).
|
|
25
25
|
|
|
26
26
|
A composed line naming a `(not available: ...)` row is refused by name and the menu re-renders; the two `merge anyway` forms are the only overrides.
|
|
27
27
|
|
|
@@ -15,7 +15,7 @@ Split each AC row once into a **mechanism half** (code + test + doc that make th
|
|
|
15
15
|
| Outcome | Condition | Renders as | Blocks |
|
|
16
16
|
|---|---|---|---|
|
|
17
17
|
| `covered` | evidence matches what the AC promises: executable behavior needs the code path plus a real test that exercises it; a documentation-only AC is judged against the promised doc text and demands no test; in both cases docs that describe the behavior agree with it | counted in `Delivers` | no |
|
|
18
|
-
| `gap` | any part of the mechanism absent in this PR | one `Blockers` item | yes - a mechanism `gap` is a blocker |
|
|
18
|
+
| `gap` | any part of the mechanism absent in this PR and the row not explicitly split (see Whole or part) | one `Blockers` item | yes - a mechanism `gap` is a blocker |
|
|
19
19
|
| `not judged here` | the observation half; the mechanism half of the same row is still judged `covered`/`gap` | one `Delivers` clause ("<row>'s observable half is checked after merge, not here"); nothing is written, listed, or handed to check-delivery | no |
|
|
20
20
|
| `impossible` | a `gap` whose fix is on the ticket - all four conditions below hold | one `Ticket changes` item with a drafted replacement AC text, in state `drafted` or `proposed` | withholds `merge` until the ticket body changes or the human picks `merge anyway - accept AC<n> as impossible` |
|
|
21
21
|
|
|
@@ -25,7 +25,7 @@ Split each AC row once into a **mechanism half** (code + test + doc that make th
|
|
|
25
25
|
|
|
26
26
|
**No ticket** (none linked, or fetch failed): no AC rows; `Delivers` states the PR's intent as read from its title and body; `not judged here`, `impossible`, `Ticket changes`, and scope creep do not apply.
|
|
27
27
|
|
|
28
|
-
**Whole or part.** With a ticket linked, `Delivers` names coverage: *whole* when every row is `covered` or is `not judged here` with its mechanism half `covered`; *part, acceptable* when every uncovered row is either an observation half or **explicitly split** - the ticket body or a human-authored ticket comment names another tracker ref for that row
|
|
28
|
+
**Whole or part.** With a ticket linked, `Delivers` names coverage: *whole* when every row is `covered` or is `not judged here` with its mechanism half `covered`; *part, acceptable* when every uncovered row is either an observation half or **explicitly split** - the ticket body or a human-authored ticket comment, read from the tracker this run, names another tracker ref or a PR in another repository for that row; a cross-repo PR named there waives exactly the rows whose mechanism lives in that repository. Only the tracker waives an obligation: a spec `deferred: <where>`, a later PR in the same repository, a PR named only in this PR's body, or a `proposed` (not yet `resolved`) ticket change is a lead to check the tracker, never a waiver. Any other uncovered row is `gap`: "a later PR will add X" in the PR body, an unchecked box with no tracker split, a `deferred:` the ticket does not confirm.
|
|
29
29
|
|
|
30
30
|
**Claims.** The Verifier's three dispositions stand. `contradicted` is a blocker. `unverifiable-pre-merge` is not evidence and renders nothing: a PR whose only proof of a new path is "verified on stg" is blocked by the untested-path rubric row, not by a claim rule.
|
|
31
31
|
|
|
@@ -40,6 +40,8 @@ Every finding is one of two:
|
|
|
40
40
|
|
|
41
41
|
Behavior the ticket promises is a blocker or an explicit tracker split - never deferred to a PR nobody opened.
|
|
42
42
|
|
|
43
|
+
**One defect, one blocker.** A missing mechanism surfaces through several rules at once (reviewer finding, AC `gap`, doc drift, `contradicted` claim); render it as one blocker that names the defect and lists every locator, and keep each contributing rule's ID in the ledger.
|
|
44
|
+
|
|
43
45
|
**Severity translation.** Reviewer Critical and Moderate -> blocker; Minor -> nit. A repo `REVIEW.md` mapping overrides this; a severity it names but does not map is fail-safe blocker, noted in `show evidence`.
|
|
44
46
|
|
|
45
47
|
**Triage bar** for findings the orchestrator originates itself: blocker only when it must be fixed before merge (correctness, security, a material performance trap, a convention the repo enforces); everything else is a nit, whatever its category. The bar never re-triages a classification the severity translation already made.
|
|
@@ -9,9 +9,11 @@ Render bottom-up for a terminal: least important first, verdict and menu last. E
|
|
|
9
9
|
Section order is fixed:
|
|
10
10
|
|
|
11
11
|
```
|
|
12
|
-
Delivers: <one
|
|
13
|
-
|
|
14
|
-
"
|
|
12
|
+
Delivers: <one sentence: what the PR does. With a ticket, a second sentence built from these clauses in
|
|
13
|
+
order, each present only when it applies: coverage ("The PR covers the whole <ref>" | "covers
|
|
14
|
+
AC1-2 of <ref>" | "covers none of <ref>'s ACs"); split ("AC3 belongs to <split ref>");
|
|
15
|
+
observation ("AC4's observable half (<what>) is checked after merge, not here"); joined with
|
|
16
|
+
semicolons.>
|
|
15
17
|
|
|
16
18
|
Ticket changes: (one item per unresolved `impossible` row)
|
|
17
19
|
- AC<n> asks for <X>, and <cited constraint>. Proposed wording: "<new AC text>". (drafted | proposed <comment url>; <source>)
|