@izkac/forgekit 0.3.8 → 0.3.10
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/package.json +1 -1
- package/src/cleanup-sessions.mjs +12 -0
- package/src/fleet.mjs +2 -2
- package/src/score.mjs +34 -8
- package/src/score.test.mjs +76 -0
package/package.json
CHANGED
package/src/cleanup-sessions.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
sessionAgeDays,
|
|
20
20
|
} from './lib.mjs';
|
|
21
21
|
import { unregisterSession } from './lib/fleet.mjs';
|
|
22
|
+
import { appendScorecardLedger } from './score.mjs';
|
|
22
23
|
|
|
23
24
|
const args = new Set(process.argv.slice(2));
|
|
24
25
|
const dryRun = args.has('--dry-run');
|
|
@@ -58,6 +59,17 @@ for (const entry of fs.readdirSync(SESSIONS_DIR, { withFileTypes: true })) {
|
|
|
58
59
|
|
|
59
60
|
if (shouldRemove) {
|
|
60
61
|
if (!dryRun) {
|
|
62
|
+
// Harvest the scorecard into the durable ledger before the session dir
|
|
63
|
+
// (and its scoring history) is erased. Covers sessions scored before
|
|
64
|
+
// the ledger existed; a no-op when the ledger already has the line.
|
|
65
|
+
try {
|
|
66
|
+
const cardFile = path.join(dir, 'scorecard.json');
|
|
67
|
+
if (fs.existsSync(cardFile)) {
|
|
68
|
+
appendScorecardLedger(dir, JSON.parse(fs.readFileSync(cardFile, 'utf8')), session);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
/* ledger is advisory — never block cleanup */
|
|
72
|
+
}
|
|
61
73
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
62
74
|
if (isActive) clearActive();
|
|
63
75
|
unregisterSession(process.cwd(), sessionId);
|
package/src/fleet.mjs
CHANGED
|
@@ -72,7 +72,7 @@ function pad(str, len) {
|
|
|
72
72
|
|
|
73
73
|
function renderTable(entries) {
|
|
74
74
|
if (entries.length === 0) return 'No fleet sessions. Start one with `forge new <slug>`.\n';
|
|
75
|
-
const header = `${pad('PROJECT', 18)} ${pad('SESSION', 26)} ${pad('ENGINE', 7)} ${pad('PHASE', 18)} ${pad('TASKS',
|
|
75
|
+
const header = `${pad('PROJECT', 18)} ${pad('SESSION', 26)} ${pad('ENGINE', 7)} ${pad('PHASE', 18)} ${pad('TASKS', 16)} ${pad('PACE', 9)} ${pad('AGE', 4)} MSGS`;
|
|
76
76
|
const lines = [header, '-'.repeat(header.length)];
|
|
77
77
|
for (const e of entries) {
|
|
78
78
|
const pending = e.missing ? 0 : peekInbox(sessionDirFor(e)).length;
|
|
@@ -80,7 +80,7 @@ function renderTable(entries) {
|
|
|
80
80
|
`${pad(e.projectName, 18)} ${pad(e.slug, 26)} ${pad(e.engine ?? '—', 7)} ${pad(
|
|
81
81
|
e.missing ? 'missing' : phaseBar(e.phase),
|
|
82
82
|
18,
|
|
83
|
-
)} ${pad(tasksCell(e),
|
|
83
|
+
)} ${pad(tasksCell(e), 16)} ${pad(e.pace ?? '—', 9)} ${pad(relTime(e.lastSeen ?? e.updatedAt), 4)} ${
|
|
84
84
|
pending > 0 ? `✉ ${pending}` : ''
|
|
85
85
|
}`,
|
|
86
86
|
);
|
package/src/score.mjs
CHANGED
|
@@ -11,6 +11,8 @@ import path from 'node:path';
|
|
|
11
11
|
import { readJson, writeJson } from './lib.mjs';
|
|
12
12
|
import {
|
|
13
13
|
JOBS_SIGNAL_RE,
|
|
14
|
+
checkE2eGate,
|
|
15
|
+
e2ePath,
|
|
14
16
|
loadDeferrals,
|
|
15
17
|
openDeferrals,
|
|
16
18
|
resolveChangeDir,
|
|
@@ -110,10 +112,29 @@ function reviewSelfCheckCount(sessionDir) {
|
|
|
110
112
|
return n;
|
|
111
113
|
}
|
|
112
114
|
|
|
115
|
+
/**
|
|
116
|
+
* True when a green e2e run was executed against the current e2e.json — the
|
|
117
|
+
* primary product-loop signal: score what ran, not what was written. A phrase
|
|
118
|
+
* match in verify-evidence is only the fallback (a session that titled its
|
|
119
|
+
* section differently but ran the loop green must not score 0).
|
|
120
|
+
*
|
|
121
|
+
* @param {{ cwd?: string, session?: Record<string, unknown> | null, sessionDir: string }} opts
|
|
122
|
+
*/
|
|
123
|
+
function e2eRunGreen(opts) {
|
|
124
|
+
try {
|
|
125
|
+
const e2eFile = e2ePath(opts);
|
|
126
|
+
const gate = checkE2eGate({ e2eFile, sessionDir: opts.sessionDir });
|
|
127
|
+
return gate.problems.length === 0 && !gate.notApplicable;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
113
133
|
/**
|
|
114
134
|
* @param {string} body
|
|
135
|
+
* @param {boolean} executedGreen
|
|
115
136
|
*/
|
|
116
|
-
function scoreProductLoopBody(body) {
|
|
137
|
+
function scoreProductLoopBody(body, executedGreen = false) {
|
|
117
138
|
/** @type {string[]} */
|
|
118
139
|
const notes = [];
|
|
119
140
|
let pts = 0;
|
|
@@ -122,12 +143,16 @@ function scoreProductLoopBody(body) {
|
|
|
122
143
|
notes.push('verify-evidence contains BLOCKED — product-loop not proven');
|
|
123
144
|
return { points: 0, max, notes };
|
|
124
145
|
}
|
|
125
|
-
if (
|
|
126
|
-
|
|
146
|
+
if (executedGreen) {
|
|
147
|
+
pts += 8;
|
|
148
|
+
notes.push('green e2e run executed against current e2e.json — loop proven by execution');
|
|
149
|
+
} else if (/product[- ]loop/i.test(body)) {
|
|
150
|
+
pts += 8;
|
|
151
|
+
notes.push('product-loop section present (phrase-based — no executed green e2e run)');
|
|
152
|
+
} else {
|
|
153
|
+
notes.push('no executed green e2e run and no product-loop section in verify-evidence');
|
|
127
154
|
return { points: 0, max, notes };
|
|
128
155
|
}
|
|
129
|
-
pts += 8;
|
|
130
|
-
notes.push('product-loop section present');
|
|
131
156
|
if (/\b(fixture|OP\d+|testdata|sample)\b/i.test(body)) {
|
|
132
157
|
pts += 4;
|
|
133
158
|
notes.push('names a fixture / corpus');
|
|
@@ -237,14 +262,15 @@ export function scoreSession(opts) {
|
|
|
237
262
|
loopNotes = ['verify-evidence.md missing'];
|
|
238
263
|
} else {
|
|
239
264
|
const body = fs.readFileSync(evidenceFile, 'utf8');
|
|
265
|
+
const executedGreen = e2eRunGreen({ cwd, session, sessionDir });
|
|
240
266
|
if (spineHasRows || JOBS_SIGNAL_RE.test(sessionJobsSignalText(session))) {
|
|
241
|
-
const scored = scoreProductLoopBody(body);
|
|
267
|
+
const scored = scoreProductLoopBody(body, executedGreen);
|
|
242
268
|
loopPts = scored.points;
|
|
243
269
|
loopNotes = scored.notes;
|
|
244
270
|
} else {
|
|
245
271
|
// Rows expected but maybe empty invalid spine — still look for loop
|
|
246
|
-
if (/product[- ]loop/i.test(body)) {
|
|
247
|
-
const scored = scoreProductLoopBody(body);
|
|
272
|
+
if (executedGreen || /product[- ]loop/i.test(body)) {
|
|
273
|
+
const scored = scoreProductLoopBody(body, executedGreen);
|
|
248
274
|
loopPts = scored.points;
|
|
249
275
|
loopNotes = scored.notes;
|
|
250
276
|
} else {
|
package/src/score.test.mjs
CHANGED
|
@@ -11,9 +11,11 @@ import {
|
|
|
11
11
|
scoreSession,
|
|
12
12
|
writeSessionScorecard,
|
|
13
13
|
} from './score.mjs';
|
|
14
|
+
import { e2eStepsHash } from './integrity.mjs';
|
|
14
15
|
|
|
15
16
|
const PHASE_SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'set-phase.mjs');
|
|
16
17
|
const SCORE_SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'score-cli.mjs');
|
|
18
|
+
const CLEANUP_SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'cleanup-sessions.mjs');
|
|
17
19
|
|
|
18
20
|
function tmp(prefix) {
|
|
19
21
|
return fs.mkdtempSync(path.join(tmpdir(), prefix));
|
|
@@ -177,6 +179,80 @@ Fixture: OP1086
|
|
|
177
179
|
}
|
|
178
180
|
});
|
|
179
181
|
|
|
182
|
+
test('scoreSession: green e2e run proves the loop even without the phrase', () => {
|
|
183
|
+
const root = tmp('forge-score-e2e-');
|
|
184
|
+
try {
|
|
185
|
+
const { sessionDir, session } = makeSession(root, { slug: 'jobs-loop' });
|
|
186
|
+
fs.writeFileSync(
|
|
187
|
+
path.join(sessionDir, 'spine.json'),
|
|
188
|
+
`${JSON.stringify(validSpine([validRow()]), null, 2)}\n`,
|
|
189
|
+
'utf8',
|
|
190
|
+
);
|
|
191
|
+
// Evidence deliberately avoids the words "product loop" — before the fix
|
|
192
|
+
// this session scored 0/20 despite an executed green run.
|
|
193
|
+
fs.writeFileSync(
|
|
194
|
+
path.join(sessionDir, 'verify-evidence.md'),
|
|
195
|
+
'# Verify\n\n## What the evidence actually proves\n\nReverted the fix, reproduced the broken row, re-applied, ran the loop green.\n',
|
|
196
|
+
'utf8',
|
|
197
|
+
);
|
|
198
|
+
const steps = [{ name: 'probe', cmd: 'node -e "console.log(1)"' }];
|
|
199
|
+
fs.writeFileSync(
|
|
200
|
+
path.join(sessionDir, 'e2e.json'),
|
|
201
|
+
`${JSON.stringify({ change: null, notApplicable: null, steps }, null, 2)}\n`,
|
|
202
|
+
'utf8',
|
|
203
|
+
);
|
|
204
|
+
fs.writeFileSync(
|
|
205
|
+
path.join(sessionDir, 'e2e-results.json'),
|
|
206
|
+
`${JSON.stringify({ ok: true, stepsHash: e2eStepsHash(steps), steps: [{ name: 'probe', ok: true, exitCode: 0 }] }, null, 2)}\n`,
|
|
207
|
+
'utf8',
|
|
208
|
+
);
|
|
209
|
+
const card = scoreSession({ cwd: root, sessionDir, session });
|
|
210
|
+
const loop = card.checks.find((c) => c.id === 'product_loop');
|
|
211
|
+
assert.ok(loop.points >= 8, `expected >= 8 loop points, got ${loop.points}`);
|
|
212
|
+
assert.match(loop.notes.join('\n'), /proven by execution/);
|
|
213
|
+
} finally {
|
|
214
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('forge cleanup harvests scorecard.json into the ledger before pruning', () => {
|
|
219
|
+
const root = tmp('forge-cleanup-harvest-');
|
|
220
|
+
try {
|
|
221
|
+
const { sessionDir, session } = makeSession(root, { phase: 'done' });
|
|
222
|
+
// A scorecard as an older (pre-ledger) forgekit would have written it.
|
|
223
|
+
fs.writeFileSync(
|
|
224
|
+
path.join(sessionDir, 'scorecard.json'),
|
|
225
|
+
`${JSON.stringify({
|
|
226
|
+
scoredAt: '2026-06-01T00:00:00.000Z',
|
|
227
|
+
sessionId: session.id,
|
|
228
|
+
slug: session.slug,
|
|
229
|
+
openspecChange: null,
|
|
230
|
+
score: 83,
|
|
231
|
+
maxScore: 100,
|
|
232
|
+
grade: 'B',
|
|
233
|
+
integrityOk: true,
|
|
234
|
+
caps: [],
|
|
235
|
+
checks: [{ id: 'spine', label: 'Spine matrix quality', points: 20, max: 25, notes: ['one row thin'] }],
|
|
236
|
+
}, null, 2)}\n`,
|
|
237
|
+
'utf8',
|
|
238
|
+
);
|
|
239
|
+
execFileSync(process.execPath, [CLEANUP_SCRIPT, '--include-active'], {
|
|
240
|
+
cwd: root,
|
|
241
|
+
env: { ...process.env, FORGEKIT_FLEET_DIR: path.join(root, 'fleet') },
|
|
242
|
+
});
|
|
243
|
+
assert.equal(fs.existsSync(sessionDir), false);
|
|
244
|
+
const ledger = path.join(root, '.forge', 'scorecards.jsonl');
|
|
245
|
+
const lines = fs.readFileSync(ledger, 'utf8').split('\n').filter(Boolean);
|
|
246
|
+
assert.equal(lines.length, 1);
|
|
247
|
+
const entry = JSON.parse(lines[0]);
|
|
248
|
+
assert.equal(entry.sessionId, session.id);
|
|
249
|
+
assert.equal(entry.grade, 'B');
|
|
250
|
+
assert.equal(entry.deductions[0].id, 'spine');
|
|
251
|
+
} finally {
|
|
252
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
180
256
|
test('writeSessionScorecard writes json and md', () => {
|
|
181
257
|
const root = tmp('forge-score-write-');
|
|
182
258
|
try {
|