@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/bin/forge.mjs +4 -0
- package/package.json +1 -1
- package/src/checkpoint.mjs +357 -0
- package/src/checkpoint.test.mjs +252 -0
- package/src/findings-cli.mjs +129 -0
- package/src/findings.mjs +103 -0
- package/src/findings.test.mjs +140 -0
- package/src/fleet.mjs +27 -2
- package/src/health.mjs +144 -0
- package/src/health.test.mjs +138 -0
- package/src/ledger.mjs +148 -0
- package/src/ledger.test.mjs +132 -0
- package/src/new-session.mjs +15 -0
- package/src/review-census.mjs +62 -0
- package/src/score.mjs +98 -30
- package/src/score.test.mjs +225 -0
- package/src/session-reminder.mjs +10 -0
- package/src/session-status.mjs +15 -0
- package/vendor/skills/forge/SKILL.md +4 -1
- package/vendor/skills/forge/docs/forge.md +82 -2
- package/vendor/skills/forge/phases/implement.md +19 -2
- package/vendor/skills/forge/subagents/task-reviewer-prompt.md +1 -1
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
|
|
390
|
-
const
|
|
391
|
-
let reviewPts =
|
|
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 (
|
|
395
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/score.test.mjs
CHANGED
|
@@ -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 {
|
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,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.
|
|
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
|
|