@izkac/forgekit 0.3.8 → 0.3.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@izkac/forgekit",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "Forgekit CLIs — forgekit (install), forge (workflow), review (code review)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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/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 (!/product[- ]loop/i.test(body)) {
126
- notes.push('no ## Product loop section');
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 {
@@ -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 {