@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,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
+ }