@izkac/forgekit 0.3.13 → 0.3.15

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/bin/forge.mjs CHANGED
@@ -9,6 +9,7 @@
9
9
  import path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { spawnSync } from 'node:child_process';
12
+ import { findRepoRoot } from '../src/repo-root.mjs';
12
13
 
13
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
15
  const SRC = path.join(__dirname, '..', 'src');
@@ -19,6 +20,7 @@ const COMMANDS = {
19
20
  status: { script: 'session-status.mjs' },
20
21
  cleanup: { script: 'cleanup-sessions.mjs' },
21
22
  phase: { script: 'set-phase.mjs', aliases: ['set-phase'] },
23
+ checkpoint: { script: 'checkpoint.mjs', aliases: ['ckpt'] },
22
24
  prefs: { script: 'set-prefs.mjs' },
23
25
  models: { script: 'set-models.mjs' },
24
26
  'resolve-model': { script: 'resolve-model.mjs' },
@@ -49,6 +51,7 @@ Commands:
49
51
  new <slug> Create a Forge session under .forge/
50
52
  status Show active session
51
53
  phase <phase> Update session phase
54
+ checkpoint Commit the group's work (opt-in; never pushes)
52
55
  cleanup Prune old/finished sessions
53
56
  prefs [pace] Get/set pace preferences
54
57
  models [lane] Get/set subagent billing (included|metered)
@@ -93,12 +96,23 @@ if (!entry) {
93
96
  process.exit(1);
94
97
  }
95
98
 
96
- const args = [...(entry.prependArgs ?? []), ...rest];
99
+ // Every subcommand is project-scoped, so run it from the project root rather
100
+ // than wherever the shell happens to sit: `cd crates && forge status` used to
101
+ // report "no session" and `forge new` would have written a second .forge tree
102
+ // inside the workspace. An explicit relative `--cwd` still means what the
103
+ // caller typed, so absolutize it against the invocation dir first.
104
+ const invokedFrom = process.cwd();
105
+ const repoRoot = findRepoRoot(invokedFrom);
106
+ const args = [...(entry.prependArgs ?? []), ...rest].map((arg, i, all) =>
107
+ i > 0 && all[i - 1] === '--cwd' && !path.isAbsolute(arg) ? path.resolve(invokedFrom, arg) : arg,
108
+ );
109
+
97
110
  const r = spawnSync(process.execPath, [path.join(SRC, entry.script), ...args], {
98
111
  stdio: 'inherit',
99
- cwd: process.cwd(),
112
+ cwd: repoRoot,
100
113
  env: {
101
114
  ...process.env,
115
+ FORGE_INVOKED_FROM: invokedFrom,
102
116
  FORGEKIT_ROOT: path.resolve(__dirname, '..', '..', '..'),
103
117
  FORGEKIT_CLI_ROOT: path.resolve(__dirname, '..'),
104
118
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@izkac/forgekit",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
4
4
  "description": "Forgekit CLIs — forgekit (install), forge (workflow), review (code review)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,7 @@
16
16
  ],
17
17
  "scripts": {
18
18
  "prepack": "node scripts/prepack.mjs",
19
+ "prepublishOnly": "npm run lint && npm test",
19
20
  "test": "node scripts/run-tests.mjs",
20
21
  "lint": "eslint src bin scripts"
21
22
  },
@@ -0,0 +1,357 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `forge checkpoint` — an authorized, boring commit at a task-group boundary.
4
+ *
5
+ * Forge forbids autonomous commits, which is right, but the missing half was
6
+ * an *explicit* one: a 32-task session accumulated 6k lines across 37 files
7
+ * and 18 untracked ones in a single working tree, so a stray `git checkout`
8
+ * could erase a day of work and every reviewer after task 1 saw a diff
9
+ * containing all previous tasks.
10
+ *
11
+ * Checkpoints are opt-in per project (`.forge/config.json` → `git.checkpoint`),
12
+ * never push, never run on the default branch unless explicitly allowed, and
13
+ * record the resulting sha on the session so reviewers get a real diff range.
14
+ *
15
+ * Usage:
16
+ * forge checkpoint --group <name> [--tasks <ids>] [--message <subject>]
17
+ * forge checkpoint --dry-run
18
+ * forge checkpoint --range [--last]
19
+ */
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { spawnSync } from 'node:child_process';
24
+ import { loadSession, readActive, REPO_ROOT, saveSession } from './lib.mjs';
25
+ import { loadProjectConfig } from './config.mjs';
26
+
27
+ const MODES = ['off', 'per-group', 'per-task'];
28
+ const DEFAULT_BRANCHES = new Set(['main', 'master']);
29
+
30
+ function usage() {
31
+ process.stderr.write(
32
+ `Usage:
33
+ forge checkpoint --group <name> [--tasks <ids>] [--message <subject>]
34
+ forge checkpoint --dry-run what would be committed
35
+ forge checkpoint --range [--last] diff range for a reviewer brief
36
+
37
+ Enable per project in .forge/config.json:
38
+ { "git": { "checkpoint": "per-group" } } # or "per-task", "off" (default)
39
+ `,
40
+ );
41
+ }
42
+
43
+ /**
44
+ * @param {string} cwd
45
+ * @param {string[]} args
46
+ */
47
+ function git(cwd, args) {
48
+ return spawnSync('git', args, { cwd, encoding: 'utf8' });
49
+ }
50
+
51
+ /**
52
+ * @param {string} cwd
53
+ * @param {string[]} args
54
+ */
55
+ function gitOut(cwd, args) {
56
+ return gitOutRaw(cwd, args).trim();
57
+ }
58
+
59
+ /**
60
+ * Untrimmed stdout. `git status --porcelain` encodes the unstaged marker as a
61
+ * leading space, so trimming shifts every path in the first line by one.
62
+ *
63
+ * @param {string} cwd
64
+ * @param {string[]} args
65
+ */
66
+ function gitOutRaw(cwd, args) {
67
+ const r = git(cwd, args);
68
+ if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr.trim()}`);
69
+ return r.stdout;
70
+ }
71
+
72
+ function fail(message, payload = null) {
73
+ process.stderr.write(`${message}\n`);
74
+ if (payload) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
75
+ process.exit(1);
76
+ }
77
+
78
+ function emit(payload) {
79
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
80
+ }
81
+
82
+ /**
83
+ * Session bookkeeping is not product work: `.forge/` churns on every phase
84
+ * write (including this command's own), so committing it would make every
85
+ * checkpoint dirty the tree again and bury the real diff in scratch.
86
+ */
87
+ const EXCLUDE_SCRATCH = ['--', '.', ':(exclude).forge'];
88
+
89
+ /**
90
+ * Working-tree entries with their porcelain status, including untracked ones —
91
+ * agent work is mostly new files, so a tracked-only view would miss most of it.
92
+ *
93
+ * @param {string} cwd
94
+ * @returns {{ path: string, status: string, untracked: boolean }[]}
95
+ */
96
+ export function pendingEntries(cwd) {
97
+ const porcelain = gitOutRaw(cwd, [
98
+ 'status',
99
+ '--porcelain',
100
+ '--untracked-files=all',
101
+ ...EXCLUDE_SCRATCH,
102
+ ]);
103
+ if (!porcelain) return [];
104
+ return porcelain
105
+ .split('\n')
106
+ .filter((line) => line.length > 3)
107
+ .map((line) => {
108
+ const status = line.slice(0, 2);
109
+ let file = line.slice(3);
110
+ // Renames read `old -> new`; the new path is what landed.
111
+ if (file.includes(' -> ')) file = file.split(' -> ')[1];
112
+ return { path: file.replace(/^"|"$/g, '').trim(), status, untracked: status === '??' };
113
+ })
114
+ .filter((e) => e.path)
115
+ .sort((a, b) => a.path.localeCompare(b.path));
116
+ }
117
+
118
+ /**
119
+ * @param {string} cwd
120
+ * @returns {string[]}
121
+ */
122
+ export function pendingFiles(cwd) {
123
+ return pendingEntries(cwd).map((e) => e.path);
124
+ }
125
+
126
+ /**
127
+ * What a reviewer should actually read.
128
+ *
129
+ * A group review runs *before* that group's checkpoint, so HEAD is still the
130
+ * previous checkpoint and `<base>..HEAD` is empty — the reviewer would be
131
+ * handed a range containing nothing. While the tree is dirty the target is the
132
+ * working tree instead, and untracked files are named because `git diff` never
133
+ * shows them.
134
+ *
135
+ * @param {string} base
136
+ * @param {{ path: string, untracked: boolean }[]} pending
137
+ * @returns {string}
138
+ */
139
+ export function reviewTargetFor(base, pending) {
140
+ if (pending.length === 0) return `${base}..HEAD`;
141
+ const untracked = pending.filter((e) => e.untracked).map((e) => e.path);
142
+ const target = `git diff ${base} (working tree vs the last checkpoint)`;
143
+ if (untracked.length === 0) return target;
144
+ return `${target} — plus ${untracked.length} untracked file(s) that no diff shows, read them in full: ${untracked.join(', ')}`;
145
+ }
146
+
147
+ /**
148
+ * Git operations that must finish before anything else touches the index.
149
+ * @param {string} cwd
150
+ */
151
+ function inProgressOperation(cwd) {
152
+ const gitDir = path.join(cwd, '.git');
153
+ for (const [file, label] of [
154
+ ['MERGE_HEAD', 'merge'],
155
+ ['CHERRY_PICK_HEAD', 'cherry-pick'],
156
+ ['REVERT_HEAD', 'revert'],
157
+ ['rebase-merge', 'rebase'],
158
+ ['rebase-apply', 'rebase'],
159
+ ['BISECT_LOG', 'bisect'],
160
+ ]) {
161
+ if (fs.existsSync(path.join(gitDir, file))) return label;
162
+ }
163
+ return null;
164
+ }
165
+
166
+ /**
167
+ * @param {{ slug?: string, openspecChange?: string }} session
168
+ * @param {{ group?: string, tasks?: string, message?: string }} opts
169
+ */
170
+ export function checkpointSubject(session, opts) {
171
+ if (opts.message) return opts.message;
172
+ const scope = session.openspecChange || session.slug || 'session';
173
+ const label = [opts.group, opts.tasks && `tasks ${opts.tasks}`].filter(Boolean).join(' ');
174
+ return `forge(${scope}): checkpoint${label ? ` — ${label}` : ''}`;
175
+ }
176
+
177
+ const args = process.argv.slice(2);
178
+ if (args.includes('--help') || args.includes('-h')) {
179
+ usage();
180
+ process.exit(0);
181
+ }
182
+
183
+ /** @type {{ group?: string, tasks?: string, message?: string }} */
184
+ const opts = {};
185
+ let dryRun = false;
186
+ let rangeOnly = false;
187
+ let sinceLast = false;
188
+ let allowDefaultBranch = false;
189
+ let sessionId = null;
190
+ for (let i = 0; i < args.length; i += 1) {
191
+ const a = args[i];
192
+ if (a === '--group' && args[i + 1]) opts.group = args[(i += 1)];
193
+ else if (a === '--tasks' && args[i + 1]) opts.tasks = args[(i += 1)];
194
+ else if ((a === '--message' || a === '-m') && args[i + 1]) opts.message = args[(i += 1)];
195
+ else if (a === '--session' && args[i + 1]) sessionId = args[(i += 1)];
196
+ else if (a === '--dry-run') dryRun = true;
197
+ else if (a === '--range') rangeOnly = true;
198
+ else if (a === '--last') sinceLast = true;
199
+ else if (a === '--allow-default-branch') allowDefaultBranch = true;
200
+ else {
201
+ usage();
202
+ fail(`Unknown argument: ${a}`);
203
+ }
204
+ }
205
+
206
+ const cwd = REPO_ROOT;
207
+
208
+ if (!sessionId) {
209
+ const active = readActive();
210
+ if (!active?.sessionId) fail('No active Forge session — run forge new <slug> first.');
211
+ sessionId = active.sessionId;
212
+ }
213
+ const { dir: sessionDir, session } = loadSession(sessionId);
214
+
215
+ const checkpoints = Array.isArray(session.checkpoints) ? session.checkpoints : [];
216
+
217
+ // --- `--range`: what a reviewer should read, no repo mutation ---
218
+ if (rangeOnly) {
219
+ const base = sinceLast && checkpoints.length ? checkpoints[checkpoints.length - 1].sha : session.baseCommit;
220
+ if (!base) {
221
+ emit({
222
+ ok: false,
223
+ range: null,
224
+ base: null,
225
+ checkpoints: checkpoints.length,
226
+ note: 'No base commit recorded — this session started before checkpoints, so reviewers read the working tree (git diff).',
227
+ });
228
+ process.exit(1);
229
+ }
230
+ let pending = [];
231
+ try {
232
+ pending = pendingEntries(cwd);
233
+ } catch {
234
+ /* not a git repo / unreadable — fall back to the bare commit range */
235
+ }
236
+ emit({
237
+ ok: true,
238
+ base,
239
+ range: `${base}..HEAD`,
240
+ dirty: pending.length > 0,
241
+ pending: pending.map((e) => e.path),
242
+ untracked: pending.filter((e) => e.untracked).map((e) => e.path),
243
+ reviewTarget: reviewTargetFor(base, pending),
244
+ checkpoints: checkpoints.length,
245
+ note: 'Pass reviewTarget as {DIFF_RANGE}. `range` is the commit range only — it is empty while the group under review is still uncommitted.',
246
+ });
247
+ process.exit(0);
248
+ }
249
+
250
+ // --- gates ---
251
+ const config = loadProjectConfig(cwd);
252
+ const mode = config?.git?.checkpoint ?? 'off';
253
+ if (!MODES.includes(mode)) {
254
+ fail(`Unknown git.checkpoint mode "${mode}" in .forge/config.json (expected ${MODES.join(' | ')}).`);
255
+ }
256
+ if (mode === 'off') {
257
+ fail(
258
+ 'Checkpoints are off for this project. Enable with .forge/config.json → { "git": { "checkpoint": "per-group" } }.',
259
+ { ok: false, reason: 'git.checkpoint is "off" — checkpoints disabled for this project' },
260
+ );
261
+ }
262
+
263
+ if (!fs.existsSync(path.join(cwd, '.git'))) {
264
+ fail(`Not a git repository: ${cwd} — nothing to checkpoint.`, { ok: false, reason: 'not a git repo' });
265
+ }
266
+
267
+ const busy = inProgressOperation(cwd);
268
+ if (busy) {
269
+ fail(`A ${busy} is in progress — finish it before checkpointing.`, { ok: false, reason: `${busy} in progress` });
270
+ }
271
+
272
+ let branch;
273
+ try {
274
+ branch = gitOut(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
275
+ } catch (err) {
276
+ fail(`Could not read the current branch: ${err instanceof Error ? err.message : err}`);
277
+ }
278
+ if (DEFAULT_BRANCHES.has(branch) && !allowDefaultBranch && config?.git?.allowDefaultBranch !== true) {
279
+ fail(
280
+ `Refusing to checkpoint on the default branch "${branch}". Forge work belongs on a branch — create one, or pass --allow-default-branch (or set git.allowDefaultBranch: true) if this project really commits to ${branch}.`,
281
+ { ok: false, reason: `default branch "${branch}"` },
282
+ );
283
+ }
284
+
285
+ // --- what would land ---
286
+ let files;
287
+ try {
288
+ files = pendingFiles(cwd);
289
+ } catch (err) {
290
+ fail(`Could not read the working tree: ${err instanceof Error ? err.message : err}`);
291
+ }
292
+
293
+ const subject = checkpointSubject(session, opts);
294
+
295
+ if (files.length === 0) {
296
+ emit({
297
+ ok: true,
298
+ committed: false,
299
+ reason: 'nothing to checkpoint — working tree is clean',
300
+ branch,
301
+ range: session.baseCommit ? `${session.baseCommit}..HEAD` : null,
302
+ });
303
+ process.exit(0);
304
+ }
305
+
306
+ if (dryRun) {
307
+ emit({ ok: true, committed: false, dryRun: true, branch, subject, files });
308
+ process.exit(0);
309
+ }
310
+
311
+ // --- commit (never push) ---
312
+ const prev = checkpoints.length ? checkpoints[checkpoints.length - 1].sha : null;
313
+ try {
314
+ gitOut(cwd, ['add', '-A', ...EXCLUDE_SCRATCH]);
315
+ } catch (err) {
316
+ fail(`git add failed: ${err instanceof Error ? err.message : err}`);
317
+ }
318
+ const commit = git(cwd, ['commit', '-m', subject, '--no-verify']);
319
+ if (commit.status !== 0) {
320
+ fail(`git commit failed: ${commit.stderr.trim() || commit.stdout.trim()}`);
321
+ }
322
+ const sha = gitOut(cwd, ['rev-parse', 'HEAD']);
323
+
324
+ // A session created before checkpoints existed has no base; this commit's
325
+ // parent is the closest honest answer.
326
+ if (!session.baseCommit) {
327
+ try {
328
+ session.baseCommit = gitOut(cwd, ['rev-parse', `${sha}^`]);
329
+ } catch {
330
+ session.baseCommit = sha; // root commit — nothing before it
331
+ }
332
+ }
333
+ if (!session.branch) session.branch = branch;
334
+
335
+ checkpoints.push({
336
+ sha,
337
+ subject,
338
+ group: opts.group ?? null,
339
+ tasks: opts.tasks ?? null,
340
+ files: files.length,
341
+ at: new Date().toISOString(),
342
+ });
343
+ session.checkpoints = checkpoints;
344
+ saveSession(sessionDir, session);
345
+
346
+ emit({
347
+ ok: true,
348
+ committed: true,
349
+ sha,
350
+ subject,
351
+ branch,
352
+ files,
353
+ range: `${session.baseCommit}..HEAD`,
354
+ groupRange: prev ? `${prev}..${sha}` : `${session.baseCommit}..${sha}`,
355
+ pushed: false,
356
+ note: 'Checkpoints never push. Pass groupRange as {DIFF_RANGE} to the group reviewer.',
357
+ });
@@ -0,0 +1,252 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { execFileSync, spawnSync } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const SRC = path.dirname(fileURLToPath(import.meta.url));
10
+ const CHECKPOINT = path.join(SRC, 'checkpoint.mjs');
11
+
12
+ function tmp(prefix) {
13
+ return fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), prefix)));
14
+ }
15
+
16
+ function git(cwd, ...args) {
17
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
18
+ if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
19
+ return r.stdout.trim();
20
+ }
21
+
22
+ /**
23
+ * Git project on a feature branch with one commit, a Forge session, and
24
+ * uncommitted work — the shape of an implement phase at a group boundary.
25
+ */
26
+ function makeProject({ branch = 'feature-x', config = { git: { checkpoint: 'per-group' } } } = {}) {
27
+ const cwd = tmp('forge-ckpt-');
28
+ git(cwd, 'init', '-q', '-b', 'main');
29
+ git(cwd, 'config', 'user.email', 'test@example.com');
30
+ git(cwd, 'config', 'user.name', 'Test');
31
+ fs.writeFileSync(path.join(cwd, 'README.md'), '# base\n', 'utf8');
32
+ git(cwd, 'add', '-A');
33
+ git(cwd, 'commit', '-q', '-m', 'base');
34
+ const baseSha = git(cwd, 'rev-parse', 'HEAD');
35
+ if (branch !== 'main') git(cwd, 'checkout', '-q', '-b', branch);
36
+
37
+ const sessionDir = path.join(cwd, '.forge', 'sessions', 's1');
38
+ fs.mkdirSync(sessionDir, { recursive: true });
39
+ const now = new Date().toISOString();
40
+ fs.writeFileSync(
41
+ path.join(sessionDir, 'session.json'),
42
+ `${JSON.stringify({
43
+ id: 's1',
44
+ slug: 'phase-1',
45
+ createdAt: now,
46
+ updatedAt: now,
47
+ phase: 'implement',
48
+ planType: 'specs',
49
+ openspecChange: 'phase-1',
50
+ tasksTotal: 8,
51
+ tasksComplete: 4,
52
+ baseCommit: baseSha,
53
+ branch,
54
+ })}\n`,
55
+ 'utf8',
56
+ );
57
+ fs.writeFileSync(
58
+ path.join(cwd, '.forge', 'active.json'),
59
+ `${JSON.stringify({ sessionId: 's1' })}\n`,
60
+ 'utf8',
61
+ );
62
+ if (config) {
63
+ fs.writeFileSync(
64
+ path.join(cwd, '.forge', 'config.json'),
65
+ `${JSON.stringify(config)}\n`,
66
+ 'utf8',
67
+ );
68
+ }
69
+ // Agent work: one edit, one new file.
70
+ fs.appendFileSync(path.join(cwd, 'README.md'), 'edited by task 1.1\n');
71
+ fs.writeFileSync(path.join(cwd, 'new-module.mjs'), 'export const x = 1;\n', 'utf8');
72
+ return { cwd, sessionDir, baseSha };
73
+ }
74
+
75
+ function run(cwd, args = []) {
76
+ const env = { ...process.env, FORGEKIT_FLEET_DIR: path.join(tmp('ckpt-fleet-'), 's') };
77
+ try {
78
+ const stdout = execFileSync(process.execPath, [CHECKPOINT, ...args], { cwd, env });
79
+ return { status: 0, out: JSON.parse(stdout.toString()), stderr: '' };
80
+ } catch (err) {
81
+ let out = null;
82
+ try {
83
+ out = JSON.parse(String(err.stdout));
84
+ } catch {
85
+ /* non-JSON failure */
86
+ }
87
+ return { status: err.status, out, stderr: String(err.stderr) };
88
+ }
89
+ }
90
+
91
+ function readSession(sessionDir) {
92
+ return JSON.parse(fs.readFileSync(path.join(sessionDir, 'session.json'), 'utf8'));
93
+ }
94
+
95
+ test('checkpoint commits the working tree and records the commit on the session', () => {
96
+ const { cwd, sessionDir, baseSha } = makeProject();
97
+
98
+ const { status, out } = run(cwd, ['--group', 'group-02-testkit', '--tasks', '2.1-2.4']);
99
+ assert.equal(status, 0);
100
+ assert.equal(out.ok, true);
101
+ assert.equal(out.committed, true);
102
+ assert.match(out.subject, /phase-1/);
103
+ assert.match(out.subject, /group-02-testkit/);
104
+ // Both the edit and the untracked file are in — agent work is mostly new files.
105
+ assert.deepEqual(out.files.sort(), ['README.md', 'new-module.mjs']);
106
+
107
+ // Product files are all in; session scratch is deliberately left out, so a
108
+ // checkpoint cannot bury the diff under .forge churn (or dirty the tree
109
+ // again with its own session write).
110
+ assert.equal(
111
+ git(cwd, 'status', '--porcelain', '--', '.', ':(exclude).forge'),
112
+ '',
113
+ 'no product file is left uncommitted',
114
+ );
115
+ assert.equal(
116
+ git(cwd, 'show', '--name-only', '--format=', 'HEAD').split('\n').some((f) => f.startsWith('.forge')),
117
+ false,
118
+ 'session scratch is never committed by a checkpoint',
119
+ );
120
+ const session = readSession(sessionDir);
121
+ assert.equal(session.checkpoints.length, 1);
122
+ assert.equal(session.checkpoints[0].sha, git(cwd, 'rev-parse', 'HEAD'));
123
+ assert.equal(session.checkpoints[0].group, 'group-02-testkit');
124
+ assert.equal(session.checkpoints[0].tasks, '2.1-2.4');
125
+ // The range a reviewer needs: everything this session has produced.
126
+ assert.equal(out.range, `${baseSha}..HEAD`);
127
+ });
128
+
129
+ test('a second checkpoint reports the range covering only the new group', () => {
130
+ const { cwd, sessionDir } = makeProject();
131
+ const first = run(cwd, ['--group', 'group-01']);
132
+ assert.equal(first.status, 0);
133
+
134
+ fs.writeFileSync(path.join(cwd, 'second.mjs'), 'export const y = 2;\n', 'utf8');
135
+ const second = run(cwd, ['--group', 'group-02']);
136
+
137
+ assert.equal(second.status, 0);
138
+ assert.equal(second.out.groupRange, `${first.out.sha}..${second.out.sha}`);
139
+ assert.deepEqual(second.out.files, ['second.mjs']);
140
+ assert.equal(readSession(sessionDir).checkpoints.length, 2);
141
+ });
142
+
143
+ test('a clean tree is not an error and creates no empty commit', () => {
144
+ const { cwd } = makeProject();
145
+ run(cwd, ['--group', 'group-01']);
146
+ const head = git(cwd, 'rev-parse', 'HEAD');
147
+
148
+ const { status, out } = run(cwd, ['--group', 'group-02']);
149
+ assert.equal(status, 0);
150
+ assert.equal(out.ok, true);
151
+ assert.equal(out.committed, false);
152
+ assert.match(out.reason, /nothing to checkpoint/i);
153
+ assert.equal(git(cwd, 'rev-parse', 'HEAD'), head, 'no empty commit');
154
+ });
155
+
156
+ test('--dry-run reports what would be committed and commits nothing', () => {
157
+ const { cwd, sessionDir } = makeProject();
158
+ const head = git(cwd, 'rev-parse', 'HEAD');
159
+
160
+ const { status, out } = run(cwd, ['--group', 'group-01', '--dry-run']);
161
+ assert.equal(status, 0);
162
+ assert.equal(out.committed, false);
163
+ assert.deepEqual(out.files.sort(), ['README.md', 'new-module.mjs']);
164
+ assert.equal(git(cwd, 'rev-parse', 'HEAD'), head);
165
+ assert.equal(readSession(sessionDir).checkpoints, undefined);
166
+ });
167
+
168
+ test('checkpoints are refused on the default branch unless explicitly allowed', () => {
169
+ // Forge work belongs on a branch; committing straight to main is the one
170
+ // mistake an automated commit must never make on its own.
171
+ const { cwd } = makeProject({ branch: 'main' });
172
+
173
+ const refused = run(cwd, ['--group', 'group-01']);
174
+ assert.equal(refused.status, 1);
175
+ assert.match(refused.stderr, /default branch/i);
176
+ assert.equal(git(cwd, 'status', '--porcelain') === '', false, 'work is left untouched');
177
+
178
+ const allowed = run(cwd, ['--group', 'group-01', '--allow-default-branch']);
179
+ assert.equal(allowed.status, 0);
180
+ assert.equal(allowed.out.committed, true);
181
+ });
182
+
183
+ test('checkpoints are off unless the project opts in', () => {
184
+ const { cwd } = makeProject({ config: {} });
185
+ const { status, out, stderr } = run(cwd, ['--group', 'group-01']);
186
+ assert.equal(status, 1);
187
+ assert.equal(out?.ok, false);
188
+ assert.match(`${stderr}${out?.reason ?? ''}`, /checkpoint/i);
189
+ assert.match(`${stderr}${out?.reason ?? ''}`, /git\.checkpoint/);
190
+ });
191
+
192
+ test('an in-progress merge blocks a checkpoint', () => {
193
+ const { cwd } = makeProject();
194
+ fs.writeFileSync(path.join(cwd, '.git', 'MERGE_HEAD'), `${'0'.repeat(40)}\n`, 'utf8');
195
+
196
+ const { status, stderr } = run(cwd, ['--group', 'group-01']);
197
+ assert.equal(status, 1);
198
+ assert.match(stderr, /merge|rebase|in progress/i);
199
+ });
200
+
201
+ test('--range --last targets the working tree while the group is still uncommitted', () => {
202
+ // The group review runs BEFORE its checkpoint, so at review time the group's
203
+ // work is uncommitted and HEAD is still the previous checkpoint: a
204
+ // `<sha>..HEAD` range would be empty and the reviewer would read nothing.
205
+ const { cwd } = makeProject();
206
+ const first = run(cwd, ['--group', 'group-01']);
207
+ fs.writeFileSync(path.join(cwd, 'second.mjs'), 'export const y = 2;\n', 'utf8');
208
+ fs.appendFileSync(path.join(cwd, 'README.md'), 'more\n');
209
+
210
+ const { status, out } = run(cwd, ['--range', '--last']);
211
+ assert.equal(status, 0);
212
+ assert.equal(out.base, first.out.sha);
213
+ assert.equal(out.dirty, true);
214
+ // Untracked files are most of what an agent writes and never appear in a
215
+ // plain `git diff`, so they must be called out by name.
216
+ assert.deepEqual(out.untracked, ['second.mjs']);
217
+ assert.match(out.reviewTarget, new RegExp(`git diff ${first.out.sha}`));
218
+ assert.match(out.reviewTarget, /second\.mjs/);
219
+ });
220
+
221
+ test('--range --last is a plain commit range once the group is checkpointed', () => {
222
+ const { cwd } = makeProject();
223
+ run(cwd, ['--group', 'group-01']);
224
+ fs.writeFileSync(path.join(cwd, 'second.mjs'), 'export const y = 2;\n', 'utf8');
225
+ const second = run(cwd, ['--group', 'group-02']);
226
+
227
+ const { out } = run(cwd, ['--range', '--last']);
228
+ assert.equal(out.dirty, false);
229
+ assert.deepEqual(out.untracked, []);
230
+ assert.equal(out.base, second.out.sha);
231
+ assert.equal(out.reviewTarget, `${second.out.sha}..HEAD`);
232
+ });
233
+
234
+ test('--range without --last spans the whole session from its base commit', () => {
235
+ const { cwd, baseSha } = makeProject();
236
+ run(cwd, ['--group', 'group-01']);
237
+
238
+ const { out } = run(cwd, ['--range']);
239
+ assert.equal(out.base, baseSha);
240
+ assert.equal(out.range, `${baseSha}..HEAD`);
241
+ assert.equal(out.dirty, false);
242
+ });
243
+
244
+ test('checkpoint never pushes', () => {
245
+ const { cwd } = makeProject();
246
+ // A remote that would fail loudly if anything tried to reach it.
247
+ git(cwd, 'remote', 'add', 'origin', 'file:///nonexistent-remote.git');
248
+ const { status, out } = run(cwd, ['--group', 'group-01']);
249
+ assert.equal(status, 0);
250
+ assert.equal(out.committed, true);
251
+ assert.equal(git(cwd, 'log', '--oneline', '-1', '--format=%s').includes('phase-1'), true);
252
+ });