@izkac/forgekit 0.3.11 → 0.3.13
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 +107 -107
- package/bin/forgekit.mjs +83 -83
- package/bin/review.mjs +81 -81
- package/package.json +1 -1
- package/scripts/prepack.mjs +78 -78
- package/scripts/run-tests.mjs +50 -50
- package/src/adr.mjs +236 -236
- package/src/adr.test.mjs +170 -170
- package/src/change.mjs +327 -234
- package/src/change.test.mjs +145 -83
- package/src/cleanup-sessions.mjs +84 -84
- package/src/config.mjs +103 -103
- package/src/defer.mjs +75 -75
- package/src/doctor.mjs +350 -341
- package/src/doctor.test.mjs +114 -114
- package/src/init.mjs +680 -621
- package/src/install.mjs +815 -815
- package/src/install.test.mjs +180 -180
- package/src/integrity-check.mjs +60 -60
- package/src/integrity.mjs +682 -682
- package/src/integrity.test.mjs +566 -566
- package/src/lib.mjs +143 -143
- package/src/models.defaults.json +41 -41
- package/src/new-session.mjs +99 -99
- package/src/openspec-overlays/README.md +19 -19
- package/src/openspec-overlays/openspec-apply-change-footer.md +14 -14
- package/src/openspec-overlays/opsx-apply-completion-step.md +1 -1
- package/src/openspec-overlays/opsx-apply-implement-step.md +11 -11
- package/src/paths.mjs +92 -92
- package/src/plan-engine.mjs +321 -278
- package/src/plan-engine.test.mjs +447 -283
- package/src/preferences.defaults.json +78 -78
- package/src/preferences.mjs +438 -438
- package/src/preferences.test.mjs +174 -174
- package/src/record-evidence.mjs +204 -204
- package/src/resolve-model.mjs +312 -312
- package/src/resolve-model.test.mjs +194 -194
- package/src/review/cli.test.mjs +117 -117
- package/src/review/export.mjs +172 -172
- package/src/review/export.test.mjs +197 -197
- package/src/review/fixtures/valid-review.json +42 -42
- package/src/review/lib.mjs +894 -894
- package/src/review/lib.test.mjs +266 -266
- package/src/review/schema.json +196 -196
- package/src/review/signals.test.mjs +62 -62
- package/src/score-cli.mjs +68 -68
- package/src/score.mjs +568 -566
- package/src/score.test.mjs +366 -340
- package/src/session-reminder.mjs +207 -207
- package/src/session-status.mjs +70 -70
- package/src/set-models.mjs +186 -186
- package/src/set-phase.mjs +205 -205
- package/src/set-prefs.mjs +294 -294
- package/src/specs-sync.mjs +234 -0
- package/src/specs-sync.test.mjs +114 -0
- package/src/spine.mjs +93 -93
- package/src/triage-prompt.mjs +175 -175
- package/src/triage-prompt.test.mjs +50 -50
- package/src/vendor-openspec-overlays.mjs +176 -176
- package/src/vendor-openspec-overlays.test.mjs +62 -62
- package/vendor/skills/archive-to-adr/SKILL.md +149 -149
- package/vendor/skills/forge/SKILL.md +136 -136
- package/vendor/skills/forge/docs/forge.md +650 -647
- package/vendor/skills/forge/phases/brainstorm.md +23 -23
- package/vendor/skills/forge/phases/finish.md +90 -87
- package/vendor/skills/forge/phases/implement.md +77 -77
- package/vendor/skills/forge/phases/plan-openspec.md +60 -60
- package/vendor/skills/forge/phases/plan-specs.md +163 -117
- package/vendor/skills/forge/phases/review.md +25 -25
- package/vendor/skills/forge/phases/verify.md +124 -124
- package/vendor/skills/forge/references/forge-layout.md +85 -85
- package/vendor/skills/forge/references/pace.md +115 -115
- package/vendor/skills/forge/references/plan-routing.md +52 -51
- package/vendor/skills/forge/references/runtime-integrity.md +232 -225
- package/vendor/skills/forge/references/substantial-work.md +37 -37
- package/vendor/skills/forge/references/test-evidence.md +30 -30
- package/vendor/skills/forge/references/test-strategy.md +68 -68
- package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -87
- package/vendor/skills/forge/subagents/final-reviewer-prompt.md +56 -56
- package/vendor/skills/forge/subagents/implementer-prompt.md +38 -38
- package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -132
- package/vendor/skills/thorough-code-review/SKILL.md +290 -290
- package/vendor/skills/thorough-code-review/examples.md +133 -133
- package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -26
- package/vendor/skills/thorough-code-review/reference/lenses.md +96 -96
- package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -62
- package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -105
- package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -222
- package/vendor/skills/thorough-code-review/reference/report-template.md +115 -115
- package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -49
- package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -55
- package/vendor/templates/adr/README.md +7 -7
- package/vendor/templates/adr/decisions.md +141 -141
- package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -74
- package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -3
- package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -52
- package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -3
- package/vendor/templates/project/claude/commands/forge-apply.md +75 -75
- package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -7
- package/vendor/templates/project/claude/commands/forge-build.md +17 -17
- package/vendor/templates/project/claude/commands/forge-plan.md +12 -12
- package/vendor/templates/project/claude/commands/forge-skip.md +14 -14
- package/vendor/templates/project/claude/commands/forge-status.md +16 -16
- package/vendor/templates/project/claude/commands/forge.md +16 -16
- package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -73
- package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -19
- package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -77
- package/vendor/templates/project/cursor/commands/forge-apply.md +75 -75
- package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -10
- package/vendor/templates/project/cursor/commands/forge-build.md +17 -17
- package/vendor/templates/project/cursor/commands/forge-plan.md +15 -15
- package/vendor/templates/project/cursor/commands/forge-skip.md +14 -14
- package/vendor/templates/project/cursor/commands/forge-status.md +16 -16
- package/vendor/templates/project/cursor/commands/forge.md +16 -16
- package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -30
- package/vendor/templates/project/cursor/hooks/forge-session-start.sh +3 -3
package/src/score.mjs
CHANGED
|
@@ -1,566 +1,568 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Forge session scorecard — L2 measurement after (or before) phase done.
|
|
3
|
-
*
|
|
4
|
-
* Grades session *artifacts*, not the agent's self-report. L1 integrity is a
|
|
5
|
-
* prerequisite signal inside the score; L3 golden/product outcomes stay
|
|
6
|
-
* human/CI. See docs/usage.md § Session success.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import fs from 'node:fs';
|
|
10
|
-
import path from 'node:path';
|
|
11
|
-
import { readJson, writeJson } from './lib.mjs';
|
|
12
|
-
import {
|
|
13
|
-
JOBS_SIGNAL_RE,
|
|
14
|
-
checkE2eGate,
|
|
15
|
-
e2eDisabledReason,
|
|
16
|
-
e2ePath,
|
|
17
|
-
loadDeferrals,
|
|
18
|
-
openDeferrals,
|
|
19
|
-
resolveChangeDir,
|
|
20
|
-
runIntegrityChecks,
|
|
21
|
-
sessionJobsSignalText,
|
|
22
|
-
spinePath,
|
|
23
|
-
validateSpine,
|
|
24
|
-
} from './integrity.mjs';
|
|
25
|
-
|
|
26
|
-
/** Keep in sync with set-phase.mjs TASK_COUNT_ESCALATION_THRESHOLD. */
|
|
27
|
-
const TASK_COUNT_ESCALATION_THRESHOLD = 15;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* @param {unknown} value
|
|
31
|
-
*/
|
|
32
|
-
function isNonEmptyString(value) {
|
|
33
|
-
return typeof value === 'string' && value.trim().length > 0;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* @param {number} score
|
|
38
|
-
*/
|
|
39
|
-
export function gradeForScore(score) {
|
|
40
|
-
if (score >= 90) return 'A';
|
|
41
|
-
if (score >= 80) return 'B';
|
|
42
|
-
if (score >= 60) return 'C';
|
|
43
|
-
if (score >= 40) return 'D';
|
|
44
|
-
return 'F';
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* @param {string} sessionDir
|
|
49
|
-
*/
|
|
50
|
-
function listTaskEvidence(sessionDir) {
|
|
51
|
-
const tasksDir = path.join(sessionDir, 'tasks');
|
|
52
|
-
if (!fs.existsSync(tasksDir)) return { taskDirs: 0, withEvidence: 0, exitNonZero: 0 };
|
|
53
|
-
const entries = fs.readdirSync(tasksDir, { withFileTypes: true }).filter((e) => e.isDirectory());
|
|
54
|
-
let withEvidence = 0;
|
|
55
|
-
let exitNonZero = 0;
|
|
56
|
-
for (const e of entries) {
|
|
57
|
-
const file = path.join(tasksDir, e.name, 'test-evidence.md');
|
|
58
|
-
if (!fs.existsSync(file)) continue;
|
|
59
|
-
withEvidence += 1;
|
|
60
|
-
const body = fs.readFileSync(file, 'utf8');
|
|
61
|
-
const m = body.match(/\*\*Exit code:\*\*\s*`?(-?\d+)`?/i) || body.match(/Exit code:\s*`?(-?\d+)`?/i);
|
|
62
|
-
if (m && Number(m[1]) !== 0) exitNonZero += 1;
|
|
63
|
-
// Ceremony-only heuristic
|
|
64
|
-
if (
|
|
65
|
-
/\bstatus\s*===?\s*['"]?succeeded['"]?/i.test(body) &&
|
|
66
|
-
!/\b(assert|expect|differ|baseline|parquet|proposal|ingestion_stats)\b/i.test(body)
|
|
67
|
-
) {
|
|
68
|
-
// counted in evidenceHonesty separately via scanning all files
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
return { taskDirs: entries.length, withEvidence, exitNonZero };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* @param {string} sessionDir
|
|
76
|
-
*/
|
|
77
|
-
function evidenceHonestyIssues(sessionDir) {
|
|
78
|
-
/** @type {string[]} */
|
|
79
|
-
const issues = [];
|
|
80
|
-
const tasksDir = path.join(sessionDir, 'tasks');
|
|
81
|
-
if (!fs.existsSync(tasksDir)) return issues;
|
|
82
|
-
for (const e of fs.readdirSync(tasksDir, { withFileTypes: true })) {
|
|
83
|
-
if (!e.isDirectory()) continue;
|
|
84
|
-
const file = path.join(tasksDir, e.name, 'test-evidence.md');
|
|
85
|
-
if (!fs.existsSync(file)) continue;
|
|
86
|
-
const body = fs.readFileSync(file, 'utf8');
|
|
87
|
-
const ceremonyOnly =
|
|
88
|
-
/\b(succeeded|job status|handler (was )?called|claim|lease)\b/i.test(body) &&
|
|
89
|
-
!/\b(assert|expect|differ|baseline|parquet|proposal|ingestion|fixture|side.?effect)\b/i.test(body);
|
|
90
|
-
if (ceremonyOnly) {
|
|
91
|
-
issues.push(`${e.name}: tier-2 evidence looks ceremony-only (status/claim without domain asserts)`);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return issues;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* @param {string} sessionDir
|
|
99
|
-
*/
|
|
100
|
-
function reviewSelfCheckCount(sessionDir) {
|
|
101
|
-
const tasksDir = path.join(sessionDir, 'tasks');
|
|
102
|
-
if (!fs.existsSync(tasksDir)) return 0;
|
|
103
|
-
let n = 0;
|
|
104
|
-
for (const e of fs.readdirSync(tasksDir, { withFileTypes: true })) {
|
|
105
|
-
if (!e.isDirectory()) continue;
|
|
106
|
-
for (const name of ['task-review.md', 'group-review.md']) {
|
|
107
|
-
const file = path.join(tasksDir, e.name, name);
|
|
108
|
-
if (!fs.existsSync(file)) continue;
|
|
109
|
-
const body = fs.readFileSync(file, 'utf8');
|
|
110
|
-
if (/pace self-check|APPROVED \(pace/i.test(body)) n += 1;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
return n;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* True when a green e2e run was executed against the current e2e.json — the
|
|
118
|
-
* primary product-loop signal: score what ran, not what was written. A phrase
|
|
119
|
-
* match in verify-evidence is only the fallback (a session that titled its
|
|
120
|
-
* section differently but ran the loop green must not score 0).
|
|
121
|
-
*
|
|
122
|
-
* @param {{ cwd?: string, session?: Record<string, unknown> | null, sessionDir: string }} opts
|
|
123
|
-
*/
|
|
124
|
-
function e2eRunGreen(opts) {
|
|
125
|
-
try {
|
|
126
|
-
const e2eFile = e2ePath(opts);
|
|
127
|
-
const gate = checkE2eGate({ e2eFile, sessionDir: opts.sessionDir });
|
|
128
|
-
return gate.problems.length === 0 && !gate.notApplicable;
|
|
129
|
-
} catch {
|
|
130
|
-
return false;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* @param {string} body
|
|
136
|
-
* @param {boolean} executedGreen
|
|
137
|
-
*/
|
|
138
|
-
function scoreProductLoopBody(body, executedGreen = false) {
|
|
139
|
-
/** @type {string[]} */
|
|
140
|
-
const notes = [];
|
|
141
|
-
let pts = 0;
|
|
142
|
-
const max = 20;
|
|
143
|
-
if (/\bBLOCKED\b/.test(body)) {
|
|
144
|
-
notes.push('verify-evidence contains BLOCKED — product-loop not proven');
|
|
145
|
-
return { points: 0, max, notes };
|
|
146
|
-
}
|
|
147
|
-
if (executedGreen) {
|
|
148
|
-
pts += 8;
|
|
149
|
-
notes.push('green e2e run executed against current e2e.json — loop proven by execution');
|
|
150
|
-
} else if (/product[- ]loop/i.test(body)) {
|
|
151
|
-
pts += 8;
|
|
152
|
-
notes.push('product-loop section present (phrase-based — no executed green e2e run)');
|
|
153
|
-
} else {
|
|
154
|
-
notes.push('no executed green e2e run and no product-loop section in verify-evidence');
|
|
155
|
-
return { points: 0, max, notes };
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
notes.push('
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
*
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
/** @type {string[]} */
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
'
|
|
189
|
-
'
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
let
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
loopNotes = ['
|
|
264
|
-
} else {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
deferNotes.push(
|
|
306
|
-
} else {
|
|
307
|
-
deferNotes.push(
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
} else {
|
|
383
|
-
paceNotes.push(`
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
reviewNotes.push(`${selfChecks} pace self-check(s)
|
|
397
|
-
} else {
|
|
398
|
-
reviewNotes.push(
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
* @param {
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
lines
|
|
468
|
-
lines.push(
|
|
469
|
-
lines.push(
|
|
470
|
-
lines.push(`- **
|
|
471
|
-
lines.push(`- **
|
|
472
|
-
lines.push(`- **
|
|
473
|
-
lines.push(`- **
|
|
474
|
-
lines.push(`- **
|
|
475
|
-
lines.push(
|
|
476
|
-
lines.push(card.
|
|
477
|
-
lines.push('');
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
lines.push('');
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
lines.push('
|
|
486
|
-
lines.push('
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
lines.push('');
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
lines.push('');
|
|
500
|
-
lines.push('
|
|
501
|
-
lines.push('
|
|
502
|
-
lines.push('
|
|
503
|
-
lines.push('
|
|
504
|
-
lines.push('');
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
* @param {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
return
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Forge session scorecard — L2 measurement after (or before) phase done.
|
|
3
|
+
*
|
|
4
|
+
* Grades session *artifacts*, not the agent's self-report. L1 integrity is a
|
|
5
|
+
* prerequisite signal inside the score; L3 golden/product outcomes stay
|
|
6
|
+
* human/CI. See docs/usage.md § Session success.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { readJson, writeJson } from './lib.mjs';
|
|
12
|
+
import {
|
|
13
|
+
JOBS_SIGNAL_RE,
|
|
14
|
+
checkE2eGate,
|
|
15
|
+
e2eDisabledReason,
|
|
16
|
+
e2ePath,
|
|
17
|
+
loadDeferrals,
|
|
18
|
+
openDeferrals,
|
|
19
|
+
resolveChangeDir,
|
|
20
|
+
runIntegrityChecks,
|
|
21
|
+
sessionJobsSignalText,
|
|
22
|
+
spinePath,
|
|
23
|
+
validateSpine,
|
|
24
|
+
} from './integrity.mjs';
|
|
25
|
+
|
|
26
|
+
/** Keep in sync with set-phase.mjs TASK_COUNT_ESCALATION_THRESHOLD. */
|
|
27
|
+
const TASK_COUNT_ESCALATION_THRESHOLD = 15;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {unknown} value
|
|
31
|
+
*/
|
|
32
|
+
function isNonEmptyString(value) {
|
|
33
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {number} score
|
|
38
|
+
*/
|
|
39
|
+
export function gradeForScore(score) {
|
|
40
|
+
if (score >= 90) return 'A';
|
|
41
|
+
if (score >= 80) return 'B';
|
|
42
|
+
if (score >= 60) return 'C';
|
|
43
|
+
if (score >= 40) return 'D';
|
|
44
|
+
return 'F';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} sessionDir
|
|
49
|
+
*/
|
|
50
|
+
function listTaskEvidence(sessionDir) {
|
|
51
|
+
const tasksDir = path.join(sessionDir, 'tasks');
|
|
52
|
+
if (!fs.existsSync(tasksDir)) return { taskDirs: 0, withEvidence: 0, exitNonZero: 0 };
|
|
53
|
+
const entries = fs.readdirSync(tasksDir, { withFileTypes: true }).filter((e) => e.isDirectory());
|
|
54
|
+
let withEvidence = 0;
|
|
55
|
+
let exitNonZero = 0;
|
|
56
|
+
for (const e of entries) {
|
|
57
|
+
const file = path.join(tasksDir, e.name, 'test-evidence.md');
|
|
58
|
+
if (!fs.existsSync(file)) continue;
|
|
59
|
+
withEvidence += 1;
|
|
60
|
+
const body = fs.readFileSync(file, 'utf8');
|
|
61
|
+
const m = body.match(/\*\*Exit code:\*\*\s*`?(-?\d+)`?/i) || body.match(/Exit code:\s*`?(-?\d+)`?/i);
|
|
62
|
+
if (m && Number(m[1]) !== 0) exitNonZero += 1;
|
|
63
|
+
// Ceremony-only heuristic
|
|
64
|
+
if (
|
|
65
|
+
/\bstatus\s*===?\s*['"]?succeeded['"]?/i.test(body) &&
|
|
66
|
+
!/\b(assert|expect|differ|baseline|parquet|proposal|ingestion_stats)\b/i.test(body)
|
|
67
|
+
) {
|
|
68
|
+
// counted in evidenceHonesty separately via scanning all files
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { taskDirs: entries.length, withEvidence, exitNonZero };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {string} sessionDir
|
|
76
|
+
*/
|
|
77
|
+
function evidenceHonestyIssues(sessionDir) {
|
|
78
|
+
/** @type {string[]} */
|
|
79
|
+
const issues = [];
|
|
80
|
+
const tasksDir = path.join(sessionDir, 'tasks');
|
|
81
|
+
if (!fs.existsSync(tasksDir)) return issues;
|
|
82
|
+
for (const e of fs.readdirSync(tasksDir, { withFileTypes: true })) {
|
|
83
|
+
if (!e.isDirectory()) continue;
|
|
84
|
+
const file = path.join(tasksDir, e.name, 'test-evidence.md');
|
|
85
|
+
if (!fs.existsSync(file)) continue;
|
|
86
|
+
const body = fs.readFileSync(file, 'utf8');
|
|
87
|
+
const ceremonyOnly =
|
|
88
|
+
/\b(succeeded|job status|handler (was )?called|claim|lease)\b/i.test(body) &&
|
|
89
|
+
!/\b(assert|expect|differ|baseline|parquet|proposal|ingestion|fixture|side.?effect)\b/i.test(body);
|
|
90
|
+
if (ceremonyOnly) {
|
|
91
|
+
issues.push(`${e.name}: tier-2 evidence looks ceremony-only (status/claim without domain asserts)`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return issues;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {string} sessionDir
|
|
99
|
+
*/
|
|
100
|
+
function reviewSelfCheckCount(sessionDir) {
|
|
101
|
+
const tasksDir = path.join(sessionDir, 'tasks');
|
|
102
|
+
if (!fs.existsSync(tasksDir)) return 0;
|
|
103
|
+
let n = 0;
|
|
104
|
+
for (const e of fs.readdirSync(tasksDir, { withFileTypes: true })) {
|
|
105
|
+
if (!e.isDirectory()) continue;
|
|
106
|
+
for (const name of ['task-review.md', 'group-review.md']) {
|
|
107
|
+
const file = path.join(tasksDir, e.name, name);
|
|
108
|
+
if (!fs.existsSync(file)) continue;
|
|
109
|
+
const body = fs.readFileSync(file, 'utf8');
|
|
110
|
+
if (/pace self-check|APPROVED \(pace/i.test(body)) n += 1;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return n;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* True when a green e2e run was executed against the current e2e.json — the
|
|
118
|
+
* primary product-loop signal: score what ran, not what was written. A phrase
|
|
119
|
+
* match in verify-evidence is only the fallback (a session that titled its
|
|
120
|
+
* section differently but ran the loop green must not score 0).
|
|
121
|
+
*
|
|
122
|
+
* @param {{ cwd?: string, session?: Record<string, unknown> | null, sessionDir: string }} opts
|
|
123
|
+
*/
|
|
124
|
+
function e2eRunGreen(opts) {
|
|
125
|
+
try {
|
|
126
|
+
const e2eFile = e2ePath(opts);
|
|
127
|
+
const gate = checkE2eGate({ e2eFile, sessionDir: opts.sessionDir });
|
|
128
|
+
return gate.problems.length === 0 && !gate.notApplicable;
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {string} body
|
|
136
|
+
* @param {boolean} executedGreen
|
|
137
|
+
*/
|
|
138
|
+
function scoreProductLoopBody(body, executedGreen = false) {
|
|
139
|
+
/** @type {string[]} */
|
|
140
|
+
const notes = [];
|
|
141
|
+
let pts = 0;
|
|
142
|
+
const max = 20;
|
|
143
|
+
if (/\bBLOCKED\b/.test(body)) {
|
|
144
|
+
notes.push('verify-evidence contains BLOCKED — product-loop not proven');
|
|
145
|
+
return { points: 0, max, notes };
|
|
146
|
+
}
|
|
147
|
+
if (executedGreen) {
|
|
148
|
+
pts += 8;
|
|
149
|
+
notes.push('green e2e run executed against current e2e.json — loop proven by execution');
|
|
150
|
+
} else if (/product[- ]loop/i.test(body)) {
|
|
151
|
+
pts += 8;
|
|
152
|
+
notes.push('product-loop section present (phrase-based — no executed green e2e run)');
|
|
153
|
+
} else {
|
|
154
|
+
notes.push('no executed green e2e run and no product-loop section in verify-evidence');
|
|
155
|
+
return { points: 0, max, notes };
|
|
156
|
+
}
|
|
157
|
+
// \w* suffixes: a trailing \b silently rejected inflected forms ("asserts",
|
|
158
|
+
// "fixtures", "ratify") and cost honest sessions real points.
|
|
159
|
+
if (/\b(fixture\w*|OP\d+|testdata\w*|sample\w*)/i.test(body)) {
|
|
160
|
+
pts += 4;
|
|
161
|
+
notes.push('names a fixture / corpus');
|
|
162
|
+
}
|
|
163
|
+
if (/\b(differ\w*|baseline\w*|ratif\w*|assert\w*|before.?after|output changed)/i.test(body)) {
|
|
164
|
+
pts += 5;
|
|
165
|
+
notes.push('asserts decision/output change vs baseline');
|
|
166
|
+
} else {
|
|
167
|
+
notes.push('missing baseline-diff / ratify-changes-output assertion');
|
|
168
|
+
}
|
|
169
|
+
if (/\b(1\.|2\.|step)\b/i.test(body) || body.split('\n').filter((l) => /^\s*\d+\./.test(l)).length >= 2) {
|
|
170
|
+
pts += 3;
|
|
171
|
+
notes.push('multi-step loop listed');
|
|
172
|
+
}
|
|
173
|
+
return { points: Math.min(pts, max), max, notes };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Score an active Forge session from on-disk artifacts.
|
|
178
|
+
*
|
|
179
|
+
* @param {{ cwd?: string, sessionDir: string, session: Record<string, unknown> }} opts
|
|
180
|
+
*/
|
|
181
|
+
export function scoreSession(opts) {
|
|
182
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
183
|
+
const { sessionDir, session } = opts;
|
|
184
|
+
/** @type {{ id: string, label: string, points: number, max: number, notes: string[] }[]} */
|
|
185
|
+
const checks = [];
|
|
186
|
+
/** @type {string[]} */
|
|
187
|
+
const humanPrompts = [
|
|
188
|
+
'Can you name the production path for the main REQ? (job / endpoint / CLI)',
|
|
189
|
+
'If you exercise that path (fixture upload / API), does the UI show real data — not empty queues?',
|
|
190
|
+
'If governance was in scope: does ratify change the next run’s output?',
|
|
191
|
+
'Would you ship this to a customer tomorrow? (yes / no / only with a follow-on change)',
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
// --- integrity (20) ---
|
|
195
|
+
const integrity = runIntegrityChecks({ cwd, sessionDir, session });
|
|
196
|
+
checks.push({
|
|
197
|
+
id: 'integrity',
|
|
198
|
+
label: 'L1 integrity gate (spine / deferrals / product-loop presence)',
|
|
199
|
+
points: integrity.ok ? 20 : 0,
|
|
200
|
+
max: 20,
|
|
201
|
+
notes: integrity.ok ? ['integrity-check would pass'] : integrity.problems.slice(0, 8),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// --- spine quality (25) ---
|
|
205
|
+
let spinePts = 0;
|
|
206
|
+
const spineMax = 25;
|
|
207
|
+
/** @type {string[]} */
|
|
208
|
+
const spineNotes = [];
|
|
209
|
+
const spineFile = spinePath({ cwd, session, sessionDir });
|
|
210
|
+
if (!fs.existsSync(spineFile)) {
|
|
211
|
+
spineNotes.push('spine.json missing');
|
|
212
|
+
} else {
|
|
213
|
+
try {
|
|
214
|
+
const doc = readJson(spineFile);
|
|
215
|
+
const v = validateSpine(doc);
|
|
216
|
+
if (!v.ok) {
|
|
217
|
+
spineNotes.push(...v.problems);
|
|
218
|
+
} else if (isNonEmptyString(doc.notApplicable)) {
|
|
219
|
+
spinePts = 25;
|
|
220
|
+
spineNotes.push(`notApplicable: ${doc.notApplicable}`);
|
|
221
|
+
} else {
|
|
222
|
+
const rows = Array.isArray(doc.rows) ? doc.rows : [];
|
|
223
|
+
spinePts = 25;
|
|
224
|
+
spineNotes.push(`${rows.length} wired row(s)`);
|
|
225
|
+
const weak = rows.filter(
|
|
226
|
+
(r) =>
|
|
227
|
+
r &&
|
|
228
|
+
typeof r === 'object' &&
|
|
229
|
+
(!isNonEmptyString(/** @type {Record<string, unknown>} */ (r).runtimeOwner) ||
|
|
230
|
+
!isNonEmptyString(/** @type {Record<string, unknown>} */ (r).writes)),
|
|
231
|
+
);
|
|
232
|
+
if (weak.length) {
|
|
233
|
+
spinePts = Math.max(5, spinePts - weak.length * 5);
|
|
234
|
+
spineNotes.push(`${weak.length} row(s) look library-weak`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
} catch (err) {
|
|
238
|
+
spineNotes.push(`unreadable: ${err instanceof Error ? err.message : err}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
checks.push({ id: 'spine', label: 'Spine matrix quality', points: spinePts, max: spineMax, notes: spineNotes });
|
|
242
|
+
|
|
243
|
+
// --- product loop (20) ---
|
|
244
|
+
const evidenceFile = path.join(sessionDir, 'verify-evidence.md');
|
|
245
|
+
let loopPts = 0;
|
|
246
|
+
const loopMax = 20;
|
|
247
|
+
/** @type {string[]} */
|
|
248
|
+
let loopNotes = [];
|
|
249
|
+
let spineHasRows = false;
|
|
250
|
+
let spineNotApplicable = false;
|
|
251
|
+
if (fs.existsSync(spineFile)) {
|
|
252
|
+
try {
|
|
253
|
+
const doc = readJson(spineFile);
|
|
254
|
+
spineNotApplicable = isNonEmptyString(doc.notApplicable);
|
|
255
|
+
spineHasRows =
|
|
256
|
+
Array.isArray(doc.rows) && doc.rows.length > 0 && !spineNotApplicable;
|
|
257
|
+
} catch {
|
|
258
|
+
// ignore
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (spineNotApplicable) {
|
|
262
|
+
loopPts = 20;
|
|
263
|
+
loopNotes = ['sync/docs notApplicable — product-loop N/A (full credit)'];
|
|
264
|
+
} else if (!fs.existsSync(evidenceFile)) {
|
|
265
|
+
loopNotes = ['verify-evidence.md missing'];
|
|
266
|
+
} else {
|
|
267
|
+
const body = fs.readFileSync(evidenceFile, 'utf8');
|
|
268
|
+
const e2eOff = e2eDisabledReason(cwd);
|
|
269
|
+
const executedGreen = !e2eOff && e2eRunGreen({ cwd, session, sessionDir });
|
|
270
|
+
if (spineHasRows || JOBS_SIGNAL_RE.test(sessionJobsSignalText(session))) {
|
|
271
|
+
const scored = scoreProductLoopBody(body, executedGreen);
|
|
272
|
+
loopPts = scored.points;
|
|
273
|
+
loopNotes = scored.notes;
|
|
274
|
+
} else {
|
|
275
|
+
// Rows expected but maybe empty invalid spine — still look for loop
|
|
276
|
+
if (executedGreen || /product[- ]loop/i.test(body)) {
|
|
277
|
+
const scored = scoreProductLoopBody(body, executedGreen);
|
|
278
|
+
loopPts = scored.points;
|
|
279
|
+
loopNotes = scored.notes;
|
|
280
|
+
} else {
|
|
281
|
+
loopPts = 10;
|
|
282
|
+
loopNotes = ['no spine rows and no jobs signal — partial credit without product-loop'];
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (e2eOff) {
|
|
286
|
+
loopNotes.push(`e2e disabled by project config ("${e2eOff}") — scored from evidence text only`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
checks.push({
|
|
290
|
+
id: 'product_loop',
|
|
291
|
+
label: 'Product-loop evidence quality',
|
|
292
|
+
points: loopPts,
|
|
293
|
+
max: loopMax,
|
|
294
|
+
notes: loopNotes,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// --- deferrals (10) ---
|
|
298
|
+
const deferrals = loadDeferrals(sessionDir).deferrals;
|
|
299
|
+
const open = openDeferrals(sessionDir);
|
|
300
|
+
let deferPts = 10;
|
|
301
|
+
/** @type {string[]} */
|
|
302
|
+
const deferNotes = [];
|
|
303
|
+
if (open.length > 0) {
|
|
304
|
+
deferPts = 0;
|
|
305
|
+
deferNotes.push(`unresolved: ${open.map((d) => d.task).join(', ')}`);
|
|
306
|
+
} else if (deferrals.length === 0) {
|
|
307
|
+
deferNotes.push('no deferrals registered');
|
|
308
|
+
} else {
|
|
309
|
+
deferNotes.push(`${deferrals.length} deferral(s), all resolved`);
|
|
310
|
+
}
|
|
311
|
+
checks.push({
|
|
312
|
+
id: 'deferrals',
|
|
313
|
+
label: 'Deferral hygiene',
|
|
314
|
+
points: deferPts,
|
|
315
|
+
max: 10,
|
|
316
|
+
notes: deferNotes,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// --- tasks + evidence (10) ---
|
|
320
|
+
const total = Number(session.tasksTotal) || 0;
|
|
321
|
+
const complete = Number(session.tasksComplete) || 0;
|
|
322
|
+
const ev = listTaskEvidence(sessionDir);
|
|
323
|
+
let taskPts = 0;
|
|
324
|
+
/** @type {string[]} */
|
|
325
|
+
const taskNotes = [];
|
|
326
|
+
if (total === 0) {
|
|
327
|
+
taskPts = 5;
|
|
328
|
+
taskNotes.push('tasksTotal=0 — partial credit');
|
|
329
|
+
} else if (complete >= total) {
|
|
330
|
+
taskPts = 6;
|
|
331
|
+
taskNotes.push(`tasks ${complete}/${total} complete`);
|
|
332
|
+
} else {
|
|
333
|
+
taskPts = Math.round((complete / total) * 6);
|
|
334
|
+
taskNotes.push(`tasks incomplete ${complete}/${total}`);
|
|
335
|
+
}
|
|
336
|
+
if (ev.taskDirs === 0) {
|
|
337
|
+
taskNotes.push('no task dirs yet');
|
|
338
|
+
} else {
|
|
339
|
+
const ratio = ev.withEvidence / ev.taskDirs;
|
|
340
|
+
taskPts += Math.round(ratio * 4);
|
|
341
|
+
taskNotes.push(`tier-2 evidence in ${ev.withEvidence}/${ev.taskDirs} task dirs`);
|
|
342
|
+
if (ev.exitNonZero) {
|
|
343
|
+
taskPts = Math.max(0, taskPts - ev.exitNonZero);
|
|
344
|
+
taskNotes.push(`${ev.exitNonZero} evidence file(s) with non-zero exit`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
taskPts = Math.min(10, taskPts);
|
|
348
|
+
checks.push({
|
|
349
|
+
id: 'tasks',
|
|
350
|
+
label: 'Task completion + tier-2 evidence coverage',
|
|
351
|
+
points: taskPts,
|
|
352
|
+
max: 10,
|
|
353
|
+
notes: taskNotes,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// --- evidence honesty (5) ---
|
|
357
|
+
const honestyIssues = evidenceHonestyIssues(sessionDir);
|
|
358
|
+
const honestyPts = honestyIssues.length === 0 ? 5 : Math.max(0, 5 - honestyIssues.length * 2);
|
|
359
|
+
checks.push({
|
|
360
|
+
id: 'evidence_honesty',
|
|
361
|
+
label: 'Evidence honesty (not ceremony-only)',
|
|
362
|
+
points: honestyPts,
|
|
363
|
+
max: 5,
|
|
364
|
+
notes: honestyIssues.length ? honestyIssues.slice(0, 5) : ['no ceremony-only heuristics fired'],
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// --- pace sanity (5) ---
|
|
368
|
+
let pacePts = 5;
|
|
369
|
+
/** @type {string[]} */
|
|
370
|
+
const paceNotes = [];
|
|
371
|
+
const resolved = session.resolvedPace;
|
|
372
|
+
if (
|
|
373
|
+
(resolved === 'brisk' || resolved === 'lite') &&
|
|
374
|
+
total >= TASK_COUNT_ESCALATION_THRESHOLD &&
|
|
375
|
+
session.paceEscalated !== true &&
|
|
376
|
+
session.pacePinned !== true
|
|
377
|
+
) {
|
|
378
|
+
pacePts = 0;
|
|
379
|
+
paceNotes.push(
|
|
380
|
+
`resolvedPace=${resolved} with ${total} tasks — expected escalation to standard`,
|
|
381
|
+
);
|
|
382
|
+
} else if (session.paceEscalated) {
|
|
383
|
+
paceNotes.push(`escalated: ${session.paceReason ?? 'task count'}`);
|
|
384
|
+
} else {
|
|
385
|
+
paceNotes.push(`resolvedPace=${resolved ?? 'unset'}`);
|
|
386
|
+
}
|
|
387
|
+
checks.push({ id: 'pace', label: 'Pace sanity', points: pacePts, max: 5, notes: paceNotes });
|
|
388
|
+
|
|
389
|
+
// --- review depth soft signal (5) ---
|
|
390
|
+
const selfChecks = reviewSelfCheckCount(sessionDir);
|
|
391
|
+
let reviewPts = 5;
|
|
392
|
+
/** @type {string[]} */
|
|
393
|
+
const reviewNotes = [];
|
|
394
|
+
if (selfChecks > 0 && (resolved === 'thorough' || total >= TASK_COUNT_ESCALATION_THRESHOLD)) {
|
|
395
|
+
reviewPts = Math.max(0, 5 - Math.min(5, selfChecks));
|
|
396
|
+
reviewNotes.push(`${selfChecks} pace self-check review(s) on a large/thorough session`);
|
|
397
|
+
} else if (selfChecks > 0) {
|
|
398
|
+
reviewNotes.push(`${selfChecks} pace self-check(s) — ok under brisk/standard mid-group`);
|
|
399
|
+
} else {
|
|
400
|
+
reviewNotes.push('no pace self-check markers found');
|
|
401
|
+
}
|
|
402
|
+
checks.push({
|
|
403
|
+
id: 'reviews',
|
|
404
|
+
label: 'Review depth signal',
|
|
405
|
+
points: reviewPts,
|
|
406
|
+
max: 5,
|
|
407
|
+
notes: reviewNotes,
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
let score = checks.reduce((s, c) => s + c.points, 0);
|
|
411
|
+
const maxScore = checks.reduce((s, c) => s + c.max, 0);
|
|
412
|
+
/** @type {string[]} */
|
|
413
|
+
const caps = [];
|
|
414
|
+
|
|
415
|
+
if (isNonEmptyString(session.incompleteReason)) {
|
|
416
|
+
const before = score;
|
|
417
|
+
score = Math.min(score, 59);
|
|
418
|
+
caps.push(
|
|
419
|
+
`incompleteReason set ("${session.incompleteReason}") — score capped at 59 (was ${before})`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const grade = gradeForScore(score);
|
|
424
|
+
const changeDir = resolveChangeDir({ cwd, session });
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
version: 1,
|
|
428
|
+
scoredAt: new Date().toISOString(),
|
|
429
|
+
sessionId: session.id ?? null,
|
|
430
|
+
slug: session.slug ?? null,
|
|
431
|
+
phase: session.phase ?? null,
|
|
432
|
+
openspecChange: session.openspecChange ?? null,
|
|
433
|
+
changeDir,
|
|
434
|
+
score,
|
|
435
|
+
maxScore,
|
|
436
|
+
grade,
|
|
437
|
+
caps,
|
|
438
|
+
checks,
|
|
439
|
+
integrityOk: integrity.ok,
|
|
440
|
+
humanPrompts,
|
|
441
|
+
interpretation: interpretGrade(grade, score, session),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* @param {string} grade
|
|
447
|
+
* @param {number} score
|
|
448
|
+
* @param {Record<string, unknown>} session
|
|
449
|
+
*/
|
|
450
|
+
function interpretGrade(grade, score, session) {
|
|
451
|
+
if (isNonEmptyString(session.incompleteReason)) {
|
|
452
|
+
return `Session finished incomplete (${session.incompleteReason}). Process may be honest; product outcome is unproven — treat as Forge follow-up, not a green ship.`;
|
|
453
|
+
}
|
|
454
|
+
if (grade === 'A' || grade === 'B') {
|
|
455
|
+
return `Strong L2 artifacts (${score}/100). Still confirm L3: exercise the product path or golden scenario before calling Forge successful.`;
|
|
456
|
+
}
|
|
457
|
+
if (grade === 'C') {
|
|
458
|
+
return `Mixed L2 (${score}/100). Likely process gaps (spine/loop/evidence). Do not equate task checkboxes with product success.`;
|
|
459
|
+
}
|
|
460
|
+
return `Weak L2 (${score}/100). High risk of checkbox-green / product-hollow — same failure mode integrity was built to catch.`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* @param {ReturnType<typeof scoreSession>} card
|
|
465
|
+
*/
|
|
466
|
+
export function formatScorecardMarkdown(card) {
|
|
467
|
+
const lines = [];
|
|
468
|
+
lines.push(`# Forge session scorecard`);
|
|
469
|
+
lines.push('');
|
|
470
|
+
lines.push(`- **Session:** ${card.sessionId ?? '?'}`);
|
|
471
|
+
lines.push(`- **Slug:** ${card.slug ?? '?'}`);
|
|
472
|
+
lines.push(`- **Change:** ${card.openspecChange ?? '(none)'}`);
|
|
473
|
+
lines.push(`- **Phase:** ${card.phase ?? '?'}`);
|
|
474
|
+
lines.push(`- **Score:** ${card.score}/${card.maxScore} **Grade: ${card.grade}**`);
|
|
475
|
+
lines.push(`- **Scored at:** ${card.scoredAt}`);
|
|
476
|
+
lines.push(`- **Integrity OK:** ${card.integrityOk ? 'yes' : 'no'}`);
|
|
477
|
+
lines.push('');
|
|
478
|
+
lines.push(card.interpretation);
|
|
479
|
+
lines.push('');
|
|
480
|
+
if (card.caps.length) {
|
|
481
|
+
lines.push('## Caps');
|
|
482
|
+
for (const c of card.caps) lines.push(`- ${c}`);
|
|
483
|
+
lines.push('');
|
|
484
|
+
}
|
|
485
|
+
lines.push('## Checks');
|
|
486
|
+
lines.push('');
|
|
487
|
+
lines.push('| Check | Points | Notes |');
|
|
488
|
+
lines.push('| ----- | ------ | ----- |');
|
|
489
|
+
for (const c of card.checks) {
|
|
490
|
+
const notes = c.notes.map((n) => n.replace(/\|/g, '/')).join('; ') || '—';
|
|
491
|
+
lines.push(`| ${c.label} | ${c.points}/${c.max} | ${notes} |`);
|
|
492
|
+
}
|
|
493
|
+
lines.push('');
|
|
494
|
+
lines.push('## Human ship-check (L3 — answer after done)');
|
|
495
|
+
lines.push('');
|
|
496
|
+
for (const [i, q] of card.humanPrompts.entries()) {
|
|
497
|
+
lines.push(`${i + 1}. ${q}`);
|
|
498
|
+
}
|
|
499
|
+
lines.push('');
|
|
500
|
+
lines.push('Record answers below (optional but required for platform/async spines):');
|
|
501
|
+
lines.push('');
|
|
502
|
+
lines.push('```');
|
|
503
|
+
lines.push('shipTomorrow: yes|no|follow-on');
|
|
504
|
+
lines.push('notes:');
|
|
505
|
+
lines.push('```');
|
|
506
|
+
lines.push('');
|
|
507
|
+
return `${lines.join('\n')}\n`;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Durable one-line-per-session ledger at `.forge/scorecards.jsonl`. Sessions
|
|
512
|
+
* are pruned after RETENTION_DAYS and scorecards die with them; the ledger
|
|
513
|
+
* survives — it is the history `/forge:analyze` reads for trends. Re-scoring
|
|
514
|
+
* a session replaces its line (latest score wins). Never throws.
|
|
515
|
+
*
|
|
516
|
+
* @param {string} sessionDir
|
|
517
|
+
* @param {ReturnType<typeof scoreSession>} card
|
|
518
|
+
* @param {Record<string, unknown>} session
|
|
519
|
+
*/
|
|
520
|
+
export function appendScorecardLedger(sessionDir, card, session = {}) {
|
|
521
|
+
try {
|
|
522
|
+
const file = path.join(path.resolve(sessionDir, '..', '..'), 'scorecards.jsonl');
|
|
523
|
+
const line = {
|
|
524
|
+
scoredAt: card.scoredAt,
|
|
525
|
+
sessionId: card.sessionId,
|
|
526
|
+
slug: card.slug,
|
|
527
|
+
change: card.openspecChange,
|
|
528
|
+
score: card.score,
|
|
529
|
+
grade: card.grade,
|
|
530
|
+
integrityOk: card.integrityOk,
|
|
531
|
+
pace: session.resolvedPace ?? null,
|
|
532
|
+
incompleteReason: session.incompleteReason ?? null,
|
|
533
|
+
caps: card.caps,
|
|
534
|
+
deductions: card.checks
|
|
535
|
+
.filter((c) => c.points < c.max)
|
|
536
|
+
.map((c) => ({ id: c.id, points: c.points, max: c.max, notes: c.notes })),
|
|
537
|
+
};
|
|
538
|
+
const kept = (fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n') : [])
|
|
539
|
+
.filter(Boolean)
|
|
540
|
+
.filter((l) => {
|
|
541
|
+
try {
|
|
542
|
+
return JSON.parse(l).sessionId !== card.sessionId;
|
|
543
|
+
} catch {
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
kept.push(JSON.stringify(line));
|
|
548
|
+
fs.writeFileSync(file, `${kept.join('\n')}\n`, 'utf8');
|
|
549
|
+
} catch {
|
|
550
|
+
/* ledger is advisory — never block a scorecard write */
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Write scorecard.json + scorecard.md into the session dir, and mirror a
|
|
556
|
+
* summary line into the durable `.forge/scorecards.jsonl` ledger.
|
|
557
|
+
*
|
|
558
|
+
* @param {{ cwd?: string, sessionDir: string, session: Record<string, unknown> }} opts
|
|
559
|
+
*/
|
|
560
|
+
export function writeSessionScorecard(opts) {
|
|
561
|
+
const card = scoreSession(opts);
|
|
562
|
+
const jsonPath = path.join(opts.sessionDir, 'scorecard.json');
|
|
563
|
+
const mdPath = path.join(opts.sessionDir, 'scorecard.md');
|
|
564
|
+
writeJson(jsonPath, card);
|
|
565
|
+
fs.writeFileSync(mdPath, formatScorecardMarkdown(card), 'utf8');
|
|
566
|
+
appendScorecardLedger(opts.sessionDir, card, opts.session);
|
|
567
|
+
return { card, jsonPath, mdPath };
|
|
568
|
+
}
|