@izkac/forgekit 0.3.14 → 0.3.16

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/src/score.mjs CHANGED
@@ -22,10 +22,17 @@ import {
22
22
  spinePath,
23
23
  validateSpine,
24
24
  } from './integrity.mjs';
25
+ import { sessionHealth } from './health.mjs';
26
+ import { isHighRiskText } from './preferences.mjs';
27
+ import { reviewCensus } from './review-census.mjs';
28
+ import { appendDeferralLedger, appendSessionDigest } from './ledger.mjs';
25
29
 
26
30
  /** Keep in sync with set-phase.mjs TASK_COUNT_ESCALATION_THRESHOLD. */
27
31
  const TASK_COUNT_ESCALATION_THRESHOLD = 15;
28
32
 
33
+ /** Ceiling (grade C) for sessions whose outcome is unproven or unreviewed. */
34
+ const OUTCOME_CAP = 69;
35
+
29
36
  /**
30
37
  * @param {unknown} value
31
38
  */
@@ -94,25 +101,6 @@ function evidenceHonestyIssues(sessionDir) {
94
101
  return issues;
95
102
  }
96
103
 
97
- /**
98
- * @param {string} sessionDir
99
- */
100
- function reviewSelfCheckCount(sessionDir) {
101
- const tasksDir = path.join(sessionDir, 'tasks');
102
- if (!fs.existsSync(tasksDir)) return 0;
103
- let n = 0;
104
- for (const e of fs.readdirSync(tasksDir, { withFileTypes: true })) {
105
- if (!e.isDirectory()) continue;
106
- for (const name of ['task-review.md', 'group-review.md']) {
107
- const file = path.join(tasksDir, e.name, name);
108
- if (!fs.existsSync(file)) continue;
109
- const body = fs.readFileSync(file, 'utf8');
110
- if (/pace self-check|APPROVED \(pace/i.test(body)) n += 1;
111
- }
112
- }
113
- return n;
114
- }
115
-
116
104
  /**
117
105
  * True when a green e2e run was executed against the current e2e.json — the
118
106
  * primary product-loop signal: score what ran, not what was written. A phrase
@@ -207,11 +195,15 @@ export function scoreSession(opts) {
207
195
  /** @type {string[]} */
208
196
  const spineNotes = [];
209
197
  const spineFile = spinePath({ cwd, session, sessionDir });
198
+ // What the change actually touches, for risk detection below — a slug
199
+ // written at session start rarely says "auth" even when the change is one.
200
+ let spineText = '';
210
201
  if (!fs.existsSync(spineFile)) {
211
202
  spineNotes.push('spine.json missing');
212
203
  } else {
213
204
  try {
214
205
  const doc = readJson(spineFile);
206
+ spineText = JSON.stringify(doc);
215
207
  const v = validateSpine(doc);
216
208
  if (!v.ok) {
217
209
  spineNotes.push(...v.problems);
@@ -386,23 +378,56 @@ export function scoreSession(opts) {
386
378
  }
387
379
  checks.push({ id: 'pace', label: 'Pace sanity', points: pacePts, max: 5, notes: paceNotes });
388
380
 
389
- // --- review depth soft signal (5) ---
390
- const selfChecks = reviewSelfCheckCount(sessionDir);
391
- let reviewPts = 5;
381
+ // --- review depth (5) — scored by what was dispatched ---
382
+ const census = reviewCensus(sessionDir);
383
+ let reviewPts = 0;
392
384
  /** @type {string[]} */
393
385
  const reviewNotes = [];
394
- if (selfChecks > 0 && (resolved === 'thorough' || total >= TASK_COUNT_ESCALATION_THRESHOLD)) {
395
- reviewPts = Math.max(0, 5 - Math.min(5, selfChecks));
396
- reviewNotes.push(`${selfChecks} pace self-check review(s) on a large/thorough session`);
397
- } else if (selfChecks > 0) {
398
- reviewNotes.push(`${selfChecks} pace self-check(s) — ok under brisk/standard mid-group`);
386
+ if (census.total === 0 && !census.finalReview) {
387
+ reviewNotes.push('no review artifacts at all — nobody read this work but the author');
399
388
  } else {
400
- reviewNotes.push('no pace self-check markers found');
389
+ // Coverage, not presence: one review across eight task groups is not the
390
+ // same signal as nine across nine.
391
+ const groups = Math.max(ev.taskDirs, census.total);
392
+ const coverage = groups > 0 ? census.independent / groups : 0;
393
+ if (census.independent > 0 && coverage >= 0.5) {
394
+ reviewPts += 2;
395
+ reviewNotes.push(`${census.independent} dispatched review(s) across ${groups} task group(s)`);
396
+ } else if (census.independent > 0) {
397
+ reviewPts += 1;
398
+ reviewNotes.push(
399
+ `${census.independent} dispatched review(s) across ${groups} task group(s) — thin coverage`,
400
+ );
401
+ } else {
402
+ reviewNotes.push(`${census.selfChecks} self-check(s), no dispatched reviewer`);
403
+ }
404
+ if (census.finalReview === 'independent') {
405
+ reviewPts += 2;
406
+ reviewNotes.push('independent final review');
407
+ } else if (census.finalReview === 'self') {
408
+ reviewPts += 1;
409
+ reviewNotes.push('final review is self-authored — weaker than an outside reader');
410
+ } else {
411
+ reviewNotes.push('no final review');
412
+ }
413
+ // A review that never rejected anything may still be a rubber stamp; one
414
+ // that sent work back demonstrably was not.
415
+ if (census.rejections > 0) {
416
+ reviewPts += 1;
417
+ reviewNotes.push(`${census.rejections} review round(s) rejected work before approving`);
418
+ }
419
+ }
420
+ if (
421
+ census.selfChecks > 0 &&
422
+ census.independent === 0 &&
423
+ (resolved === 'thorough' || total >= TASK_COUNT_ESCALATION_THRESHOLD)
424
+ ) {
425
+ reviewNotes.push('large/thorough session carried by self-checks only');
401
426
  }
402
427
  checks.push({
403
428
  id: 'reviews',
404
- label: 'Review depth signal',
405
- points: reviewPts,
429
+ label: 'Review depth (dispatched reviewers, not absence of markers)',
430
+ points: Math.min(5, reviewPts),
406
431
  max: 5,
407
432
  notes: reviewNotes,
408
433
  });
@@ -420,6 +445,45 @@ export function scoreSession(opts) {
420
445
  );
421
446
  }
422
447
 
448
+ // A failing product loop is an outcome, and outcomes outrank artifacts: no
449
+ // amount of spine/evidence polish should let a session with a red e2e run
450
+ // read as an A.
451
+ const health = sessionHealth({ cwd, sessionDir, session });
452
+ if (health.state === 'red') {
453
+ const before = score;
454
+ if (score > OUTCOME_CAP) {
455
+ score = OUTCOME_CAP;
456
+ caps.push(`${health.reasons.join('; ')} — score capped at ${OUTCOME_CAP} (was ${before})`);
457
+ }
458
+ }
459
+
460
+ // Money/auth/contracts/migrations have a hard floor: an independent
461
+ // reviewer. Prose saying dispatch was declined does not survive session
462
+ // cleanup; a cap does.
463
+ // Fails closed, like pace resolution: a *negated* mention ("carries
464
+ // consumption, never money") still counts as a money-shaped change, because
465
+ // the cost of being wrong is one dispatched reviewer.
466
+ const riskText = [session.paceSignal, session.slug, session.openspecChange, spineText]
467
+ .filter(isNonEmptyString)
468
+ .join(' ');
469
+ // The floor for a high-risk change is an independent reader of the *whole*
470
+ // change. Per-group reviews do not substitute: they each saw one slice.
471
+ if (isHighRiskText(riskText) && census.finalReview !== 'independent') {
472
+ const before = score;
473
+ const what =
474
+ census.finalReview === 'self'
475
+ ? 'high-risk session whose final review is self-authored'
476
+ : 'high-risk session with no independent final review';
477
+ if (score > OUTCOME_CAP) {
478
+ score = OUTCOME_CAP;
479
+ caps.push(
480
+ `${what} — score capped at ${OUTCOME_CAP} (was ${before}); dispatch a final reviewer, or record the refusal with forge defer so it survives cleanup`,
481
+ );
482
+ } else {
483
+ caps.push(what);
484
+ }
485
+ }
486
+
423
487
  const grade = gradeForScore(score);
424
488
  const changeDir = resolveChangeDir({ cwd, session });
425
489
 
@@ -564,5 +628,9 @@ export function writeSessionScorecard(opts) {
564
628
  writeJson(jsonPath, card);
565
629
  fs.writeFileSync(mdPath, formatScorecardMarkdown(card), 'utf8');
566
630
  appendScorecardLedger(opts.sessionDir, card, opts.session);
631
+ // Durable ledgers: the session dir is deleted at cleanup, so the digest and
632
+ // any unresolved deferrals have to leave the session while it still exists.
633
+ appendSessionDigest({ cwd: opts.cwd, sessionDir: opts.sessionDir, session: opts.session, card });
634
+ appendDeferralLedger({ cwd: opts.cwd, sessionDir: opts.sessionDir, session: opts.session });
567
635
  return { card, jsonPath, mdPath };
568
636
  }
@@ -105,6 +105,231 @@ test('scoreSession: strong sync-only session scores high', () => {
105
105
  }
106
106
  });
107
107
 
108
+ /** Session with everything except reviews, so review depth is the only variable. */
109
+ function makeReviewFixture(root, sessionOverrides = {}) {
110
+ const { sessionDir, session } = makeSession(root, {
111
+ slug: 'add-billing',
112
+ tasksTotal: 20,
113
+ tasksComplete: 20,
114
+ ...sessionOverrides,
115
+ });
116
+ fs.writeFileSync(
117
+ path.join(sessionDir, 'spine.json'),
118
+ `${JSON.stringify({ rows: [], notApplicable: 'sync HTTP only' }, null, 2)}\n`,
119
+ 'utf8',
120
+ );
121
+ fs.writeFileSync(path.join(sessionDir, 'verify-evidence.md'), '# Verify\n\nExit 0\n', 'utf8');
122
+ const taskDir = path.join(sessionDir, 'tasks', '01-model');
123
+ fs.mkdirSync(taskDir, { recursive: true });
124
+ fs.writeFileSync(
125
+ path.join(taskDir, 'test-evidence.md'),
126
+ '# Test evidence\n\n- **Exit code:** 0\n- **Summary:** asserts the row is written\n',
127
+ 'utf8',
128
+ );
129
+ return { sessionDir, session, taskDir };
130
+ }
131
+
132
+ function reviewCheck(card) {
133
+ return card.checks.find((c) => c.id === 'reviews');
134
+ }
135
+
136
+ test('review depth: no reviewer artifacts at all scores zero, not full marks', () => {
137
+ // Regression: reviewPts started at 5 and was only ever *reduced* by finding
138
+ // self-check markers, so a session with no reviews of any kind scored 5/5.
139
+ // That is how a 38-task, high-risk, self-reviewed session reached 100/100.
140
+ const root = tmp('forge-score-noreview-');
141
+ try {
142
+ const { sessionDir, session } = makeReviewFixture(root);
143
+ const check = reviewCheck(scoreSession({ cwd: root, sessionDir, session }));
144
+ assert.equal(check.points, 0);
145
+ assert.match(check.notes.join(' '), /no review/i);
146
+ } finally {
147
+ fs.rmSync(root, { recursive: true, force: true });
148
+ }
149
+ });
150
+
151
+ test('review depth: a dispatched reviewer beats a self-check', () => {
152
+ const root = tmp('forge-score-dispatched-');
153
+ try {
154
+ const { sessionDir, session, taskDir } = makeReviewFixture(root);
155
+ fs.writeFileSync(
156
+ path.join(taskDir, 'task-review.md'),
157
+ '# Task review\n\nAPPROVED (pace self-check) — coordinator read the diff.\n',
158
+ 'utf8',
159
+ );
160
+ const selfOnly = reviewCheck(scoreSession({ cwd: root, sessionDir, session }));
161
+
162
+ fs.writeFileSync(
163
+ path.join(taskDir, 'group-review.md'),
164
+ '# Group review\n\n**Verdict: APPROVED** (opus reviewer a3cbc561b60655bb8)\n',
165
+ 'utf8',
166
+ );
167
+ const dispatched = reviewCheck(scoreSession({ cwd: root, sessionDir, session }));
168
+
169
+ assert.ok(
170
+ dispatched.points > selfOnly.points,
171
+ `dispatched (${dispatched.points}) should beat self-check-only (${selfOnly.points})`,
172
+ );
173
+ } finally {
174
+ fs.rmSync(root, { recursive: true, force: true });
175
+ }
176
+ });
177
+
178
+ test('review depth: a recorded rejection round scores as evidence the review had teeth', () => {
179
+ // helm's group-6 REJECT→fix→APPROVE caught a flaky-green test about to
180
+ // become 3-OS CI evidence. It is the most valuable artifact in the corpus
181
+ // and used to score nothing.
182
+ const root = tmp('forge-score-reject-');
183
+ try {
184
+ const { sessionDir, session, taskDir } = makeReviewFixture(root);
185
+ fs.writeFileSync(
186
+ path.join(taskDir, 'group-review.md'),
187
+ '# Group 6 review\n\n**Verdict: APPROVED** (opus reviewer 9f2, after one fix round)\n\n## Round 1 — REJECTED\n\nOne blocker, four majors.\n',
188
+ 'utf8',
189
+ );
190
+ const withReject = reviewCheck(scoreSession({ cwd: root, sessionDir, session }));
191
+
192
+ fs.writeFileSync(
193
+ path.join(taskDir, 'group-review.md'),
194
+ '# Group 6 review\n\n**Verdict: APPROVED** (opus reviewer 9f2)\n',
195
+ 'utf8',
196
+ );
197
+ const cleanApprove = reviewCheck(scoreSession({ cwd: root, sessionDir, session }));
198
+
199
+ assert.ok(
200
+ withReject.points > cleanApprove.points,
201
+ `a rejection round (${withReject.points}) should outscore a first-pass approval (${cleanApprove.points})`,
202
+ );
203
+ assert.match(withReject.notes.join(' '), /reject/i);
204
+ } finally {
205
+ fs.rmSync(root, { recursive: true, force: true });
206
+ }
207
+ });
208
+
209
+ test('review depth scores coverage, not presence — 1 review across 8 groups is thin', () => {
210
+ const root = tmp('forge-score-coverage-');
211
+ try {
212
+ const { sessionDir, session } = makeReviewFixture(root);
213
+ const tasksDir = path.join(sessionDir, 'tasks');
214
+ for (const g of ['02-api', '03-mail', '04-client']) {
215
+ fs.mkdirSync(path.join(tasksDir, g), { recursive: true });
216
+ fs.writeFileSync(
217
+ path.join(tasksDir, g, 'test-evidence.md'),
218
+ '# Test evidence\n\n- **Exit code:** 0\n- **Summary:** asserts output\n',
219
+ 'utf8',
220
+ );
221
+ }
222
+ fs.writeFileSync(
223
+ path.join(tasksDir, '01-model', 'group-review.md'),
224
+ '# Group review\n\n**Verdict: APPROVED** (opus reviewer 7c1)\n',
225
+ 'utf8',
226
+ );
227
+ const thin = reviewCheck(scoreSession({ cwd: root, sessionDir, session }));
228
+ assert.match(thin.notes.join(' '), /thin coverage/);
229
+
230
+ for (const g of ['02-api', '03-mail', '04-client']) {
231
+ fs.writeFileSync(
232
+ path.join(tasksDir, g, 'group-review.md'),
233
+ '# Group review\n\n**Verdict: APPROVED** (opus reviewer 7c1)\n',
234
+ 'utf8',
235
+ );
236
+ }
237
+ const full = reviewCheck(scoreSession({ cwd: root, sessionDir, session }));
238
+ assert.ok(full.points > thin.points, `full coverage (${full.points}) should beat thin (${thin.points})`);
239
+ } finally {
240
+ fs.rmSync(root, { recursive: true, force: true });
241
+ }
242
+ });
243
+
244
+ test('a high-risk session with no independent review is capped, however good its artifacts', () => {
245
+ const root = tmp('forge-score-riskcap-');
246
+ try {
247
+ // Money/auth signal: the hard floor says an independent reviewer is
248
+ // mandatory regardless of pace.
249
+ const { sessionDir, session } = makeReviewFixture(root, {
250
+ slug: 'add-stripe-refund-auth',
251
+ paceSignal: 'payment refunds behind an authorization gate',
252
+ });
253
+ const card = scoreSession({ cwd: root, sessionDir, session });
254
+
255
+ assert.ok(card.score <= 69, `expected a cap at C, got ${card.score}`);
256
+ assert.match(card.caps.join(' '), /independent final review/i);
257
+
258
+ // Per-group reviews do NOT lift the cap: each saw one slice, and the floor
259
+ // is an independent reader of the whole change.
260
+ const taskDir = path.join(sessionDir, 'tasks', '01-model');
261
+ fs.writeFileSync(
262
+ path.join(taskDir, 'group-review.md'),
263
+ '# Group review\n\n**Verdict: APPROVED** (opus reviewer 7c1)\n',
264
+ 'utf8',
265
+ );
266
+ assert.ok(scoreSession({ cwd: root, sessionDir, session }).score <= 69);
267
+
268
+ // A self-authored final review is named as such, and still capped.
269
+ fs.mkdirSync(path.join(sessionDir, 'reviews'), { recursive: true });
270
+ fs.writeFileSync(
271
+ path.join(sessionDir, 'reviews', 'final-review.md'),
272
+ '# Final review\n\nReviewer: the coordinator — this is a self-review, dispatch was declined.\n',
273
+ 'utf8',
274
+ );
275
+ const selfFinal = scoreSession({ cwd: root, sessionDir, session });
276
+ assert.ok(selfFinal.score <= 69);
277
+ assert.match(selfFinal.caps.join(' '), /self-authored/i);
278
+
279
+ // An independent final review lifts it.
280
+ fs.writeFileSync(
281
+ path.join(sessionDir, 'reviews', 'final-review.md'),
282
+ '# Final review\n\n**Verdict: APPROVED** — opus reviewer 4d2 read the whole diff.\n',
283
+ 'utf8',
284
+ );
285
+ const reviewed = scoreSession({ cwd: root, sessionDir, session });
286
+ assert.equal(
287
+ reviewed.caps.some((c) => /final review/i.test(c)),
288
+ false,
289
+ );
290
+ assert.ok(reviewed.score > 69, `expected no cap, got ${reviewed.score}`);
291
+ } finally {
292
+ fs.rmSync(root, { recursive: true, force: true });
293
+ }
294
+ });
295
+
296
+ test('a red e2e run caps the score — artifacts cannot outvote a failing product loop', () => {
297
+ const root = tmp('forge-score-redcap-');
298
+ try {
299
+ const { sessionDir, session } = makeSession(root, {
300
+ slug: 'phase-1',
301
+ openspecChange: 'phase-1',
302
+ planType: 'specs',
303
+ });
304
+ const changeDir = path.join(root, 'specs', 'changes', 'phase-1');
305
+ fs.mkdirSync(changeDir, { recursive: true });
306
+ const steps = [{ name: 'bench-gate', cmd: 'true' }];
307
+ fs.writeFileSync(
308
+ path.join(changeDir, 'spine.json'),
309
+ `${JSON.stringify({ rows: [], notApplicable: 'sync only' }, null, 2)}\n`,
310
+ 'utf8',
311
+ );
312
+ fs.writeFileSync(path.join(changeDir, 'e2e.json'), `${JSON.stringify({ steps })}\n`, 'utf8');
313
+ fs.writeFileSync(
314
+ path.join(sessionDir, 'e2e-results.json'),
315
+ `${JSON.stringify({
316
+ ok: false,
317
+ ranAt: new Date().toISOString(),
318
+ stepsHash: e2eStepsHash(steps),
319
+ steps: [{ name: 'bench-gate', ok: false, exitCode: 1 }],
320
+ })}\n`,
321
+ 'utf8',
322
+ );
323
+ fs.writeFileSync(path.join(sessionDir, 'verify-evidence.md'), '# Verify\n\n## Product loop\n\n1. run it\n', 'utf8');
324
+
325
+ const card = scoreSession({ cwd: root, sessionDir, session });
326
+ assert.ok(card.score <= 69, `expected a cap at C, got ${card.score}`);
327
+ assert.match(card.caps.join(' '), /e2e|product loop/i);
328
+ } finally {
329
+ fs.rmSync(root, { recursive: true, force: true });
330
+ }
331
+ });
332
+
108
333
  test('scoreSession: missing spine scores poorly', () => {
109
334
  const root = tmp('forge-score-weak-');
110
335
  try {
@@ -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
  }
@@ -16,6 +16,8 @@ import {
16
16
  REPO_ROOT,
17
17
  } from './lib.mjs';
18
18
  import { resolveEffectivePreferences } from './preferences.mjs';
19
+ import { sessionHealth } from './health.mjs';
20
+ import { openFindings } from './findings.mjs';
19
21
 
20
22
  const args = process.argv.slice(2);
21
23
  let sessionId = null;
@@ -46,12 +48,25 @@ const pace = resolveEffectivePreferences({
46
48
  signalText: session.paceSignal || session.slug || '',
47
49
  });
48
50
 
51
+ const health = sessionHealth({ cwd: REPO_ROOT, sessionDir: dir, session });
52
+
53
+ // Open findings are project-level, not session-level: they outlive the session
54
+ // that raised them, which is the entire point of the ledger.
55
+ const findings = openFindings(FORGE_DIR);
56
+
49
57
  process.stdout.write(
50
58
  JSON.stringify(
51
59
  {
52
60
  status: 'ok',
53
61
  sessionId,
54
62
  sessionPath: path.relative(REPO_ROOT, dir).replace(/\\/g, '/'),
63
+ // Verdict first: a status dump that never says "this session is red and
64
+ // nobody has touched it since yesterday" makes the operator derive it.
65
+ health,
66
+ openFindings: {
67
+ count: findings.length,
68
+ latest: findings.slice(-5).map((f) => ({ id: f.id, severity: f.severity, text: f.text, change: f.change })),
69
+ },
55
70
  session,
56
71
  progress: status,
57
72
  pace: {
@@ -113,7 +113,10 @@ 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
118
+ - **High-risk floor** — money/auth/contracts/migrations need an **independent final review** (a reviewer other than you reading the whole change). `forge score` caps the session at 69 without one. If the user declines dispatch, record it with `forge defer` — prose caveats do not survive session cleanup
119
+ - **Findings** — anything you notice that deserves work but is out of scope goes to `forge finding add "<text>" [--change <slug>]`, not into a report the next session will not read
117
120
  - Tests required for behavior changes
118
121
  - Trace ecosystem consumers when contracts change
119
122
  - Honor `openspec/config.yaml` prefixes when the project uses them (OpenSpec engine)
@@ -268,8 +268,15 @@ 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})
277
+ forge finding add "<text>" [--change <slug>] [--severity blocker|major|minor|note]
278
+ # findings ledger (.forge/findings.jsonl)
279
+ forge finding list|resolve <id> # open findings appear in forge status
273
280
  forge cleanup [--dry-run] # prune sessions >14 days or finished
274
281
  forge evidence --task <nn>-<slug> --command "<cmd>" --exit 0 --summary "<text>"
275
282
  # stamp tier-2 test-evidence.md
@@ -572,7 +579,7 @@ forge models metered # WRITE .forge/models.local.json
572
579
 
573
580
  Guardrails in every subagent brief (honor the **project’s** agent docs too):
574
581
 
575
- - No autonomous `git commit` / push unless the user asks
582
+ - No autonomous `git commit` / push unless the user asks — subagents never commit at all
576
583
  - Implementer runs tier 1 (scoped) + tier 2 (narrow) tests; coordinator saves `tasks/<nn>-<slug>/test-evidence.md` before marking task done
577
584
  - Trace downstream consumers when contracts change
578
585
 
@@ -580,6 +587,79 @@ Prompt templates: [subagents/](../subagents/)
580
587
 
581
588
  ---
582
589
 
590
+ ## Checkpoints (opt-in commits)
591
+
592
+ Off by default. Enable per project in `.forge/config.json`:
593
+
594
+ ```json
595
+ { "git": { "checkpoint": "per-group" } }
596
+ ```
597
+
598
+ | Mode | When the coordinator runs `forge checkpoint` |
599
+ | ---- | -------------------------------------------- |
600
+ | `off` (default) | never — nothing is committed, reviewers read the working tree |
601
+ | `per-group` | at each `tasks.md` group boundary |
602
+ | `per-task` | after each task |
603
+
604
+ Why: a long session otherwise accumulates the whole change as one uncommitted
605
+ working tree — one bad `git checkout` from losing a day of agent work, with
606
+ every reviewer after task 1 reading a diff that contains all previous tasks.
607
+
608
+ Guarantees — the reason this is safe to automate:
609
+
610
+ - **Never pushes.** Nothing leaves the machine.
611
+ - **Refuses on the default branch** (`main` / `master`) unless
612
+ `--allow-default-branch` or `git.allowDefaultBranch: true`.
613
+ - **Refuses mid-merge / rebase / cherry-pick / revert / bisect.**
614
+ - **Excludes `.forge/`** — session scratch never lands in project history.
615
+ - Nothing to commit is success, not an error, and never makes an empty commit.
616
+ - Records `{ sha, group, tasks, at }` on the session, so reviewers get a real
617
+ range: `groupRange` (this group) and `range` (whole session, from
618
+ `session.baseCommit`, which `forge new` records even when checkpoints are off).
619
+
620
+ ```bash
621
+ forge checkpoint --group 06-helm-cli --tasks 6.1-6.4
622
+ forge checkpoint --dry-run # list what would be committed
623
+ forge checkpoint --range --last # {DIFF_RANGE} for the group reviewer
624
+ ```
625
+
626
+ **Reviewer scope.** A group review happens *before* that group's checkpoint,
627
+ so HEAD is still the previous one and a `<base>..HEAD` range would be empty.
628
+ Use the `reviewTarget` field from `forge checkpoint --range --last`:
629
+
630
+ | Tree state | `reviewTarget` |
631
+ | ---------- | -------------- |
632
+ | 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 |
633
+ | group checkpointed | `<last checkpoint>..HEAD` |
634
+
635
+ `range` in the same output is the commit range only; it is empty mid-group by
636
+ design. `--last` scopes to the current group, without it the base is
637
+ `session.baseCommit` (the whole session).
638
+
639
+ Caveat: a checkpoint stages **everything outside `.forge/`**, including
640
+ unrelated edits sitting in the tree. Check `--dry-run` when the working tree
641
+ was not clean before the session started.
642
+
643
+ ---
644
+
645
+ ## Session health
646
+
647
+ `forge status` returns a verdict next to the data, and the reminder hook
648
+ surfaces it on resume when it is not healthy:
649
+
650
+ | State | Meaning |
651
+ | ----- | ------- |
652
+ | `red` | e2e run failing (named step), or `verify-evidence.md` records BLOCKED |
653
+ | `stale` | no session write for `health.idleHours` (default 4), or e2e results no longer match `e2e.json` |
654
+ | `healthy` | none of the above |
655
+ | `done` | phase `done` / `skipped` |
656
+
657
+ `forge fleet list` renders the same verdict as a HEALTH column plus a reason
658
+ line per unhealthy session, so a red or abandoned session is visible without
659
+ opening the project.
660
+
661
+ ---
662
+
583
663
  ## Bundled skills (self-contained)
584
664
 
585
665
  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. Repeat.
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} <!-- e.g. `git diff` (uncommitted) or BASE..HEAD -->
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