@izkac/forgekit 0.1.0

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