@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.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +58 -0
- package/CHANGELOG.md +380 -0
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/agents/ping.md +8 -0
- package/agents/worker.md +8 -0
- package/bin/interlock +2655 -0
- package/bin/interlock-graph +6 -0
- package/bin/interlock-run +508 -0
- package/bin/interlock-ship-acp +39 -0
- package/hooks/_shared.mjs +162 -0
- package/hooks/guard-commit.mjs +77 -0
- package/hooks/guard-tasks.mjs +107 -0
- package/hooks/guard-tests.mjs +63 -0
- package/hooks/preflight.mjs +107 -0
- package/lib/artifacts.mjs +316 -0
- package/lib/autonomy.mjs +149 -0
- package/lib/conformance.mjs +132 -0
- package/lib/doctor.mjs +1176 -0
- package/lib/drift.mjs +409 -0
- package/lib/eval-history.mjs +304 -0
- package/lib/evals-calibrate.mjs +317 -0
- package/lib/evals-capture.mjs +437 -0
- package/lib/evals-promote.mjs +257 -0
- package/lib/evals-triage.mjs +216 -0
- package/lib/findings.mjs +340 -0
- package/lib/graph/cli.mjs +266 -0
- package/lib/graph/extract/config-json.mjs +74 -0
- package/lib/graph/extract/create-extractor.mjs +35 -0
- package/lib/graph/extract/memory.mjs +99 -0
- package/lib/graph/extract/openspec.mjs +80 -0
- package/lib/graph/extract/python.mjs +137 -0
- package/lib/graph/extract/regex-fallback.mjs +187 -0
- package/lib/graph/extract/shell.mjs +71 -0
- package/lib/graph/extract/typescript.mjs +217 -0
- package/lib/graph/extract/walk.mjs +272 -0
- package/lib/graph/format.mjs +51 -0
- package/lib/graph/graph/build.mjs +374 -0
- package/lib/graph/graph/query.mjs +249 -0
- package/lib/graph/graph/report.mjs +122 -0
- package/lib/graph/graph/update.mjs +47 -0
- package/lib/graph/paths.mjs +66 -0
- package/lib/graph/retrieve/context.mjs +86 -0
- package/lib/graph/retrieve/docs-index.mjs +116 -0
- package/lib/graph/retrieve/markdown.mjs +246 -0
- package/lib/host/acp.mjs +551 -0
- package/lib/host/claude-cli.mjs +285 -0
- package/lib/host/codex.mjs +231 -0
- package/lib/host/model-map.mjs +129 -0
- package/lib/host/qwen.mjs +176 -0
- package/lib/host/registry.mjs +213 -0
- package/lib/host.mjs +393 -0
- package/lib/ledger.mjs +444 -0
- package/lib/limits.mjs +525 -0
- package/lib/locators.mjs +99 -0
- package/lib/merge-lanes.mjs +148 -0
- package/lib/metrics.mjs +99 -0
- package/lib/notify.mjs +229 -0
- package/lib/outcomes.mjs +560 -0
- package/lib/plan-fingerprint.mjs +570 -0
- package/lib/project-slug.mjs +18 -0
- package/lib/prompts/commit.mjs +26 -0
- package/lib/prompts/dimensions.mjs +70 -0
- package/lib/prompts/handoff.mjs +86 -0
- package/lib/prompts/implementer.mjs +150 -0
- package/lib/prompts/index.mjs +250 -0
- package/lib/prompts/planner.mjs +70 -0
- package/lib/prompts/remediate.mjs +137 -0
- package/lib/prompts/replan.mjs +23 -0
- package/lib/prompts/review.mjs +107 -0
- package/lib/prompts/schemas.mjs +206 -0
- package/lib/prompts/stage.mjs +48 -0
- package/lib/prompts/verify.mjs +88 -0
- package/lib/ready.mjs +812 -0
- package/lib/receipt.mjs +434 -0
- package/lib/remediate.mjs +275 -0
- package/lib/report-html.mjs +623 -0
- package/lib/report.mjs +1124 -0
- package/lib/review-core.mjs +713 -0
- package/lib/risk.mjs +370 -0
- package/lib/run-log.mjs +1004 -0
- package/lib/run-paths.mjs +239 -0
- package/lib/run.mjs +2921 -0
- package/lib/ship-stage.mjs +223 -0
- package/lib/spill.mjs +150 -0
- package/lib/surface.mjs +195 -0
- package/lib/verify.mjs +1074 -0
- package/lib/waves.mjs +2661 -0
- package/package.json +40 -0
- package/shared/CONTEXT-HYGIENE.md +84 -0
- package/shared/DECISION-LEDGER.md +141 -0
- package/shared/EXPLORE-BRIEF.md +96 -0
- package/shared/INVARIANT-SWEEP.md +57 -0
- package/shared/SESSION-SHAPES.md +261 -0
- package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
- package/shared/TEST-PROFILE.md +204 -0
- package/shared/TOOL-ECONOMY.md +155 -0
- package/skills/bootstrap/SKILL.md +226 -0
- package/skills/commit/SKILL.md +99 -0
- package/skills/dispatch/SKILL.md +78 -0
- package/skills/docs-digest/SKILL.md +109 -0
- package/skills/evals/SKILL.md +160 -0
- package/skills/explain-code/SKILL.md +42 -0
- package/skills/explain-code/commit-teachin.md +136 -0
- package/skills/explain-code/module-walkthrough.md +358 -0
- package/skills/explore/SKILL.md +159 -0
- package/skills/fix-tests/SKILL.md +120 -0
- package/skills/graph/SKILL.md +136 -0
- package/skills/manual-test-plan/SKILL.md +121 -0
- package/skills/mr/SKILL.md +170 -0
- package/skills/report/SKILL.md +121 -0
- package/skills/review-artifacts/SKILL.md +127 -0
- package/skills/review-code/REVIEW.template.md +49 -0
- package/skills/review-code/SKILL.md +156 -0
- package/skills/review-code/dimensions/architecture.md +10 -0
- package/skills/review-code/dimensions/devops.md +12 -0
- package/skills/review-code/dimensions/language.md +12 -0
- package/skills/review-code/dimensions/qa.md +12 -0
- package/skills/review-code/dimensions/security.md +12 -0
- package/skills/review-code/dimensions/technical-lead.md +19 -0
- package/skills/ship/SKILL.md +67 -0
- package/skills/spec/SKILL.md +264 -0
- package/skills/spec/continuity.md +66 -0
- 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
|
+
}
|