@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
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { DEFAULT_IDLE_HOURS, sessionHealth } from './health.mjs';
|
|
7
|
+
import { e2eStepsHash } from './integrity.mjs';
|
|
8
|
+
|
|
9
|
+
function tmp(prefix) {
|
|
10
|
+
return fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), prefix)));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const HOUR = 3600 * 1000;
|
|
14
|
+
const NOW = new Date('2026-07-25T12:00:00.000Z').getTime();
|
|
15
|
+
const ago = (hours) => new Date(NOW - hours * HOUR).toISOString();
|
|
16
|
+
|
|
17
|
+
/** Project + session dir; session.json is passed separately. */
|
|
18
|
+
function makeProject(overrides = {}) {
|
|
19
|
+
const cwd = tmp('forge-health-');
|
|
20
|
+
const sessionDir = path.join(cwd, '.forge', 'sessions', 's1');
|
|
21
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
22
|
+
const session = {
|
|
23
|
+
id: 's1',
|
|
24
|
+
slug: 'phase-1',
|
|
25
|
+
phase: 'implement',
|
|
26
|
+
planType: 'specs',
|
|
27
|
+
openspecChange: 'phase-1',
|
|
28
|
+
tasksTotal: 32,
|
|
29
|
+
tasksComplete: 27,
|
|
30
|
+
createdAt: ago(30),
|
|
31
|
+
updatedAt: ago(1),
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
return { cwd, sessionDir, session };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** e2e.json in the change dir + a results file in the session dir. */
|
|
38
|
+
function writeE2e({ cwd, sessionDir }, { ok, stale = false, ranAt = ago(2) } = {}) {
|
|
39
|
+
const changeDir = path.join(cwd, 'specs', 'changes', 'phase-1');
|
|
40
|
+
fs.mkdirSync(changeDir, { recursive: true });
|
|
41
|
+
const steps = [{ name: 'bench-check-enforces-the-budget-gate', cmd: 'true' }];
|
|
42
|
+
fs.writeFileSync(path.join(changeDir, 'e2e.json'), `${JSON.stringify({ steps })}\n`, 'utf8');
|
|
43
|
+
// stepsHash is computed by integrity.e2eStepsHash; a wrong hash is what a
|
|
44
|
+
// post-run edit of e2e.json looks like.
|
|
45
|
+
const results = {
|
|
46
|
+
ok,
|
|
47
|
+
ranAt,
|
|
48
|
+
stepsHash: stale ? 'deadbeef' : e2eStepsHash(steps),
|
|
49
|
+
steps: [{ name: 'bench-check-enforces-the-budget-gate', ok, exitCode: ok ? 0 : 1 }],
|
|
50
|
+
};
|
|
51
|
+
fs.writeFileSync(
|
|
52
|
+
path.join(sessionDir, 'e2e-results.json'),
|
|
53
|
+
`${JSON.stringify(results)}\n`,
|
|
54
|
+
'utf8',
|
|
55
|
+
);
|
|
56
|
+
return changeDir;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test('a session that ran recently and has no failing proof is healthy', () => {
|
|
60
|
+
const p = makeProject();
|
|
61
|
+
const health = sessionHealth({ ...p, now: NOW });
|
|
62
|
+
assert.equal(health.state, 'healthy');
|
|
63
|
+
assert.deepEqual(health.reasons, []);
|
|
64
|
+
assert.match(health.line, /^HEALTHY/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('a failing e2e run is RED and names the failing step', () => {
|
|
68
|
+
// helm phase-1: 27/32 with e2e red on the bench budget gate, and nothing
|
|
69
|
+
// anywhere said so.
|
|
70
|
+
const p = makeProject();
|
|
71
|
+
writeE2e(p, { ok: false });
|
|
72
|
+
const health = sessionHealth({ ...p, now: NOW });
|
|
73
|
+
|
|
74
|
+
assert.equal(health.state, 'red');
|
|
75
|
+
assert.equal(health.reasons.length, 1);
|
|
76
|
+
assert.match(health.reasons[0], /e2e failing/);
|
|
77
|
+
assert.match(health.reasons[0], /bench-check-enforces-the-budget-gate/);
|
|
78
|
+
assert.match(health.line, /^RED —/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('e2e results that no longer match e2e.json are stale, not green', () => {
|
|
82
|
+
const p = makeProject();
|
|
83
|
+
writeE2e(p, { ok: true, stale: true });
|
|
84
|
+
const health = sessionHealth({ ...p, now: NOW });
|
|
85
|
+
|
|
86
|
+
assert.equal(health.state, 'stale');
|
|
87
|
+
assert.match(health.reasons.join(' '), /e2e results.*stale|stale.*e2e/i);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('a green current e2e run keeps the session healthy', () => {
|
|
91
|
+
const p = makeProject();
|
|
92
|
+
writeE2e(p, { ok: true });
|
|
93
|
+
assert.equal(sessionHealth({ ...p, now: NOW }).state, 'healthy');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('an idle session is STALE and says where it stopped', () => {
|
|
97
|
+
const p = makeProject({ updatedAt: ago(14) });
|
|
98
|
+
const health = sessionHealth({ ...p, now: NOW });
|
|
99
|
+
|
|
100
|
+
assert.equal(health.state, 'stale');
|
|
101
|
+
assert.match(health.reasons[0], /idle 14h/);
|
|
102
|
+
assert.match(health.reasons[0], /implement 27\/32/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('idle threshold is configurable and defaults to DEFAULT_IDLE_HOURS', () => {
|
|
106
|
+
const p = makeProject({ updatedAt: ago(DEFAULT_IDLE_HOURS + 1) });
|
|
107
|
+
assert.equal(sessionHealth({ ...p, now: NOW }).state, 'stale');
|
|
108
|
+
assert.equal(sessionHealth({ ...p, now: NOW, idleHours: 48 }).state, 'healthy');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('BLOCKED verify evidence is RED', () => {
|
|
112
|
+
const p = makeProject({ phase: 'verify' });
|
|
113
|
+
fs.writeFileSync(
|
|
114
|
+
path.join(p.sessionDir, 'verify-evidence.md'),
|
|
115
|
+
'# Verify\n\n## Product loop\n\nBLOCKED — the queue worker has no runtime owner yet.\n',
|
|
116
|
+
'utf8',
|
|
117
|
+
);
|
|
118
|
+
const health = sessionHealth({ ...p, now: NOW });
|
|
119
|
+
assert.equal(health.state, 'red');
|
|
120
|
+
assert.match(health.reasons.join(' '), /BLOCKED/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('a finished session is neither stale nor red', () => {
|
|
124
|
+
// Idle for a month and carrying an old failing run: done is done.
|
|
125
|
+
const p = makeProject({ phase: 'done', updatedAt: ago(720), tasksComplete: 32 });
|
|
126
|
+
writeE2e(p, { ok: false });
|
|
127
|
+
const health = sessionHealth({ ...p, now: NOW });
|
|
128
|
+
assert.equal(health.state, 'done');
|
|
129
|
+
assert.match(health.line, /^DONE/);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('red outranks stale when both fire', () => {
|
|
133
|
+
const p = makeProject({ updatedAt: ago(14) });
|
|
134
|
+
writeE2e(p, { ok: false });
|
|
135
|
+
const health = sessionHealth({ ...p, now: NOW });
|
|
136
|
+
assert.equal(health.state, 'red');
|
|
137
|
+
assert.equal(health.reasons.length, 2, 'both reasons are reported, severity picks the state');
|
|
138
|
+
});
|
package/src/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
|
+
});
|
package/src/new-session.mjs
CHANGED
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
* forge new <slug> [--chat-id <id>] [--signal <text>]
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
10
11
|
import {
|
|
11
12
|
defaultSession,
|
|
12
13
|
defaultStatus,
|
|
13
14
|
ensureForgeLayout,
|
|
14
15
|
FORGE_DIR,
|
|
15
16
|
makeSessionId,
|
|
17
|
+
REPO_ROOT,
|
|
16
18
|
saveSession,
|
|
17
19
|
scaffoldSessionDirs,
|
|
18
20
|
sessionPath,
|
|
@@ -55,6 +57,19 @@ scaffoldSessionDirs(dir);
|
|
|
55
57
|
const session = defaultSession(sessionId, slug);
|
|
56
58
|
if (cursorChatId) session.cursorChatId = cursorChatId;
|
|
57
59
|
|
|
60
|
+
// Where this session started, so reviewers have a diff range even when the
|
|
61
|
+
// project never enables checkpoints (and `forge checkpoint --range` has a
|
|
62
|
+
// base from commit one).
|
|
63
|
+
const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT, encoding: 'utf8' });
|
|
64
|
+
if (head.status === 0) {
|
|
65
|
+
session.baseCommit = head.stdout.trim();
|
|
66
|
+
const branch = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
67
|
+
cwd: REPO_ROOT,
|
|
68
|
+
encoding: 'utf8',
|
|
69
|
+
});
|
|
70
|
+
if (branch.status === 0) session.branch = branch.stdout.trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
const paceFields = resolveSessionPaceFields({
|
|
59
74
|
forgeDir: FORGE_DIR,
|
|
60
75
|
slug: session.slug,
|
|
@@ -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
|
+
}
|