@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.
- package/bin/forge.mjs +100 -0
- package/bin/forgekit.mjs +84 -0
- package/bin/review.mjs +82 -0
- package/package.json +46 -0
- package/scripts/prepack.mjs +78 -0
- package/scripts/run-tests.mjs +43 -0
- package/src/adr.mjs +236 -0
- package/src/adr.test.mjs +170 -0
- package/src/change.mjs +234 -0
- package/src/change.test.mjs +83 -0
- package/src/cleanup-sessions.mjs +70 -0
- package/src/config.mjs +103 -0
- package/src/defer.mjs +75 -0
- package/src/doctor.mjs +341 -0
- package/src/doctor.test.mjs +114 -0
- package/src/init.mjs +575 -0
- package/src/install.mjs +777 -0
- package/src/install.test.mjs +104 -0
- package/src/integrity-check.mjs +58 -0
- package/src/integrity.mjs +317 -0
- package/src/integrity.test.mjs +296 -0
- package/src/lib/workspaces.mjs +55 -0
- package/src/lib.mjs +138 -0
- package/src/models.defaults.json +41 -0
- package/src/new-session.mjs +82 -0
- package/src/openspec-overlays/README.md +19 -0
- package/src/openspec-overlays/openspec-apply-change-footer.md +14 -0
- package/src/openspec-overlays/opsx-apply-completion-step.md +1 -0
- package/src/openspec-overlays/opsx-apply-implement-step.md +11 -0
- package/src/paths.mjs +92 -0
- package/src/plan-engine.mjs +260 -0
- package/src/plan-engine.test.mjs +245 -0
- package/src/preferences.defaults.json +78 -0
- package/src/preferences.mjs +438 -0
- package/src/preferences.test.mjs +174 -0
- package/src/record-evidence.mjs +204 -0
- package/src/record-evidence.test.mjs +260 -0
- package/src/resolve-model.mjs +312 -0
- package/src/resolve-model.test.mjs +194 -0
- package/src/review/carryforward.mjs +413 -0
- package/src/review/carryforward.test.mjs +587 -0
- package/src/review/cli.test.mjs +117 -0
- package/src/review/export.mjs +172 -0
- package/src/review/export.test.mjs +197 -0
- package/src/review/fixtures/valid-review.json +42 -0
- package/src/review/lib.mjs +894 -0
- package/src/review/lib.test.mjs +266 -0
- package/src/review/merge-tentative.mjs +292 -0
- package/src/review/merge-tentative.test.mjs +363 -0
- package/src/review/new-review.mjs +200 -0
- package/src/review/render.mjs +108 -0
- package/src/review/schema-consistency.test.mjs +83 -0
- package/src/review/schema.json +196 -0
- package/src/review/signals.mjs +144 -0
- package/src/review/signals.test.mjs +62 -0
- package/src/score-cli.mjs +68 -0
- package/src/score.mjs +489 -0
- package/src/score.test.mjs +253 -0
- package/src/session-reminder.mjs +168 -0
- package/src/session-status.mjs +70 -0
- package/src/set-models.mjs +186 -0
- package/src/set-phase.mjs +177 -0
- package/src/set-phase.test.mjs +317 -0
- package/src/set-prefs.mjs +294 -0
- package/src/spine.mjs +91 -0
- package/src/triage-prompt.mjs +175 -0
- package/src/triage-prompt.test.mjs +50 -0
- package/src/vendor-openspec-overlays.mjs +176 -0
- package/src/vendor-openspec-overlays.test.mjs +62 -0
- package/vendor/skills/archive-to-adr/SKILL.md +149 -0
- package/vendor/skills/forge/SKILL.md +136 -0
- package/vendor/skills/forge/phases/brainstorm.md +23 -0
- package/vendor/skills/forge/phases/finish.md +87 -0
- package/vendor/skills/forge/phases/implement.md +76 -0
- package/vendor/skills/forge/phases/plan-openspec.md +40 -0
- package/vendor/skills/forge/phases/plan-specs.md +97 -0
- package/vendor/skills/forge/phases/review.md +25 -0
- package/vendor/skills/forge/phases/verify.md +120 -0
- package/vendor/skills/forge/references/forge-layout.md +85 -0
- package/vendor/skills/forge/references/pace.md +115 -0
- package/vendor/skills/forge/references/plan-routing.md +51 -0
- package/vendor/skills/forge/references/runtime-integrity.md +157 -0
- package/vendor/skills/forge/references/substantial-work.md +37 -0
- package/vendor/skills/forge/references/tdd-core.md +29 -0
- package/vendor/skills/forge/references/test-evidence.md +30 -0
- package/vendor/skills/forge/references/test-strategy.md +68 -0
- package/vendor/skills/forge/skills/NOTICE.md +17 -0
- package/vendor/skills/forge/skills/brainstorming/SKILL.md +120 -0
- package/vendor/skills/forge/skills/requesting-code-review/SKILL.md +67 -0
- package/vendor/skills/forge/skills/requesting-code-review/code-reviewer.md +146 -0
- package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -0
- package/vendor/skills/forge/skills/systematic-debugging/SKILL.md +234 -0
- package/vendor/skills/forge/skills/systematic-debugging/condition-based-waiting.md +115 -0
- package/vendor/skills/forge/skills/systematic-debugging/defense-in-depth.md +122 -0
- package/vendor/skills/forge/skills/systematic-debugging/find-polluter.sh +63 -0
- package/vendor/skills/forge/skills/systematic-debugging/root-cause-tracing.md +169 -0
- package/vendor/skills/forge/skills/test-driven-development/SKILL.md +290 -0
- package/vendor/skills/forge/skills/test-driven-development/testing-anti-patterns.md +299 -0
- package/vendor/skills/forge/skills/verification-before-completion/SKILL.md +59 -0
- package/vendor/skills/forge/subagents/final-reviewer-prompt.md +53 -0
- package/vendor/skills/forge/subagents/implementer-prompt.md +38 -0
- package/vendor/skills/forge/subagents/task-reviewer-prompt.md +61 -0
- package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -0
- package/vendor/skills/thorough-code-review/SKILL.md +290 -0
- package/vendor/skills/thorough-code-review/examples/accepted-risks-janus.md +32 -0
- package/vendor/skills/thorough-code-review/examples.md +133 -0
- package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -0
- package/vendor/skills/thorough-code-review/reference/lenses.md +96 -0
- package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -0
- package/vendor/skills/thorough-code-review/reference/phase1c-coverage.md +44 -0
- package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -0
- package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -0
- package/vendor/skills/thorough-code-review/reference/report-template.md +115 -0
- package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -0
- package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -0
- package/vendor/templates/adr/README.md +7 -0
- package/vendor/templates/adr/decisions.md +141 -0
- package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -0
- package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -0
- package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -0
- package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -0
- package/vendor/templates/project/claude/commands/forge-apply.md +75 -0
- package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -0
- package/vendor/templates/project/claude/commands/forge-build.md +17 -0
- package/vendor/templates/project/claude/commands/forge-plan.md +12 -0
- package/vendor/templates/project/claude/commands/forge-skip.md +14 -0
- package/vendor/templates/project/claude/commands/forge-status.md +16 -0
- package/vendor/templates/project/claude/commands/forge.md +16 -0
- package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -0
- package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -0
- package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -0
- package/vendor/templates/project/claude/rules/forge.md +16 -0
- package/vendor/templates/project/codex/rules/forge.md +10 -0
- package/vendor/templates/project/cursor/commands/forge-apply.md +75 -0
- package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -0
- package/vendor/templates/project/cursor/commands/forge-build.md +17 -0
- package/vendor/templates/project/cursor/commands/forge-plan.md +15 -0
- package/vendor/templates/project/cursor/commands/forge-skip.md +14 -0
- package/vendor/templates/project/cursor/commands/forge-status.md +16 -0
- package/vendor/templates/project/cursor/commands/forge.md +16 -0
- package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -0
- package/vendor/templates/project/cursor/hooks/forge-session-start.sh +3 -0
- 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
|
+
}
|