@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
package/lib/outcomes.mjs
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
// The outcomes corpus (§F4, §F6.5) — one line per planning→ship attempt.
|
|
2
|
+
//
|
|
3
|
+
// `.claude/memory/` already records code gotchas for the next implementer. It
|
|
4
|
+
// cannot answer the only question continuity actually needs answered: *should
|
|
5
|
+
// we have skipped the human that time?* That question needs a record per
|
|
6
|
+
// attempt, in a fixed shape, written for both modes — the checkpoint runs are
|
|
7
|
+
// the control group, and a corpus of only the runs we already trusted would
|
|
8
|
+
// tell us nothing.
|
|
9
|
+
//
|
|
10
|
+
// Per §4.15(a) this release **records and nothing else**. There is no
|
|
11
|
+
// eligibility gating, no scorecard, no threshold: F6.6 is deliberately out of
|
|
12
|
+
// scope until real cold-start data exists. Anyone wiring a gate to this file
|
|
13
|
+
// is implementing a decision that has not been made.
|
|
14
|
+
//
|
|
15
|
+
// This is the second impure module in `lib/`, and it copies `lib/metrics.mjs`'s
|
|
16
|
+
// contract exactly, for the same reasons:
|
|
17
|
+
//
|
|
18
|
+
// 1. **It never throws.** Bookkeeping is a side effect of a run, never part
|
|
19
|
+
// of it. A read-only checkout or an unwritable `.claude/` degrades to a
|
|
20
|
+
// reported no-op — a run must not fail because the corpus could not grow.
|
|
21
|
+
// 2. **It writes a fixed shape, not whatever it was handed.** The payload is
|
|
22
|
+
// built key by key, so passing an entire readiness result (artifact text,
|
|
23
|
+
// finding bodies, evidence strings) cannot leak content into a file that
|
|
24
|
+
// will be read back by future agents.
|
|
25
|
+
//
|
|
26
|
+
// Append-only, one JSON object per line. The reader tolerates a torn final
|
|
27
|
+
// line: a log truncated by a crash must cost you that one record, not every
|
|
28
|
+
// record before it.
|
|
29
|
+
//
|
|
30
|
+
// Exposed to skills as `interlock outcome append|list`.
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
appendFileSync,
|
|
34
|
+
closeSync,
|
|
35
|
+
existsSync,
|
|
36
|
+
mkdirSync,
|
|
37
|
+
openSync,
|
|
38
|
+
readFileSync,
|
|
39
|
+
readSync,
|
|
40
|
+
statSync,
|
|
41
|
+
writeFileSync
|
|
42
|
+
} from 'node:fs'
|
|
43
|
+
import { join } from 'node:path'
|
|
44
|
+
|
|
45
|
+
/** Where the corpus lives, relative to the repo root. */
|
|
46
|
+
export const LEARNING_DIR = join('.claude', 'learning')
|
|
47
|
+
|
|
48
|
+
/** The append-only log itself. */
|
|
49
|
+
export const OUTCOMES_FILE = 'outcomes.jsonl'
|
|
50
|
+
|
|
51
|
+
/** Bump when the record shape changes, so old lines stay readable. */
|
|
52
|
+
export const OUTCOME_SCHEMA = 'interlock.outcome/2'
|
|
53
|
+
|
|
54
|
+
/** The two modes a change reaches ship by. Both are recorded (§F6.5). */
|
|
55
|
+
export const MODES = Object.freeze(['checkpoint', 'continue'])
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The observed group: values the orchestrator itself saw during the run, all
|
|
59
|
+
* derived from its receipt (`observedFromReceipt`).
|
|
60
|
+
*
|
|
61
|
+
* An agent may never supply one. `appendOutcome` rejects any of these names
|
|
62
|
+
* arriving in the reported group or in the legacy flat `ship` group, because a
|
|
63
|
+
* corpus that exists to assess a run must not be partly written by the run.
|
|
64
|
+
* This is the same stance `lib/ledger.mjs` takes on `agent_resolved`: the
|
|
65
|
+
* prompt is documentation, the writer is the mechanism.
|
|
66
|
+
*/
|
|
67
|
+
export const OBSERVED_FIELDS = Object.freeze([
|
|
68
|
+
'receipt',
|
|
69
|
+
'ok',
|
|
70
|
+
'haltReason',
|
|
71
|
+
'remediationRounds',
|
|
72
|
+
'codeBlockersSurviving',
|
|
73
|
+
'waves',
|
|
74
|
+
'commit'
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The reported group: values only an agent can supply, because nothing else in
|
|
79
|
+
* the run has seen them — they live in the wave state and in the suite result.
|
|
80
|
+
*
|
|
81
|
+
* These stay agent-sourced, and are labelled as reports rather than as
|
|
82
|
+
* measurements. Removing them would lose them; nothing else knows them.
|
|
83
|
+
*/
|
|
84
|
+
export const REPORTED_FIELDS = Object.freeze([
|
|
85
|
+
'unitGreen',
|
|
86
|
+
'skippedVerificationReasons',
|
|
87
|
+
'capExhaustedVerifications',
|
|
88
|
+
'unresolvedErrors'
|
|
89
|
+
])
|
|
90
|
+
|
|
91
|
+
// `halted` is not an OBSERVED_FIELDS entry — the record stores `ok` — but it is
|
|
92
|
+
// the name a receipt and an old flat payload both use for the same fact, so it
|
|
93
|
+
// is refused alongside them rather than passing through unnoticed.
|
|
94
|
+
const OBSERVED_ALIASES = Object.freeze(['halted', 'reviewBlockers'])
|
|
95
|
+
|
|
96
|
+
// How many wave tallies one record keeps. A run has a handful; the bound is
|
|
97
|
+
// here so a malformed input cannot make one line dominate the corpus.
|
|
98
|
+
const MAX_WAVES = 50
|
|
99
|
+
|
|
100
|
+
// How many skip reasons one record keeps, for the same reason.
|
|
101
|
+
const MAX_REASONS = 50
|
|
102
|
+
|
|
103
|
+
/** §F4's 0–5 self-assessments. Absent scores stay null rather than becoming 0. */
|
|
104
|
+
export const SCORE_KEYS = Object.freeze(['implementability', 'specFidelity', 'postShipChurn'])
|
|
105
|
+
|
|
106
|
+
const MAX_SCORE = 5
|
|
107
|
+
|
|
108
|
+
// Free text is the one place a human's words are kept. Bounded so one pasted
|
|
109
|
+
// stack trace cannot dominate the corpus.
|
|
110
|
+
const MAX_TEXT = 500
|
|
111
|
+
|
|
112
|
+
// A change name is a value here, never a path component, but it is still model-
|
|
113
|
+
// or branch-derived, so it is bounded and stripped of newlines that would break
|
|
114
|
+
// the one-record-per-line invariant.
|
|
115
|
+
const MAX_NAME = 120
|
|
116
|
+
|
|
117
|
+
function messageOf(err) {
|
|
118
|
+
return (err && err.message) || String(err)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function text(value, max = MAX_TEXT) {
|
|
122
|
+
if (typeof value !== 'string') return ''
|
|
123
|
+
const s = value.replace(/\s+/g, ' ').trim()
|
|
124
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function count(value) {
|
|
128
|
+
const n = Number(value)
|
|
129
|
+
if (!Number.isFinite(n) || n < 0) return 0
|
|
130
|
+
return Math.trunc(n)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Tri-state on purpose: `null` means "nobody said", which is different from
|
|
134
|
+
// "no". Coercing an unknown to false would invent data in a corpus whose whole
|
|
135
|
+
// job is to be honest about what happened.
|
|
136
|
+
function bool(value) {
|
|
137
|
+
return typeof value === 'boolean' ? value : null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The nullable trio, for the two provenance groups. `count()` and `text()`
|
|
141
|
+
// answer an absent value with 0 and '' — right for a top-level census field
|
|
142
|
+
// that is always known, wrong for a group whose whole point is to distinguish
|
|
143
|
+
// "the run measured zero" from "nobody ever found out". A corpus that read
|
|
144
|
+
// absence as cleanliness would flatter exactly the halted runs it exists to
|
|
145
|
+
// explain, and it holds the checkpoint runs that are meant to be a control
|
|
146
|
+
// group — a biased control group is worse than none, because it looks like
|
|
147
|
+
// evidence.
|
|
148
|
+
function nullableCount(value) {
|
|
149
|
+
if (value === null || value === undefined) return null
|
|
150
|
+
const n = Number(value)
|
|
151
|
+
if (!Number.isFinite(n) || n < 0) return null
|
|
152
|
+
return Math.trunc(n)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function nullableText(value, max = MAX_TEXT) {
|
|
156
|
+
const s = text(value, max)
|
|
157
|
+
return s ? s : null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function nullableStringArray(value, max = MAX_TEXT, cap = MAX_REASONS) {
|
|
161
|
+
if (!Array.isArray(value)) return null
|
|
162
|
+
return value
|
|
163
|
+
.map(item => text(item, max))
|
|
164
|
+
.filter(Boolean)
|
|
165
|
+
.slice(0, cap)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Built element by element, like every other nested group here: a wave object
|
|
169
|
+
// arrives from a receipt that was itself assembled from the orchestrator's
|
|
170
|
+
// whole summary, and passing it through would put failure lists and handoff
|
|
171
|
+
// bodies into a file future agents read back.
|
|
172
|
+
function waveTallies(value) {
|
|
173
|
+
if (!Array.isArray(value)) return null
|
|
174
|
+
return value.slice(0, MAX_WAVES).map(wave => {
|
|
175
|
+
const w = isObject(wave) ? wave : {}
|
|
176
|
+
return {
|
|
177
|
+
wave: nullableCount(w.wave),
|
|
178
|
+
ok: nullableCount(w.ok),
|
|
179
|
+
failed: nullableCount(w.failed),
|
|
180
|
+
notAttempted: nullableStringArray(w.notAttempted, MAX_NAME) || []
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function score(value) {
|
|
186
|
+
const n = Number(value)
|
|
187
|
+
if (!Number.isFinite(n)) return null
|
|
188
|
+
return Math.min(MAX_SCORE, Math.max(0, Math.round(n)))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isObject(value) {
|
|
192
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The observed group, derived from a run's `run-receipt` event.
|
|
197
|
+
*
|
|
198
|
+
* The single source of the observed half, so the receipt and the outcome line
|
|
199
|
+
* cannot disagree about one run. Assembling them separately from the same
|
|
200
|
+
* `summary` object would let them drift — a bug invisible until someone
|
|
201
|
+
* compared the two, and unresolvable afterwards because nothing would say which
|
|
202
|
+
* was right.
|
|
203
|
+
*
|
|
204
|
+
* A run that died before writing a receipt gets `receipt: false` and nulls
|
|
205
|
+
* throughout. It deliberately does NOT fall back to asking an agent: falling
|
|
206
|
+
* back to the untrusted source exactly when the trusted one is missing would
|
|
207
|
+
* defeat the partition on the runs most likely to need it. "No receipt" is an
|
|
208
|
+
* honest and still-useful record.
|
|
209
|
+
*
|
|
210
|
+
* @param {object|null} receipt a `run-receipt` event, as `readRunLog` returns it
|
|
211
|
+
* @returns {{receipt: boolean, ok: boolean|null, haltReason: string|null,
|
|
212
|
+
* remediationRounds: number|null, codeBlockersSurviving: number|null,
|
|
213
|
+
* waves: Array<object>|null, commit: string|null}}
|
|
214
|
+
*/
|
|
215
|
+
export function observedFromReceipt(receipt) {
|
|
216
|
+
if (!isObject(receipt)) {
|
|
217
|
+
return {
|
|
218
|
+
receipt: false,
|
|
219
|
+
ok: null,
|
|
220
|
+
haltReason: null,
|
|
221
|
+
remediationRounds: null,
|
|
222
|
+
codeBlockersSurviving: null,
|
|
223
|
+
waves: null,
|
|
224
|
+
commit: null
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const halted = typeof receipt.halted === 'boolean' ? receipt.halted : null
|
|
228
|
+
return {
|
|
229
|
+
receipt: true,
|
|
230
|
+
// Tri-state, and not the same fact as `halted`: a receipt that never
|
|
231
|
+
// observed whether the run halted reports unknown, not "it was fine".
|
|
232
|
+
ok: halted === null ? null : !halted,
|
|
233
|
+
haltReason: nullableText(receipt.haltReason),
|
|
234
|
+
remediationRounds: nullableCount(receipt.remediationRounds),
|
|
235
|
+
// The receipt counts blockers and warnings separately; only the blocker
|
|
236
|
+
// count belongs here, and an unobserved review stays unobserved rather
|
|
237
|
+
// than becoming zero blockers.
|
|
238
|
+
codeBlockersSurviving: nullableCount(receipt.reviewBlockers),
|
|
239
|
+
waves: waveTallies(receipt.waves),
|
|
240
|
+
commit: nullableText(receipt.commit, MAX_NAME)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Absolute-from-root path of the corpus. */
|
|
245
|
+
export function outcomesPath(root) {
|
|
246
|
+
return join(root, LEARNING_DIR, OUTCOMES_FILE)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Written once, never clobbered: a human may well have added notes to it, and
|
|
250
|
+
// this module is not important enough to overwrite someone's file.
|
|
251
|
+
const README = `# Learning corpus
|
|
252
|
+
|
|
253
|
+
One line per planning→ship attempt, in \`${OUTCOMES_FILE}\` — append-only JSON Lines.
|
|
254
|
+
|
|
255
|
+
## What it is for
|
|
256
|
+
|
|
257
|
+
Interlock's default flow stops at a human checkpoint between \`spec\` and \`ship\`.
|
|
258
|
+
The opt-in continuity path can skip that checkpoint. This corpus exists to
|
|
259
|
+
answer, later and from evidence rather than from memory, whether skipping it
|
|
260
|
+
was a good idea: each record pairs what the gate believed before the run
|
|
261
|
+
(readiness, risk class, decisions needing a human, artifact-review counts) with
|
|
262
|
+
what actually happened afterwards (ship outcome, remediation rounds, whether a
|
|
263
|
+
human had to step in).
|
|
264
|
+
|
|
265
|
+
Records are written for **both** modes. The checkpoint runs are the control
|
|
266
|
+
group — a corpus containing only the runs we already trusted would prove
|
|
267
|
+
nothing.
|
|
268
|
+
|
|
269
|
+
## What currently reads it
|
|
270
|
+
|
|
271
|
+
**Nothing gates on this file.** Per decision §4.15(a) this release records and
|
|
272
|
+
nothing more: no eligibility rules, no failure-streak threshold, no scorecard.
|
|
273
|
+
Continuity is gated solely by \`interlock ready\` (structural checks + risk class)
|
|
274
|
+
and by the user passing the flag.
|
|
275
|
+
|
|
276
|
+
If you are about to wire a gate to these records, that is decision §4.16 / slice
|
|
277
|
+
F6.6, and it has not been made.
|
|
278
|
+
|
|
279
|
+
## Contents of a record
|
|
280
|
+
|
|
281
|
+
\`schema\`, \`ts\`, \`change\`, \`mode\`, \`riskClass\`, \`ready\`, \`decisionsHuman\`,
|
|
282
|
+
\`reviewArtifacts\`, \`observed\`, \`reported\`, \`human\`, \`scores\`, \`feedback\`.
|
|
283
|
+
|
|
284
|
+
Counts, classes and short free-text notes only. No artifact text, no finding
|
|
285
|
+
bodies, no diffs, no transcripts — the payload is assembled field by field so
|
|
286
|
+
that handing the writer a fat object cannot leak content in here.
|
|
287
|
+
|
|
288
|
+
## Provenance: \`observed\` versus \`reported\`
|
|
289
|
+
|
|
290
|
+
The two groups are never merged, so you can tell per value where it came from
|
|
291
|
+
without reading the code that wrote it.
|
|
292
|
+
|
|
293
|
+
- **\`observed\`** — what the orchestrator itself saw: \`ok\` (did the run halt),
|
|
294
|
+
\`haltReason\`, \`remediationRounds\`, \`codeBlockersSurviving\`, \`waves\`,
|
|
295
|
+
\`commit\`. Derived from the run's \`run-receipt\` event and from nowhere else,
|
|
296
|
+
so the receipt and this line cannot disagree about one run. No agent can
|
|
297
|
+
supply one: \`interlock outcomes append\` refuses an \`observed\` group in the
|
|
298
|
+
record file, and the writer refuses an observed field smuggled into
|
|
299
|
+
\`reported\`. \`receipt: false\` means the run died before writing a receipt —
|
|
300
|
+
the rest of the group then reads \`null\`, which is *not observed*, not clean.
|
|
301
|
+
- **\`reported\`** — what an agent said, because nothing else in the run has seen
|
|
302
|
+
it: \`unitGreen\`, \`skippedVerificationReasons\`, \`capExhaustedVerifications\`,
|
|
303
|
+
\`unresolvedErrors\`. These are reports, not measurements. \`null\` means nobody
|
|
304
|
+
said — never zero skips and never a green suite.
|
|
305
|
+
|
|
306
|
+
Absence stays absence in both groups. This corpus holds \`checkpoint\` runs as a
|
|
307
|
+
control group against the \`continue\` runs; defaulting an unknown to a clean
|
|
308
|
+
value would bias exactly that group, and a biased control group is worse than
|
|
309
|
+
none because it still looks like evidence.
|
|
310
|
+
|
|
311
|
+
## Older records
|
|
312
|
+
|
|
313
|
+
Lines written before \`${OUTCOME_SCHEMA}\` carry a flat \`ship\` group instead of
|
|
314
|
+
the two above. Branch on \`schema\` rather than sniffing for keys. The log is
|
|
315
|
+
append-only and is never rewritten in place — that is the one operation its
|
|
316
|
+
design forbids.
|
|
317
|
+
|
|
318
|
+
This file is written once and never overwritten, so a repository whose corpus
|
|
319
|
+
predates the partition still has the older description here. That is
|
|
320
|
+
deliberate: the file may have been hand-edited, and the records themselves are
|
|
321
|
+
self-describing through \`schema\`.
|
|
322
|
+
|
|
323
|
+
## Safe to delete
|
|
324
|
+
|
|
325
|
+
Deleting this directory loses history and breaks nothing.
|
|
326
|
+
`
|
|
327
|
+
|
|
328
|
+
function ensureReadme(dir) {
|
|
329
|
+
const path = join(dir, 'README.md')
|
|
330
|
+
if (existsSync(path)) return
|
|
331
|
+
writeFileSync(path, README)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// If a previous append was torn mid-line (crash, full disk), a naive append
|
|
335
|
+
// would splice the new record onto the broken one and destroy two records
|
|
336
|
+
// instead of one. Reading the final byte is cheap and makes the log
|
|
337
|
+
// self-healing at the seam.
|
|
338
|
+
function endsWithNewline(file) {
|
|
339
|
+
const size = statSync(file).size
|
|
340
|
+
if (size === 0) return true
|
|
341
|
+
const fd = openSync(file, 'r')
|
|
342
|
+
try {
|
|
343
|
+
const buf = Buffer.alloc(1)
|
|
344
|
+
readSync(fd, buf, 0, 1, size - 1)
|
|
345
|
+
return buf[0] === 0x0a
|
|
346
|
+
} finally {
|
|
347
|
+
closeSync(fd)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Append one outcome record to `.claude/learning/outcomes.jsonl`.
|
|
353
|
+
*
|
|
354
|
+
* Every field is copied out by name; anything else on `input` is ignored, so a
|
|
355
|
+
* caller may pass a whole readiness or review result without leaking its text.
|
|
356
|
+
*
|
|
357
|
+
* The run's own values travel in `observed` and are only ever built by
|
|
358
|
+
* `observedFromReceipt`. Anything an agent contributes travels in `reported`.
|
|
359
|
+
* An observed field arriving in `reported` — or in the legacy flat `ship`
|
|
360
|
+
* group — is refused by name and the record is written with the run's own
|
|
361
|
+
* value anyway: a partly-refused payload still yields a correct line, and the
|
|
362
|
+
* refusal is reported rather than silently dropped.
|
|
363
|
+
*
|
|
364
|
+
* @param {string} root repo root; `.claude/learning` is created beneath it
|
|
365
|
+
* @param {{
|
|
366
|
+
* change?: string,
|
|
367
|
+
* mode?: 'checkpoint'|'continue',
|
|
368
|
+
* riskClass?: string|null,
|
|
369
|
+
* ready?: boolean,
|
|
370
|
+
* decisionsHuman?: number,
|
|
371
|
+
* reviewArtifacts?: {blockers?: number, warnings?: number},
|
|
372
|
+
* observed?: ReturnType<typeof observedFromReceipt>,
|
|
373
|
+
* reported?: {unitGreen?: boolean, skippedVerificationReasons?: string[],
|
|
374
|
+
* capExhaustedVerifications?: number, unresolvedErrors?: number},
|
|
375
|
+
* ship?: {unitGreen?: boolean},
|
|
376
|
+
* human?: {intervened?: boolean, wouldRejectSpec?: boolean, notes?: string},
|
|
377
|
+
* scores?: {implementability?: number, specFidelity?: number, postShipChurn?: number},
|
|
378
|
+
* feedback?: string,
|
|
379
|
+
* now?: Date|string
|
|
380
|
+
* }} [input]
|
|
381
|
+
* @returns {{written: boolean, path: string|null, reason: string|null}}
|
|
382
|
+
* `reason` explains a `written: false`, or names the fields a `written: true`
|
|
383
|
+
* refused — it is never an exception
|
|
384
|
+
*/
|
|
385
|
+
export function appendOutcome(root, input = {}) {
|
|
386
|
+
try {
|
|
387
|
+
if (typeof root !== 'string' || !root.trim()) {
|
|
388
|
+
return { written: false, path: null, reason: 'no root directory given' }
|
|
389
|
+
}
|
|
390
|
+
if (!existsSync(root)) {
|
|
391
|
+
return { written: false, path: null, reason: `root does not exist: ${root}` }
|
|
392
|
+
}
|
|
393
|
+
const source = isObject(input) ? input : {}
|
|
394
|
+
|
|
395
|
+
const mode = typeof source.mode === 'string' ? source.mode.trim() : ''
|
|
396
|
+
if (!MODES.includes(mode)) {
|
|
397
|
+
// Not defaulted: a record whose mode we guessed is worse than no record,
|
|
398
|
+
// because the whole corpus is a comparison between the two modes.
|
|
399
|
+
return { written: false, path: null, reason: `mode must be one of ${MODES.join(', ')}` }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const when = source.now instanceof Date ? source.now : new Date(source.now || Date.now())
|
|
403
|
+
const iso = Number.isNaN(when.getTime()) ? new Date().toISOString() : when.toISOString()
|
|
404
|
+
|
|
405
|
+
const review = isObject(source.reviewArtifacts) ? source.reviewArtifacts : {}
|
|
406
|
+
const observed = isObject(source.observed) ? source.observed : {}
|
|
407
|
+
const reported = isObject(source.reported) ? source.reported : {}
|
|
408
|
+
// `ship` is the pre-partition group. It survives as an alias for the one
|
|
409
|
+
// value in it that was always legitimately agent-sourced; the three that
|
|
410
|
+
// were not are refused below by the same rule that refuses them in
|
|
411
|
+
// `reported`, so a caller left on the old shape fails loudly rather than
|
|
412
|
+
// writing an observation it never made.
|
|
413
|
+
const ship = isObject(source.ship) ? source.ship : {}
|
|
414
|
+
const human = isObject(source.human) ? source.human : {}
|
|
415
|
+
const scores = isObject(source.scores) ? source.scores : {}
|
|
416
|
+
|
|
417
|
+
// Enforcement lives here, not in the prompt that assembles the payload.
|
|
418
|
+
// The prompt is documentation and one sentence in it already lost once;
|
|
419
|
+
// this is the mechanism, and it holds whatever a future prompt says.
|
|
420
|
+
const refused = []
|
|
421
|
+
for (const key of [...OBSERVED_FIELDS, ...OBSERVED_ALIASES]) {
|
|
422
|
+
if (Object.prototype.hasOwnProperty.call(reported, key)) refused.push(`reported.${key}`)
|
|
423
|
+
if (Object.prototype.hasOwnProperty.call(ship, key)) refused.push(`ship.${key}`)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Built key by key: see the module header. Nothing not named here is
|
|
427
|
+
// written, whatever the caller passed.
|
|
428
|
+
const payload = {
|
|
429
|
+
schema: OUTCOME_SCHEMA,
|
|
430
|
+
ts: iso,
|
|
431
|
+
change: text(source.change, MAX_NAME) || 'unnamed',
|
|
432
|
+
mode,
|
|
433
|
+
riskClass: typeof source.riskClass === 'string' ? text(source.riskClass, 32) : null,
|
|
434
|
+
// Fail closed in the record too: anything that is not literally `true`
|
|
435
|
+
// was not a ready run.
|
|
436
|
+
ready: source.ready === true,
|
|
437
|
+
decisionsHuman: count(source.decisionsHuman),
|
|
438
|
+
reviewArtifacts: {
|
|
439
|
+
blockers: count(review.blockers),
|
|
440
|
+
warnings: count(review.warnings)
|
|
441
|
+
},
|
|
442
|
+
// What the orchestrator saw, from its receipt and from nowhere else.
|
|
443
|
+
observed: {
|
|
444
|
+
receipt: observed.receipt === true,
|
|
445
|
+
ok: bool(observed.ok),
|
|
446
|
+
haltReason: nullableText(observed.haltReason),
|
|
447
|
+
remediationRounds: nullableCount(observed.remediationRounds),
|
|
448
|
+
codeBlockersSurviving: nullableCount(observed.codeBlockersSurviving),
|
|
449
|
+
waves: waveTallies(observed.waves),
|
|
450
|
+
commit: nullableText(observed.commit, MAX_NAME)
|
|
451
|
+
},
|
|
452
|
+
// What an agent said, labelled as a report. `null` throughout means
|
|
453
|
+
// nobody said — never zero skips, never a green suite.
|
|
454
|
+
reported: {
|
|
455
|
+
unitGreen: bool('unitGreen' in reported ? reported.unitGreen : ship.unitGreen),
|
|
456
|
+
skippedVerificationReasons: nullableStringArray(reported.skippedVerificationReasons),
|
|
457
|
+
capExhaustedVerifications: nullableCount(reported.capExhaustedVerifications),
|
|
458
|
+
unresolvedErrors: nullableCount(reported.unresolvedErrors)
|
|
459
|
+
},
|
|
460
|
+
human: {
|
|
461
|
+
intervened: bool(human.intervened),
|
|
462
|
+
wouldRejectSpec: bool(human.wouldRejectSpec),
|
|
463
|
+
notes: text(human.notes)
|
|
464
|
+
},
|
|
465
|
+
scores: {},
|
|
466
|
+
feedback: text(source.feedback)
|
|
467
|
+
}
|
|
468
|
+
for (const key of SCORE_KEYS) payload.scores[key] = score(scores[key])
|
|
469
|
+
|
|
470
|
+
const dir = join(root, LEARNING_DIR)
|
|
471
|
+
mkdirSync(dir, { recursive: true })
|
|
472
|
+
ensureReadme(dir)
|
|
473
|
+
|
|
474
|
+
const file = join(dir, OUTCOMES_FILE)
|
|
475
|
+
const prefix = existsSync(file) && !endsWithNewline(file) ? '\n' : ''
|
|
476
|
+
// JSON.stringify never emits a raw newline, so one record is always one line.
|
|
477
|
+
appendFileSync(file, `${prefix}${JSON.stringify(payload)}\n`)
|
|
478
|
+
// A refusal is reported, not swallowed: a closing step whose payload was
|
|
479
|
+
// partly refused should be able to say so. It is not fatal either — the
|
|
480
|
+
// line is written with the run's own values, and losing a corpus line must
|
|
481
|
+
// never fail the run that produced it.
|
|
482
|
+
return {
|
|
483
|
+
written: true,
|
|
484
|
+
path: file,
|
|
485
|
+
reason: refused.length
|
|
486
|
+
? `rejected agent-supplied observed field(s): ${refused.join(', ')} — ` +
|
|
487
|
+
`the observed group is derived from the run receipt`
|
|
488
|
+
: null
|
|
489
|
+
}
|
|
490
|
+
} catch (err) {
|
|
491
|
+
// Deliberately swallowed: see the module header. The caller gets a reason,
|
|
492
|
+
// never an exception.
|
|
493
|
+
return { written: false, path: null, reason: messageOf(err) }
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Read the corpus back. Never throws, and never lets one bad line cost you the
|
|
499
|
+
* rest of the log — an append-only file torn by a crash is expected, not
|
|
500
|
+
* exceptional.
|
|
501
|
+
*
|
|
502
|
+
* @param {string} root repo root
|
|
503
|
+
* @returns {{
|
|
504
|
+
* path: string|null, exists: boolean,
|
|
505
|
+
* records: object[], skipped: Array<{line: number, reason: string}>,
|
|
506
|
+
* reason: string|null
|
|
507
|
+
* }}
|
|
508
|
+
*/
|
|
509
|
+
export function readOutcomes(root) {
|
|
510
|
+
if (typeof root !== 'string' || !root.trim()) {
|
|
511
|
+
return { path: null, exists: false, records: [], skipped: [], reason: 'no root directory given' }
|
|
512
|
+
}
|
|
513
|
+
const path = outcomesPath(root)
|
|
514
|
+
try {
|
|
515
|
+
if (!existsSync(path)) {
|
|
516
|
+
return { path, exists: false, records: [], skipped: [], reason: null }
|
|
517
|
+
}
|
|
518
|
+
const lines = readFileSync(path, 'utf8').split('\n')
|
|
519
|
+
const records = []
|
|
520
|
+
const skipped = []
|
|
521
|
+
lines.forEach((line, i) => {
|
|
522
|
+
if (!line.trim()) return
|
|
523
|
+
try {
|
|
524
|
+
const parsed = JSON.parse(line)
|
|
525
|
+
if (isObject(parsed)) records.push(parsed)
|
|
526
|
+
else skipped.push({ line: i + 1, reason: 'line is not a JSON object' })
|
|
527
|
+
} catch (err) {
|
|
528
|
+
skipped.push({ line: i + 1, reason: messageOf(err) })
|
|
529
|
+
}
|
|
530
|
+
})
|
|
531
|
+
return { path, exists: true, records, skipped, reason: null }
|
|
532
|
+
} catch (err) {
|
|
533
|
+
return { path, exists: true, records: [], skipped: [], reason: messageOf(err) }
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Human-readable rollup, for a skill to echo. Counts only — never a verdict. */
|
|
538
|
+
export function formatOutcomes(result) {
|
|
539
|
+
if (!result.exists) return `OUTCOMES — none recorded (${result.path ?? LEARNING_DIR})\n`
|
|
540
|
+
const lines = []
|
|
541
|
+
const byMode = {}
|
|
542
|
+
for (const r of result.records) {
|
|
543
|
+
const mode = typeof r.mode === 'string' ? r.mode : 'unknown'
|
|
544
|
+
byMode[mode] = (byMode[mode] || 0) + 1
|
|
545
|
+
}
|
|
546
|
+
lines.push(
|
|
547
|
+
`OUTCOMES — ${result.records.length} record(s): ` +
|
|
548
|
+
(Object.keys(byMode).length
|
|
549
|
+
? Object.keys(byMode)
|
|
550
|
+
.sort()
|
|
551
|
+
.map(m => `${byMode[m]} ${m}`)
|
|
552
|
+
.join(', ')
|
|
553
|
+
: 'none')
|
|
554
|
+
)
|
|
555
|
+
if (result.skipped.length) {
|
|
556
|
+
lines.push(` ${result.skipped.length} unreadable line(s) skipped: ${result.skipped.map(s => s.line).join(', ')}`)
|
|
557
|
+
}
|
|
558
|
+
lines.push(' nothing gates on these records (§4.15a)')
|
|
559
|
+
return lines.join('\n') + '\n'
|
|
560
|
+
}
|