@izkac/forgekit 0.3.7 → 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.7",
3
+ "version": "0.3.9",
4
4
  "description": "Forgekit CLIs — forgekit (install), forge (workflow), review (code review)",
5
5
  "type": "module",
6
6
  "bin": {
package/src/brief-cli.mjs CHANGED
@@ -3,9 +3,9 @@
3
3
  * Operator-brief CLI (core in brief.mjs).
4
4
  *
5
5
  * Usage:
6
- * forge brief stamp [--session <id>] [--no-open] stamp freshness hash + open in browser
7
- * forge brief check [--session <id>] exit 1 when missing/stale/unstamped
8
- * forge brief open [--session <id>] open in default browser
6
+ * forge brief stamp [--session <id>] stamp freshness hash (never auto-opens)
7
+ * forge brief check [--session <id>] exit 1 when missing/stale/unstamped
8
+ * forge brief open [--session <id>] open in default browser (explicit only)
9
9
  */
10
10
 
11
11
  import fs from 'node:fs';
@@ -16,7 +16,7 @@ import { BRIEF_FILE, briefPath, checkBrief, openInBrowser, stampBrief } from './
16
16
  const [cmd, ...rest] = process.argv.slice(2);
17
17
  if (!cmd || !['stamp', 'check', 'open'].includes(cmd)) {
18
18
  process.stderr.write(
19
- 'Usage: forge brief stamp [--session <id>] [--no-open] | check [--session <id>] | open [--session <id>]\n',
19
+ 'Usage: forge brief stamp [--session <id>] | check [--session <id>] | open [--session <id>]\n',
20
20
  );
21
21
  process.exit(1);
22
22
  }
@@ -45,12 +45,13 @@ if (!changeDir) {
45
45
  }
46
46
 
47
47
  if (cmd === 'stamp') {
48
+ // Never auto-open: re-stamps happen several times a session and each open
49
+ // stole the operator's focus. `forge brief open` is the explicit opt-in.
48
50
  const hash = stampBrief(changeDir);
49
51
  process.stdout.write(`Stamped ${briefPath(changeDir)} (specs hash ${hash})\n`);
50
- if (!rest.includes('--no-open')) {
51
- openInBrowser(briefPath(changeDir));
52
- process.stdout.write('Opened in default browser.\n');
53
- }
52
+ process.stdout.write(
53
+ `Operator: review the brief at ${briefPath(changeDir)} — open it with \`forge brief open\`.\n`,
54
+ );
54
55
  } else {
55
56
  const file = briefPath(changeDir);
56
57
  if (!fs.existsSync(file)) {
@@ -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 {
@@ -37,7 +37,7 @@ Thin wrapper around the project **`openspec-propose`** skill (or `/opsx:propose`
37
37
  where helpful), then:
38
38
 
39
39
  ```bash
40
- forge brief stamp # records specs hash + opens it in the operator's browser
40
+ forge brief stamp # records specs hash (does NOT auto-open)
41
41
  ```
42
42
 
43
43
  `forge phase implement` hard-refuses while the brief is missing or stale
@@ -51,7 +51,8 @@ Thin wrapper around the project **`openspec-propose`** skill (or `/opsx:propose`
51
51
  Count tasks from `tasks.md` checkboxes.
52
52
 
53
53
  7. Get user approval to proceed to implement (unless they already said "go").
54
- The brief (opened by `forge brief stamp`) is what the operator reviews.
54
+ The brief is what the operator reviews tell them its path and that
55
+ `forge brief open` launches it; never open it for them.
55
56
 
56
57
  ## Session tracking
57
58
 
@@ -85,7 +85,7 @@ OpenSpec propose flow without the vendor CLI. Change lives under
85
85
  where helpful), then:
86
86
 
87
87
  ```bash
88
- forge brief stamp # records specs hash + opens it in the operator's browser
88
+ forge brief stamp # records specs hash (does NOT auto-open)
89
89
  ```
90
90
 
91
91
  `forge phase implement` hard-refuses while the brief is missing or stale
@@ -102,7 +102,8 @@ OpenSpec propose flow without the vendor CLI. Change lives under
102
102
  name for both engines.)
103
103
 
104
104
  7. Get user approval on the artefacts before implementing (unless they already said "go").
105
- The brief (opened by `forge brief stamp`) is what the operator reviews.
105
+ The brief is what the operator reviews tell them its path and that
106
+ `forge brief open` launches it; never open it for them.
106
107
 
107
108
  ## Compatibility
108
109
 
@@ -21,7 +21,8 @@ freshness and opens the file.
21
21
  ```bash
22
22
  # 1. specs are final (proposal.md / design.md / tasks.md)
23
23
  # 2. write <changeDir>/brief.html (structure below)
24
- forge brief stamp # records specs hash + opens it in the operator's browser
24
+ forge brief stamp # records specs hash tell the operator where the brief is
25
+ # (it is NOT auto-opened; they can run `forge brief open`)
25
26
  forge phase implement --tasks-total <N> # hard-gated on a fresh stamped brief
26
27
  ```
27
28