@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 +1 -1
- package/src/brief-cli.mjs +9 -8
- package/src/cleanup-sessions.mjs +12 -0
- package/src/score.mjs +34 -8
- package/src/score.test.mjs +76 -0
- package/vendor/skills/forge/phases/plan-openspec.md +3 -2
- package/vendor/skills/forge/phases/plan-specs.md +3 -2
- package/vendor/skills/forge/references/operator-brief.md +2 -1
package/package.json
CHANGED
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>]
|
|
7
|
-
* forge brief check [--session <id>]
|
|
8
|
-
* forge brief open [--session <id>]
|
|
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>]
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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)) {
|
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/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 {
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|