@izkac/forgekit 0.3.14 → 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 +2 -0
- package/package.json +1 -1
- package/src/checkpoint.mjs +357 -0
- package/src/checkpoint.test.mjs +252 -0
- package/src/fleet.mjs +27 -2
- package/src/health.mjs +144 -0
- package/src/health.test.mjs +138 -0
- package/src/new-session.mjs +15 -0
- package/src/session-reminder.mjs +10 -0
- package/src/session-status.mjs +6 -0
- package/vendor/skills/forge/SKILL.md +2 -1
- package/vendor/skills/forge/docs/forge.md +79 -2
- package/vendor/skills/forge/phases/implement.md +19 -2
- package/vendor/skills/forge/subagents/task-reviewer-prompt.md +1 -1
package/bin/forge.mjs
CHANGED
|
@@ -20,6 +20,7 @@ const COMMANDS = {
|
|
|
20
20
|
status: { script: 'session-status.mjs' },
|
|
21
21
|
cleanup: { script: 'cleanup-sessions.mjs' },
|
|
22
22
|
phase: { script: 'set-phase.mjs', aliases: ['set-phase'] },
|
|
23
|
+
checkpoint: { script: 'checkpoint.mjs', aliases: ['ckpt'] },
|
|
23
24
|
prefs: { script: 'set-prefs.mjs' },
|
|
24
25
|
models: { script: 'set-models.mjs' },
|
|
25
26
|
'resolve-model': { script: 'resolve-model.mjs' },
|
|
@@ -50,6 +51,7 @@ Commands:
|
|
|
50
51
|
new <slug> Create a Forge session under .forge/
|
|
51
52
|
status Show active session
|
|
52
53
|
phase <phase> Update session phase
|
|
54
|
+
checkpoint Commit the group's work (opt-in; never pushes)
|
|
53
55
|
cleanup Prune old/finished sessions
|
|
54
56
|
prefs [pace] Get/set pace preferences
|
|
55
57
|
models [lane] Get/set subagent billing (included|metered)
|
package/package.json
CHANGED
|
@@ -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
|
+
});
|
package/src/fleet.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
queueMessage,
|
|
25
25
|
sessionDirFor,
|
|
26
26
|
} from './lib/fleet.mjs';
|
|
27
|
+
import { healthCell, sessionHealth } from './health.mjs';
|
|
27
28
|
|
|
28
29
|
function usage() {
|
|
29
30
|
process.stderr.write(
|
|
@@ -70,21 +71,45 @@ function pad(str, len) {
|
|
|
70
71
|
return s.length >= len ? s.slice(0, len) : s + ' '.repeat(len - s.length);
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Health per entry, read from that project's session dir. A row showing a
|
|
76
|
+
* healthy-looking `27/32` while its e2e run is red and nobody has touched it
|
|
77
|
+
* for 14 hours is the failure this column exists to prevent.
|
|
78
|
+
*/
|
|
79
|
+
function healthFor(entry) {
|
|
80
|
+
if (entry.missing) return { state: 'unknown', line: 'missing' };
|
|
81
|
+
try {
|
|
82
|
+
const sessionDir = sessionDirFor(entry);
|
|
83
|
+
const session = JSON.parse(fs.readFileSync(path.join(sessionDir, 'session.json'), 'utf8'));
|
|
84
|
+
return sessionHealth({ cwd: entry.project, sessionDir, session });
|
|
85
|
+
} catch {
|
|
86
|
+
return { state: 'unknown', line: 'unknown' };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
73
90
|
function renderTable(entries) {
|
|
74
91
|
if (entries.length === 0) return 'No fleet sessions. Start one with `forge new <slug>`.\n';
|
|
75
|
-
const header = `${pad('PROJECT', 18)} ${pad('SESSION', 26)} ${pad('ENGINE', 7)} ${pad('PHASE', 18)} ${pad('TASKS', 16)} ${pad('PACE', 9)} ${pad('AGE', 4)} MSGS`;
|
|
92
|
+
const header = `${pad('PROJECT', 18)} ${pad('SESSION', 26)} ${pad('ENGINE', 7)} ${pad('PHASE', 18)} ${pad('TASKS', 16)} ${pad('HEALTH', 6)} ${pad('PACE', 9)} ${pad('AGE', 4)} MSGS`;
|
|
76
93
|
const lines = [header, '-'.repeat(header.length)];
|
|
94
|
+
/** @type {string[]} */
|
|
95
|
+
const notes = [];
|
|
77
96
|
for (const e of entries) {
|
|
78
97
|
const pending = e.missing ? 0 : peekInbox(sessionDirFor(e)).length;
|
|
98
|
+
const health = healthFor(e);
|
|
99
|
+
if (health.state === 'red' || health.state === 'stale') {
|
|
100
|
+
notes.push(`${e.projectName}/${e.slug}: ${health.line}`);
|
|
101
|
+
}
|
|
79
102
|
lines.push(
|
|
80
103
|
`${pad(e.projectName, 18)} ${pad(e.slug, 26)} ${pad(e.engine ?? '—', 7)} ${pad(
|
|
81
104
|
e.missing ? 'missing' : phaseBar(e.phase),
|
|
82
105
|
18,
|
|
83
|
-
)} ${pad(tasksCell(e), 16)} ${pad(e.pace ?? '—', 9)} ${pad(relTime(e.lastSeen ?? e.updatedAt), 4)} ${
|
|
106
|
+
)} ${pad(tasksCell(e), 16)} ${pad(healthCell(health.state), 6)} ${pad(e.pace ?? '—', 9)} ${pad(relTime(e.lastSeen ?? e.updatedAt), 4)} ${
|
|
84
107
|
pending > 0 ? `✉ ${pending}` : ''
|
|
85
108
|
}`,
|
|
86
109
|
);
|
|
87
110
|
}
|
|
111
|
+
// The column says which row; the notes say why, without a second command.
|
|
112
|
+
if (notes.length) lines.push('', ...notes.map((n) => ` ! ${n}`));
|
|
88
113
|
return `${lines.join('\n')}\n`;
|
|
89
114
|
}
|
|
90
115
|
|
package/src/health.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Session health — a verdict, not a data dump.
|
|
4
|
+
*
|
|
5
|
+
* `forge status` printed every field a session had and said nothing about
|
|
6
|
+
* whether the session was in trouble, so a session could sit at
|
|
7
|
+
* `implement 27/32` with a red e2e run for 14 hours and look exactly like one
|
|
8
|
+
* that was mid-stride. Health answers the one question the operator actually
|
|
9
|
+
* asks: is this session fine, stopped, or broken?
|
|
10
|
+
*
|
|
11
|
+
* Cheap on purpose — file reads only, no subprocesses — because it runs on
|
|
12
|
+
* every `forge status`, every reminder hook, and once per row in
|
|
13
|
+
* `forge fleet list`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { e2ePath, e2eStepsHash, loadE2eResults } from './integrity.mjs';
|
|
19
|
+
import { readJson } from './lib.mjs';
|
|
20
|
+
|
|
21
|
+
/** Hours of no session write after which an unfinished session reads as stopped. */
|
|
22
|
+
export const DEFAULT_IDLE_HOURS = 4;
|
|
23
|
+
|
|
24
|
+
const SEVERITY = { done: 0, healthy: 1, stale: 2, red: 3 };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {number} ms
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
function humanDuration(ms) {
|
|
31
|
+
const min = Math.floor(ms / 60000);
|
|
32
|
+
if (min < 60) return `${Math.max(min, 1)}m`;
|
|
33
|
+
const hours = Math.floor(min / 60);
|
|
34
|
+
if (hours < 48) return `${hours}h`;
|
|
35
|
+
return `${Math.floor(hours / 24)}d`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The failing step of a red run, for a reason line that names the problem
|
|
40
|
+
* instead of pointing at a file.
|
|
41
|
+
*
|
|
42
|
+
* @param {Record<string, any> | null} results
|
|
43
|
+
*/
|
|
44
|
+
function failedStepName(results) {
|
|
45
|
+
if (!results || !Array.isArray(results.steps)) return null;
|
|
46
|
+
const failed = results.steps.find((s) => s && s.ok === false);
|
|
47
|
+
return failed?.name ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {{ cwd?: string, sessionDir: string, session: Record<string, any>, now?: number, idleHours?: number }} opts
|
|
52
|
+
* @returns {{ state: 'healthy'|'stale'|'red'|'done', reasons: string[], line: string }}
|
|
53
|
+
*/
|
|
54
|
+
export function sessionHealth(opts) {
|
|
55
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
56
|
+
const { sessionDir, session } = opts;
|
|
57
|
+
const now = opts.now ?? Date.now();
|
|
58
|
+
const idleHours = Number.isFinite(opts.idleHours) ? Number(opts.idleHours) : DEFAULT_IDLE_HOURS;
|
|
59
|
+
|
|
60
|
+
/** @type {string[]} */
|
|
61
|
+
const reasons = [];
|
|
62
|
+
let state = 'healthy';
|
|
63
|
+
const escalate = (next) => {
|
|
64
|
+
if (SEVERITY[next] > SEVERITY[state]) state = next;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
if (session.phase === 'done' || session.phase === 'skipped') {
|
|
68
|
+
return {
|
|
69
|
+
state: 'done',
|
|
70
|
+
reasons: [],
|
|
71
|
+
line: `DONE — ${session.slug ?? session.id} (${session.phase})`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- executed product loop: the strongest signal we have on disk ---
|
|
76
|
+
try {
|
|
77
|
+
const results = loadE2eResults(sessionDir);
|
|
78
|
+
if (results) {
|
|
79
|
+
let currentHash = null;
|
|
80
|
+
try {
|
|
81
|
+
const doc = readJson(e2ePath({ cwd, session, sessionDir }));
|
|
82
|
+
currentHash = e2eStepsHash(doc.steps);
|
|
83
|
+
} catch {
|
|
84
|
+
currentHash = null;
|
|
85
|
+
}
|
|
86
|
+
// A failed run outranks a stale one: staleness asks "does this proof
|
|
87
|
+
// still describe the current steps", a failure says the loop is broken
|
|
88
|
+
// either way.
|
|
89
|
+
if (results.ok === false) {
|
|
90
|
+
const step = failedStepName(results);
|
|
91
|
+
const since = results.ranAt ? ` since ${results.ranAt}` : '';
|
|
92
|
+
reasons.push(`e2e failing${since}${step ? ` at step "${step}"` : ''}`);
|
|
93
|
+
escalate('red');
|
|
94
|
+
} else if (currentHash && results.stepsHash !== currentHash) {
|
|
95
|
+
reasons.push('e2e results are stale — e2e.json changed since the last run');
|
|
96
|
+
escalate('stale');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
/* health must never throw — a broken artifact is the caller's problem */
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- an explicit BLOCKED beats any inference we could make ---
|
|
104
|
+
try {
|
|
105
|
+
const verify = path.join(sessionDir, 'verify-evidence.md');
|
|
106
|
+
if (fs.existsSync(verify) && /\bBLOCKED\b/.test(fs.readFileSync(verify, 'utf8'))) {
|
|
107
|
+
reasons.push('verify-evidence records BLOCKED — product loop not proven');
|
|
108
|
+
escalate('red');
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
/* ignore */
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- idle: nobody is driving this session ---
|
|
115
|
+
const updatedAt = new Date(session.updatedAt ?? session.createdAt ?? NaN).getTime();
|
|
116
|
+
if (!Number.isNaN(updatedAt)) {
|
|
117
|
+
const idleMs = now - updatedAt;
|
|
118
|
+
if (idleMs > idleHours * 3600 * 1000) {
|
|
119
|
+
const where = `${session.phase ?? 'unknown'}${
|
|
120
|
+
Number(session.tasksTotal) > 0 ? ` ${session.tasksComplete ?? 0}/${session.tasksTotal}` : ''
|
|
121
|
+
}`;
|
|
122
|
+
reasons.push(`idle ${humanDuration(idleMs)} at ${where}`);
|
|
123
|
+
escalate('stale');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const label = state.toUpperCase();
|
|
128
|
+
const line = reasons.length ? `${label} — ${reasons.join('; ')}` : `${label} — ${session.phase}`;
|
|
129
|
+
return { state, reasons, line };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Short cell for table renderers (`forge fleet list`). */
|
|
133
|
+
export function healthCell(state) {
|
|
134
|
+
switch (state) {
|
|
135
|
+
case 'red':
|
|
136
|
+
return 'RED';
|
|
137
|
+
case 'stale':
|
|
138
|
+
return 'STALE';
|
|
139
|
+
case 'done':
|
|
140
|
+
return 'done';
|
|
141
|
+
default:
|
|
142
|
+
return 'ok';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
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 { DEFAULT_IDLE_HOURS, sessionHealth } from './health.mjs';
|
|
7
|
+
import { e2eStepsHash } from './integrity.mjs';
|
|
8
|
+
|
|
9
|
+
function tmp(prefix) {
|
|
10
|
+
return fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), prefix)));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const HOUR = 3600 * 1000;
|
|
14
|
+
const NOW = new Date('2026-07-25T12:00:00.000Z').getTime();
|
|
15
|
+
const ago = (hours) => new Date(NOW - hours * HOUR).toISOString();
|
|
16
|
+
|
|
17
|
+
/** Project + session dir; session.json is passed separately. */
|
|
18
|
+
function makeProject(overrides = {}) {
|
|
19
|
+
const cwd = tmp('forge-health-');
|
|
20
|
+
const sessionDir = path.join(cwd, '.forge', 'sessions', 's1');
|
|
21
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
22
|
+
const session = {
|
|
23
|
+
id: 's1',
|
|
24
|
+
slug: 'phase-1',
|
|
25
|
+
phase: 'implement',
|
|
26
|
+
planType: 'specs',
|
|
27
|
+
openspecChange: 'phase-1',
|
|
28
|
+
tasksTotal: 32,
|
|
29
|
+
tasksComplete: 27,
|
|
30
|
+
createdAt: ago(30),
|
|
31
|
+
updatedAt: ago(1),
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
return { cwd, sessionDir, session };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** e2e.json in the change dir + a results file in the session dir. */
|
|
38
|
+
function writeE2e({ cwd, sessionDir }, { ok, stale = false, ranAt = ago(2) } = {}) {
|
|
39
|
+
const changeDir = path.join(cwd, 'specs', 'changes', 'phase-1');
|
|
40
|
+
fs.mkdirSync(changeDir, { recursive: true });
|
|
41
|
+
const steps = [{ name: 'bench-check-enforces-the-budget-gate', cmd: 'true' }];
|
|
42
|
+
fs.writeFileSync(path.join(changeDir, 'e2e.json'), `${JSON.stringify({ steps })}\n`, 'utf8');
|
|
43
|
+
// stepsHash is computed by integrity.e2eStepsHash; a wrong hash is what a
|
|
44
|
+
// post-run edit of e2e.json looks like.
|
|
45
|
+
const results = {
|
|
46
|
+
ok,
|
|
47
|
+
ranAt,
|
|
48
|
+
stepsHash: stale ? 'deadbeef' : e2eStepsHash(steps),
|
|
49
|
+
steps: [{ name: 'bench-check-enforces-the-budget-gate', ok, exitCode: ok ? 0 : 1 }],
|
|
50
|
+
};
|
|
51
|
+
fs.writeFileSync(
|
|
52
|
+
path.join(sessionDir, 'e2e-results.json'),
|
|
53
|
+
`${JSON.stringify(results)}\n`,
|
|
54
|
+
'utf8',
|
|
55
|
+
);
|
|
56
|
+
return changeDir;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test('a session that ran recently and has no failing proof is healthy', () => {
|
|
60
|
+
const p = makeProject();
|
|
61
|
+
const health = sessionHealth({ ...p, now: NOW });
|
|
62
|
+
assert.equal(health.state, 'healthy');
|
|
63
|
+
assert.deepEqual(health.reasons, []);
|
|
64
|
+
assert.match(health.line, /^HEALTHY/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('a failing e2e run is RED and names the failing step', () => {
|
|
68
|
+
// helm phase-1: 27/32 with e2e red on the bench budget gate, and nothing
|
|
69
|
+
// anywhere said so.
|
|
70
|
+
const p = makeProject();
|
|
71
|
+
writeE2e(p, { ok: false });
|
|
72
|
+
const health = sessionHealth({ ...p, now: NOW });
|
|
73
|
+
|
|
74
|
+
assert.equal(health.state, 'red');
|
|
75
|
+
assert.equal(health.reasons.length, 1);
|
|
76
|
+
assert.match(health.reasons[0], /e2e failing/);
|
|
77
|
+
assert.match(health.reasons[0], /bench-check-enforces-the-budget-gate/);
|
|
78
|
+
assert.match(health.line, /^RED —/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('e2e results that no longer match e2e.json are stale, not green', () => {
|
|
82
|
+
const p = makeProject();
|
|
83
|
+
writeE2e(p, { ok: true, stale: true });
|
|
84
|
+
const health = sessionHealth({ ...p, now: NOW });
|
|
85
|
+
|
|
86
|
+
assert.equal(health.state, 'stale');
|
|
87
|
+
assert.match(health.reasons.join(' '), /e2e results.*stale|stale.*e2e/i);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('a green current e2e run keeps the session healthy', () => {
|
|
91
|
+
const p = makeProject();
|
|
92
|
+
writeE2e(p, { ok: true });
|
|
93
|
+
assert.equal(sessionHealth({ ...p, now: NOW }).state, 'healthy');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('an idle session is STALE and says where it stopped', () => {
|
|
97
|
+
const p = makeProject({ updatedAt: ago(14) });
|
|
98
|
+
const health = sessionHealth({ ...p, now: NOW });
|
|
99
|
+
|
|
100
|
+
assert.equal(health.state, 'stale');
|
|
101
|
+
assert.match(health.reasons[0], /idle 14h/);
|
|
102
|
+
assert.match(health.reasons[0], /implement 27\/32/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('idle threshold is configurable and defaults to DEFAULT_IDLE_HOURS', () => {
|
|
106
|
+
const p = makeProject({ updatedAt: ago(DEFAULT_IDLE_HOURS + 1) });
|
|
107
|
+
assert.equal(sessionHealth({ ...p, now: NOW }).state, 'stale');
|
|
108
|
+
assert.equal(sessionHealth({ ...p, now: NOW, idleHours: 48 }).state, 'healthy');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('BLOCKED verify evidence is RED', () => {
|
|
112
|
+
const p = makeProject({ phase: 'verify' });
|
|
113
|
+
fs.writeFileSync(
|
|
114
|
+
path.join(p.sessionDir, 'verify-evidence.md'),
|
|
115
|
+
'# Verify\n\n## Product loop\n\nBLOCKED — the queue worker has no runtime owner yet.\n',
|
|
116
|
+
'utf8',
|
|
117
|
+
);
|
|
118
|
+
const health = sessionHealth({ ...p, now: NOW });
|
|
119
|
+
assert.equal(health.state, 'red');
|
|
120
|
+
assert.match(health.reasons.join(' '), /BLOCKED/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('a finished session is neither stale nor red', () => {
|
|
124
|
+
// Idle for a month and carrying an old failing run: done is done.
|
|
125
|
+
const p = makeProject({ phase: 'done', updatedAt: ago(720), tasksComplete: 32 });
|
|
126
|
+
writeE2e(p, { ok: false });
|
|
127
|
+
const health = sessionHealth({ ...p, now: NOW });
|
|
128
|
+
assert.equal(health.state, 'done');
|
|
129
|
+
assert.match(health.line, /^DONE/);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('red outranks stale when both fire', () => {
|
|
133
|
+
const p = makeProject({ updatedAt: ago(14) });
|
|
134
|
+
writeE2e(p, { ok: false });
|
|
135
|
+
const health = sessionHealth({ ...p, now: NOW });
|
|
136
|
+
assert.equal(health.state, 'red');
|
|
137
|
+
assert.equal(health.reasons.length, 2, 'both reasons are reported, severity picks the state');
|
|
138
|
+
});
|
package/src/new-session.mjs
CHANGED
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
* forge new <slug> [--chat-id <id>] [--signal <text>]
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
10
11
|
import {
|
|
11
12
|
defaultSession,
|
|
12
13
|
defaultStatus,
|
|
13
14
|
ensureForgeLayout,
|
|
14
15
|
FORGE_DIR,
|
|
15
16
|
makeSessionId,
|
|
17
|
+
REPO_ROOT,
|
|
16
18
|
saveSession,
|
|
17
19
|
scaffoldSessionDirs,
|
|
18
20
|
sessionPath,
|
|
@@ -55,6 +57,19 @@ scaffoldSessionDirs(dir);
|
|
|
55
57
|
const session = defaultSession(sessionId, slug);
|
|
56
58
|
if (cursorChatId) session.cursorChatId = cursorChatId;
|
|
57
59
|
|
|
60
|
+
// Where this session started, so reviewers have a diff range even when the
|
|
61
|
+
// project never enables checkpoints (and `forge checkpoint --range` has a
|
|
62
|
+
// base from commit one).
|
|
63
|
+
const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT, encoding: 'utf8' });
|
|
64
|
+
if (head.status === 0) {
|
|
65
|
+
session.baseCommit = head.stdout.trim();
|
|
66
|
+
const branch = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
67
|
+
cwd: REPO_ROOT,
|
|
68
|
+
encoding: 'utf8',
|
|
69
|
+
});
|
|
70
|
+
if (branch.status === 0) session.branch = branch.stdout.trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
const paceFields = resolveSessionPaceFields({
|
|
59
74
|
forgeDir: FORGE_DIR,
|
|
60
75
|
slug: session.slug,
|
package/src/session-reminder.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
touchSession,
|
|
18
18
|
} from './lib/fleet.mjs';
|
|
19
19
|
import { resolveEffectivePreferences } from './preferences.mjs';
|
|
20
|
+
import { sessionHealth } from './health.mjs';
|
|
20
21
|
|
|
21
22
|
function getActiveSessionInfo() {
|
|
22
23
|
const active = readActive();
|
|
@@ -88,6 +89,15 @@ export function buildForgeMessage(info) {
|
|
|
88
89
|
if (session.tasksTotal > 0) {
|
|
89
90
|
lines.push(`Tasks: ${session.tasksComplete}/${session.tasksTotal}`);
|
|
90
91
|
}
|
|
92
|
+
// Only when something is wrong: a resumed session that is red or was
|
|
93
|
+
// abandoned mid-implement should say so before the agent picks up where it
|
|
94
|
+
// thinks it left off.
|
|
95
|
+
if (info.dir) {
|
|
96
|
+
const health = sessionHealth({ cwd: REPO_ROOT, sessionDir: info.dir, session });
|
|
97
|
+
if (health.state === 'red' || health.state === 'stale') {
|
|
98
|
+
lines.push(`Health: ${health.line}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
91
101
|
if (needsOpenSpecPlan(session)) {
|
|
92
102
|
lines.push(OPENSPEC_PLAN_REMINDER);
|
|
93
103
|
}
|
package/src/session-status.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
REPO_ROOT,
|
|
17
17
|
} from './lib.mjs';
|
|
18
18
|
import { resolveEffectivePreferences } from './preferences.mjs';
|
|
19
|
+
import { sessionHealth } from './health.mjs';
|
|
19
20
|
|
|
20
21
|
const args = process.argv.slice(2);
|
|
21
22
|
let sessionId = null;
|
|
@@ -46,12 +47,17 @@ const pace = resolveEffectivePreferences({
|
|
|
46
47
|
signalText: session.paceSignal || session.slug || '',
|
|
47
48
|
});
|
|
48
49
|
|
|
50
|
+
const health = sessionHealth({ cwd: REPO_ROOT, sessionDir: dir, session });
|
|
51
|
+
|
|
49
52
|
process.stdout.write(
|
|
50
53
|
JSON.stringify(
|
|
51
54
|
{
|
|
52
55
|
status: 'ok',
|
|
53
56
|
sessionId,
|
|
54
57
|
sessionPath: path.relative(REPO_ROOT, dir).replace(/\\/g, '/'),
|
|
58
|
+
// Verdict first: a status dump that never says "this session is red and
|
|
59
|
+
// nobody has touched it since yesterday" makes the operator derive it.
|
|
60
|
+
health,
|
|
55
61
|
session,
|
|
56
62
|
progress: status,
|
|
57
63
|
pace: {
|
|
@@ -113,7 +113,8 @@ Testing: [references/test-strategy.md](./references/test-strategy.md) — tier 1
|
|
|
113
113
|
|
|
114
114
|
## Guardrails (every phase)
|
|
115
115
|
|
|
116
|
-
- No autonomous `git commit` / push unless the user explicitly asks
|
|
116
|
+
- No autonomous `git commit` / push unless the user explicitly asks. **Never push.** The one sanctioned commit is `forge checkpoint` at a task-group boundary, and only when the project set `.forge/config.json` → `git.checkpoint` (default `off`); it refuses on the default branch and excludes `.forge/` scratch
|
|
117
|
+
- **Session health** — `forge status` returns a `health` verdict (`healthy` / `stale` / `red` / `done`). On resume, read it before continuing: a red e2e run or an idle session mid-implement is the first thing to tell the user about
|
|
117
118
|
- Tests required for behavior changes
|
|
118
119
|
- Trace ecosystem consumers when contracts change
|
|
119
120
|
- Honor `openspec/config.yaml` prefixes when the project uses them (OpenSpec engine)
|
|
@@ -268,8 +268,12 @@ OpenSpec commands remain available standalone (OpenSpec-engine projects):
|
|
|
268
268
|
|
|
269
269
|
```bash
|
|
270
270
|
forge new <slug> [--signal "…"] # new session + set active (resolves pace; warn-only doctor)
|
|
271
|
-
forge status # active session JSON (+ effective pace)
|
|
271
|
+
forge status # active session JSON (+ effective pace + health verdict)
|
|
272
272
|
forge phase <phase> […] # update phase / openspec / task counters
|
|
273
|
+
forge checkpoint --group <name> [--tasks <ids>]
|
|
274
|
+
# commit this group's work (opt-in; never pushes)
|
|
275
|
+
forge checkpoint --dry-run # what a checkpoint would commit
|
|
276
|
+
forge checkpoint --range [--last] # diff range for a reviewer brief ({DIFF_RANGE})
|
|
273
277
|
forge cleanup [--dry-run] # prune sessions >14 days or finished
|
|
274
278
|
forge evidence --task <nn>-<slug> --command "<cmd>" --exit 0 --summary "<text>"
|
|
275
279
|
# stamp tier-2 test-evidence.md
|
|
@@ -572,7 +576,7 @@ forge models metered # WRITE .forge/models.local.json
|
|
|
572
576
|
|
|
573
577
|
Guardrails in every subagent brief (honor the **project’s** agent docs too):
|
|
574
578
|
|
|
575
|
-
- No autonomous `git commit` / push unless the user asks
|
|
579
|
+
- No autonomous `git commit` / push unless the user asks — subagents never commit at all
|
|
576
580
|
- Implementer runs tier 1 (scoped) + tier 2 (narrow) tests; coordinator saves `tasks/<nn>-<slug>/test-evidence.md` before marking task done
|
|
577
581
|
- Trace downstream consumers when contracts change
|
|
578
582
|
|
|
@@ -580,6 +584,79 @@ Prompt templates: [subagents/](../subagents/)
|
|
|
580
584
|
|
|
581
585
|
---
|
|
582
586
|
|
|
587
|
+
## Checkpoints (opt-in commits)
|
|
588
|
+
|
|
589
|
+
Off by default. Enable per project in `.forge/config.json`:
|
|
590
|
+
|
|
591
|
+
```json
|
|
592
|
+
{ "git": { "checkpoint": "per-group" } }
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
| Mode | When the coordinator runs `forge checkpoint` |
|
|
596
|
+
| ---- | -------------------------------------------- |
|
|
597
|
+
| `off` (default) | never — nothing is committed, reviewers read the working tree |
|
|
598
|
+
| `per-group` | at each `tasks.md` group boundary |
|
|
599
|
+
| `per-task` | after each task |
|
|
600
|
+
|
|
601
|
+
Why: a long session otherwise accumulates the whole change as one uncommitted
|
|
602
|
+
working tree — one bad `git checkout` from losing a day of agent work, with
|
|
603
|
+
every reviewer after task 1 reading a diff that contains all previous tasks.
|
|
604
|
+
|
|
605
|
+
Guarantees — the reason this is safe to automate:
|
|
606
|
+
|
|
607
|
+
- **Never pushes.** Nothing leaves the machine.
|
|
608
|
+
- **Refuses on the default branch** (`main` / `master`) unless
|
|
609
|
+
`--allow-default-branch` or `git.allowDefaultBranch: true`.
|
|
610
|
+
- **Refuses mid-merge / rebase / cherry-pick / revert / bisect.**
|
|
611
|
+
- **Excludes `.forge/`** — session scratch never lands in project history.
|
|
612
|
+
- Nothing to commit is success, not an error, and never makes an empty commit.
|
|
613
|
+
- Records `{ sha, group, tasks, at }` on the session, so reviewers get a real
|
|
614
|
+
range: `groupRange` (this group) and `range` (whole session, from
|
|
615
|
+
`session.baseCommit`, which `forge new` records even when checkpoints are off).
|
|
616
|
+
|
|
617
|
+
```bash
|
|
618
|
+
forge checkpoint --group 06-helm-cli --tasks 6.1-6.4
|
|
619
|
+
forge checkpoint --dry-run # list what would be committed
|
|
620
|
+
forge checkpoint --range --last # {DIFF_RANGE} for the group reviewer
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
**Reviewer scope.** A group review happens *before* that group's checkpoint,
|
|
624
|
+
so HEAD is still the previous one and a `<base>..HEAD` range would be empty.
|
|
625
|
+
Use the `reviewTarget` field from `forge checkpoint --range --last`:
|
|
626
|
+
|
|
627
|
+
| Tree state | `reviewTarget` |
|
|
628
|
+
| ---------- | -------------- |
|
|
629
|
+
| group still uncommitted | `git diff <last checkpoint>` **plus** the untracked files listed by name — `git diff` never shows them, and new files are most of what an implementer writes |
|
|
630
|
+
| group checkpointed | `<last checkpoint>..HEAD` |
|
|
631
|
+
|
|
632
|
+
`range` in the same output is the commit range only; it is empty mid-group by
|
|
633
|
+
design. `--last` scopes to the current group, without it the base is
|
|
634
|
+
`session.baseCommit` (the whole session).
|
|
635
|
+
|
|
636
|
+
Caveat: a checkpoint stages **everything outside `.forge/`**, including
|
|
637
|
+
unrelated edits sitting in the tree. Check `--dry-run` when the working tree
|
|
638
|
+
was not clean before the session started.
|
|
639
|
+
|
|
640
|
+
---
|
|
641
|
+
|
|
642
|
+
## Session health
|
|
643
|
+
|
|
644
|
+
`forge status` returns a verdict next to the data, and the reminder hook
|
|
645
|
+
surfaces it on resume when it is not healthy:
|
|
646
|
+
|
|
647
|
+
| State | Meaning |
|
|
648
|
+
| ----- | ------- |
|
|
649
|
+
| `red` | e2e run failing (named step), or `verify-evidence.md` records BLOCKED |
|
|
650
|
+
| `stale` | no session write for `health.idleHours` (default 4), or e2e results no longer match `e2e.json` |
|
|
651
|
+
| `healthy` | none of the above |
|
|
652
|
+
| `done` | phase `done` / `skipped` |
|
|
653
|
+
|
|
654
|
+
`forge fleet list` renders the same verdict as a HEALTH column plus a reason
|
|
655
|
+
line per unhealthy session, so a red or abandoned session is visible without
|
|
656
|
+
opening the project.
|
|
657
|
+
|
|
658
|
+
---
|
|
659
|
+
|
|
583
660
|
## Bundled skills (self-contained)
|
|
584
661
|
|
|
585
662
|
Forge vendors adapted Superpowers skills (MIT) under `skills/forge/skills/`.
|
|
@@ -56,7 +56,24 @@ Honor [../references/runtime-integrity.md](../references/runtime-integrity.md) i
|
|
|
56
56
|
```
|
|
57
57
|
(Refuses non-zero exit without `--allow-fail`; template + rules in [../references/test-evidence.md](../references/test-evidence.md).)
|
|
58
58
|
7. Mark task complete (`tasks.md` `- [x]` or update `tasksComplete`). Detect group boundary: next line in `tasks.md` is a new `##` heading, or no remaining tasks under the current heading.
|
|
59
|
-
8.
|
|
59
|
+
8. **Checkpoint** — when the project opts in (`.forge/config.json` → `git.checkpoint`):
|
|
60
|
+
```bash
|
|
61
|
+
forge checkpoint --group <nn>-<slug> --tasks <ids> # per-group: at the boundary; per-task: after each task
|
|
62
|
+
```
|
|
63
|
+
Commits the group's work and records the sha on the session. Never pushes,
|
|
64
|
+
refuses on the default branch, and leaves `.forge/` scratch out of the
|
|
65
|
+
commit. Default is `off`: nothing is committed and reviewers read the
|
|
66
|
+
working tree, as before.
|
|
67
|
+
|
|
68
|
+
**Reviewer scope (step 4).** The group review runs *before* this
|
|
69
|
+
checkpoint, so fill `{DIFF_RANGE}` from `forge checkpoint --range --last` →
|
|
70
|
+
its **`reviewTarget`** field. While the group is uncommitted that is
|
|
71
|
+
`git diff <last checkpoint>` plus the untracked files named explicitly (a
|
|
72
|
+
diff never shows them); once checkpointed it collapses to a plain commit
|
|
73
|
+
range. Do **not** paste `range` during a pre-checkpoint review — it is
|
|
74
|
+
empty until the group lands. Without checkpoints, every reviewer after task
|
|
75
|
+
1 re-reads all previous tasks' diffs.
|
|
76
|
+
9. Repeat.
|
|
60
77
|
|
|
61
78
|
**Batching:** consecutive small same-area tasks (docs, config, wording) may share one implementer brief + one review — see the batching rules in [subagent-driven-development](../skills/subagent-driven-development/SKILL.md). Never batch money/auth/contract/migration tasks.
|
|
62
79
|
|
|
@@ -66,7 +83,7 @@ forge phase implement --tasks-complete <N> --subagents <total dispatched so far>
|
|
|
66
83
|
|
|
67
84
|
## Forge constraints (include in every brief)
|
|
68
85
|
|
|
69
|
-
- **No** autonomous `git commit` or `git push`
|
|
86
|
+
- **No** autonomous `git commit` or `git push` — implementer subagents never commit. Checkpoints are the coordinator's job and only when `git.checkpoint` is enabled (`forge checkpoint`, which still never pushes)
|
|
70
87
|
- **Tier 2 tests only** before claiming task done — narrowest command for this task ([test-strategy.md](../references/test-strategy.md)); **not** the full workspace suite unless the task requires it
|
|
71
88
|
- Trace ecosystem consumers when contracts change
|
|
72
89
|
- Minimal diff — surgical changes only
|
|
@@ -21,7 +21,7 @@ Capability specs beat narrow task wording when they conflict. See
|
|
|
21
21
|
|
|
22
22
|
{FILE_LIST}
|
|
23
23
|
|
|
24
|
-
Diff range: {DIFF_RANGE} <!--
|
|
24
|
+
Diff range: {DIFF_RANGE} <!-- `forge checkpoint --range --last` → paste its `reviewTarget` (scopes to this group; names untracked files a diff hides). No checkpoints: `git diff` + the untracked files in `git status`. -->
|
|
25
25
|
|
|
26
26
|
**Read the actual code.** The summary above was written by the party under review — it is a map, not evidence. Read the changed files (or the diff range) before any verdict; verify each spec requirement against what the code does, not what the summary says it does.
|
|
27
27
|
|