@izkac/forgekit 0.3.12 → 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.
Files changed (116) hide show
  1. package/bin/forge.mjs +107 -107
  2. package/bin/forgekit.mjs +83 -83
  3. package/bin/review.mjs +81 -81
  4. package/package.json +1 -1
  5. package/scripts/prepack.mjs +78 -78
  6. package/scripts/run-tests.mjs +50 -50
  7. package/src/adr.mjs +236 -236
  8. package/src/adr.test.mjs +170 -170
  9. package/src/change.mjs +327 -234
  10. package/src/change.test.mjs +145 -83
  11. package/src/cleanup-sessions.mjs +84 -84
  12. package/src/config.mjs +103 -103
  13. package/src/defer.mjs +75 -75
  14. package/src/doctor.mjs +350 -341
  15. package/src/doctor.test.mjs +114 -114
  16. package/src/init.mjs +680 -621
  17. package/src/install.mjs +815 -815
  18. package/src/install.test.mjs +180 -180
  19. package/src/integrity-check.mjs +60 -60
  20. package/src/integrity.mjs +682 -682
  21. package/src/integrity.test.mjs +566 -566
  22. package/src/lib.mjs +143 -143
  23. package/src/models.defaults.json +41 -41
  24. package/src/new-session.mjs +99 -99
  25. package/src/openspec-overlays/README.md +19 -19
  26. package/src/openspec-overlays/openspec-apply-change-footer.md +14 -14
  27. package/src/openspec-overlays/opsx-apply-completion-step.md +1 -1
  28. package/src/openspec-overlays/opsx-apply-implement-step.md +11 -11
  29. package/src/paths.mjs +92 -92
  30. package/src/plan-engine.mjs +321 -278
  31. package/src/plan-engine.test.mjs +447 -283
  32. package/src/preferences.defaults.json +78 -78
  33. package/src/preferences.mjs +438 -438
  34. package/src/preferences.test.mjs +174 -174
  35. package/src/record-evidence.mjs +204 -204
  36. package/src/resolve-model.mjs +312 -312
  37. package/src/resolve-model.test.mjs +194 -194
  38. package/src/review/cli.test.mjs +117 -117
  39. package/src/review/export.mjs +172 -172
  40. package/src/review/export.test.mjs +197 -197
  41. package/src/review/fixtures/valid-review.json +42 -42
  42. package/src/review/lib.mjs +894 -894
  43. package/src/review/lib.test.mjs +266 -266
  44. package/src/review/schema.json +196 -196
  45. package/src/review/signals.test.mjs +62 -62
  46. package/src/score-cli.mjs +68 -68
  47. package/src/score.mjs +568 -568
  48. package/src/score.test.mjs +366 -366
  49. package/src/session-reminder.mjs +207 -207
  50. package/src/session-status.mjs +70 -70
  51. package/src/set-models.mjs +186 -186
  52. package/src/set-phase.mjs +205 -205
  53. package/src/set-prefs.mjs +294 -294
  54. package/src/specs-sync.mjs +234 -0
  55. package/src/specs-sync.test.mjs +114 -0
  56. package/src/spine.mjs +93 -93
  57. package/src/triage-prompt.mjs +175 -175
  58. package/src/triage-prompt.test.mjs +50 -50
  59. package/src/vendor-openspec-overlays.mjs +176 -176
  60. package/src/vendor-openspec-overlays.test.mjs +62 -62
  61. package/vendor/skills/archive-to-adr/SKILL.md +149 -149
  62. package/vendor/skills/forge/SKILL.md +136 -136
  63. package/vendor/skills/forge/docs/forge.md +650 -647
  64. package/vendor/skills/forge/phases/brainstorm.md +23 -23
  65. package/vendor/skills/forge/phases/finish.md +90 -87
  66. package/vendor/skills/forge/phases/implement.md +77 -77
  67. package/vendor/skills/forge/phases/plan-openspec.md +60 -60
  68. package/vendor/skills/forge/phases/plan-specs.md +163 -117
  69. package/vendor/skills/forge/phases/review.md +25 -25
  70. package/vendor/skills/forge/phases/verify.md +124 -124
  71. package/vendor/skills/forge/references/forge-layout.md +85 -85
  72. package/vendor/skills/forge/references/pace.md +115 -115
  73. package/vendor/skills/forge/references/plan-routing.md +52 -51
  74. package/vendor/skills/forge/references/runtime-integrity.md +232 -232
  75. package/vendor/skills/forge/references/substantial-work.md +37 -37
  76. package/vendor/skills/forge/references/test-evidence.md +30 -30
  77. package/vendor/skills/forge/references/test-strategy.md +68 -68
  78. package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -87
  79. package/vendor/skills/forge/subagents/final-reviewer-prompt.md +56 -56
  80. package/vendor/skills/forge/subagents/implementer-prompt.md +38 -38
  81. package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -132
  82. package/vendor/skills/thorough-code-review/SKILL.md +290 -290
  83. package/vendor/skills/thorough-code-review/examples.md +133 -133
  84. package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -26
  85. package/vendor/skills/thorough-code-review/reference/lenses.md +96 -96
  86. package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -62
  87. package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -105
  88. package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -222
  89. package/vendor/skills/thorough-code-review/reference/report-template.md +115 -115
  90. package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -49
  91. package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -55
  92. package/vendor/templates/adr/README.md +7 -7
  93. package/vendor/templates/adr/decisions.md +141 -141
  94. package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -74
  95. package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -3
  96. package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -52
  97. package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -3
  98. package/vendor/templates/project/claude/commands/forge-apply.md +75 -75
  99. package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -7
  100. package/vendor/templates/project/claude/commands/forge-build.md +17 -17
  101. package/vendor/templates/project/claude/commands/forge-plan.md +12 -12
  102. package/vendor/templates/project/claude/commands/forge-skip.md +14 -14
  103. package/vendor/templates/project/claude/commands/forge-status.md +16 -16
  104. package/vendor/templates/project/claude/commands/forge.md +16 -16
  105. package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -73
  106. package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -19
  107. package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -77
  108. package/vendor/templates/project/cursor/commands/forge-apply.md +75 -75
  109. package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -10
  110. package/vendor/templates/project/cursor/commands/forge-build.md +17 -17
  111. package/vendor/templates/project/cursor/commands/forge-plan.md +15 -15
  112. package/vendor/templates/project/cursor/commands/forge-skip.md +14 -14
  113. package/vendor/templates/project/cursor/commands/forge-status.md +16 -16
  114. package/vendor/templates/project/cursor/commands/forge.md +16 -16
  115. package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -30
  116. package/vendor/templates/project/cursor/hooks/forge-session-start.sh +3 -3
package/src/score.mjs CHANGED
@@ -1,568 +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
- // \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
- }
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
+ }