@izkac/forgekit 0.3.15 → 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 CHANGED
@@ -39,6 +39,7 @@ const COMMANDS = {
39
39
  score: { script: 'score-cli.mjs', aliases: ['scorecard'] },
40
40
  fleet: { script: 'fleet.mjs' },
41
41
  brief: { script: 'brief-cli.mjs' },
42
+ finding: { script: 'findings-cli.mjs', aliases: ['findings'] },
42
43
  };
43
44
 
44
45
  function printHelp() {
@@ -72,6 +73,7 @@ Commands:
72
73
  score [--write] L2 session scorecard (auto-written at phase done)
73
74
  fleet list|watch|view|send Cross-project session control terminal
74
75
  brief stamp|check|open Operator brief (plain-language HTML, gates implement)
76
+ finding add|list|resolve Findings ledger — give an observation a home
75
77
 
76
78
  Prefer \`forgekit install\` to pick multiple skills + agents at once.
77
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@izkac/forgekit",
3
- "version": "0.3.15",
3
+ "version": "0.3.16",
4
4
  "description": "Forgekit CLIs — forgekit (install), forge (workflow), review (code review)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `forge finding` — file, list and close findings (see findings.mjs).
4
+ *
5
+ * Usage:
6
+ * forge finding add "<text>" [--change <slug>] [--severity blocker|major|minor|note]
7
+ * forge finding list [--json] [--all]
8
+ * forge finding resolve <id> [--note "<text>"]
9
+ */
10
+
11
+ import { FORGE_DIR, loadSession, readActive } from './lib.mjs';
12
+ import { addFinding, readFindings, resolveFinding } from './findings.mjs';
13
+
14
+ function usage() {
15
+ process.stderr.write(
16
+ `Usage:
17
+ forge finding add "<text>" [--change <slug>] [--severity blocker|major|minor|note]
18
+ forge finding list [--json] [--all]
19
+ forge finding resolve <id> [--note "<text>"]
20
+ `,
21
+ );
22
+ }
23
+
24
+ function fail(message) {
25
+ process.stderr.write(`${message}\n`);
26
+ process.exit(1);
27
+ }
28
+
29
+ function emit(payload) {
30
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
31
+ }
32
+
33
+ /** Active session, or nulls — findings are often filed between sessions. */
34
+ function activeSessionInfo() {
35
+ try {
36
+ const active = readActive();
37
+ if (!active?.sessionId) return { sessionId: null, slug: null };
38
+ const { session } = loadSession(active.sessionId);
39
+ return { sessionId: session.id ?? null, slug: session.slug ?? null };
40
+ } catch {
41
+ return { sessionId: null, slug: null };
42
+ }
43
+ }
44
+
45
+ const argv = process.argv.slice(2);
46
+ const cmd = argv[0];
47
+ if (!cmd || cmd === '--help' || cmd === '-h') {
48
+ usage();
49
+ process.exit(cmd ? 0 : 1);
50
+ }
51
+
52
+ if (cmd === 'add') {
53
+ const text = argv[1] ?? '';
54
+ if (!text.trim() || text.startsWith('--')) {
55
+ fail('A finding needs text: forge finding add "<text>"');
56
+ }
57
+ let change = null;
58
+ let severity = 'major';
59
+ for (let i = 2; i < argv.length; i += 1) {
60
+ if (argv[i] === '--change' && argv[i + 1]) change = argv[(i += 1)];
61
+ else if (argv[i] === '--severity' && argv[i + 1]) severity = argv[(i += 1)];
62
+ else fail(`Unknown argument: ${argv[i]}`);
63
+ }
64
+ try {
65
+ const entry = addFinding({
66
+ forgeDir: FORGE_DIR,
67
+ text,
68
+ severity,
69
+ change,
70
+ session: activeSessionInfo(),
71
+ });
72
+ emit({
73
+ ok: true,
74
+ ...entry,
75
+ next: change
76
+ ? `Open its home: forge change new ${change}`
77
+ : 'Give it a home: forge change new <slug>, forge defer add, or fix it now — otherwise label it a note (--severity note).',
78
+ });
79
+ } catch (err) {
80
+ fail(err instanceof Error ? err.message : String(err));
81
+ }
82
+ process.exit(0);
83
+ }
84
+
85
+ if (cmd === 'list') {
86
+ const all = argv.includes('--all');
87
+ const entries = readFindings(FORGE_DIR);
88
+ const open = entries.filter((e) => e.status === 'open');
89
+ const resolved = entries.filter((e) => e.status !== 'open');
90
+ if (argv.includes('--json')) {
91
+ emit({ open, resolved: all ? resolved : resolved.slice(-10), total: entries.length });
92
+ process.exit(0);
93
+ }
94
+ if (entries.length === 0) {
95
+ process.stdout.write('No findings recorded. File one: forge finding add "<text>"\n');
96
+ process.exit(0);
97
+ }
98
+ const rows = (all ? entries : open).map(
99
+ (e) =>
100
+ `${e.id} ${String(e.severity ?? 'major').padEnd(7)} ${e.status === 'open' ? ' ' : '✓'} ${e.text}${
101
+ e.change ? ` → ${e.change}` : ''
102
+ }`,
103
+ );
104
+ process.stdout.write(
105
+ `${rows.join('\n')}\n\n${open.length} open, ${resolved.length} resolved${
106
+ all ? '' : ' (--all to see resolved)'
107
+ }\n`,
108
+ );
109
+ process.exit(0);
110
+ }
111
+
112
+ if (cmd === 'resolve') {
113
+ const id = argv[1];
114
+ if (!id) fail('Usage: forge finding resolve <id> [--note "<text>"]');
115
+ let note = null;
116
+ for (let i = 2; i < argv.length; i += 1) {
117
+ if (argv[i] === '--note' && argv[i + 1]) note = argv[(i += 1)];
118
+ else fail(`Unknown argument: ${argv[i]}`);
119
+ }
120
+ try {
121
+ emit({ ok: true, ...resolveFinding({ forgeDir: FORGE_DIR, id, note }) });
122
+ } catch (err) {
123
+ fail(err instanceof Error ? err.message : String(err));
124
+ }
125
+ process.exit(0);
126
+ }
127
+
128
+ usage();
129
+ fail(`Unknown subcommand: ${cmd}`);
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Findings ledger — give an observation a home the day it is written.
4
+ *
5
+ * Analysis reports kept re-listing the same unactioned items ("untick 6.2",
6
+ * "the e2e parallel race", "grouping.ts D1 extraction") because nothing turned
7
+ * a report line into tracked work: prose in a gitignored report is not a
8
+ * queue. A finding either becomes a change, a deferral, or an edit made on the
9
+ * spot — or it is a note, and should be labelled one.
10
+ *
11
+ * Library half (`findings-cli.mjs` is the command). Ledger:
12
+ * `.forge/findings.jsonl`, durable across session cleanup.
13
+ */
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { readLedger } from './ledger.mjs';
18
+
19
+ export const SEVERITIES = ['blocker', 'major', 'minor', 'note'];
20
+
21
+ /** @param {string} forgeDir */
22
+ export function findingsPath(forgeDir) {
23
+ return path.join(forgeDir, 'findings.jsonl');
24
+ }
25
+
26
+ /** @param {string} forgeDir */
27
+ export function readFindings(forgeDir) {
28
+ return readLedger(findingsPath(forgeDir));
29
+ }
30
+
31
+ /** @param {string} forgeDir */
32
+ export function openFindings(forgeDir) {
33
+ return readFindings(forgeDir).filter((f) => f && f.status === 'open');
34
+ }
35
+
36
+ /**
37
+ * Next id: F1, F2, … Stable across resolves so a report can cite one.
38
+ * @param {Record<string, any>[]} entries
39
+ */
40
+ export function nextFindingId(entries) {
41
+ let max = 0;
42
+ for (const e of entries) {
43
+ const m = /^F(\d+)$/.exec(String(e?.id ?? ''));
44
+ if (m) max = Math.max(max, Number(m[1]));
45
+ }
46
+ return `F${max + 1}`;
47
+ }
48
+
49
+ /**
50
+ * @param {string} forgeDir
51
+ * @param {Record<string, any>[]} entries
52
+ */
53
+ function writeAll(forgeDir, entries) {
54
+ const file = findingsPath(forgeDir);
55
+ fs.mkdirSync(path.dirname(file), { recursive: true });
56
+ fs.writeFileSync(file, `${entries.map((e) => JSON.stringify(e)).join('\n')}\n`, 'utf8');
57
+ }
58
+
59
+ /**
60
+ * @param {{ forgeDir: string, text: string, severity?: string, change?: string | null,
61
+ * session?: { sessionId?: string | null, slug?: string | null }, now?: () => Date }} opts
62
+ */
63
+ export function addFinding(opts) {
64
+ const text = String(opts.text ?? '').trim();
65
+ if (!text) throw new Error('A finding needs text: forge finding add "<text>"');
66
+ const severity = opts.severity ?? 'major';
67
+ if (!SEVERITIES.includes(severity)) {
68
+ throw new Error(`Unknown severity "${severity}" (expected ${SEVERITIES.join(' | ')}).`);
69
+ }
70
+ const entries = readFindings(opts.forgeDir);
71
+ const entry = {
72
+ id: nextFindingId(entries),
73
+ text,
74
+ severity,
75
+ status: 'open',
76
+ change: opts.change ?? null,
77
+ sessionId: opts.session?.sessionId ?? null,
78
+ slug: opts.session?.slug ?? null,
79
+ createdAt: (opts.now?.() ?? new Date()).toISOString(),
80
+ };
81
+ writeAll(opts.forgeDir, [...entries, entry]);
82
+ return entry;
83
+ }
84
+
85
+ /**
86
+ * @param {{ forgeDir: string, id: string, note?: string | null, now?: () => Date }} opts
87
+ */
88
+ export function resolveFinding(opts) {
89
+ const entries = readFindings(opts.forgeDir);
90
+ const idx = entries.findIndex((e) => e.id === opts.id);
91
+ if (idx < 0) throw new Error(`No finding with id ${opts.id}. See: forge finding list --all`);
92
+ if (entries[idx].status !== 'open') {
93
+ throw new Error(`Finding ${opts.id} is already ${entries[idx].status}.`);
94
+ }
95
+ entries[idx] = {
96
+ ...entries[idx],
97
+ status: 'resolved',
98
+ note: opts.note ?? entries[idx].note ?? null,
99
+ resolvedAt: (opts.now?.() ?? new Date()).toISOString(),
100
+ };
101
+ writeAll(opts.forgeDir, entries);
102
+ return entries[idx];
103
+ }
@@ -0,0 +1,140 @@
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 } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const FINDINGS = path.join(path.dirname(fileURLToPath(import.meta.url)), 'findings-cli.mjs');
10
+
11
+ function tmp(prefix) {
12
+ return fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), prefix)));
13
+ }
14
+
15
+ function makeProject() {
16
+ const cwd = tmp('forge-finding-');
17
+ const sessionDir = path.join(cwd, '.forge', 'sessions', 's1');
18
+ fs.mkdirSync(sessionDir, { recursive: true });
19
+ fs.writeFileSync(
20
+ path.join(sessionDir, 'session.json'),
21
+ `${JSON.stringify({
22
+ id: 's1',
23
+ slug: 'phase-1',
24
+ openspecChange: 'phase-1',
25
+ phase: 'verify',
26
+ createdAt: new Date().toISOString(),
27
+ updatedAt: new Date().toISOString(),
28
+ })}\n`,
29
+ 'utf8',
30
+ );
31
+ fs.writeFileSync(
32
+ path.join(cwd, '.forge', 'active.json'),
33
+ `${JSON.stringify({ sessionId: 's1' })}\n`,
34
+ 'utf8',
35
+ );
36
+ return cwd;
37
+ }
38
+
39
+ function run(cwd, args) {
40
+ const env = { ...process.env, FORGEKIT_FLEET_DIR: path.join(tmp('finding-fleet-'), 's') };
41
+ try {
42
+ const stdout = execFileSync(process.execPath, [FINDINGS, ...args], { cwd, env });
43
+ const text = stdout.toString();
44
+ try {
45
+ return { status: 0, out: JSON.parse(text), text };
46
+ } catch {
47
+ return { status: 0, out: null, text };
48
+ }
49
+ } catch (err) {
50
+ return { status: err.status, out: null, text: String(err.stdout), stderr: String(err.stderr) };
51
+ }
52
+ }
53
+
54
+ test('a finding lands in a durable ledger with the session that raised it', () => {
55
+ // "A finding either gets a home the day it is written, or it is not a
56
+ // finding — it is a note." Three analysis reports in a row carried the same
57
+ // unactioned items because nothing converted a line into tracked work.
58
+ const cwd = makeProject();
59
+ const { status, out } = run(cwd, [
60
+ 'add',
61
+ 'smoke suite pinned --workers=1 in 6/6 changes; the shared-state race is never fixed',
62
+ ]);
63
+
64
+ assert.equal(status, 0);
65
+ assert.equal(out.ok, true);
66
+ assert.match(out.id, /^F\d+$/);
67
+
68
+ const ledger = path.join(cwd, '.forge', 'findings.jsonl');
69
+ const [entry] = fs
70
+ .readFileSync(ledger, 'utf8')
71
+ .split('\n')
72
+ .filter(Boolean)
73
+ .map((l) => JSON.parse(l));
74
+ assert.equal(entry.status, 'open');
75
+ assert.equal(entry.sessionId, 's1');
76
+ assert.match(entry.text, /workers=1/);
77
+ });
78
+
79
+ test('a finding can name the change that will carry it', () => {
80
+ const cwd = makeProject();
81
+ const { out } = run(cwd, ['add', 'grouping.ts D1 extraction', '--change', 'fix-grouping-d1']);
82
+ assert.equal(out.change, 'fix-grouping-d1');
83
+ // The command tells you how to open that change rather than creating it
84
+ // behind your back.
85
+ assert.match(out.next, /forge change new fix-grouping-d1/);
86
+ });
87
+
88
+ test('list shows open findings and resolve closes one by id', () => {
89
+ const cwd = makeProject();
90
+ const first = run(cwd, ['add', 'server suite baseline is 1-4 varying failures']);
91
+ run(cwd, ['add', 'untick 6.2 in the archived tasks.md']);
92
+
93
+ const open = run(cwd, ['list', '--json']);
94
+ assert.equal(open.out.open.length, 2);
95
+
96
+ const resolved = run(cwd, ['resolve', first.out.id, '--note', 'quarantined the flaky names']);
97
+ assert.equal(resolved.status, 0);
98
+ assert.equal(resolved.out.status, 'resolved');
99
+
100
+ const after = run(cwd, ['list', '--json']);
101
+ assert.equal(after.out.open.length, 1);
102
+ assert.equal(after.out.resolved.length, 1);
103
+ assert.match(after.out.resolved[0].note, /quarantined/);
104
+ });
105
+
106
+ test('resolving an unknown id fails loudly instead of silently succeeding', () => {
107
+ const cwd = makeProject();
108
+ const { status, stderr } = run(cwd, ['resolve', 'F999']);
109
+ assert.equal(status, 1);
110
+ assert.match(stderr, /F999/);
111
+ });
112
+
113
+ test('add refuses an empty finding', () => {
114
+ const cwd = makeProject();
115
+ const { status, stderr } = run(cwd, ['add', ' ']);
116
+ assert.equal(status, 1);
117
+ assert.match(stderr, /text/i);
118
+ });
119
+
120
+ test('findings work without an active session', () => {
121
+ // Reports are often written between sessions; that must not block filing.
122
+ const cwd = tmp('forge-finding-nosession-');
123
+ fs.mkdirSync(path.join(cwd, '.forge'), { recursive: true });
124
+ const { status, out } = run(cwd, ['add', 'pace auto never selects brisk or lite']);
125
+ assert.equal(status, 0);
126
+ assert.equal(out.sessionId, null);
127
+ });
128
+
129
+ test('forge status reports open findings so they cannot be forgotten', () => {
130
+ const cwd = makeProject();
131
+ run(cwd, ['add', 'e2e parallel race unfixed']);
132
+ const statusScript = path.join(path.dirname(FINDINGS), 'session-status.mjs');
133
+ const stdout = execFileSync(process.execPath, [statusScript], {
134
+ cwd,
135
+ env: { ...process.env, FORGEKIT_FLEET_DIR: path.join(tmp('finding-fleet-'), 's') },
136
+ }).toString();
137
+ const status = JSON.parse(stdout);
138
+ assert.equal(status.openFindings.count, 1);
139
+ assert.match(status.openFindings.latest[0].text, /parallel race/);
140
+ });
package/src/ledger.mjs ADDED
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Durable session ledgers.
4
+ *
5
+ * `cleanup-sessions` deletes the whole session dir at done, so reviews,
6
+ * deferrals, fix-round briefs and test evidence all disappear — 5 of 6 scored
7
+ * sessions in one project were already gone, and what the reviews actually
8
+ * caught survived nowhere. `scorecards.jsonl` solved exactly this problem for
9
+ * scores; these are the same trick for the rest:
10
+ *
11
+ * .forge/sessions.jsonl one digest line per finished session
12
+ * .forge/deferrals.jsonl unresolved deferrals, with the session that owed them
13
+ *
14
+ * Both are append-with-replace (keyed by sessionId), tolerant of corrupt
15
+ * lines, and never throw — a ledger must not block a phase transition.
16
+ */
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { loadDeferrals } from './integrity.mjs';
21
+ import { reviewCensus } from './review-census.mjs';
22
+ import { sessionHealth } from './health.mjs';
23
+
24
+ /**
25
+ * @param {string} file
26
+ * @returns {Record<string, any>[]}
27
+ */
28
+ export function readLedger(file) {
29
+ if (!fs.existsSync(file)) return [];
30
+ /** @type {Record<string, any>[]} */
31
+ const out = [];
32
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
33
+ if (!line.trim()) continue;
34
+ try {
35
+ out.push(JSON.parse(line));
36
+ } catch {
37
+ // A half-written line from a killed process must not hide the rest.
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+
43
+ /**
44
+ * @param {string} file
45
+ * @param {Record<string, any>[]} lines
46
+ * @param {(entry: Record<string, any>) => boolean} [replaceWhen] existing entries matching this are dropped
47
+ */
48
+ function appendLines(file, lines, replaceWhen) {
49
+ if (lines.length === 0) return 0;
50
+ try {
51
+ fs.mkdirSync(path.dirname(file), { recursive: true });
52
+ const kept = replaceWhen ? readLedger(file).filter((e) => !replaceWhen(e)) : readLedger(file);
53
+ const all = [...kept, ...lines].map((e) => JSON.stringify(e));
54
+ fs.writeFileSync(file, `${all.join('\n')}\n`, 'utf8');
55
+ return lines.length;
56
+ } catch {
57
+ return 0; // advisory
58
+ }
59
+ }
60
+
61
+ /** @param {{ cwd?: string }} opts */
62
+ function forgeDirOf(opts, sessionDir) {
63
+ return opts.cwd ? path.join(opts.cwd, '.forge') : path.resolve(sessionDir, '..', '..');
64
+ }
65
+
66
+ /**
67
+ * One line summarising how a session actually went — the fields a later
68
+ * analysis would otherwise have to reconstruct from deleted directories.
69
+ *
70
+ * @param {{ cwd?: string, sessionDir: string, session: Record<string, any>, card?: Record<string, any> | null }} opts
71
+ */
72
+ export function appendSessionDigest(opts) {
73
+ const { sessionDir, session } = opts;
74
+ try {
75
+ const census = reviewCensus(sessionDir);
76
+ const health = sessionHealth({ cwd: opts.cwd, sessionDir, session });
77
+ const started = new Date(session.createdAt ?? NaN).getTime();
78
+ const ended = new Date(session.updatedAt ?? NaN).getTime();
79
+ const durationHours =
80
+ Number.isNaN(started) || Number.isNaN(ended)
81
+ ? null
82
+ : Math.round(((ended - started) / 3600000) * 100) / 100;
83
+
84
+ const entry = {
85
+ sessionId: session.id ?? null,
86
+ slug: session.slug ?? null,
87
+ change: session.openspecChange ?? null,
88
+ phase: session.phase ?? null,
89
+ planType: session.planType ?? null,
90
+ pace: session.resolvedPace ?? session.pace ?? null,
91
+ tasks: `${session.tasksComplete ?? 0}/${session.tasksTotal ?? 0}`,
92
+ subagentsDispatched: session.subagentsDispatched ?? null,
93
+ reviews: {
94
+ total: census.total,
95
+ independent: census.independent,
96
+ selfChecks: census.selfChecks,
97
+ rejections: census.rejections,
98
+ final: census.finalReview,
99
+ },
100
+ checkpoints: Array.isArray(session.checkpoints) ? session.checkpoints.length : 0,
101
+ health: health.state,
102
+ healthReasons: health.reasons,
103
+ score: opts.card?.score ?? session.score ?? null,
104
+ grade: opts.card?.grade ?? session.scoreGrade ?? null,
105
+ incompleteReason: session.incompleteReason ?? null,
106
+ durationHours,
107
+ startedAt: session.createdAt ?? null,
108
+ endedAt: session.updatedAt ?? null,
109
+ };
110
+ return appendLines(
111
+ path.join(forgeDirOf(opts, sessionDir), 'sessions.jsonl'),
112
+ [entry],
113
+ (e) => e.sessionId === entry.sessionId,
114
+ );
115
+ } catch {
116
+ return 0;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Unresolved deferrals, promoted out of the session before it is deleted.
122
+ * Resolved ones are not debt and are left behind.
123
+ *
124
+ * @param {{ cwd?: string, sessionDir: string, session: Record<string, any> }} opts
125
+ */
126
+ export function appendDeferralLedger(opts) {
127
+ const { sessionDir, session } = opts;
128
+ try {
129
+ const doc = loadDeferrals(sessionDir);
130
+ const open = (doc?.deferrals ?? []).filter((d) => d && !d.resolvedAt);
131
+ const lines = open.map((d) => ({
132
+ sessionId: session.id ?? null,
133
+ slug: session.slug ?? null,
134
+ change: session.openspecChange ?? null,
135
+ task: d.task ?? null,
136
+ reason: d.reason ?? null,
137
+ createdAt: d.createdAt ?? null,
138
+ carriedAt: new Date().toISOString(),
139
+ }));
140
+ return appendLines(
141
+ path.join(forgeDirOf(opts, sessionDir), 'deferrals.jsonl'),
142
+ lines,
143
+ (e) => e.sessionId === (session.id ?? null),
144
+ );
145
+ } catch {
146
+ return 0;
147
+ }
148
+ }
@@ -0,0 +1,132 @@
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 { appendDeferralLedger, appendSessionDigest, readLedger } from './ledger.mjs';
7
+
8
+ function tmp(prefix) {
9
+ return fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), prefix)));
10
+ }
11
+
12
+ function makeSession(root, id = 's1', overrides = {}) {
13
+ const sessionDir = path.join(root, '.forge', 'sessions', id);
14
+ fs.mkdirSync(path.join(sessionDir, 'tasks', '01-model'), { recursive: true });
15
+ const session = {
16
+ id,
17
+ slug: 'add-billing',
18
+ openspecChange: 'add-billing',
19
+ phase: 'done',
20
+ planType: 'specs',
21
+ tasksTotal: 20,
22
+ tasksComplete: 20,
23
+ subagentsDispatched: 12,
24
+ createdAt: '2026-07-25T08:00:00.000Z',
25
+ updatedAt: '2026-07-25T14:30:00.000Z',
26
+ checkpoints: [{ sha: 'abc123', group: '01', tasks: '1.1-1.4', at: '2026-07-25T09:00:00.000Z' }],
27
+ ...overrides,
28
+ };
29
+ fs.writeFileSync(
30
+ path.join(sessionDir, 'session.json'),
31
+ `${JSON.stringify(session, null, 2)}\n`,
32
+ 'utf8',
33
+ );
34
+ return { sessionDir, session };
35
+ }
36
+
37
+ test('a session digest survives the deletion of its session dir', () => {
38
+ // cleanup-sessions removes the whole session dir at done, taking reviews,
39
+ // deferrals and evidence with it — 5 of volo's 6 scored sessions were
40
+ // already gone, so what review actually caught existed nowhere.
41
+ const root = tmp('forge-ledger-');
42
+ const { sessionDir, session } = makeSession(root);
43
+ fs.writeFileSync(
44
+ path.join(sessionDir, 'tasks', '01-model', 'group-review.md'),
45
+ '# Group review\n\n**Verdict: APPROVED** (opus reviewer 9f2)\n\n## Round 1 — REJECTED\n',
46
+ 'utf8',
47
+ );
48
+
49
+ appendSessionDigest({ cwd: root, sessionDir, session, card: { score: 88, grade: 'B' } });
50
+
51
+ fs.rmSync(sessionDir, { recursive: true, force: true });
52
+ const [entry] = readLedger(path.join(root, '.forge', 'sessions.jsonl'));
53
+
54
+ assert.equal(entry.sessionId, 's1');
55
+ assert.equal(entry.slug, 'add-billing');
56
+ assert.equal(entry.score, 88);
57
+ assert.equal(entry.tasks, '20/20');
58
+ assert.equal(entry.subagentsDispatched, 12);
59
+ assert.equal(entry.reviews.independent, 1);
60
+ assert.equal(entry.reviews.rejections, 1);
61
+ assert.equal(entry.checkpoints, 1);
62
+ assert.equal(entry.durationHours, 6.5);
63
+ });
64
+
65
+ test('the digest records the health verdict, not just the score', () => {
66
+ const root = tmp('forge-ledger-health-');
67
+ const { sessionDir, session } = makeSession(root, 's2', { phase: 'implement' });
68
+ fs.writeFileSync(
69
+ path.join(sessionDir, 'verify-evidence.md'),
70
+ '# Verify\n\nBLOCKED — no runtime owner for the queue worker.\n',
71
+ 'utf8',
72
+ );
73
+
74
+ appendSessionDigest({ cwd: root, sessionDir, session, card: { score: 40, grade: 'D' } });
75
+ const [entry] = readLedger(path.join(root, '.forge', 'sessions.jsonl'));
76
+
77
+ assert.equal(entry.health, 'red');
78
+ assert.match(entry.healthReasons.join(' '), /BLOCKED/);
79
+ });
80
+
81
+ test('re-running a digest replaces that session line instead of duplicating it', () => {
82
+ const root = tmp('forge-ledger-dupe-');
83
+ const { sessionDir, session } = makeSession(root);
84
+ appendSessionDigest({ cwd: root, sessionDir, session, card: { score: 70, grade: 'C' } });
85
+ appendSessionDigest({ cwd: root, sessionDir, session, card: { score: 88, grade: 'B' } });
86
+
87
+ const entries = readLedger(path.join(root, '.forge', 'sessions.jsonl'));
88
+ assert.equal(entries.length, 1);
89
+ assert.equal(entries[0].score, 88);
90
+ });
91
+
92
+ test('unresolved deferrals outlive the session that raised them', () => {
93
+ // volo carried four standing deferrals that lived only in analysis reports:
94
+ // per-session deferrals.json is deleted with the session dir.
95
+ const root = tmp('forge-ledger-defer-');
96
+ const { sessionDir, session } = makeSession(root);
97
+ fs.writeFileSync(
98
+ path.join(sessionDir, 'deferrals.json'),
99
+ `${JSON.stringify({
100
+ deferrals: [
101
+ { task: '5.4', reason: 'gating tests land in group 6', createdAt: '2026-07-25T10:00:00.000Z', resolvedAt: '2026-07-25T11:00:00.000Z' },
102
+ { task: '7.1', reason: 'grouping.ts D1 extraction — three duplicated pipelines', createdAt: '2026-07-25T12:00:00.000Z' },
103
+ ],
104
+ })}\n`,
105
+ 'utf8',
106
+ );
107
+
108
+ const written = appendDeferralLedger({ cwd: root, sessionDir, session });
109
+ assert.equal(written, 1, 'only the unresolved one is debt worth carrying');
110
+
111
+ fs.rmSync(sessionDir, { recursive: true, force: true });
112
+ const entries = readLedger(path.join(root, '.forge', 'deferrals.jsonl'));
113
+ assert.equal(entries.length, 1);
114
+ assert.equal(entries[0].task, '7.1');
115
+ assert.equal(entries[0].sessionId, 's1');
116
+ assert.equal(entries[0].change, 'add-billing');
117
+ assert.match(entries[0].reason, /grouping\.ts/);
118
+ });
119
+
120
+ test('a session with no deferrals writes no ledger noise', () => {
121
+ const root = tmp('forge-ledger-empty-');
122
+ const { sessionDir, session } = makeSession(root);
123
+ assert.equal(appendDeferralLedger({ cwd: root, sessionDir, session }), 0);
124
+ assert.equal(fs.existsSync(path.join(root, '.forge', 'deferrals.jsonl')), false);
125
+ });
126
+
127
+ test('readLedger tolerates a truncated or corrupt line', () => {
128
+ const root = tmp('forge-ledger-corrupt-');
129
+ const file = path.join(root, 'x.jsonl');
130
+ fs.writeFileSync(file, '{"a":1}\n{ broken\n{"a":2}\n', 'utf8');
131
+ assert.deepEqual(readLedger(file), [{ a: 1 }, { a: 2 }]);
132
+ });
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Census of a session's review artifacts.
4
+ *
5
+ * Its own module because both the scorer and the durable ledger need it, and
6
+ * a scorer↔ledger import cycle would force one of them into a lazy import.
7
+ */
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ /** A review the coordinator wrote about its own work — a weaker signal. */
13
+ const SELF_REVIEW_RE = /pace self-check|APPROVED \(pace|self-review|reviewed by the coordinator/i;
14
+ /** A round that sent work back: proof the review was not a rubber stamp. */
15
+ const REJECTION_RE = /\bREJECT(ED)?\b/;
16
+
17
+ /**
18
+ * Census of the review artifacts on disk.
19
+ *
20
+ * Counts what was *dispatched*, not what is absent: the previous version
21
+ * started at full marks and only ever subtracted, so a session with no
22
+ * reviewer of any kind scored 5/5 — which is how a 38-task, high-risk,
23
+ * self-reviewed session reached 100/100.
24
+ *
25
+ * @param {string} sessionDir
26
+ */
27
+ export function reviewCensus(sessionDir) {
28
+ const census = { total: 0, independent: 0, selfChecks: 0, rejections: 0, finalReview: null };
29
+ /** @param {string} file */
30
+ const read = (file) => {
31
+ if (!fs.existsSync(file)) return null;
32
+ try {
33
+ return fs.readFileSync(file, 'utf8');
34
+ } catch {
35
+ return null;
36
+ }
37
+ };
38
+
39
+ const tasksDir = path.join(sessionDir, 'tasks');
40
+ if (fs.existsSync(tasksDir)) {
41
+ for (const e of fs.readdirSync(tasksDir, { withFileTypes: true })) {
42
+ if (!e.isDirectory()) continue;
43
+ for (const name of ['task-review.md', 'group-review.md']) {
44
+ const body = read(path.join(tasksDir, e.name, name));
45
+ if (body === null) continue;
46
+ census.total += 1;
47
+ if (SELF_REVIEW_RE.test(body)) census.selfChecks += 1;
48
+ else census.independent += 1;
49
+ if (REJECTION_RE.test(body)) census.rejections += 1;
50
+ }
51
+ }
52
+ }
53
+
54
+ for (const name of ['final-review.md', 'final-review-outcome.md']) {
55
+ const body = read(path.join(sessionDir, 'reviews', name));
56
+ if (body === null) continue;
57
+ census.finalReview = SELF_REVIEW_RE.test(body) ? 'self' : 'independent';
58
+ if (REJECTION_RE.test(body)) census.rejections += 1;
59
+ break;
60
+ }
61
+ return census;
62
+ }
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
  } from './lib.mjs';
18
18
  import { resolveEffectivePreferences } from './preferences.mjs';
19
19
  import { sessionHealth } from './health.mjs';
20
+ import { openFindings } from './findings.mjs';
20
21
 
21
22
  const args = process.argv.slice(2);
22
23
  let sessionId = null;
@@ -49,6 +50,10 @@ const pace = resolveEffectivePreferences({
49
50
 
50
51
  const health = sessionHealth({ cwd: REPO_ROOT, sessionDir: dir, session });
51
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
+
52
57
  process.stdout.write(
53
58
  JSON.stringify(
54
59
  {
@@ -58,6 +63,10 @@ process.stdout.write(
58
63
  // Verdict first: a status dump that never says "this session is red and
59
64
  // nobody has touched it since yesterday" makes the operator derive it.
60
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
+ },
61
70
  session,
62
71
  progress: status,
63
72
  pace: {
@@ -115,6 +115,8 @@ Testing: [references/test-strategy.md](./references/test-strategy.md) — tier 1
115
115
 
116
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
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
118
120
  - Tests required for behavior changes
119
121
  - Trace ecosystem consumers when contracts change
120
122
  - Honor `openspec/config.yaml` prefixes when the project uses them (OpenSpec engine)
@@ -274,6 +274,9 @@ forge checkpoint --group <name> [--tasks <ids>]
274
274
  # commit this group's work (opt-in; never pushes)
275
275
  forge checkpoint --dry-run # what a checkpoint would commit
276
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
277
280
  forge cleanup [--dry-run] # prune sessions >14 days or finished
278
281
  forge evidence --task <nn>-<slug> --command "<cmd>" --exit 0 --summary "<text>"
279
282
  # stamp tier-2 test-evidence.md