@renzrollon/interlock 1.0.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 (125) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +58 -0
  3. package/CHANGELOG.md +380 -0
  4. package/LICENSE +21 -0
  5. package/README.md +358 -0
  6. package/agents/ping.md +8 -0
  7. package/agents/worker.md +8 -0
  8. package/bin/interlock +2655 -0
  9. package/bin/interlock-graph +6 -0
  10. package/bin/interlock-run +508 -0
  11. package/bin/interlock-ship-acp +39 -0
  12. package/hooks/_shared.mjs +162 -0
  13. package/hooks/guard-commit.mjs +77 -0
  14. package/hooks/guard-tasks.mjs +107 -0
  15. package/hooks/guard-tests.mjs +63 -0
  16. package/hooks/preflight.mjs +107 -0
  17. package/lib/artifacts.mjs +316 -0
  18. package/lib/autonomy.mjs +149 -0
  19. package/lib/conformance.mjs +132 -0
  20. package/lib/doctor.mjs +1176 -0
  21. package/lib/drift.mjs +409 -0
  22. package/lib/eval-history.mjs +304 -0
  23. package/lib/evals-calibrate.mjs +317 -0
  24. package/lib/evals-capture.mjs +437 -0
  25. package/lib/evals-promote.mjs +257 -0
  26. package/lib/evals-triage.mjs +216 -0
  27. package/lib/findings.mjs +340 -0
  28. package/lib/graph/cli.mjs +266 -0
  29. package/lib/graph/extract/config-json.mjs +74 -0
  30. package/lib/graph/extract/create-extractor.mjs +35 -0
  31. package/lib/graph/extract/memory.mjs +99 -0
  32. package/lib/graph/extract/openspec.mjs +80 -0
  33. package/lib/graph/extract/python.mjs +137 -0
  34. package/lib/graph/extract/regex-fallback.mjs +187 -0
  35. package/lib/graph/extract/shell.mjs +71 -0
  36. package/lib/graph/extract/typescript.mjs +217 -0
  37. package/lib/graph/extract/walk.mjs +272 -0
  38. package/lib/graph/format.mjs +51 -0
  39. package/lib/graph/graph/build.mjs +374 -0
  40. package/lib/graph/graph/query.mjs +249 -0
  41. package/lib/graph/graph/report.mjs +122 -0
  42. package/lib/graph/graph/update.mjs +47 -0
  43. package/lib/graph/paths.mjs +66 -0
  44. package/lib/graph/retrieve/context.mjs +86 -0
  45. package/lib/graph/retrieve/docs-index.mjs +116 -0
  46. package/lib/graph/retrieve/markdown.mjs +246 -0
  47. package/lib/host/acp.mjs +551 -0
  48. package/lib/host/claude-cli.mjs +285 -0
  49. package/lib/host/codex.mjs +231 -0
  50. package/lib/host/model-map.mjs +129 -0
  51. package/lib/host/qwen.mjs +176 -0
  52. package/lib/host/registry.mjs +213 -0
  53. package/lib/host.mjs +393 -0
  54. package/lib/ledger.mjs +444 -0
  55. package/lib/limits.mjs +525 -0
  56. package/lib/locators.mjs +99 -0
  57. package/lib/merge-lanes.mjs +148 -0
  58. package/lib/metrics.mjs +99 -0
  59. package/lib/notify.mjs +229 -0
  60. package/lib/outcomes.mjs +560 -0
  61. package/lib/plan-fingerprint.mjs +570 -0
  62. package/lib/project-slug.mjs +18 -0
  63. package/lib/prompts/commit.mjs +26 -0
  64. package/lib/prompts/dimensions.mjs +70 -0
  65. package/lib/prompts/handoff.mjs +86 -0
  66. package/lib/prompts/implementer.mjs +150 -0
  67. package/lib/prompts/index.mjs +250 -0
  68. package/lib/prompts/planner.mjs +70 -0
  69. package/lib/prompts/remediate.mjs +137 -0
  70. package/lib/prompts/replan.mjs +23 -0
  71. package/lib/prompts/review.mjs +107 -0
  72. package/lib/prompts/schemas.mjs +206 -0
  73. package/lib/prompts/stage.mjs +48 -0
  74. package/lib/prompts/verify.mjs +88 -0
  75. package/lib/ready.mjs +812 -0
  76. package/lib/receipt.mjs +434 -0
  77. package/lib/remediate.mjs +275 -0
  78. package/lib/report-html.mjs +623 -0
  79. package/lib/report.mjs +1124 -0
  80. package/lib/review-core.mjs +713 -0
  81. package/lib/risk.mjs +370 -0
  82. package/lib/run-log.mjs +1004 -0
  83. package/lib/run-paths.mjs +239 -0
  84. package/lib/run.mjs +2921 -0
  85. package/lib/ship-stage.mjs +223 -0
  86. package/lib/spill.mjs +150 -0
  87. package/lib/surface.mjs +195 -0
  88. package/lib/verify.mjs +1074 -0
  89. package/lib/waves.mjs +2661 -0
  90. package/package.json +40 -0
  91. package/shared/CONTEXT-HYGIENE.md +84 -0
  92. package/shared/DECISION-LEDGER.md +141 -0
  93. package/shared/EXPLORE-BRIEF.md +96 -0
  94. package/shared/INVARIANT-SWEEP.md +57 -0
  95. package/shared/SESSION-SHAPES.md +261 -0
  96. package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
  97. package/shared/TEST-PROFILE.md +204 -0
  98. package/shared/TOOL-ECONOMY.md +155 -0
  99. package/skills/bootstrap/SKILL.md +226 -0
  100. package/skills/commit/SKILL.md +99 -0
  101. package/skills/dispatch/SKILL.md +78 -0
  102. package/skills/docs-digest/SKILL.md +109 -0
  103. package/skills/evals/SKILL.md +160 -0
  104. package/skills/explain-code/SKILL.md +42 -0
  105. package/skills/explain-code/commit-teachin.md +136 -0
  106. package/skills/explain-code/module-walkthrough.md +358 -0
  107. package/skills/explore/SKILL.md +159 -0
  108. package/skills/fix-tests/SKILL.md +120 -0
  109. package/skills/graph/SKILL.md +136 -0
  110. package/skills/manual-test-plan/SKILL.md +121 -0
  111. package/skills/mr/SKILL.md +170 -0
  112. package/skills/report/SKILL.md +121 -0
  113. package/skills/review-artifacts/SKILL.md +127 -0
  114. package/skills/review-code/REVIEW.template.md +49 -0
  115. package/skills/review-code/SKILL.md +156 -0
  116. package/skills/review-code/dimensions/architecture.md +10 -0
  117. package/skills/review-code/dimensions/devops.md +12 -0
  118. package/skills/review-code/dimensions/language.md +12 -0
  119. package/skills/review-code/dimensions/qa.md +12 -0
  120. package/skills/review-code/dimensions/security.md +12 -0
  121. package/skills/review-code/dimensions/technical-lead.md +19 -0
  122. package/skills/ship/SKILL.md +67 -0
  123. package/skills/spec/SKILL.md +264 -0
  124. package/skills/spec/continuity.md +66 -0
  125. package/workflows/ship.js +565 -0
@@ -0,0 +1,437 @@
1
+ // Turning a recorded ship-run trajectory into an eval-case skeleton.
2
+ //
3
+ // The step this closes is the first arrow of the flywheel: production failure →
4
+ // regression case → outcome measurement. Today the only route from a consumer's
5
+ // misbehaving run to an Interlock regression case is a human pasting a
6
+ // transcript into a chat, which loses the one thing a case needs most — a
7
+ // citation a reader can follow back to the evidence.
8
+ //
9
+ // What this emits is a DRAFT, and every property below exists to keep it
10
+ // readable as one:
11
+ //
12
+ // 1. IT REFUSES A RUN IT CANNOT CITE. Reconstructability is `checkRunLog`,
13
+ // called here and reported verbatim. A second, laxer private notion ("has a
14
+ // run-start and any event") would let a skeleton cite an event range a
15
+ // reader following the citation could not replay, which destroys the only
16
+ // thing a captured skeleton has going for it.
17
+ //
18
+ // 2. IT QUOTES, IT NEVER INVENTS. The grader's pattern is a value read out of
19
+ // the cited events. Where no such value exists, the skeleton says so and
20
+ // carries a stated placeholder — it does not guess a pattern, and it never
21
+ // emits a judged grader, because nothing about a draft has been shown to
22
+ // need judgement.
23
+ //
24
+ // 3. EVERY DERIVED VALUE IS MARKED. The grader pattern and the prompt both
25
+ // carry an explicit confirmation marker, and `skills/evals/SKILL.md`
26
+ // forbids authoring a case that still carries one.
27
+ //
28
+ // 4. IT NEVER WRITES INTO THE SUITE. Adding a case is a human decision taken
29
+ // after reading the draft. A command that could write into `evals/` would
30
+ // make a draft indistinguishable from a reviewed case.
31
+ //
32
+ // The quotable surface is deliberately narrow, and the reason is worth stating:
33
+ // `lib/run-log.mjs` coerces every enum-typed field to `null` (or to `other`) at
34
+ // the write site, so a value outside `RUN_MODES`, `WAVE_SOURCES`,
35
+ // `AGENT_KINDS` or `VERIFY_CONTEXTS` never reaches the trajectory to be quoted
36
+ // from it. What survives is the free-text fields — which is why the two rules
37
+ // below read exactly those, and why the placeholder path is the common one.
38
+ //
39
+ // Exposed to skills as `interlock evals capture --run <id> [--task <id>] --out <dir>`.
40
+
41
+ import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs'
42
+ import { join, relative, resolve, sep } from 'node:path'
43
+ import { checkRunLog, readRunLog, RUN_LOG_DIR } from './run-log.mjs'
44
+ import {
45
+ REUSE_MATCH,
46
+ REUSE_MISMATCH,
47
+ REUSE_NO_PLAN,
48
+ REUSE_NO_FINGERPRINT,
49
+ REUSE_UNREADABLE_PLAN,
50
+ REUSE_UNREADABLE_FINGERPRINT,
51
+ REUSE_FORMAT_VERSION,
52
+ REUSE_ERROR
53
+ } from './plan-fingerprint.mjs'
54
+
55
+ /** The eval suite, relative to the repository root. Capture never writes here. */
56
+ export const EVAL_SUITE_DIR = 'evals'
57
+
58
+ /** The case schema version an emitted skeleton pins, matching the seeded cases. */
59
+ export const CASE_SCHEMA_VERSION = '1.0'
60
+
61
+ /** The marker every value capture derived rather than read carries. */
62
+ export const CONFIRM_MARKER = 'CONFIRM'
63
+
64
+ /** What a skeleton carries in place of a value the trajectory did not record. */
65
+ export const PLACEHOLDER = `${CONFIRM_MARKER}-REPLACE-ME`
66
+
67
+ /**
68
+ * Fields whose vocabulary is declared somewhere and whose value the trajectory
69
+ * stores as free text — the only place an out-of-set value can survive the
70
+ * writer's coercion and still be quotable. Each set is imported from the module
71
+ * that owns it, never restated here, so a vocabulary that grows does not leave a
72
+ * second stale copy behind.
73
+ */
74
+ const OUT_OF_SET_FIELDS = [
75
+ {
76
+ type: 'run-receipt',
77
+ field: 'planStatus',
78
+ allowed: new Set([
79
+ REUSE_MATCH,
80
+ REUSE_MISMATCH,
81
+ REUSE_NO_PLAN,
82
+ REUSE_NO_FINGERPRINT,
83
+ REUSE_UNREADABLE_PLAN,
84
+ REUSE_UNREADABLE_FINGERPRINT,
85
+ REUSE_FORMAT_VERSION,
86
+ REUSE_ERROR
87
+ ]),
88
+ why: 'the plan-reuse vocabulary lib/plan-fingerprint.mjs declares'
89
+ }
90
+ ]
91
+
92
+ /** Event types whose recorded reason names the offending value in double quotes. */
93
+ const QUOTING_REASON_TYPES = new Set(['run-halt', 'verify-judgement'])
94
+
95
+ function messageOf(err) {
96
+ return (err && err.message) || String(err)
97
+ }
98
+
99
+ function refuse(reason, problems = []) {
100
+ return { ok: false, reason, problems, files: [], notes: [] }
101
+ }
102
+
103
+ /** A directory name safe to hand a filesystem and readable as a case name. */
104
+ function safeName(value, fallback) {
105
+ const s = String(value || '')
106
+ .trim()
107
+ .toLowerCase()
108
+ .replace(/[^a-z0-9]+/g, '-')
109
+ .replace(/^-+|-+$/g, '')
110
+ .slice(0, 60)
111
+ return s || fallback
112
+ }
113
+
114
+ /** Escape for a YAML single-quoted scalar: the one escape YAML has is doubling. */
115
+ function yamlQuote(value) {
116
+ return `'${String(value).replace(/'/g, "''")}'`
117
+ }
118
+
119
+ /** Escape for a regex literal, so a quoted value matches itself and nothing else. */
120
+ function escapeRegex(value) {
121
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
122
+ }
123
+
124
+ /**
125
+ * The events the skeleton cites. With no task id that is the whole run; with
126
+ * one, the events carrying it — a narrower citation for a narrower claim.
127
+ *
128
+ * A task id the run never recorded is reported rather than silently widened:
129
+ * a citation that says "this task" while covering the whole run is exactly the
130
+ * kind of provenance a reader cannot check.
131
+ */
132
+ function citedEvents(records, taskId) {
133
+ if (!taskId) return { events: records, scope: 'the whole run', note: null }
134
+ const matching = records.filter(r => typeof r.taskId === 'string' && r.taskId === taskId)
135
+ if (!matching.length) {
136
+ return {
137
+ events: records,
138
+ scope: 'the whole run',
139
+ note: `no event in this trajectory carries taskId "${taskId}", so the citation covers the whole run`
140
+ }
141
+ }
142
+ return { events: matching, scope: `task ${taskId}`, note: null }
143
+ }
144
+
145
+ /**
146
+ * One value the run should not have produced, quoted out of the cited events.
147
+ *
148
+ * Two rules, both deterministic and both reading recorded text rather than
149
+ * inferring intent:
150
+ *
151
+ * - a field whose declared vocabulary does not contain its recorded value, and
152
+ * - a value the run itself named in double quotes while halting or judging,
153
+ * which is how this repository's halts report an offending value
154
+ * (`status "done" is not one of ok, blocked, partial`).
155
+ *
156
+ * Returns `null` when neither fires. That is not a failure — it is the common
157
+ * case, and the caller states it rather than inventing a pattern.
158
+ */
159
+ function quotableValue(events) {
160
+ for (const event of events) {
161
+ for (const rule of OUT_OF_SET_FIELDS) {
162
+ if (event.type !== rule.type) continue
163
+ const value = event[rule.field]
164
+ if (typeof value !== 'string' || !value.trim()) continue
165
+ if (rule.allowed.has(value)) continue
166
+ return {
167
+ value,
168
+ seq: event.seq,
169
+ source: `${event.type}.${rule.field}`,
170
+ why: `"${value}" is outside ${rule.why}`
171
+ }
172
+ }
173
+ }
174
+ for (const event of events) {
175
+ if (!QUOTING_REASON_TYPES.has(event.type)) continue
176
+ const reason = typeof event.reason === 'string' ? event.reason : ''
177
+ const quoted = /"([^"\n]{1,120})"/.exec(reason)
178
+ if (!quoted) continue
179
+ return {
180
+ value: quoted[1],
181
+ seq: event.seq,
182
+ source: `${event.type}.reason`,
183
+ why: `the run named "${quoted[1]}" while reporting: ${reason.slice(0, 200)}`
184
+ }
185
+ }
186
+ return null
187
+ }
188
+
189
+ /**
190
+ * The prompt snapshot the cited events recorded, if any.
191
+ *
192
+ * The trajectory records no prompt today — the writer's whitelist has no such
193
+ * field — so this reads what a host may have recorded and otherwise reports
194
+ * nothing. It never reconstructs a prompt from the events: a reconstructed
195
+ * prompt is not the prompt the model was given, and a case built on one tests
196
+ * something that never ran.
197
+ */
198
+ function recordedPrompt(events) {
199
+ for (const event of events) {
200
+ if (typeof event.prompt === 'string' && event.prompt.trim()) {
201
+ return { prompt: event.prompt, seq: event.seq }
202
+ }
203
+ }
204
+ return null
205
+ }
206
+
207
+ function indentBlock(text, indent = ' ') {
208
+ return String(text)
209
+ .split('\n')
210
+ .map(line => (line.trim() ? `${indent}${line}` : ''))
211
+ .join('\n')
212
+ }
213
+
214
+ /**
215
+ * Build an eval-case skeleton from a recorded run. Reads only; writes nothing.
216
+ *
217
+ * @param {string} root repository root
218
+ * @param {{runId?: string, taskId?: string}} opts
219
+ * @returns {{
220
+ * ok: boolean, reason: string|null, problems: string[],
221
+ * name?: string, provenance?: string, files?: Array<{path: string, contents: string}>,
222
+ * notes?: string[], quoted?: object|null, promptRecorded?: boolean
223
+ * }}
224
+ */
225
+ export function buildSkeleton(root, opts = {}) {
226
+ try {
227
+ const runId = typeof opts.runId === 'string' ? opts.runId.trim() : ''
228
+ const taskId = typeof opts.taskId === 'string' && opts.taskId.trim() ? opts.taskId.trim() : null
229
+ if (!runId) return refuse('no run identifier was given')
230
+
231
+ // The published decision, reused rather than re-derived. Its problems are
232
+ // reported verbatim: the caller learns why the run cannot be cited, not
233
+ // merely that it cannot.
234
+ const reconstructable = checkRunLog(root, runId)
235
+ if (!reconstructable.ok) {
236
+ return refuse(
237
+ `run ${runId} is not reconstructable, so nothing may cite it`,
238
+ reconstructable.problems
239
+ )
240
+ }
241
+
242
+ const read = readRunLog(root, runId)
243
+ const cited = citedEvents(read.records, taskId)
244
+ const seqs = cited.events.map(e => Number(e.seq)).filter(n => Number.isFinite(n))
245
+ const first = seqs.length ? Math.min(...seqs) : 1
246
+ const last = seqs.length ? Math.max(...seqs) : read.records.length
247
+
248
+ const locator = join(RUN_LOG_DIR, `${runId}.jsonl`)
249
+ const provenance = `${locator} events ${first}-${last} (${cited.scope})`
250
+
251
+ const notes = []
252
+ if (cited.note) notes.push(cited.note)
253
+
254
+ const quoted = quotableValue(cited.events)
255
+ if (!quoted) {
256
+ notes.push(
257
+ 'no out-of-set or fabricated value could be quoted from the cited events, so the grader ' +
258
+ `carries a stated placeholder — replace ${PLACEHOLDER} with the value this case should catch`
259
+ )
260
+ }
261
+
262
+ const prompt = recordedPrompt(cited.events)
263
+ if (!prompt) {
264
+ notes.push(
265
+ 'no prompt was recorded for the cited events, so the prompt is a stated placeholder — ' +
266
+ 'paste the briefing the run actually gave the agent, never a paraphrase'
267
+ )
268
+ }
269
+
270
+ const name = taskId
271
+ ? `captured-${safeName(runId, 'run')}-task-${safeName(taskId, 'task')}`
272
+ : `captured-${safeName(runId, 'run')}`
273
+
274
+ const pattern = quoted ? escapeRegex(quoted.value) : PLACEHOLDER
275
+
276
+ const caseYaml =
277
+ `# CAPTURED DRAFT — emitted by \`interlock evals capture\` from a recorded ship-run\n` +
278
+ `# trajectory. This is NOT a reviewed case. Every ${CONFIRM_MARKER} marker below is a value\n` +
279
+ `# capture derived rather than confirmed, and skills/evals/SKILL.md forbids authoring a\n` +
280
+ `# case that still carries one. Resolve each against the cited trajectory, then move the\n` +
281
+ `# directory into evals/ yourself — capture never writes there.\n` +
282
+ `#\n` +
283
+ `# Provenance: ${provenance}\n` +
284
+ notes.map(n => `# ${CONFIRM_MARKER}: ${n}\n`).join('') +
285
+ `schema_version: ${yamlQuote(CASE_SCHEMA_VERSION)}\n` +
286
+ `name: ${name}\n` +
287
+ // No `smoke` tag, deliberately: that tag runs a case on every pull
288
+ // request, and a pattern nobody has confirmed must not gate anyone's PR.
289
+ `tags: []\n` +
290
+ `provenance: ${yamlQuote(provenance)}\n` +
291
+ `runs: 1\n` +
292
+ `execution:\n` +
293
+ ` prompt: |\n` +
294
+ (prompt
295
+ ? indentBlock(prompt.prompt)
296
+ : indentBlock(
297
+ `${CONFIRM_MARKER}: no prompt was recorded for the cited events.\n` +
298
+ `Replace this block with the verbatim briefing the run gave the agent.\n` +
299
+ `Do not paraphrase it and do not reconstruct it from the trajectory.`
300
+ )) +
301
+ `\n max_turns: 8\n` +
302
+ ` timeout_seconds: 600\n`
303
+
304
+ const graderMd =
305
+ `---\n` +
306
+ `type: regex\n` +
307
+ `pattern: ${yamlQuote(pattern)}\n` +
308
+ `weight: 1\n` +
309
+ `---\n` +
310
+ `\n` +
311
+ `${CONFIRM_MARKER} BEFORE USE — this grader was captured, not authored.\n` +
312
+ `\n` +
313
+ (quoted
314
+ ? `The pattern is the value \`${quoted.value}\` quoted from ${quoted.source} at event ` +
315
+ `${quoted.seq} of ${provenance}. ${quoted.why}\n`
316
+ : `No out-of-set or fabricated value could be quoted from the cited events, so the ` +
317
+ `pattern is the placeholder \`${PLACEHOLDER}\`. Capture does not invent a pattern: ` +
318
+ `replace it with the value this case should catch, read from the cited trajectory.\n`) +
319
+ `\n` +
320
+ `Deterministic by construction. Capture never emits a judged grader — nothing about a ` +
321
+ `draft has been shown to need judgement, and a judged grader's run-to-run variance is ` +
322
+ `exactly what triage would (correctly) refuse to call a regression.\n`
323
+
324
+ return {
325
+ ok: true,
326
+ reason: null,
327
+ problems: [],
328
+ name,
329
+ provenance,
330
+ quoted,
331
+ promptRecorded: Boolean(prompt),
332
+ notes,
333
+ files: [
334
+ { path: 'case.yaml', contents: caseYaml },
335
+ { path: join('graders', 'observed-value.md'), contents: graderMd }
336
+ ]
337
+ }
338
+ } catch (err) {
339
+ // Never throws: a capture that crashes tells a caller nothing about their
340
+ // run, which is the one thing they came here for.
341
+ return refuse(`the skeleton could not be built: ${messageOf(err)}`)
342
+ }
343
+ }
344
+
345
+ /** Does `dir` resolve inside the eval suite at `root`? */
346
+ function insideSuite(root, dir) {
347
+ const suite = resolve(root, EVAL_SUITE_DIR)
348
+ const target = resolve(dir)
349
+ if (target === suite) return true
350
+ const rel = relative(suite, target)
351
+ return Boolean(rel) && !rel.startsWith('..') && !rel.startsWith(`..${sep}`)
352
+ }
353
+
354
+ /** Does `dir` already hold a case? */
355
+ function holdsCase(dir) {
356
+ if (!existsSync(dir)) return false
357
+ try {
358
+ if (existsSync(join(dir, 'case.yaml')) || existsSync(join(dir, 'prompt.md'))) return true
359
+ return readdirSync(dir).some(entry => entry === 'graders')
360
+ } catch {
361
+ // An unreadable directory is treated as occupied. Refusing costs the caller
362
+ // one command; overwriting costs them a case they may not have backed up.
363
+ return true
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Build a skeleton and write it into a directory the caller named.
369
+ *
370
+ * Refuses — writing nothing — when the run is not reconstructable, when the
371
+ * output directory resolves inside the eval suite, and when it already holds a
372
+ * case. Every refusal carries its reason; the CLI turns one into an exit code.
373
+ *
374
+ * @param {string} root repository root
375
+ * @param {{runId?: string, taskId?: string, outDir?: string}} opts
376
+ * @returns {{ok: boolean, reason: string|null, problems: string[], written: string[], notes: string[], dir: string|null, provenance: string|null}}
377
+ */
378
+ export function captureCase(root, opts = {}) {
379
+ const base = { written: [], dir: null, provenance: null }
380
+ try {
381
+ const outDir = typeof opts.outDir === 'string' && opts.outDir.trim() ? opts.outDir.trim() : ''
382
+ if (!outDir) return { ...refuse('no output directory was given'), ...base }
383
+
384
+ const target = resolve(root || '.', outDir)
385
+ if (insideSuite(root || '.', target)) {
386
+ return {
387
+ ...refuse(
388
+ `${outDir} resolves inside ${EVAL_SUITE_DIR}/, and capture never writes into the suite — ` +
389
+ 'adding a case is a decision taken after reading the draft'
390
+ ),
391
+ ...base
392
+ }
393
+ }
394
+ if (holdsCase(target)) {
395
+ return { ...refuse(`${outDir} already holds a case; capture refuses rather than overwrite one`), ...base }
396
+ }
397
+
398
+ // Built before anything is created, so a refused run leaves no directory
399
+ // behind either.
400
+ const skeleton = buildSkeleton(root, opts)
401
+ if (!skeleton.ok) return { ...skeleton, ...base }
402
+
403
+ const written = []
404
+ for (const file of skeleton.files) {
405
+ const path = join(target, file.path)
406
+ mkdirSync(join(path, '..'), { recursive: true })
407
+ writeFileSync(path, file.contents)
408
+ written.push(path)
409
+ }
410
+
411
+ return {
412
+ ok: true,
413
+ reason: null,
414
+ problems: [],
415
+ written,
416
+ dir: target,
417
+ provenance: skeleton.provenance,
418
+ notes: skeleton.notes
419
+ }
420
+ } catch (err) {
421
+ return { ...refuse(`the skeleton could not be written: ${messageOf(err)}`), ...base }
422
+ }
423
+ }
424
+
425
+ /** Human-readable rendering of `captureCase`, for the CLI. */
426
+ export function formatCapture(result) {
427
+ if (!result.ok) {
428
+ const lines = [`CAPTURE REFUSED — ${result.reason}`]
429
+ for (const p of result.problems || []) lines.push(` ${p}`)
430
+ return lines.join('\n') + '\n'
431
+ }
432
+ const lines = [`CAPTURED DRAFT — ${result.dir}`, ` provenance: ${result.provenance}`]
433
+ for (const path of result.written) lines.push(` wrote ${path}`)
434
+ for (const note of result.notes || []) lines.push(` ${CONFIRM_MARKER}: ${note}`)
435
+ lines.push(' This is a draft, not a case. Resolve every CONFIRM marker before authoring one.')
436
+ return lines.join('\n') + '\n'
437
+ }
@@ -0,0 +1,257 @@
1
+ // Eval promotion — decide whether a case has earned the right to fail a build.
2
+ //
3
+ // The eval job is advisory. Moving a case from advisory to blocking is the one
4
+ // decision in this suite with real consequences for everyone else's pull
5
+ // requests, and until now the rule that governed it was a sentence in a
6
+ // specification — "promotion requires observed run-to-run variance" — which is a
7
+ // sentence a reviewer re-argues once per proposal. This module is that rule with
8
+ // an exit code on it, which is the same argument `lib/evals-triage.mjs` makes one
9
+ // layer down: the one rule a model or a reviewer could re-argue per run is
10
+ // exactly the rule to put behind a command.
11
+ //
12
+ // It reads a history of prior runs and per-grader judge agreement, and nothing
13
+ // else: no model, no network, same inputs same verdict.
14
+ //
15
+ // The rule, whose numbers all come from EVAL_CAPS and appear nowhere in prose:
16
+ //
17
+ // - A case is promotable when it passed EVERY trial of EVERY qualifying run
18
+ // across the last `promotionRuns` qualifying runs. This is pass^k, not
19
+ // pass@k: eventual success is not success. A run must carry at least
20
+ // `promotionTrialsPerRun` trials of the case to qualify as evidence.
21
+ // - A run triage classified no-signal or configuration does not qualify. It is
22
+ // EXCLUDED and NAMED, and it does not break the chain — an aborted run is
23
+ // missing evidence, not evidence against.
24
+ // - A case with a judged grader additionally requires a measured agreement at
25
+ // or above `judgeAgreementFloor` for each of its judged graders. "No
26
+ // measurement" is refused distinctly from "insufficient history": the first
27
+ // is a fact about this case, the second is a fact about the whole window.
28
+ //
29
+ // Exposed as `interlock evals promote --history <dir>`.
30
+ //
31
+ // The verdict is the exit code:
32
+ // 0 promotable every case in the window may be promoted
33
+ // 1 refused at least one may not, each with its reason
34
+ // 2 insufficient the history cannot decide — NOT a refusal
35
+ // 2 is deliberately the same shape as triage's `no_signal`: "not enough
36
+ // evidence" must never read as "the evidence says no" (promotion spec, "The exit
37
+ // status is the promotion verdict").
38
+
39
+ import { EVAL_CAPS } from './limits.mjs'
40
+
41
+ /** Verdict → exit code. The exit code IS the verdict; nothing re-derives it. */
42
+ export const EXIT = { promotable: 0, refused: 1, insufficient_history: 2 }
43
+
44
+ /** Triage verdicts that make a run unusable as promotion evidence. */
45
+ const NON_QUALIFYING = new Set(['no_signal', 'no-signal', 'nosignal', 'configuration'])
46
+
47
+ function caseId(kase) {
48
+ return kase && typeof kase.id === 'string' && kase.id ? kase.id : '(unnamed)'
49
+ }
50
+
51
+ /** The judged graders a history record attributes to a case, as `<case>/<grader>`. */
52
+ function judgedGradersOf(kase) {
53
+ const id = caseId(kase)
54
+ const named = Array.isArray(kase && kase.judged_graders)
55
+ ? kase.judged_graders
56
+ : Array.isArray(kase && kase.judgedGraders)
57
+ ? kase.judgedGraders
58
+ : []
59
+ const ids = named.filter(g => typeof g === 'string' && g).map(g => (g.includes('/') ? g : `${id}/${g}`))
60
+ // A record may flag a case as judged without naming which grader. That is
61
+ // still enough to refuse it — the reason just cannot name the grader, and it
62
+ // says so rather than pretending the case is deterministic.
63
+ if (ids.length === 0 && (kase && (kase.judged === true))) return [`${id}/(unnamed judged grader)`]
64
+ return ids
65
+ }
66
+
67
+ /**
68
+ * Decide promotion over a history of prior runs.
69
+ *
70
+ * Pure: no fs, no network, no model.
71
+ *
72
+ * @param {{history?: Array, agreement?: Object}} input
73
+ * `history` — entries in chronological order, oldest first. Each is either
74
+ * `{ file, record }` for a parsed results/history file, or
75
+ * `{ file, error }` for one that could not be read or parsed.
76
+ * `agreement` — `<case>/<grader>` → measured agreement (0..1), from
77
+ * `interlock evals calibrate`. A grader absent from this map is
78
+ * unmeasured, which is not the same as measured at zero.
79
+ * @returns {{verdict, exitCode, reason, cases, excluded, unreadable, window}}
80
+ */
81
+ export function promote({ history = [], agreement = {} } = {}) {
82
+ const entries = Array.isArray(history) ? history : []
83
+
84
+ // An entry nobody could parse is named and stated as excluded. It is never
85
+ // treated as an absent one, because an unreadable run silently narrowing the
86
+ // window is exactly how a case gets promoted on less evidence than the rule
87
+ // requires (promotion spec, "An unreadable history entry is spoken").
88
+ const unreadable = entries
89
+ .filter(e => e && (e.error || !e.record))
90
+ .map(e => ({
91
+ file: (e && e.file) || '(unnamed)',
92
+ reason: (e && e.error) || 'no parsed record'
93
+ }))
94
+
95
+ const parsed = entries.filter(e => e && e.record && !e.error)
96
+
97
+ // Runs that cannot serve as evidence. Excluded and named; the chain steps over
98
+ // them rather than breaking on them.
99
+ const excluded = []
100
+ const qualifying = []
101
+ for (const entry of parsed) {
102
+ const verdict = String(
103
+ entry.record.triage || entry.record.verdict || entry.record.classification || ''
104
+ ).toLowerCase()
105
+ if (NON_QUALIFYING.has(verdict)) {
106
+ excluded.push({ file: entry.file, verdict: verdict || '(none)' })
107
+ continue
108
+ }
109
+ qualifying.push(entry)
110
+ }
111
+
112
+ const needRuns = EVAL_CAPS.promotionRuns
113
+ const needTrials = EVAL_CAPS.promotionTrialsPerRun
114
+ const floor = EVAL_CAPS.judgeAgreementFloor
115
+
116
+ // The window is the most recent `promotionRuns` qualifying runs.
117
+ const window = qualifying.slice(-needRuns)
118
+
119
+ const insufficient = (reason) => ({
120
+ verdict: 'insufficient_history',
121
+ exitCode: EXIT.insufficient_history,
122
+ reason,
123
+ cases: [],
124
+ excluded,
125
+ unreadable,
126
+ window: window.map(e => e.file)
127
+ })
128
+
129
+ if (parsed.length === 0) {
130
+ return insufficient(
131
+ entries.length === 0
132
+ ? 'the history directory holds no records'
133
+ : `no history entry could be parsed (${unreadable.length} unreadable)`
134
+ )
135
+ }
136
+ if (window.length < needRuns) {
137
+ return insufficient(
138
+ `the history holds ${window.length} qualifying run(s); the rule requires ${needRuns} ` +
139
+ `consecutive` + (excluded.length ? ` (${excluded.length} excluded as non-qualifying)` : '')
140
+ )
141
+ }
142
+
143
+ // Every case named anywhere in the window is in scope.
144
+ const scope = new Set()
145
+ for (const entry of window) {
146
+ for (const kase of Array.isArray(entry.record.cases) ? entry.record.cases : []) {
147
+ scope.add(caseId(kase))
148
+ }
149
+ }
150
+ if (scope.size === 0) {
151
+ return insufficient('the qualifying runs name no case; there is nothing to decide')
152
+ }
153
+
154
+ const cases = []
155
+ for (const id of [...scope].sort()) {
156
+ let refusal = null
157
+ const judged = new Set()
158
+
159
+ for (const entry of window) {
160
+ const kase = (Array.isArray(entry.record.cases) ? entry.record.cases : []).find(
161
+ c => caseId(c) === id
162
+ )
163
+ if (!kase) {
164
+ refusal = refusal || `absent from qualifying run ${entry.file}`
165
+ continue
166
+ }
167
+ for (const g of judgedGradersOf(kase)) judged.add(g)
168
+
169
+ const trials = Number.isFinite(kase.trials) ? kase.trials : Number(kase.runs)
170
+ const passed = Number.isFinite(kase.passed) ? kase.passed : Number(kase.passes)
171
+ if (!Number.isFinite(trials) || !Number.isFinite(passed)) {
172
+ refusal = refusal || `run ${entry.file} records no trial count for this case`
173
+ continue
174
+ }
175
+ if (trials < needTrials) {
176
+ refusal =
177
+ refusal ||
178
+ `run ${entry.file} carried ${trials} trial(s); a qualifying run needs ${needTrials}`
179
+ continue
180
+ }
181
+ // pass^k: every trial of every run. Not most, not eventually.
182
+ if (passed < trials) {
183
+ refusal = refusal || `failed ${trials - passed} of ${trials} trial(s) in run ${entry.file}`
184
+ }
185
+ }
186
+
187
+ // The judged rule applies on top of the run history, and its two failure
188
+ // modes are reported apart: a judge measured below the floor is a fact about
189
+ // the judge, a judge never measured is a fact about the calibration set.
190
+ const judgedGraders = [...judged].sort()
191
+ if (!refusal) {
192
+ for (const g of judgedGraders) {
193
+ const measured = agreement && Object.prototype.hasOwnProperty.call(agreement, g)
194
+ ? agreement[g]
195
+ : null
196
+ if (typeof measured !== 'number' || Number.isNaN(measured)) {
197
+ refusal = `judged grader ${g} has no measured agreement — run \`interlock evals calibrate\``
198
+ break
199
+ }
200
+ if (measured < floor) {
201
+ refusal = `judged grader ${g} agrees with the human label ${Math.round(measured * 100)}% of the time, below the published floor`
202
+ break
203
+ }
204
+ }
205
+ }
206
+
207
+ cases.push({
208
+ id,
209
+ promotable: !refusal,
210
+ reason: refusal,
211
+ judgedGraders
212
+ })
213
+ }
214
+
215
+ const refused = cases.filter(c => !c.promotable)
216
+ return {
217
+ verdict: refused.length ? 'refused' : 'promotable',
218
+ exitCode: refused.length ? EXIT.refused : EXIT.promotable,
219
+ reason: null,
220
+ cases,
221
+ excluded,
222
+ unreadable,
223
+ window: window.map(e => e.file)
224
+ }
225
+ }
226
+
227
+ /** Human-readable rendering. The exit code, not this text, is the verdict. */
228
+ export function formatPromotion(result) {
229
+ const lines = []
230
+ if (result.verdict === 'insufficient_history') {
231
+ lines.push(`insufficient history — ${result.reason}`)
232
+ lines.push('no case is reported promotable; this is not a refusal')
233
+ } else {
234
+ lines.push(
235
+ result.verdict === 'refused'
236
+ ? `not yet promotable — ${result.cases.filter(c => !c.promotable).length} case(s) refused`
237
+ : `promotable — every case in the window qualifies`
238
+ )
239
+ for (const c of result.cases) {
240
+ if (c.promotable) lines.push(` promotable ${c.id}`)
241
+ else lines.push(` refused ${c.id} — ${c.reason}`)
242
+ }
243
+ }
244
+
245
+ // Excluded and unreadable entries print in every verdict, including the clean
246
+ // ones: a window that quietly stepped over half its history is exactly the
247
+ // thing a reader needs to see before trusting a promotion.
248
+ for (const e of result.excluded) {
249
+ lines.push(` excluded ${e.file} — triage classified it "${e.verdict}"; it does not break the chain`)
250
+ }
251
+ for (const u of result.unreadable) {
252
+ lines.push(` unreadable ${u.file} — excluded from the decision (${u.reason})`)
253
+ }
254
+ if (result.window.length) lines.push(` window: ${result.window.join(', ')}`)
255
+ lines.push('the exit code is the verdict (0 promotable, 1 refused, 2 insufficient history)')
256
+ return lines.join('\n') + '\n'
257
+ }