pi-gauntlet 8.3.0 → 8.4.1

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +4 -4
  3. package/bin/gauntlet-performance.mjs +477 -64
  4. package/bin/gauntlet-performance.test.mjs +27 -27
  5. package/bin/gauntlet-spec-index.mjs +290 -47
  6. package/bin/gauntlet-spec-index.test.mjs +42 -18
  7. package/bin/gauntlet-telemetry-seal.mjs +220 -77
  8. package/bin/gauntlet-telemetry-seal.test.mjs +222 -457
  9. package/extensions/lib/gauntlet-settings.test.ts +12 -14
  10. package/extensions/lib/gauntlet-settings.ts +7 -16
  11. package/extensions/lib/telemetry-branch.test.ts +102 -0
  12. package/extensions/lib/telemetry-branch.ts +145 -0
  13. package/extensions/lib/telemetry-diff.test.ts +2 -2
  14. package/extensions/lib/telemetry-diff.ts +1 -1
  15. package/extensions/lib/telemetry-leg.ts +163 -0
  16. package/extensions/lib/telemetry-record.ts +2 -0
  17. package/extensions/lib/vcs.ts +6 -23
  18. package/extensions/phase-tracker.test.ts +6 -3
  19. package/extensions/phase-tracker.ts +2 -0
  20. package/extensions/plan-tracker.test.ts +4 -2
  21. package/extensions/plan-tracker.ts +1 -0
  22. package/extensions/telemetry-leg-failures.test.ts +138 -0
  23. package/extensions/telemetry-leg-fields.test.ts +130 -0
  24. package/extensions/telemetry-leg-no-write.test.ts +90 -0
  25. package/extensions/telemetry-leg-rounds.test.ts +157 -0
  26. package/extensions/telemetry-leg-trackers.test.ts +114 -0
  27. package/extensions/telemetry-leg.test.ts +151 -0
  28. package/extensions/telemetry-settlement-contract.test.ts +67 -0
  29. package/extensions/telemetry.test.ts +44 -27
  30. package/extensions/telemetry.ts +68 -48
  31. package/extensions/test-support/git-env.mjs +13 -0
  32. package/package.json +1 -1
  33. package/skills/brainstorming/SKILL.md +1 -1
  34. package/skills/check-delivery/SKILL.md +2 -0
  35. package/skills/finishing-a-development-branch/SKILL.md +13 -13
  36. package/skills/finishing-a-development-branch/reference/verification-set.md +2 -2
  37. package/skills/gatekeep-pr/reference/batch.md +3 -1
  38. package/skills/gatekeep-pr/reference/cleanup.md +1 -1
  39. package/skills/gatekeep-pr/reference/post-selection-loop.md +7 -1
  40. package/skills/gauntlet-resume/reference/seed.md +1 -6
  41. package/skills/shape-ticket/SKILL.md +1 -1
  42. package/skills/shape-ticket/reference/repo-audit.md +2 -2
@@ -1,546 +1,311 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, chmodSync, realpathSync, symlinkSync } from "node:fs";
3
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, realpathSync, symlinkSync, chmodSync } from "node:fs";
4
4
  import { join, dirname } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { parse as parseYaml } from "yaml";
9
+ import { isolatedGitEnv } from "../extensions/test-support/git-env.mjs";
9
10
 
10
11
  const CLI = join(dirname(fileURLToPath(import.meta.url)), "gauntlet-telemetry-seal.mjs");
11
12
  const SPEC = "doc/specs/x.md";
12
- const REC = ".pi/gauntlet/telemetry/doc/specs/x.yaml";
13
- const SPEC2 = "docs/specs/y.md";
14
- const REC2 = ".pi/gauntlet/telemetry/docs/specs/y.yaml";
15
- const RECORD = [
16
- "schema: 1",
17
- "spec: doc/specs/x.md",
18
- "run_id: r-1",
19
- "status: in_progress",
20
- "created_at: 2026-09-17T16:00:00Z",
21
- "sessions: [s1]",
22
- "derived:",
23
- " duration_s: 60",
24
- " phases: {}",
25
- " personas: {}",
26
- " conformance_loops: 0",
27
- " gates: { spec_rounds: 0, plan_rounds: 0, fix_round_grants: 0, task_reopens: 0 }",
28
- " amendments: 0",
29
- " spec_edits_after_ship: 0",
30
- " spec_writes: {}",
31
- " events_dropped: 0",
32
- "accumulators: {}",
33
- "events:",
34
- " - { ts: 2026-09-17T16:00:00Z, session: s1, phase: brainstorm, kind: phase, action: start, name: brainstorm }",
35
- "",
36
- ].join("\n");
13
+ const REC = ".git/gauntlet/telemetry/doc/specs/x.yaml";
14
+ const REF = "refs/heads/gauntlet/telemetry";
15
+ const BLOB = "team/product/brainstorming/doc/specs/x.yaml";
16
+ const RECORD = `schema: 1
17
+ spec: doc/specs/x.md
18
+ run_id: r-1
19
+ status: in_progress
20
+ created_at: 2026-09-17T16:00:00Z
21
+ sessions: [s1]
22
+ derived:
23
+ duration_s: 60
24
+ phases: {}
25
+ personas: {}
26
+ conformance_loops: 0
27
+ gates: { spec_rounds: 0, plan_rounds: 0, fix_round_grants: 0, task_reopens: 0 }
28
+ amendments: 0
29
+ spec_edits_after_ship: 0
30
+ spec_writes: {}
31
+ events_dropped: 0
32
+ accumulators: {}
33
+ events:
34
+ - { ts: 2026-09-17T16:00:00Z, session: s1, phase: brainstorm, kind: phase, action: start, name: brainstorm }
35
+ `;
37
36
  const SEALED = RECORD.replace("status: in_progress", "status: shipped\nshipped_at: 2026-09-17T18:00:00Z");
38
-
39
- const git = (cwd, args, env = {}) =>
40
- spawnSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...args], { cwd, encoding: "utf8", env: { ...process.env, ...env } });
41
- const out = (cwd, args) => git(cwd, args).stdout.trim();
37
+ const EMPTY_AGENT = mkdtempSync(join(tmpdir(), "gts-env-"));
38
+ const GIT_ENV = isolatedGitEnv(EMPTY_AGENT);
39
+ process.on("exit", () => rmSync(EMPTY_AGENT, { recursive: true, force: true }));
40
+ const git = (root, args) => {
41
+ const r = spawnSync("git", ["-c", "commit.gpgsign=false", ...args], { cwd: root, encoding: "utf8", env: { ...process.env, ...GIT_ENV } });
42
+ assert.equal(r.status, 0, r.stderr);
43
+ return r.stdout.trim();
44
+ };
42
45
  const write = (root, rel, text) => {
43
46
  mkdirSync(join(root, dirname(rel)), { recursive: true });
44
47
  writeFileSync(join(root, rel), text);
45
48
  };
46
- const commit = (root, msg, ...paths) => {
47
- git(root, ["add", "-f", "--", ...paths]);
48
- git(root, ["commit", "-q", "-m", msg, "--", ...paths]);
49
- return out(root, ["rev-parse", "HEAD"]);
50
- };
51
- // main: README only. feat: spec + src/a.ts; the record is untracked unless a test commits it.
52
- const repo = (options = {}) => {
53
- const record = Object.hasOwn(options, "record") ? options.record : RECORD;
54
- const root = mkdtempSync(join(tmpdir(), "gts-"));
55
- git(root, ["init", "-q", "-b", "main"]);
56
- write(root, "README.md", "# fixture\n");
57
- commit(root, "init", "README.md");
58
- git(root, ["checkout", "-q", "-b", "feat"]);
59
- write(root, SPEC, "# Spec X\n\n**Goal:** g.\n");
60
- write(root, "src/a.ts", "export const a = 1;\n");
61
- commit(root, "Add spec and code", SPEC, "src/a.ts");
62
- if (record !== undefined) write(root, REC, record);
63
- return root;
49
+ const commit = (root, ...paths) => {
50
+ git(root, ["add", "--", ...paths]);
51
+ git(root, ["commit", "-qm", "fixture"]);
64
52
  };
65
- const docsRepo = () => {
53
+ const repo = (t, { record = RECORD, spec = SPEC } = {}) => {
66
54
  const root = mkdtempSync(join(tmpdir(), "gts-"));
55
+ t.after(() => rmSync(root, { recursive: true, force: true }));
67
56
  git(root, ["init", "-q", "-b", "main"]);
57
+ git(root, ["remote", "add", "origin", "https://example.test/team/product.git"]);
58
+ git(root, ["config", `url.${root}.insteadOf`, "https://example.test/team/product.git"]);
68
59
  write(root, "README.md", "# fixture\n");
69
- commit(root, "init", "README.md");
70
- git(root, ["checkout", "-q", "-b", "feat"]);
71
- write(root, SPEC2, "# Spec Y\n\n**Goal:** g.\n");
60
+ commit(root, "README.md");
61
+ git(root, ["checkout", "-qb", "feat"]);
62
+ write(root, spec, "# Spec\n\n**Goal:** g.\n");
72
63
  write(root, "src/a.ts", "export const a = 1;\n");
73
- write(root, "docs/plans/x.md", "# Plan X\n");
74
- commit(root, "Add spec and code", SPEC2, "src/a.ts", "docs/plans/x.md");
75
- write(root, REC2, RECORD.replace("spec: doc/specs/x.md", `spec: ${SPEC2}`));
64
+ write(root, "docs/plans/x.md", "# Plan\n");
65
+ commit(root, spec, "src/a.ts", "docs/plans/x.md");
66
+ if (record !== null) write(root, `.git/gauntlet/telemetry/${spec.replace(/\.md$/, ".yaml")}`, record.replace(`spec: ${SPEC}`, `spec: ${spec}`));
76
67
  return root;
77
68
  };
78
- const EMPTY_AGENT = mkdtempSync(join(tmpdir(), "gts-empty-agent-"));
79
- process.on("exit", () => rmSync(EMPTY_AGENT, { recursive: true, force: true }));
80
- const IDENTITY = { GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" };
81
69
  const invoke = (args, env = {}) => {
82
- const r = spawnSync(process.execPath, [CLI, ...args], { encoding: "utf8", env: { ...process.env, ...IDENTITY, PI_CODING_AGENT_DIR: EMPTY_AGENT, ...env } });
83
- return { status: r.status, stdout: r.stdout.trim(), stderr: r.stderr.trim(), lines: r.stdout.split("\n").filter(Boolean) };
70
+ const r = spawnSync(process.execPath, [CLI, ...args], { encoding: "utf8", env: { ...process.env, ...GIT_ENV, PI_CODING_AGENT_DIR: EMPTY_AGENT, ...env } });
71
+ return { status: r.status, stderr: r.stderr.trim(), lines: r.stdout.split("\n").filter(Boolean) };
84
72
  };
85
73
  const run = (root, args = [], env = {}) => invoke(["--worktree", root, "--option", "squash", "--base", "main", ...args], env);
86
- const head = (root) => out(root, ["rev-parse", "HEAD"]);
87
- const commitCount = (root) => Number(out(root, ["rev-list", "--count", "HEAD"]));
88
- const headSubject = (root) => out(root, ["log", "-1", "--format=%s"]);
89
- const headFiles = (root) => out(root, ["show", "--name-only", "--format=", "HEAD"]).split("\n").filter(Boolean);
90
- const porcelain = (root) => out(root, ["status", "--porcelain", "--untracked-files=all"]);
91
- const record = (root) => parseYaml(readFileSync(join(root, REC), "utf8"));
92
- const cleanup = (t, ...roots) => t.after(() => roots.forEach((r) => rmSync(r, { recursive: true, force: true })));
74
+ const blob = (root, spec = SPEC) => git(root, ["show", `${REF}:team/product/brainstorming/${spec.replace(/\.md$/, ".yaml")}`]);
75
+ const record = (root) => parseYaml(blob(root));
76
+ const head = (root) => git(root, ["rev-parse", "HEAD"]);
77
+ const recPath = (root, spec = SPEC) => join(realpathSync(root), `.git/gauntlet/telemetry/${spec.replace(/\.md$/, ".yaml")}`);
93
78
 
94
- test("seals an untracked in_progress record: stamp, ship event, diff, re-derived duration, one telemetry: commit touching only the record", (t) => {
95
- const root = repo(); cleanup(t, root);
96
- const n = commitCount(root);
79
+ test("seals the common-dir record: stamp, ship event, diff and re-derived duration in an orphan commit only", (t) => {
80
+ const root = repo(t);
81
+ const before = head(root);
82
+ const index = readFileSync(join(root, ".git/index"));
97
83
  const r = run(root, ["--spec", SPEC]);
98
84
  assert.equal(r.status, 0, r.stderr);
99
- assert.deepEqual(r.lines, [`sealed ${REC}`]);
85
+ assert.deepEqual(r.lines, [`sealed ${recPath(root)}`]);
100
86
  const rec = record(root);
101
87
  assert.equal(rec.status, "shipped");
102
88
  assert.match(rec.shipped_at, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
103
89
  assert.equal(rec.derived.gates.ship_option, "squash");
104
- assert.equal(typeof rec.derived.gates.ship_option, "string");
105
90
  assert.equal(rec.events.at(-1).kind, "ship");
106
91
  assert.equal(rec.events.at(-1).option, "squash");
107
92
  assert.deepEqual(rec.derived.modified_files, ["src/a.ts"]);
108
- assert.equal(rec.derived.diff.commits, 1, "the seal commit is excluded from the count");
93
+ assert.equal(rec.derived.diff.commits, 1);
109
94
  assert.equal(rec.derived.diff.buckets.code.files, 1);
110
- // created_at is 2026-09-17; sealing now re-derives duration_s from created_at to shipped_at, far above the recorded 60.
111
- assert.ok(rec.derived.duration_s > 60, `duration re-derived at seal time, got ${rec.derived.duration_s}`);
112
- assert.equal(commitCount(root), n + 1);
113
- assert.equal(headSubject(root), `telemetry: ${SPEC}`);
114
- assert.deepEqual(headFiles(root), [REC]);
115
- assert.equal(porcelain(root), "");
95
+ assert.ok(rec.derived.duration_s > 60);
96
+ assert.equal(head(root), before);
97
+ assert.deepEqual(readFileSync(join(root, ".git/index")), index);
98
+ assert.equal(git(root, ["status", "--porcelain"]), "");
99
+ assert.equal(git(root, ["rev-list", "--parents", "-1", REF]).split(" ").length, 1);
100
+ assert.equal(git(root, ["ls-tree", "-r", "--name-only", REF]), BLOB);
101
+ assert.equal(existsSync(join(root, REC)), false, "in-flight file removed only after commit");
116
102
  });
117
103
 
118
104
  test("shipped CLI records companion legs and validates complete retry sets", (t) => {
119
- const root = repo(); cleanup(t, root);
105
+ const root = repo(t);
120
106
  const args = ["--spec", SPEC, "--leg", "repo-b=abc1234", "--leg", "repo-c=https://example.test/pull/2?q=yes"];
121
- const result = run(root, args);
122
- assert.equal(result.status, 0, result.stderr);
123
- assert.deepEqual(record(root).legs, [
124
- { repo: "repo-b", sha: "abc1234" },
125
- { repo: "repo-c", pr: "https://example.test/pull/2?q=yes" },
126
- ]);
127
- const sealedHead = head(root);
107
+ assert.equal(run(root, args).status, 0);
108
+ assert.deepEqual(record(root).legs, [{ repo: "repo-b", sha: "abc1234" }, { repo: "repo-c", pr: "https://example.test/pull/2?q=yes" }]);
109
+ const tip = git(root, ["rev-parse", REF]);
128
110
  assert.equal(run(root, ["--spec", SPEC, "--leg", args[5], "--leg", args[3]]).status, 0);
129
- assert.equal(head(root), sealedHead);
130
111
  for (const legs of [[], ["--leg", "repo-b=abc1234"], ["--leg", "repo-b=changed", "--leg", args[5]]]) {
131
112
  const retry = run(root, ["--spec", SPEC, ...legs]);
132
113
  assert.equal(retry.status, 1);
133
114
  assert.match(retry.stderr, /legs differ/);
134
- assert.equal(head(root), sealedHead);
135
115
  }
136
- assert.deepEqual(headFiles(root), [REC]);
116
+ assert.equal(git(root, ["rev-parse", REF]), tip);
117
+ });
118
+
119
+ test("branch-backed retries use a fetched descendant without discarding pending local records", (t) => {
120
+ const root = repo(t);
121
+ const remote = mkdtempSync(join(tmpdir(), "gts-remote-"));
122
+ t.after(() => rmSync(remote, { recursive: true, force: true }));
123
+ git(root, ["init", "--bare", remote]);
124
+ git(root, ["config", `url.${root}.insteadOf`, "unused"]);
125
+ git(root, ["config", `url.${remote}.insteadOf`, "https://example.test/team/product.git"]);
126
+ assert.equal(run(root, ["--spec", SPEC]).status, 0);
127
+ const ancestor = git(root, ["rev-parse", REF]);
128
+ const other = "doc/specs/other.md";
129
+ write(root, `.git/gauntlet/telemetry/${other.replace(/\.md$/, ".yaml")}`, RECORD.replace(SPEC, other));
130
+ const args = ["--spec", other, "--leg", "repo-b=abc1234"];
131
+ assert.equal(run(root, args).status, 0);
132
+ const published = git(remote, ["rev-parse", REF]);
133
+ git(root, ["update-ref", REF, ancestor]);
134
+ const retry = run(root, args);
135
+ assert.equal(retry.status, 0, retry.stderr);
136
+ assert.deepEqual(retry.lines, [`already sealed ${recPath(root, other)}`]);
137
+ const mismatch = run(root, ["--spec", other]);
138
+ assert.equal(mismatch.status, 1);
139
+ assert.match(mismatch.stderr, /legs differ/);
140
+ assert.equal(git(remote, ["rev-parse", REF]), published, "retry adds no commit");
141
+
142
+ git(root, ["config", `url.${remote}.insteadOf`, "unused"]);
143
+ git(root, ["config", `url.${join(remote, "missing")}.insteadOf`, "https://example.test/team/product.git"]);
144
+ const pending = "doc/specs/pending.md";
145
+ write(root, `.git/gauntlet/telemetry/${pending.replace(/\.md$/, ".yaml")}`, RECORD.replace(SPEC, pending));
146
+ assert.match(run(root, ["--spec", pending]).lines.join("\n"), /telemetry: not pushed/);
147
+ const offlineRetry = run(root, ["--spec", pending]);
148
+ assert.equal(offlineRetry.status, 0, offlineRetry.stderr);
149
+ assert.match(offlineRetry.lines.join("\n"), /telemetry: not pushed/);
150
+ assert.doesNotMatch(offlineRetry.lines.join("\n"), /no telemetry run/);
151
+ });
152
+
153
+ test("source cleanup failure after publication warns without blocking the ship", (t) => {
154
+ const root = repo(t);
155
+ const source = recPath(root);
156
+ write(root, ".git/hooks/reference-transaction", `#!/bin/sh
157
+ if [ "$1" = committed ]; then
158
+ while read old new ref; do
159
+ if [ "$ref" = "${REF}" ] && [ -f "${source}" ]; then
160
+ rm "${source}"
161
+ mkdir "${source}"
162
+ fi
163
+ done
164
+ fi
165
+ `);
166
+ chmodSync(join(root, ".git/hooks/reference-transaction"), 0o755);
167
+ const result = run(root, ["--spec", SPEC]);
168
+ assert.equal(result.status, 0, result.stderr);
169
+ assert.match(result.stderr, /telemetry: not pushed.*(?:EISDIR|EPERM)/);
170
+ assert.equal(record(root).status, "shipped", "the published record survives failed cleanup");
171
+ assert.equal(git(root, ["rev-parse", REF]), git(root, ["rev-parse", "refs/remotes/origin/gauntlet/telemetry"]));
137
172
  });
138
173
 
139
174
  test("shipped CLI rejects malformed leg values before sealing", () => {
140
175
  for (const value of ["missing", "=abc", "repo=", "repo= "]) {
141
- const result = invoke(["--leg", value]);
142
- assert.equal(result.status, 1);
143
- assert.match(result.stderr, /invalid --leg/);
144
- assert.ok(result.stderr.includes(value.trim()));
176
+ const r = invoke(["--leg", value]);
177
+ assert.equal(r.status, 1);
178
+ assert.match(r.stderr, /invalid --leg/);
179
+ assert.ok(r.stderr.includes(value.trim()));
145
180
  }
146
181
  });
147
182
 
148
- test("docs/specs record seals with --spec (no path classification)", (t) => {
149
- const root = docsRepo(); cleanup(t, root);
150
- const r = run(root, ["--spec", SPEC2]);
151
- assert.equal(r.status, 0, r.stderr);
152
- assert.deepEqual(r.lines, [`sealed ${REC2}`]);
153
- });
154
-
155
- test("docs/specs record seals without --spec: candidates are records whose spec: is in the changed set", (t) => {
156
- const root = docsRepo(); cleanup(t, root);
157
- const r = run(root);
158
- assert.equal(r.status, 0, r.stderr);
159
- assert.deepEqual(r.lines, [`sealed ${REC2}`]);
160
- const rec = parseYaml(readFileSync(join(root, REC2), "utf8"));
161
- assert.ok(!rec.derived.modified_files.includes("docs/plans/x.md"), "docs/plans excluded from modified_files");
162
- });
163
-
164
- test("renamed spec still selects the record at its old path", (t) => {
165
- const root = mkdtempSync(join(tmpdir(), "gts-")); cleanup(t, root);
166
- git(root, ["init", "-q", "-b", "main"]);
167
- write(root, SPEC, "# Spec X\n\n**Goal:** g.\n");
168
- commit(root, "Add original spec", SPEC);
169
- git(root, ["checkout", "-q", "-b", "feat"]);
170
- assert.equal(git(root, ["mv", SPEC, "doc/specs/renamed.md"]).status, 0);
171
- commit(root, "Rename spec", SPEC, "doc/specs/renamed.md");
172
- write(root, REC, RECORD);
173
- const r = run(root);
174
- assert.equal(r.status, 0, r.stderr);
175
- assert.deepEqual(r.lines, [`sealed ${REC}`]);
183
+ test("docs/specs records seal with explicit and changed-spec selection, excluding plans", async (t) => {
184
+ for (const explicit of [true, false]) await t.test(explicit ? "explicit" : "discovered", (t) => {
185
+ const spec = "docs/specs/y.md";
186
+ const root = repo(t, { spec });
187
+ const r = run(root, explicit ? ["--spec", spec] : []);
188
+ assert.equal(r.status, 0, r.stderr);
189
+ assert.deepEqual(r.lines, [`sealed ${recPath(root, spec)}`]);
190
+ assert.deepEqual(parseYaml(blob(root, spec)).derived.modified_files, ["src/a.ts"]);
191
+ });
176
192
  });
177
193
 
178
- test("unparseable walked record warns while another candidate seals", (t) => {
179
- const root = repo(); cleanup(t, root);
180
- const bad = ".pi/gauntlet/telemetry/bad.yaml";
181
- write(root, bad, "schema: 1\nspec: doc/specs/x.md\n");
194
+ test("unrelated and malformed records do not prevent changed-spec selection", (t) => {
195
+ const root = repo(t);
196
+ write(root, ".git/gauntlet/telemetry/bad.yaml", "schema: 1\nspec: bad.md\n");
197
+ write(root, ".git/gauntlet/telemetry/doc/specs/other.yaml", RECORD.replace(SPEC, "doc/specs/other.md"));
182
198
  const r = run(root);
183
199
  assert.equal(r.status, 0, r.stderr);
184
- assert.equal(r.stderr, `warning: unparseable record ${bad}`);
185
- assert.deepEqual(r.lines, [`sealed ${REC}`]);
186
- });
187
-
188
- test("shipped-but-uncommitted docs/specs record is committed on retry without --spec", (t) => {
189
- const root = docsRepo(); cleanup(t, root);
190
- write(root, REC2, RECORD.replace("spec: doc/specs/x.md", `spec: ${SPEC2}`).replace("status: in_progress", "status: shipped\nshipped_at: 2026-09-17T18:00:00Z"));
191
- const r = run(root);
192
- assert.equal(r.status, 0, r.stderr);
193
- assert.deepEqual(r.lines, [`sealed ${REC2}`]);
194
- });
195
-
196
- test("a record whose spec did not change on the branch is ignored", (t) => {
197
- const root = repo(); cleanup(t, root);
198
- write(root, ".pi/gauntlet/telemetry/doc/specs/other.yaml", RECORD.replace("spec: doc/specs/x.md", "spec: doc/specs/other.md"));
199
- const r = run(root);
200
- assert.deepEqual(r.lines, [`sealed ${REC}`]);
200
+ assert.deepEqual(r.lines, [`sealed ${recPath(root)}`]);
201
+ assert.equal(existsSync(join(root, ".git/gauntlet/telemetry/bad.yaml")), true);
202
+ assert.equal(git(root, ["ls-tree", "-r", "--name-only", REF]), BLOB);
201
203
  });
202
204
 
203
205
  test("seal preserves derived.council through the shipped stamp", (t) => {
204
206
  const council = { chair: { model: "p/chair:medium", dispatches: 1, clusters: 0, members_reported: 0 }, members: {} };
205
- const root = repo({ record: RECORD.replace(" spec_writes: {}", ` council: ${JSON.stringify(council)}\n spec_writes: {}`) }); cleanup(t, root);
206
- const r = run(root, ["--spec", SPEC]);
207
- assert.equal(r.status, 0, r.stderr);
208
- assert.equal(record(root).status, "shipped");
207
+ const root = repo(t, { record: RECORD.replace(" spec_writes: {}", ` council: ${JSON.stringify(council)}\n spec_writes: {}`) });
208
+ assert.equal(run(root, ["--spec", SPEC]).status, 0);
209
209
  assert.deepEqual(record(root).derived.council, council);
210
- assert.deepEqual(parseYaml(out(root, ["show", `HEAD:${REC}`])).derived.council, council);
211
210
  });
212
211
 
213
- test("--spec accepts an absolute path within the checkout", (t) => {
214
- const root = repo(); cleanup(t, root);
215
- const r = run(root, ["--spec", join(realpathSync(root), SPEC)]);
216
- assert.equal(r.status, 0, r.stderr);
217
- assert.deepEqual(r.lines, [`sealed ${REC}`]);
212
+ test("--spec resolves absolute, symlinked and dot-relative checkout paths", async (t) => {
213
+ for (const form of ["absolute", "symlink", "relative"]) await t.test(form, (t) => {
214
+ const root = repo(t);
215
+ const links = mkdtempSync(join(tmpdir(), "gts-link-"));
216
+ t.after(() => rmSync(links, { recursive: true, force: true }));
217
+ symlinkSync(root, join(links, "checkout"));
218
+ const spec = form === "absolute" ? join(realpathSync(root), SPEC) : form === "symlink" ? join(links, "checkout", SPEC) : `./${SPEC}`;
219
+ const r = run(root, ["--spec", spec]);
220
+ assert.equal(r.status, 0, r.stderr);
221
+ assert.deepEqual(r.lines, [`sealed ${recPath(root)}`]);
222
+ });
218
223
  });
219
224
 
220
- test("--spec absolute through a symlinked prefix still resolves to the repo-relative record", (t) => {
221
- const root = repo();
222
- const linkDir = mkdtempSync(join(tmpdir(), "gts-link-"));
223
- cleanup(t, root, linkDir);
224
- const link = join(linkDir, "checkout");
225
- symlinkSync(root, link);
226
- const r = run(root, ["--spec", join(link, SPEC)]);
227
- assert.equal(r.status, 0, r.stderr);
228
- assert.deepEqual(r.lines, [`sealed ${REC}`]);
229
- });
230
-
231
- test("--spec accepts a dot-relative path", (t) => {
232
- const root = repo(); cleanup(t, root);
233
- const r = run(root, ["--spec", `./${SPEC}`]);
234
- assert.equal(r.status, 0, r.stderr);
235
- assert.deepEqual(r.lines, [`sealed ${REC}`]);
236
- });
237
-
238
- test("--option pr is recorded; without --spec the candidates are the existing records of changed specs", (t) => {
239
- const root = repo(); cleanup(t, root);
225
+ test("--option pr is recorded when discovering existing changed-spec records", (t) => {
226
+ const root = repo(t);
240
227
  const r = invoke(["--worktree", root, "--option", "pr", "--base", "main"]);
241
228
  assert.equal(r.status, 0, r.stderr);
242
- assert.deepEqual(r.lines, [`sealed ${REC}`]);
243
229
  assert.equal(record(root).derived.gates.ship_option, "pr");
244
230
  });
245
231
 
246
- test("already sealed: a shipped, tracked, clean record is left alone", (t) => {
247
- const root = repo({ record: SEALED }); cleanup(t, root);
248
- commit(root, "telemetry: doc/specs/x.md", REC);
232
+ test("shipped in-flight bytes commit as is, then branch-only retry is a no-op", (t) => {
233
+ const root = repo(t, { record: SEALED });
249
234
  const before = head(root);
250
- const r = run(root, ["--spec", SPEC]);
235
+ const r = run(root);
251
236
  assert.equal(r.status, 0, r.stderr);
252
- assert.deepEqual(r.lines, [`already sealed ${REC}`]);
237
+ assert.deepEqual(r.lines, [`sealed ${recPath(root)}`]);
238
+ assert.equal(blob(root), SEALED.trim());
239
+ const tip = git(root, ["rev-parse", REF]);
240
+ assert.deepEqual(run(root, ["--spec", SPEC]).lines, [`already sealed ${recPath(root)}`]);
241
+ assert.equal(git(root, ["rev-parse", REF]), tip);
253
242
  assert.equal(head(root), before);
254
- assert.equal(record(root).shipped_at, "2026-09-17T18:00:00Z");
255
243
  });
256
244
 
257
- test("an abandoned record is already sealed even when untracked", (t) => {
258
- const root = repo({ record: RECORD.replace("status: in_progress", "status: abandoned") }); cleanup(t, root);
259
- const before = head(root);
260
- const r = run(root, ["--spec", SPEC]);
261
- assert.equal(r.status, 0, r.stderr);
262
- assert.deepEqual(r.lines, [`already sealed ${REC}`]);
263
- assert.equal(head(root), before);
245
+ test("abandoned in-flight records publish without a shipped stamp", (t) => {
246
+ const root = repo(t, { record: RECORD.replace("status: in_progress", "status: abandoned") });
247
+ assert.equal(run(root, ["--spec", SPEC]).status, 0);
264
248
  assert.equal(record(root).status, "abandoned");
249
+ assert.equal(record(root).shipped_at, undefined);
265
250
  });
266
251
 
267
- test("a shipped but untracked record (previous commit failed) is committed as is on retry", (t) => {
268
- const root = repo({ record: SEALED }); cleanup(t, root);
269
- const n = commitCount(root);
270
- const r = run(root, ["--spec", SPEC]);
271
- assert.equal(r.status, 0, r.stderr);
272
- assert.deepEqual(r.lines, [`sealed ${REC}`]);
273
- assert.equal(commitCount(root), n + 1);
274
- assert.equal(record(root).shipped_at, "2026-09-17T18:00:00Z", "bytes committed as they were");
275
- assert.equal(porcelain(root), "");
276
- });
277
-
278
- test("--spec with no record -> no record at <path>, exit 2, no commit", (t) => {
279
- const root = repo({ record: undefined }); cleanup(t, root);
280
- const n = commitCount(root);
281
- const r = run(root, ["--spec", SPEC]);
282
- assert.equal(r.status, 2);
283
- assert.equal(r.stderr, `no record at ${REC}`);
284
- assert.equal(commitCount(root), n);
252
+ test("missing runs and malformed records are non-blocking and remain untouched", async (t) => {
253
+ for (const content of [null, "schema: 1\nspec: doc/specs/x.md\n"]) await t.test(content === null ? "missing" : "malformed", (t) => {
254
+ const root = repo(t, { record: content });
255
+ for (const args of [["--spec", SPEC], []]) {
256
+ const r = run(root, args);
257
+ assert.equal(r.status, 0, r.stderr);
258
+ assert.match(r.lines.join("\n"), content === null ? /no telemetry run/ : /telemetry: skipped \(unparseable record/);
259
+ }
260
+ if (content !== null) assert.equal(readFileSync(recPath(root), "utf8"), content);
261
+ });
285
262
  });
286
263
 
287
- test("unparseable record -> parser message, exit 2, file untouched", (t) => {
288
- const root = repo({ record: "schema: 1\nspec: doc/specs/x.md\n" }); cleanup(t, root);
289
- const r = run(root, ["--spec", SPEC]);
290
- assert.equal(r.status, 2);
291
- assert.match(r.stderr, new RegExp(`^unparseable record ${REC.replace(/\./g, "\\.")}`));
292
- assert.equal(readFileSync(join(root, REC), "utf8"), "schema: 1\nspec: doc/specs/x.md\n");
293
- });
294
-
295
- test("no telemetry run: a changed spec without a record is not a candidate (supersession edit)", (t) => {
296
- const root = repo({ record: undefined }); cleanup(t, root);
297
- const r = run(root);
298
- assert.equal(r.status, 0, r.stderr);
299
- assert.deepEqual(r.lines, ["no telemetry run"]);
300
- });
301
-
302
- test("telemetry disabled -> exit 0, nothing touched", (t) => {
303
- const root = repo(); cleanup(t, root);
264
+ test("telemetry disabled leaves the common-dir record untouched", (t) => {
265
+ const root = repo(t);
304
266
  write(root, ".pi/settings.json", JSON.stringify({ piGauntlet: { telemetry: { enabled: false } } }));
305
- const r = run(root, ["--spec", SPEC]);
306
- assert.equal(r.status, 0, r.stderr);
307
- assert.deepEqual(r.lines, ["telemetry disabled"]);
308
- assert.equal(record(root).status, "in_progress");
267
+ assert.deepEqual(run(root, ["--spec", SPEC]).lines, ["telemetry disabled"]);
268
+ assert.equal(readFileSync(recPath(root), "utf8"), RECORD);
309
269
  });
310
270
 
311
- test("gitignored telemetry dir is staged with -f", (t) => {
312
- const root = repo(); cleanup(t, root);
313
- write(root, ".gitignore", ".pi/gauntlet/telemetry/\n");
314
- commit(root, "ignore telemetry", ".gitignore");
315
- const r = run(root, ["--spec", SPEC]);
316
- assert.equal(r.status, 0, r.stderr);
317
- assert.deepEqual(headFiles(root), [REC]);
318
- });
319
-
320
- test("commit failure (rejecting pre-commit hook) restores the pre-seal bytes, unstages, exits 1", (t) => {
321
- const root = repo(); cleanup(t, root);
322
- const hook = join(root, ".git/hooks/pre-commit");
323
- writeFileSync(hook, "#!/bin/sh\necho rejected >&2\nexit 1\n");
324
- chmodSync(hook, 0o755);
325
- const n = commitCount(root);
326
- const r = run(root, ["--spec", SPEC]);
327
- assert.equal(r.status, 1);
328
- assert.match(r.stderr, /rejected/);
329
- assert.equal(readFileSync(join(root, REC), "utf8"), RECORD, "pre-seal bytes restored");
330
- assert.equal(out(root, ["diff", "--cached", "--name-only"]), "", "nothing left staged");
331
- assert.equal(commitCount(root), n);
332
- });
333
-
334
- test("commit failure restores a previously tracked record and clears the index", (t) => {
335
- const root = repo(); cleanup(t, root);
336
- commit(root, "Track ongoing telemetry", REC);
337
- const hook = join(root, ".git/hooks/pre-commit");
338
- writeFileSync(hook, "#!/bin/sh\necho rejected >&2\nexit 1\n");
339
- chmodSync(hook, 0o755);
340
- const n = commitCount(root);
341
- const r = run(root, ["--spec", SPEC]);
342
- assert.equal(r.status, 1);
343
- assert.match(r.stderr, /rejected/);
344
- assert.equal(readFileSync(join(root, REC), "utf8"), RECORD);
345
- assert.equal(out(root, ["diff", "--cached", "--name-only"]), "");
346
- assert.equal(commitCount(root), n);
271
+ test("failed branch commit keeps the shipped in-flight record for a successful retry", (t) => {
272
+ const root = repo(t);
273
+ const before = head(root);
274
+ const index = readFileSync(join(root, ".git/index"));
275
+ const hook = ".git/hooks/reference-transaction";
276
+ write(root, hook, '#!/bin/sh\nif [ "$1" = prepared ]; then echo rejected >&2; exit 1; fi\n');
277
+ chmodSync(join(root, hook), 0o755);
278
+ const failed = run(root, ["--spec", SPEC]);
279
+ assert.equal(failed.status, 0, failed.stderr);
280
+ assert.match(failed.lines.join("\n"), /telemetry: not pushed/);
281
+ const kept = readFileSync(recPath(root), "utf8");
282
+ assert.equal(parseYaml(kept).status, "shipped");
283
+ assert.equal(head(root), before);
284
+ assert.deepEqual(readFileSync(join(root, ".git/index")), index);
285
+ write(root, hook, "#!/bin/sh\nexit 0\n");
286
+ const retried = run(root, ["--spec", SPEC]);
287
+ assert.equal(retried.status, 0, retried.stderr);
288
+ assert.deepEqual(retried.lines, [`sealed ${recPath(root)}`]);
289
+ assert.equal(blob(root), kept.trim());
290
+ assert.equal(existsSync(recPath(root)), false);
291
+ });
292
+
293
+ test("checkout hooks do not run for plumbing-only telemetry commits", (t) => {
294
+ const root = repo(t);
295
+ write(root, ".git/hooks/pre-commit", "#!/bin/sh\necho rejected >&2\nexit 1\n");
296
+ chmodSync(join(root, ".git/hooks/pre-commit"), 0o755);
297
+ assert.equal(run(root, ["--spec", SPEC]).status, 0);
298
+ assert.equal(record(root).status, "shipped");
347
299
  });
348
300
 
349
301
  test("usage and environment errors exit 1", (t) => {
350
- const root = repo(); cleanup(t, root);
351
- const noArgs = invoke([]);
352
- assert.equal(noArgs.status, 1);
353
- assert.match(noArgs.stderr, /^usage: gauntlet-telemetry-seal --worktree <abs path> --option <pr\|squash> --base <ref> \[--spec <spec path inside the checkout>\] \[--dir <telemetry dir>\]/);
302
+ const root = repo(t);
303
+ assert.match(invoke([]).stderr, /^usage: gauntlet-telemetry-seal/);
354
304
  assert.equal(invoke(["--worktree", root, "--option", "rebase", "--base", "main"]).status, 1);
355
- assert.equal(invoke(["--worktree", root, "--option", "squash"]).status, 1, "--base is required");
356
- const noBase = invoke(["--worktree", root, "--option", "squash", "--base", "nope"]);
357
- assert.equal(noBase.status, 1);
358
- assert.equal(noBase.stderr, "no base ref nope");
359
- const invalid = run(root, ["--spec", "/tmp/elsewhere/doc/specs/x.md"]);
360
- assert.equal(invalid.status, 1);
361
- assert.match(invalid.stderr, /^usage:/);
362
- const noRec = run(root, ["--spec", "README.md"]);
363
- assert.equal(noRec.status, 2);
364
- assert.equal(noRec.stderr, "no record at .pi/gauntlet/telemetry/README.yaml");
365
- write(root, ".pi/settings.json", JSON.stringify({ piGauntlet: { telemetry: { enabled: false } } }));
366
- const disabledInvalid = run(root, ["--spec", "/tmp/elsewhere/doc/specs/x.md"]);
367
- assert.equal(disabledInvalid.status, 1);
368
- assert.match(disabledInvalid.stderr, /^usage:/);
369
- const notGit = mkdtempSync(join(tmpdir(), "gts-nogit-")); cleanup(t, notGit);
370
- const ng = invoke(["--worktree", notGit, "--option", "squash", "--base", "main"]);
371
- assert.equal(ng.status, 1);
372
- assert.equal(ng.stderr, "not a git or jj checkout");
373
- });
374
-
375
- test("--dir overrides the telemetry dir", (t) => {
376
- const root = repo({ record: undefined }); cleanup(t, root);
377
- write(root, "tele/doc/specs/x.yaml", RECORD);
378
- const r = run(root, ["--spec", SPEC, "--dir", "tele"]);
379
- assert.equal(r.status, 0, r.stderr);
380
- assert.deepEqual(r.lines, ["sealed tele/doc/specs/x.yaml"]);
381
- assert.deepEqual(headFiles(root), ["tele/doc/specs/x.yaml"]);
382
- });
383
-
384
- // --- Plain jj workspace (no .git). git and jj are PATH shims: git always fails
385
- // rev-parse and only logs; jj answers from a per-test JSON script. CI has no jj.
386
- const JJ_FORK = "f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9";
387
- // src/a.ts: +10 -2; the spec and the record ride along to prove modified_files filters them out.
388
- const JJ_PATCH = [
389
- "diff --git a/src/a.ts b/src/a.ts",
390
- "index 1111111..2222222 100644",
391
- "--- a/src/a.ts",
392
- "+++ b/src/a.ts",
393
- "@@ -1,4 +1,12 @@",
394
- ...Array.from({ length: 10 }, (_, i) => `+added ${i}`),
395
- "-removed 1",
396
- "-removed 2",
397
- " context",
398
- "diff --git a/doc/specs/x.md b/doc/specs/x.md",
399
- "new file mode 100644",
400
- "index 0000000..3333333",
401
- "--- /dev/null",
402
- "+++ b/doc/specs/x.md",
403
- "@@ -0,0 +1,5 @@",
404
- ...Array.from({ length: 5 }, (_, i) => `+spec ${i}`),
405
- "diff --git a/.pi/gauntlet/telemetry/doc/specs/x.yaml b/.pi/gauntlet/telemetry/doc/specs/x.yaml",
406
- "new file mode 100644",
407
- "index 0000000..4444444",
408
- "--- /dev/null",
409
- "+++ b/.pi/gauntlet/telemetry/doc/specs/x.yaml",
410
- "@@ -0,0 +1,3 @@",
411
- "+schema: 1",
412
- "+spec: doc/specs/x.md",
413
- "+run_id: r-1",
414
- "",
415
- ].join("\n");
416
- const GIT_SHIM = (log) => `#!${process.execPath}
417
- require("node:fs").appendFileSync(${JSON.stringify(log)}, "git " + process.argv.slice(2).join(" ") + "\\n");
418
- process.stderr.write("fatal: not a git repository (or any of the parent directories): .git\\n");
419
- process.exit(128);
420
- `;
421
- const JJ_SHIM = (log, scriptPath) => `#!${process.execPath}
422
- const fs = require("node:fs");
423
- const a = process.argv.slice(2);
424
- fs.appendFileSync(${JSON.stringify(log)}, "jj " + a.join(" ") + "\\n");
425
- const script = JSON.parse(fs.readFileSync(${JSON.stringify(scriptPath)}, "utf8"));
426
- const s = a.join(" ");
427
- 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); };
428
- if (s === "root") send(script.root);
429
- if (s.includes("--limit")) send(script.base);
430
- if (s.includes("--count")) send(script.count);
431
- if (s.includes("fork_point(")) send(script.fork);
432
- if (s.includes("--name-only")) send(script.names);
433
- if (s.startsWith("file track")) send(script.track);
434
- send(script.patch);
435
- `;
436
- const jjWorkspace = (t, script, { recordText = RECORD } = {}) => {
437
- const root = mkdtempSync(join(tmpdir(), "gts-jj-"));
438
- write(root, SPEC, "# Spec X\n\n**Goal:** g.\n");
439
- if (recordText !== undefined) write(root, REC, recordText);
440
- const shims = mkdtempSync(join(tmpdir(), "gts-shim-"));
441
- const log = join(shims, "calls.log");
442
- writeFileSync(log, "");
443
- const scriptPath = join(shims, "script.json");
444
- writeFileSync(scriptPath, JSON.stringify({ root: { code: 0, stdout: root + "\n" }, ...script }));
445
- for (const [name, body] of [["git", GIT_SHIM(log)], ["jj", JJ_SHIM(log, scriptPath)]]) {
446
- const p = join(shims, name);
447
- writeFileSync(p, body);
448
- chmodSync(p, 0o755);
449
- }
450
- cleanup(t, root, shims);
451
- return { root, log, env: { PATH: `${shims}:${process.env.PATH}` } };
452
- };
453
- const happyJj = {
454
- base: { code: 0, stdout: JJ_FORK + "\n" },
455
- fork: { code: 0, stdout: JJ_FORK + "\n" },
456
- count: { code: 0, stdout: "2\n" },
457
- patch: { code: 0, stdout: JJ_PATCH },
458
- track: { code: 0, stdout: "" },
459
- };
460
-
461
- test("jj workspace: record stamped shipped from the jj diff, one git probe, no git writes, file track instead of a commit", (t) => {
462
- const { root, log, env } = jjWorkspace(t, happyJj);
463
- const r = run(root, ["--spec", SPEC], env);
464
- assert.equal(r.status, 0, r.stderr);
465
- assert.deepEqual(r.lines, [`sealed ${REC}`]);
466
- const rec = record(root);
467
- assert.equal(rec.status, "shipped");
468
- assert.equal(rec.events.at(-1).kind, "ship");
469
- assert.equal(rec.events.at(-1).option, "squash");
470
- assert.deepEqual(rec.derived.modified_files, ["src/a.ts"], "spec and telemetry-dir paths filtered");
471
- assert.equal(rec.derived.diff.base, JJ_FORK);
472
- assert.equal(rec.derived.diff.commits, 2);
473
- assert.deepEqual(rec.derived.diff.buckets.code, { files: 1, insertions: 10, deletions: 2 });
474
- const calls = readFileSync(log, "utf8").split("\n").filter(Boolean);
475
- assert.deepEqual(calls.filter((c) => c.startsWith("git ")).map((c) => c.split(" ")[1]), ["rev-parse"], "git is only the failed checkout probe");
476
- assert.ok(calls.some((c) => c === "jj root"));
477
- assert.ok(calls.includes(`jj file track --include-ignored -- ${REC}`), "the record is tracked even under a gitignored dir");
478
- });
479
-
480
- test("jj workspace: a failing file track restores the pre-seal bytes and exits 1", (t) => {
481
- const { root, env } = jjWorkspace(t, { ...happyJj, track: { code: 1, stderr: "Error: Path is not in the workspace\n" } });
482
- const r = run(root, ["--spec", SPEC], env);
483
- assert.equal(r.status, 1);
484
- assert.equal(r.stderr, `seal failed ${REC}: Error: Path is not in the workspace`);
485
- assert.equal(record(root).status, "in_progress");
486
- });
487
-
488
- test("jj workspace with unresolved mainline: warning recorded, diff fields absent, record still sealed", (t) => {
489
- const { root, env } = jjWorkspace(t, { ...happyJj, fork: { code: 0, stdout: "" } });
490
- const r = run(root, ["--spec", SPEC], env);
491
- assert.equal(r.status, 0, r.stderr);
492
- assert.deepEqual(r.lines, [`sealed ${REC}`]);
493
- const rec = record(root);
494
- assert.equal(rec.status, "shipped");
495
- assert.ok(!("diff" in rec.derived) && !("modified_files" in rec.derived));
496
- assert.equal(rec.events.find((e) => e.kind === "warning")?.message, "diff omitted: jj mainline unresolved (trunk() is root(); no main/master bookmark)");
497
- });
498
-
499
- test("jj diff failure and unparseable patch each seal with a warning and no diff fields", (t) => {
500
- const failing = jjWorkspace(t, { ...happyJj, patch: { code: 1, stderr: "fatal: diff exploded\n" } });
501
- const r1 = run(failing.root, ["--spec", SPEC], failing.env);
502
- assert.equal(r1.status, 0, r1.stderr);
503
- let rec = record(failing.root);
504
- assert.equal(rec.status, "shipped");
505
- assert.ok(!("diff" in rec.derived) && !("modified_files" in rec.derived));
506
- assert.equal(rec.events.find((e) => e.kind === "warning")?.message, "diff omitted: jj diff failed: fatal: diff exploded");
507
-
508
- const garbage = jjWorkspace(t, { ...happyJj, patch: { code: 0, stdout: "not a git patch\n" } });
509
- const r2 = run(garbage.root, ["--spec", SPEC], garbage.env);
510
- assert.equal(r2.status, 0, r2.stderr);
511
- rec = record(garbage.root);
512
- assert.equal(rec.events.find((e) => e.kind === "warning")?.message, "diff omitted: jj diff unparseable");
513
- });
514
-
515
- test("jj workspace: an unresolvable or empty --base revset fails no base ref, exit 1", (t) => {
516
- const unknown = jjWorkspace(t, { ...happyJj, base: { code: 1, stderr: "Error: No such bookmark: nope\n" } });
517
- const r1 = invoke(["--worktree", unknown.root, "--option", "squash", "--base", "nope", "--spec", SPEC], unknown.env);
518
- assert.equal(r1.status, 1);
519
- assert.equal(r1.stderr, "no base ref nope");
520
- const empty = jjWorkspace(t, { ...happyJj, base: { code: 0, stdout: "" } });
521
- const r2 = invoke(["--worktree", empty.root, "--option", "squash", "--base", "none()"], empty.env);
522
- assert.equal(r2.status, 1);
523
- assert.equal(r2.stderr, "no base ref none()");
524
- });
525
-
526
- test("jj workspace without --spec discovers records from the jj fork-point diff", (t) => {
527
- const found = jjWorkspace(t, { ...happyJj, names: { code: 0, stdout: `${SPEC}\nsrc/a.ts\n` } });
528
- const r1 = run(found.root, [], found.env);
529
- assert.equal(r1.status, 0, r1.stderr);
530
- assert.deepEqual(r1.lines, [`sealed ${REC}`]);
531
- assert.ok(readFileSync(found.log, "utf8").includes(`jj --color=never diff --name-only --from ${JJ_FORK} --to @`));
532
- const none = jjWorkspace(t, { ...happyJj, names: { code: 0, stdout: "src/a.ts\n" } });
533
- const r2 = run(none.root, [], none.env);
534
- assert.equal(r2.status, 0, r2.stderr);
535
- assert.deepEqual(r2.lines, ["no telemetry run"]);
536
- });
537
-
538
- test("jj workspace: a shipped record is already sealed without any git checks", (t) => {
539
- const { root, log, env } = jjWorkspace(t, happyJj, { recordText: SEALED });
540
- const r = run(root, ["--spec", SPEC], env);
541
- assert.equal(r.status, 0, r.stderr);
542
- assert.deepEqual(r.lines, [`already sealed ${REC}`]);
543
- assert.equal(record(root).shipped_at, "2026-09-17T18:00:00Z");
544
- const calls = readFileSync(log, "utf8").split("\n").filter(Boolean);
545
- assert.deepEqual(calls.filter((c) => c.startsWith("git ")).map((c) => c.split(" ")[1]), ["rev-parse"]);
305
+ assert.equal(invoke(["--worktree", root, "--option", "squash"]).status, 1);
306
+ assert.equal(run(root, ["--base", "nope"]).stderr, "no base ref nope");
307
+ assert.match(run(root, ["--spec", "/tmp/elsewhere/doc/specs/x.md"]).stderr, /^usage:/);
308
+ const noGit = mkdtempSync(join(tmpdir(), "gts-nogit-"));
309
+ t.after(() => rmSync(noGit, { recursive: true, force: true }));
310
+ assert.equal(run(noGit).stderr, "not a git or jj checkout");
546
311
  });