rcf-lite 0.19.0 → 0.20.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 (38) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/bin/rcf.js +5 -0
  3. package/fixtures/canary-manifest.json +6 -6
  4. package/package.json +2 -2
  5. package/rcf/code-nodes/cn-098.json +13 -0
  6. package/rcf/code-nodes/cn-099.json +13 -0
  7. package/rcf/code-nodes/cn-100.json +13 -0
  8. package/rcf/code-nodes/cn-101.json +13 -0
  9. package/rcf/code-nodes/cn-102.json +13 -0
  10. package/rcf/code-nodes/cn-103.json +13 -0
  11. package/rcf/code-nodes/cn-104.json +13 -0
  12. package/rcf/code-nodes/cn-105.json +13 -0
  13. package/rcf/evals/eval-001.json +55 -0
  14. package/rcf/fbs/fbs-035.json +18 -0
  15. package/rcf/requirements/req-016.json +37 -0
  16. package/rcf/test-suites/ts-045.json +46 -0
  17. package/rcf/user-stories/us-1601.json +37 -0
  18. package/releases/releases.yaml +10 -1
  19. package/src/cli/create.js +14 -0
  20. package/src/cli/eval-coverage.js +221 -0
  21. package/src/cli/eval.js +43 -0
  22. package/src/cli/finalise.js +64 -0
  23. package/src/cli/help.js +4 -0
  24. package/src/core/store/ids.js +5 -1
  25. package/src/core/store/init.js +4 -0
  26. package/src/core/store/loader.js +4 -0
  27. package/src/core/store/validator.js +8 -1
  28. package/src/core/store/walker.js +71 -2
  29. package/src/core/store/writer.js +4 -0
  30. package/src/eval/judge.js +338 -0
  31. package/src/finalise/index.js +8 -0
  32. package/src/finalise/ingest.js +27 -0
  33. package/src/finalise/ship-without-eval.js +123 -0
  34. package/src/query/eval-coverage.js +162 -0
  35. package/src/verify/chain/index.js +67 -0
  36. package/src/verify/cli/run.js +15 -0
  37. package/src/verify/engine/index.js +10 -0
  38. package/src/verify/verdict/index.js +46 -0
@@ -0,0 +1,338 @@
1
+ // LLM-as-judge run path for an EVAL. rcf-eval-node spec 2026-09-04
2
+ // section 6: `judge.type: llmJudge` runs by spawning `claude` (or
3
+ // `codex`) on PATH under subscription auth, capturing structured JSON
4
+ // on stdout, validating against the declared response schema, and
5
+ // appending a runRecord[] entry on the EVAL document.
6
+ //
7
+ // Estate rule (banked): every model call goes through the `claude` or
8
+ // `codex` CLI. No API keys, no HTTP fetches into rcf-lite. The verify
9
+ // launcher demonstrates the shape today (spawn `claude` by bare name).
10
+ //
11
+ // This module is invocation-only: the spawn contract, stdout capture,
12
+ // schema validation, and runRecord append. Case orchestration
13
+ // (walking cases[], rolling up per-case scores, applying critical-must-
14
+ // pass) is intentionally minimal at v1 (spec: v1 ships one canonical
15
+ // invocation, not a full runner).
16
+
17
+ import { readFile } from 'node:fs/promises';
18
+ import { spawn } from 'node:child_process';
19
+
20
+ import Ajv2020 from 'ajv/dist/2020.js';
21
+ import addFormats from 'ajv-formats';
22
+
23
+ import { rcfError } from '#core/errors';
24
+
25
+ const RESPONSE_ENVELOPE_HINT = {
26
+ type: 'object',
27
+ required: ['perCriterion'],
28
+ properties: {
29
+ perCriterion: {
30
+ type: 'array',
31
+ items: {
32
+ type: 'object',
33
+ required: ['id', 'score'],
34
+ properties: {
35
+ id: { type: 'string' },
36
+ score: { type: 'number', minimum: 0, maximum: 1 },
37
+ rationale: { type: 'string' },
38
+ criticalPass: { type: 'boolean' },
39
+ },
40
+ },
41
+ },
42
+ aggregateScore: { type: 'number', minimum: 0, maximum: 1 },
43
+ },
44
+ };
45
+
46
+ /**
47
+ * @typedef {object} RunOneCaseArgs
48
+ * @property {object} evalDoc the EVAL document
49
+ * @property {object} caseDoc one entry from evalDoc.cases[]
50
+ * @property {string} projectRoot absolute project root
51
+ * @property {string} [systemPromptText] the resolved system prompt text
52
+ * @property {object} [responseSchema] the resolved response schema
53
+ * @property {function} [spawnImpl] seam for tests
54
+ * @property {number} [timeoutMs] default 60_000
55
+ */
56
+
57
+ /**
58
+ * Load the harness system prompt and response schema for an EVAL, from
59
+ * `judge.harness.systemPromptPath` and `judge.harness.responseSchemaPath`
60
+ * (paths relative to the project root). Returns a structured error
61
+ * when either cannot be read or the schema is not valid JSON.
62
+ *
63
+ * @param {object} args
64
+ * @param {object} args.evalDoc
65
+ * @param {string} args.projectRoot
66
+ * @returns {Promise<{ systemPromptText: string|null, responseSchema: object|null } | import('#core/errors').RcfError>}
67
+ */
68
+ export async function loadJudgeHarness({ evalDoc, projectRoot }) {
69
+ const harness = evalDoc?.judge?.harness;
70
+ if (!harness) {
71
+ return { systemPromptText: null, responseSchema: RESPONSE_ENVELOPE_HINT };
72
+ }
73
+ let systemPromptText = null;
74
+ if (typeof harness.systemPromptPath === 'string') {
75
+ try {
76
+ systemPromptText = await readFile(`${projectRoot}/${harness.systemPromptPath}`, 'utf8');
77
+ } catch (err) {
78
+ return rcfError({
79
+ kind: 'ioFailure',
80
+ message: `eval judge: could not read systemPromptPath ${harness.systemPromptPath}: ${err.message}`,
81
+ filePath: harness.systemPromptPath,
82
+ });
83
+ }
84
+ }
85
+ let responseSchema = null;
86
+ if (typeof harness.responseSchemaPath === 'string') {
87
+ try {
88
+ const raw = await readFile(`${projectRoot}/${harness.responseSchemaPath}`, 'utf8');
89
+ responseSchema = JSON.parse(raw);
90
+ } catch (err) {
91
+ return rcfError({
92
+ kind: 'parseFailure',
93
+ message: `eval judge: could not read/parse responseSchemaPath ${harness.responseSchemaPath}: ${err.message}`,
94
+ filePath: harness.responseSchemaPath,
95
+ });
96
+ }
97
+ }
98
+ return { systemPromptText, responseSchema: responseSchema ?? RESPONSE_ENVELOPE_HINT };
99
+ }
100
+
101
+ /**
102
+ * Run one case through the LLM judge. Spawns the invoker CLI (`claude`
103
+ * by default; `codex` for consumers on the codex leg) with the system
104
+ * prompt via `-p` and the case payload on stdin. Captures stdout,
105
+ * validates against the response schema, and returns the parsed graded
106
+ * result. Model-hint is passed via `--model` when supplied by the
107
+ * harness; otherwise the CLI's default model resolves.
108
+ *
109
+ * @param {RunOneCaseArgs} args
110
+ * @returns {Promise<{ graded: object, invoker: string, modelHintUsed: string|null, rawStdout: string } | import('#core/errors').RcfError>}
111
+ */
112
+ export async function runOneCase(args) {
113
+ const {
114
+ evalDoc, caseDoc,
115
+ systemPromptText = null,
116
+ responseSchema = RESPONSE_ENVELOPE_HINT,
117
+ spawnImpl = spawn,
118
+ timeoutMs = 60_000,
119
+ } = args;
120
+ const harness = evalDoc?.judge?.harness;
121
+ if (!harness) {
122
+ return rcfError({
123
+ kind: 'usage',
124
+ message: 'eval judge: judge.harness is required for llmJudge',
125
+ documentId: evalDoc?.id,
126
+ field: 'judge.harness',
127
+ });
128
+ }
129
+ const invoker = harness.invoker;
130
+ if (invoker !== 'claude' && invoker !== 'codex') {
131
+ return rcfError({
132
+ kind: 'usage',
133
+ message: `eval judge: unsupported invoker '${invoker}' (expected 'claude' or 'codex')`,
134
+ documentId: evalDoc?.id,
135
+ field: 'judge.harness.invoker',
136
+ });
137
+ }
138
+
139
+ const payload = {
140
+ caseId: caseDoc?.id ?? null,
141
+ input: caseDoc?.input ?? null,
142
+ ...(caseDoc?.expected !== undefined ? { expected: caseDoc.expected } : {}),
143
+ criteria: (evalDoc.criteria ?? []).map((c) => ({
144
+ id: c.id,
145
+ description: c.description,
146
+ critical: Boolean(c.critical),
147
+ })),
148
+ criteriaIds: caseDoc?.criteriaIds ?? [],
149
+ notes: caseDoc?.notes ?? '',
150
+ };
151
+ const stdinText = JSON.stringify(payload);
152
+ const argv = [];
153
+ if (systemPromptText) argv.push('-p', systemPromptText);
154
+ if (typeof harness.modelHint === 'string' && harness.modelHint.length > 0) {
155
+ argv.push('--model', harness.modelHint);
156
+ }
157
+ argv.push('--output-format', 'json');
158
+
159
+ const outcome = await spawnAndCollect(invoker, argv, stdinText, { spawnImpl, timeoutMs });
160
+ if ('kind' in outcome && outcome.kind === 'ioFailure') return outcome;
161
+
162
+ let graded;
163
+ try {
164
+ graded = JSON.parse(outcome.stdout.trim());
165
+ } catch (err) {
166
+ return rcfError({
167
+ kind: 'parseFailure',
168
+ message: `eval judge: invoker '${invoker}' stdout did not parse as JSON: ${err.message}`,
169
+ documentId: evalDoc.id,
170
+ });
171
+ }
172
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
173
+ addFormats(ajv);
174
+ const validate = ajv.compile(responseSchema);
175
+ if (!validate(graded)) {
176
+ const first = (validate.errors ?? [])[0];
177
+ return rcfError({
178
+ kind: 'validation',
179
+ message: `eval judge: response failed schema at ${first?.instancePath ?? '/'} ${first?.message ?? 'invalid'}`,
180
+ documentId: evalDoc.id,
181
+ });
182
+ }
183
+ return {
184
+ graded,
185
+ invoker,
186
+ modelHintUsed: harness.modelHint ?? null,
187
+ rawStdout: outcome.stdout,
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Roll up per-case graded outputs into a runRecord[] entry. Aggregate
193
+ * score is the mean of perCriterion.score across cases (weighted by
194
+ * criterion.weight when supplied). Critical failures are aggregated
195
+ * from any case's perCriterion entry with criticalPass=false on a
196
+ * criterion declared critical.
197
+ *
198
+ * @param {object} args
199
+ * @param {object} args.evalDoc
200
+ * @param {Array<{ caseId: string, graded: object, modelHintUsed: string|null }>} args.cases
201
+ * @param {string} args.runner
202
+ * @param {Date} [args.now]
203
+ * @returns {object} runRecord entry
204
+ */
205
+ export function composeRunRecord({ evalDoc, cases, runner, now = new Date() }) {
206
+ const criteriaById = new Map((evalDoc.criteria ?? []).map((c) => [c.id, c]));
207
+ const perCaseScores = [];
208
+ const criticalFailures = [];
209
+ let totalWeight = 0;
210
+ let weightedSum = 0;
211
+ for (const c of cases) {
212
+ const graded = c.graded ?? {};
213
+ const perCrit = Array.isArray(graded.perCriterion) ? graded.perCriterion : [];
214
+ let caseSum = 0;
215
+ let caseWeight = 0;
216
+ for (const entry of perCrit) {
217
+ const crit = criteriaById.get(entry.id);
218
+ const weight = typeof crit?.weight === 'number' && crit.weight > 0 ? crit.weight : 1;
219
+ const score = typeof entry.score === 'number' ? Math.max(0, Math.min(1, entry.score)) : 0;
220
+ caseWeight += weight;
221
+ caseSum += weight * score;
222
+ if (crit?.critical && entry.criticalPass === false) {
223
+ criticalFailures.push({ caseId: c.caseId, criterionId: entry.id });
224
+ }
225
+ }
226
+ const caseAggregate = caseWeight > 0 ? caseSum / caseWeight : 0;
227
+ // rcf-schemas 0.6.0 perCaseScore shape: { caseId, score } with
228
+ // additionalProperties false. Score is the weighted aggregate 0..1.
229
+ perCaseScores.push({ caseId: c.caseId, score: caseAggregate });
230
+ totalWeight += caseWeight;
231
+ weightedSum += caseSum;
232
+ }
233
+ const aggregateScore = totalWeight > 0 ? weightedSum / totalWeight : 0;
234
+ const passThreshold = evalDoc.passThreshold ?? {};
235
+ const criticalMustPass = passThreshold.criticalMustPass !== false;
236
+ const meetsAggregate = aggregateScore >= (typeof passThreshold.aggregateScore === 'number' ? passThreshold.aggregateScore : 0.85);
237
+ const criticalOk = !(criticalMustPass && criticalFailures.length > 0);
238
+ const verdict = meetsAggregate && criticalOk ? 'pass' : 'fail';
239
+ const modelPinned = cases.find((c) => c.modelHintUsed)?.modelHintUsed ?? null;
240
+ const record = {
241
+ runId: `${now.toISOString()}-${Math.random().toString(36).slice(2, 6)}`,
242
+ runAt: now.toISOString(),
243
+ runner,
244
+ aggregateScore,
245
+ criticalFailures,
246
+ perCaseScores,
247
+ verdict,
248
+ };
249
+ if (modelPinned) record.modelPinned = modelPinned;
250
+ return record;
251
+ }
252
+
253
+ /**
254
+ * Append a runRecord[] entry to an EVAL document.
255
+ *
256
+ * @param {object} args
257
+ * @param {object} args.evalDoc
258
+ * @param {object} args.runRecord
259
+ * @returns {object} updated evalDoc (new object; caller writes it)
260
+ */
261
+ export function appendRunRecord({ evalDoc, runRecord }) {
262
+ const runs = Array.isArray(evalDoc.runRecord) ? evalDoc.runRecord : [];
263
+ return {
264
+ ...evalDoc,
265
+ runRecord: [...runs, runRecord],
266
+ updatedAt: new Date().toISOString(),
267
+ };
268
+ }
269
+
270
+ /**
271
+ * Spawn the invoker and collect stdout / stderr. Returns
272
+ * `{ stdout, stderr, code }` on success, or a rcfError on failure.
273
+ *
274
+ * @param {string} invoker
275
+ * @param {string[]} argv
276
+ * @param {string} stdinText
277
+ * @param {{ spawnImpl?: function, timeoutMs?: number }} opts
278
+ * @returns {Promise<{ stdout: string, stderr: string, code: number|null } | import('#core/errors').RcfError>}
279
+ */
280
+ export function spawnAndCollect(invoker, argv, stdinText, { spawnImpl = spawn, timeoutMs = 60_000 } = {}) {
281
+ return new Promise((resolve) => {
282
+ let child;
283
+ try {
284
+ child = spawnImpl(invoker, argv, { stdio: ['pipe', 'pipe', 'pipe'] });
285
+ } catch (err) {
286
+ resolve(rcfError({
287
+ kind: 'ioFailure',
288
+ message: `eval judge: failed to spawn '${invoker}': ${err.message}`,
289
+ }));
290
+ return;
291
+ }
292
+ let stdout = '';
293
+ let stderr = '';
294
+ let done = false;
295
+ const timer = setTimeout(() => {
296
+ done = true;
297
+ try { child.kill('SIGTERM'); } catch { /* ignore */ }
298
+ resolve(rcfError({
299
+ kind: 'ioFailure',
300
+ message: `eval judge: invoker '${invoker}' timed out after ${timeoutMs}ms`,
301
+ }));
302
+ }, timeoutMs);
303
+ child.stdout.on('data', (chunk) => { stdout += String(chunk); });
304
+ child.stderr.on('data', (chunk) => { stderr += String(chunk); });
305
+ child.on('error', (err) => {
306
+ if (done) return;
307
+ done = true;
308
+ clearTimeout(timer);
309
+ resolve(rcfError({
310
+ kind: 'ioFailure',
311
+ message: `eval judge: invoker '${invoker}' error: ${err.message}`,
312
+ }));
313
+ });
314
+ child.on('close', (code) => {
315
+ if (done) return;
316
+ done = true;
317
+ clearTimeout(timer);
318
+ if (code !== 0) {
319
+ resolve(rcfError({
320
+ kind: 'ioFailure',
321
+ message: `eval judge: invoker '${invoker}' exited with code ${code}: ${stderr.slice(0, 400)}`,
322
+ }));
323
+ return;
324
+ }
325
+ resolve({ stdout, stderr, code });
326
+ });
327
+ try {
328
+ child.stdin.end(stdinText);
329
+ } catch (err) {
330
+ done = true;
331
+ clearTimeout(timer);
332
+ resolve(rcfError({
333
+ kind: 'ioFailure',
334
+ message: `eval judge: writing stdin to '${invoker}' failed: ${err.message}`,
335
+ }));
336
+ }
337
+ });
338
+ }
@@ -22,9 +22,17 @@ export {
22
22
  // 0.8.0 slug-train car 4: NV-BL-GATE-01 pull-in of the profile-vs-AC
23
23
  // scope-mismatch check into REVIEW.
24
24
  findScopeMismatchAcs, reportHasScopeMismatch,
25
+ // rcf-eval-node spec 2026-09-04 sections 5.2 + 8: EVAL-MISSING and
26
+ // EVAL-BELOW-THRESHOLD refusal on `rcf finalise`.
27
+ findEvalRefusalAcs, reportHasEvalRefusal,
25
28
  } from './ingest.js';
26
29
  export {
27
30
  composeShipWithoutVerifiedRecord,
28
31
  nextShipWithoutVerifiedId,
29
32
  writeShipWithoutVerifiedRecord,
30
33
  } from './ship-without-verified.js';
34
+ export {
35
+ composeShipWithoutEvalRecord,
36
+ nextShipWithoutEvalId,
37
+ writeShipWithoutEvalRecord,
38
+ } from './ship-without-eval.js';
@@ -158,3 +158,30 @@ export function findScopeMismatchAcs(report) {
158
158
  export function reportHasScopeMismatch(report) {
159
159
  return findScopeMismatchAcs(report).length > 0;
160
160
  }
161
+
162
+ /**
163
+ * rcf-eval-node spec 2026-09-04 sections 5.2 + 8: extract per-AC
164
+ * verdicts in {EVAL-MISSING, EVAL-BELOW-THRESHOLD} from a verify
165
+ * report. Both refuse `rcf finalise` promotion to `verified` unless
166
+ * the operator opts out via `--ship-without-eval "<reason>"`.
167
+ *
168
+ * @param {object} report
169
+ * @returns {Array<{ acId: string, verdict: string, reason?: string }>}
170
+ */
171
+ export function findEvalRefusalAcs(report) {
172
+ const perAc = Array.isArray(report?.perAcVerdicts) ? report.perAcVerdicts : [];
173
+ return perAc
174
+ .filter((e) => e && (e.verdict === 'EVAL-MISSING' || e.verdict === 'EVAL-BELOW-THRESHOLD'))
175
+ .map((e) => ({ acId: e.acId, verdict: e.verdict, reason: e.reason }));
176
+ }
177
+
178
+ /**
179
+ * True when a verify report carries at least one EVAL-MISSING or
180
+ * EVAL-BELOW-THRESHOLD verdict.
181
+ *
182
+ * @param {object} report
183
+ * @returns {boolean}
184
+ */
185
+ export function reportHasEvalRefusal(report) {
186
+ return findEvalRefusalAcs(report).length > 0;
187
+ }
@@ -0,0 +1,123 @@
1
+ // Ship-without-eval acknowledgement writer. Sister of
2
+ // ship-without-verified.js. rcf-eval-node spec 2026-09-04 sections 5.2
3
+ // and 8: `rcf finalise --ship-without-eval "<reason>"` acknowledges
4
+ // EVAL-MISSING / EVAL-BELOW-THRESHOLD per-AC verdicts on a verify
5
+ // report and lets finalise proceed with an audit-log entry.
6
+ //
7
+ // Landing shape mirrors ship-without-verified: an optional
8
+ // `shipWithoutEval[]` array on the manifest, with monotonic ids
9
+ // `swe-<fbsId>-<n>`, the operator's reason string, the declared AC
10
+ // verdicts, the report path, and an ISO timestamp. rcf-schemas 0.6.0
11
+ // does not yet declare this field on the manifest schema; consumers
12
+ // treat its absence as "no acks" and its presence as data.
13
+
14
+ import { mkdir, rename, unlink, writeFile } from 'node:fs/promises';
15
+ import { dirname, join } from 'node:path';
16
+
17
+ import { rcfError } from '#core/errors';
18
+
19
+ /**
20
+ * @typedef {object} ShipWithoutEvalDeclaredAc
21
+ * @property {string} acId
22
+ * @property {'EVAL-MISSING'|'EVAL-BELOW-THRESHOLD'} verdict
23
+ * @property {string} [reason] reason as reported by the verdict layer
24
+ */
25
+
26
+ /**
27
+ * @typedef {object} ShipWithoutEvalRecord
28
+ * @property {string} id `swe-<fbsId>-<n>` monotonic per FBS.
29
+ * @property {string} fbsId
30
+ * @property {string} ackedAt ISO timestamp.
31
+ * @property {string} reason operator-supplied reason (--ship-without-eval "…").
32
+ * @property {ShipWithoutEvalDeclaredAc[]} declaredAcs minItems 1.
33
+ * @property {string} reportPath
34
+ */
35
+
36
+ /**
37
+ * Monotonic id allocator for the shipWithoutEval array. Mirrors
38
+ * `nextShipWithoutVerifiedId` (ship-without-verified.js).
39
+ *
40
+ * @param {object|null} manifest
41
+ * @param {string} fbsId
42
+ * @returns {string}
43
+ */
44
+ export function nextShipWithoutEvalId(manifest, fbsId) {
45
+ const prefix = `swe-${fbsId}-`;
46
+ const existing = Array.isArray(manifest?.shipWithoutEval) ? manifest.shipWithoutEval : [];
47
+ let maxN = 0;
48
+ for (const rec of existing) {
49
+ if (typeof rec?.id !== 'string' || !rec.id.startsWith(prefix)) continue;
50
+ const n = Number.parseInt(rec.id.slice(prefix.length), 10);
51
+ if (Number.isFinite(n) && n > maxN) maxN = n;
52
+ }
53
+ return `${prefix}${maxN + 1}`;
54
+ }
55
+
56
+ /**
57
+ * Compose the ack record.
58
+ *
59
+ * @param {object} args
60
+ * @param {object|null} args.manifest
61
+ * @param {string} args.fbsId
62
+ * @param {string} args.reason
63
+ * @param {ShipWithoutEvalDeclaredAc[]} args.declaredAcs
64
+ * @param {string} args.reportPath
65
+ * @param {Date} [args.now]
66
+ * @returns {ShipWithoutEvalRecord}
67
+ */
68
+ export function composeShipWithoutEvalRecord({
69
+ manifest, fbsId, reason, declaredAcs, reportPath, now = new Date(),
70
+ }) {
71
+ return {
72
+ id: nextShipWithoutEvalId(manifest, fbsId),
73
+ fbsId,
74
+ ackedAt: now.toISOString(),
75
+ reason,
76
+ declaredAcs: declaredAcs.map((a) => {
77
+ const out = { acId: a.acId, verdict: a.verdict };
78
+ if (typeof a.reason === 'string' && a.reason.length > 0) out.reason = a.reason;
79
+ return out;
80
+ }),
81
+ reportPath,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Persist a ship-without-eval acknowledgement onto the manifest.
87
+ * Manifest write is atomic (tmp + rename). rcf-schemas 0.6.0's manifest
88
+ * schema does not declare this field, so the write path skips the
89
+ * validator (spec section 9: extensions land at a later minor).
90
+ *
91
+ * @param {object} args
92
+ * @param {string} args.projectRoot
93
+ * @param {object} args.tree
94
+ * @param {ShipWithoutEvalRecord} args.record
95
+ * @returns {Promise<{ record: ShipWithoutEvalRecord } | import('#core/errors').RcfError>}
96
+ */
97
+ export async function writeShipWithoutEvalRecord({ projectRoot, tree, record }) {
98
+ const manifest = tree.manifest ?? {};
99
+ const nextManifest = { ...manifest };
100
+ const existing = Array.isArray(nextManifest.shipWithoutEval) ? nextManifest.shipWithoutEval : [];
101
+ nextManifest.shipWithoutEval = [...existing, record];
102
+
103
+ const absPath = join(projectRoot, 'rcf', 'manifest.json');
104
+ try {
105
+ await mkdir(dirname(absPath), { recursive: true });
106
+ const tmp = `${absPath}.tmp`;
107
+ await writeFile(tmp, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf8');
108
+ try {
109
+ await rename(tmp, absPath);
110
+ } catch (err) {
111
+ try { await unlink(tmp); } catch { /* ignore */ }
112
+ throw err;
113
+ }
114
+ } catch (err) {
115
+ return rcfError({
116
+ kind: 'ioFailure',
117
+ message: `finalise: ship-without-eval manifest write failed: ${err.message}`,
118
+ filePath: 'rcf/manifest.json',
119
+ stack: err.stack,
120
+ });
121
+ }
122
+ return { record };
123
+ }
@@ -0,0 +1,162 @@
1
+ // Pure EVAL-coverage compute. Given a walker-produced TreeModel, walk
2
+ // the REQ chain (PRD -> REQ -> US -> AC) and report, per AC, whether
3
+ // its `determinism` classification requires an EVAL, whether an EVAL
4
+ // binds it, and whether that EVAL is "resolving" per the spec (a
5
+ // non-superseded EVAL whose most recent runRecord[] entry is not
6
+ // `pending`).
7
+ //
8
+ // spec: `projects/rcf-lite-wsd/specs/rcf-eval-node-spec-2026-09-04.md`
9
+ // sections 2.3 and 4. Determinism absence resolves to `deterministic`;
10
+ // deterministic ACs are never gated by this audit. `--strict` refuses
11
+ // only when a nonDeterministic AC lacks a resolving EVAL. Presence of
12
+ // an EVAL on a deterministic AC is reported as `covered-optional`,
13
+ // never as a defect.
14
+ //
15
+ // This module is pure and synchronous; the CLI layer walks the tree
16
+ // and formats.
17
+
18
+ /**
19
+ * @typedef {import('#core/store/walker.js').TreeModel} TreeModel
20
+ */
21
+
22
+ /**
23
+ * @typedef {'deterministic'|'nonDeterministic'} Determinism
24
+ */
25
+
26
+ /**
27
+ * @typedef {'resolving'|'pending'|'superseded'|'absent'} EvalStatus
28
+ */
29
+
30
+ /**
31
+ * @typedef {object} AcEvalStatus
32
+ * @property {string} acId
33
+ * @property {string} usId
34
+ * @property {Determinism} determinism - absence resolves to 'deterministic'
35
+ * @property {EvalStatus} evalStatus
36
+ * @property {string|null} evalId - the resolving EVAL, if any; else null
37
+ * @property {string[]} evalIds - all EVALs whose acIds[] name this AC
38
+ * @property {'covered'|'covered-optional'|'missing'|'not-required'} outcome
39
+ * - 'covered': nonDeterministic + resolving EVAL
40
+ * - 'covered-optional': deterministic + resolving EVAL (informational)
41
+ * - 'missing': nonDeterministic + no resolving EVAL
42
+ * - 'not-required': deterministic + no EVAL
43
+ */
44
+
45
+ /**
46
+ * @typedef {object} EvalCoverageReport
47
+ * @property {AcEvalStatus[]} acs - flat per-AC entries within scope
48
+ * @property {number} nonDeterministicCount
49
+ * @property {number} coveredCount - nonDeterministic with resolving EVAL
50
+ * @property {number} missingCount - nonDeterministic without resolving EVAL
51
+ * @property {boolean} ok - true when strict-gate passes: missingCount === 0
52
+ * @property {string|null} scopeId - null when tree-wide
53
+ */
54
+
55
+ /**
56
+ * Determine the resolving status for one EVAL doc.
57
+ *
58
+ * Per spec 2.3, a "resolving" EVAL is:
59
+ * - status is not 'superseded'
60
+ * - runRecord[] has at least one entry whose verdict is not 'pending'
61
+ *
62
+ * @param {object} evalDoc
63
+ * @returns {EvalStatus}
64
+ */
65
+ export function classifyEvalDoc(evalDoc) {
66
+ if (!evalDoc || typeof evalDoc !== 'object') return 'absent';
67
+ if (evalDoc.status === 'superseded') return 'superseded';
68
+ const records = Array.isArray(evalDoc.runRecord) ? evalDoc.runRecord : [];
69
+ if (records.length === 0) return 'pending';
70
+ // Latest record wins. `runAt` sorts lexicographically for ISO timestamps.
71
+ const sorted = [...records].sort((a, b) => (a?.runAt ?? '').localeCompare(b?.runAt ?? ''));
72
+ const latest = sorted[sorted.length - 1];
73
+ if (!latest || latest.verdict === 'pending') return 'pending';
74
+ return 'resolving';
75
+ }
76
+
77
+ /**
78
+ * @param {TreeModel} tree
79
+ * @param {object} [opts]
80
+ * @param {string|null} [opts.scopeId] - optional PRD / REQ / US id to scope
81
+ * @returns {EvalCoverageReport}
82
+ */
83
+ export function computeEvalCoverage(tree, { scopeId = null } = {}) {
84
+ const usIds = collectScopedUsIds(tree, scopeId);
85
+ const acs = [];
86
+ for (const usId of usIds) {
87
+ const us = tree.byId.get(usId);
88
+ if (!us) continue;
89
+ for (const ac of us.acceptanceCriteria ?? []) {
90
+ const determinism = ac.determinism === 'nonDeterministic'
91
+ ? 'nonDeterministic'
92
+ : 'deterministic';
93
+ const evalIds = tree.evalByAcId?.get(ac.id) ?? [];
94
+ let outcome;
95
+ let evalStatus;
96
+ let resolvingId = null;
97
+ if (evalIds.length === 0) {
98
+ evalStatus = 'absent';
99
+ outcome = determinism === 'nonDeterministic' ? 'missing' : 'not-required';
100
+ } else {
101
+ // Pick the best status across bound EVALs. `resolving` beats
102
+ // `pending` beats `superseded`.
103
+ let bestRank = -1;
104
+ for (const evalId of evalIds) {
105
+ const evalDoc = tree.byId.get(evalId);
106
+ const cls = classifyEvalDoc(evalDoc);
107
+ const rank = cls === 'resolving' ? 3 : cls === 'pending' ? 2 : cls === 'superseded' ? 1 : 0;
108
+ if (rank > bestRank) {
109
+ bestRank = rank;
110
+ evalStatus = cls;
111
+ if (cls === 'resolving') resolvingId = evalId;
112
+ }
113
+ }
114
+ if (evalStatus === 'resolving') {
115
+ outcome = determinism === 'nonDeterministic' ? 'covered' : 'covered-optional';
116
+ } else {
117
+ outcome = determinism === 'nonDeterministic' ? 'missing' : 'not-required';
118
+ }
119
+ }
120
+ acs.push({
121
+ acId: ac.id,
122
+ usId,
123
+ determinism,
124
+ evalStatus,
125
+ evalId: resolvingId,
126
+ evalIds,
127
+ outcome,
128
+ });
129
+ }
130
+ }
131
+ const nonDeterministicCount = acs.filter((a) => a.determinism === 'nonDeterministic').length;
132
+ const missingCount = acs.filter((a) => a.outcome === 'missing').length;
133
+ const coveredCount = nonDeterministicCount - missingCount;
134
+ return {
135
+ acs,
136
+ nonDeterministicCount,
137
+ coveredCount,
138
+ missingCount,
139
+ ok: missingCount === 0,
140
+ scopeId,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Resolve the scope positional to the US ids it covers.
146
+ *
147
+ * @param {TreeModel} tree
148
+ * @param {string|null} scopeId
149
+ * @returns {string[]} US ids sorted ascending
150
+ */
151
+ function collectScopedUsIds(tree, scopeId) {
152
+ const allUs = tree.userStories ?? [];
153
+ if (!scopeId) return allUs.map((u) => u.usId).sort();
154
+ const kind = tree.kindById.get(scopeId);
155
+ if (kind === 'userStory') return [scopeId];
156
+ if (kind === 'req') return allUs.filter((u) => u.reqId === scopeId).map((u) => u.usId).sort();
157
+ if (kind === 'prd') {
158
+ const reqIds = new Set((tree.requirements ?? []).filter((r) => r.prdId === scopeId).map((r) => r.reqId));
159
+ return allUs.filter((u) => reqIds.has(u.reqId)).map((u) => u.usId).sort();
160
+ }
161
+ return [];
162
+ }