@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
package/lib/ledger.mjs ADDED
@@ -0,0 +1,444 @@
1
+ // The decision ledger for one OpenSpec change (§F2).
2
+ //
3
+ // Explore and spec both hit ambiguities. Historically those were resolved in
4
+ // chat — which means they were resolved nowhere, because chat is not an
5
+ // artifact and the next agent (or the next context window) never sees it. The
6
+ // continuity gate needs a durable answer to one question: "is there anything
7
+ // left here that only a human can decide?" That answer has to survive a
8
+ // /clear, so it lives on disk next to the change, versioned with it:
9
+ //
10
+ // openspec/changes/<name>/decisions.md (§4.13 option (a))
11
+ //
12
+ // Two classes exist and only two: `needs_human` and `agent_resolved`.
13
+ //
14
+ // The load-bearing rule is that `agent_resolved` is a *claim*, and this module
15
+ // audits it. A row that says agent_resolved but carries no written resolution
16
+ // or no evidence is an agent asserting it decided something without saying
17
+ // what it decided or why. That row is INVALID and is treated as unresolved —
18
+ // it blocks continuity exactly like a `needs_human` row. Without that check
19
+ // the entire gate can be defeated by writing the word "agent_resolved".
20
+ //
21
+ // Parsing is correspondingly paranoid. A ledger that cannot be read must never
22
+ // come back looking empty, because an empty ledger reads as "nothing needs a
23
+ // human". So malformed rows are *collected*, never dropped and never thrown:
24
+ // anything unparseable lands in `invalid`, and anything in `invalid` blocks.
25
+ //
26
+ // Structure: parseLedger/serializeLedger/summarize/formatLedger are pure and
27
+ // testable with no filesystem; readLedger/writeLedger are thin fs wrappers
28
+ // over them. Path resolution is delegated to artifacts.mjs so there is one
29
+ // definition of where a change lives.
30
+
31
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
32
+ import { dirname, join } from 'node:path'
33
+ import { inspectChange } from './artifacts.mjs'
34
+
35
+ export const LEDGER_FILE = 'decisions.md'
36
+
37
+ /** The only two classes. Anything else is a malformed row. */
38
+ export const NEEDS_HUMAN = 'needs_human'
39
+ export const AGENT_RESOLVED = 'agent_resolved'
40
+ export const DECISION_CLASSES = Object.freeze([NEEDS_HUMAN, AGENT_RESOLVED])
41
+
42
+ export const COLUMNS = Object.freeze(['id', 'question', 'class', 'resolution', 'evidence'])
43
+
44
+ // Placeholders that mean "no value". Real ledgers are hand-edited, so all the
45
+ // dashes people actually type are accepted, and so are the hedges that mean
46
+ // the same thing as blank.
47
+ //
48
+ // The hedges are here because shared/DECISION-LEDGER.md already lists them as
49
+ // "not evidence": `obvious`, `standard practice`, `see above`. A cell claiming
50
+ // the answer is self-evident is a cell that gives no evidence, and the whole
51
+ // point of the audit is that saying `agent_resolved` is not deciding anything.
52
+ // Matched after the existing lowercase-and-trim, plus an optional trailing
53
+ // period — "Obvious." is the same non-answer as "obvious".
54
+ const EMPTY_MARKERS = new Set([
55
+ '',
56
+ '-',
57
+ '--',
58
+ '---',
59
+ '—',
60
+ '–',
61
+ 'n/a',
62
+ 'na',
63
+ 'tbd',
64
+ '?',
65
+ 'obvious',
66
+ 'self-evident',
67
+ 'self evident',
68
+ 'standard practice',
69
+ 'common sense',
70
+ 'see above',
71
+ 'as discussed',
72
+ 'n a'
73
+ ])
74
+
75
+ function fail(message) {
76
+ const err = new Error(message)
77
+ err.userFacing = true
78
+ throw err
79
+ }
80
+
81
+ // --- pure parsing --------------------------------------------------------
82
+
83
+ /**
84
+ * True when a cell carries no value: blank, a dash placeholder, or a hedge
85
+ * that asserts nothing.
86
+ *
87
+ * The match is on the WHOLE cell. A cell that merely contains a rejected word
88
+ * inside a longer, substantive citation — "lib/session.ts:42 — the obvious
89
+ * existing helper" — is real evidence and is accepted.
90
+ */
91
+ export function isEmptyCell(value) {
92
+ const folded = String(value ?? '')
93
+ .trim()
94
+ .toLowerCase()
95
+ .replace(/\.+$/, '')
96
+ return EMPTY_MARKERS.has(folded) || EMPTY_MARKERS.has(folded.replace(/\s+/g, ' '))
97
+ }
98
+
99
+ // Split one markdown table row into cells. Tolerates a missing leading or
100
+ // trailing pipe and any amount of padding. '\|' is an escaped literal pipe.
101
+ function splitRow(line) {
102
+ let s = line.trim()
103
+ if (s.startsWith('|')) s = s.slice(1)
104
+ if (/(^|[^\\])\|$/.test(s)) s = s.slice(0, -1)
105
+ return s
106
+ .split(/(?<!\\)\|/)
107
+ .map(c => c.replace(/\\\|/g, '|').trim())
108
+ }
109
+
110
+ function isSeparatorRow(cells) {
111
+ return cells.length > 0 && cells.every(c => /^:?-{1,}:?$/.test(c))
112
+ }
113
+
114
+ function isHeaderRow(cells) {
115
+ return cells.length >= 2 && cells[0].toLowerCase() === 'id' && cells[1].toLowerCase() === 'question'
116
+ }
117
+
118
+ // Tolerate "needs human", "Needs-Human", "AGENT_RESOLVED".
119
+ function normalizeClass(value) {
120
+ return String(value ?? '').trim().toLowerCase().replace(/[\s-]+/g, '_')
121
+ }
122
+
123
+ const HEADING = /^#\s*Decisions\s*[—–:-]\s*(.+?)\s*$/
124
+
125
+ /**
126
+ * Does `id` appear as a token in the design document?
127
+ *
128
+ * A token, not a heading: shared/DECISION-LEDGER.md does not constrain *where*
129
+ * in design.md a decision is recorded, so requiring a heading would invalidate
130
+ * ledgers that comply with the documented contract. Token-bounded so a
131
+ * reference to `D2` is not satisfied by a design that only mentions `D21`.
132
+ */
133
+ function referenceResolves(id, designText) {
134
+ if (typeof designText !== 'string' || !designText.trim()) return false
135
+ const token = String(id).trim()
136
+ if (!token) return false
137
+ const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
138
+ return new RegExp(`(^|[^A-Za-z0-9_])${escaped}([^A-Za-z0-9_]|$)`).test(designText)
139
+ }
140
+
141
+ /**
142
+ * Parse a decisions.md body. Never throws.
143
+ *
144
+ * @param {string} text
145
+ * @param {{designText?: string|null}} [opts]
146
+ * `designText` is the change's `design.md`. Supplying it turns on the
147
+ * reference audit shared/DECISION-LEDGER.md has always advertised: "every
148
+ * `agent_resolved` decision must also appear as a written assumption in
149
+ * design.md, referenced by id". Passing `null` means *the design document is
150
+ * absent or unreadable*, which makes every reference unresolvable — an
151
+ * unreadable design document is not one that contains every id. Omitting the
152
+ * argument entirely means "do not audit references", which is what keeps
153
+ * this function a pure parse for callers that only want the rows.
154
+ * @returns {{
155
+ * change: string|null,
156
+ * parseable: boolean,
157
+ * rows: Array<{id,question,class,resolution,evidence,line,valid,problems}>,
158
+ * invalid: Array<{row, reason: string}>
159
+ * }}
160
+ */
161
+ export function parseLedger(text, opts = {}) {
162
+ const body = typeof text === 'string' ? text : ''
163
+ const lines = body.split('\n')
164
+ const auditReferences = Object.prototype.hasOwnProperty.call(opts || {}, 'designText')
165
+ const designText = opts ? opts.designText : undefined
166
+
167
+ let change = null
168
+ let sawTable = false
169
+ const rows = []
170
+ const invalid = []
171
+ // A markdown table ends at a blank line. Tracking that lets a row omit its
172
+ // leading pipe without prose elsewhere in the file being mistaken for a row.
173
+ let inTable = false
174
+
175
+ for (let i = 0; i < lines.length; i += 1) {
176
+ const trimmed = lines[i].trim()
177
+
178
+ if (!trimmed) {
179
+ inTable = false
180
+ continue
181
+ }
182
+
183
+ if (change === null) {
184
+ const h = HEADING.exec(trimmed)
185
+ if (h) {
186
+ change = h[1].trim()
187
+ continue
188
+ }
189
+ }
190
+
191
+ const looksLikeRow = trimmed.startsWith('|') || (inTable && trimmed.includes('|'))
192
+ if (!looksLikeRow) continue
193
+
194
+ const cells = splitRow(trimmed)
195
+ if (isSeparatorRow(cells)) {
196
+ inTable = true
197
+ sawTable = true
198
+ continue
199
+ }
200
+ if (isHeaderRow(cells)) {
201
+ inTable = true
202
+ sawTable = true
203
+ continue
204
+ }
205
+ inTable = true
206
+ sawTable = true
207
+
208
+ const problems = []
209
+ if (cells.length !== COLUMNS.length) {
210
+ problems.push(`expected ${COLUMNS.length} columns (${COLUMNS.join(', ')}), found ${cells.length}`)
211
+ }
212
+
213
+ const row = {
214
+ id: cells[0] ?? '',
215
+ question: cells[1] ?? '',
216
+ class: normalizeClass(cells[2]),
217
+ resolution: isEmptyCell(cells[3]) ? '' : cells[3],
218
+ evidence: isEmptyCell(cells[4]) ? '' : cells[4],
219
+ line: i + 1,
220
+ raw: trimmed,
221
+ valid: true,
222
+ problems
223
+ }
224
+
225
+ if (isEmptyCell(row.id)) problems.push('row has no id')
226
+ if (isEmptyCell(row.question)) problems.push('row has no question')
227
+
228
+ if (!DECISION_CLASSES.includes(row.class)) {
229
+ problems.push(
230
+ row.class
231
+ ? `unknown class "${cells[2]}" (expected ${DECISION_CLASSES.join(' or ')})`
232
+ : `missing class (expected ${DECISION_CLASSES.join(' or ')})`
233
+ )
234
+ } else if (row.class === AGENT_RESOLVED) {
235
+ // The audit. agent_resolved is a claim, not a fact.
236
+ if (!row.resolution) problems.push('agent_resolved without a written resolution')
237
+ if (!row.evidence) problems.push('agent_resolved without evidence')
238
+ if (auditReferences && !referenceResolves(row.id, designText)) {
239
+ problems.push(
240
+ typeof designText === 'string' && designText.trim()
241
+ ? `agent_resolved but "${row.id}" appears nowhere in design.md — ` +
242
+ 'the resolution must be recorded there, referenced by id'
243
+ : `agent_resolved but design.md is absent or unreadable, so the reference ` +
244
+ `"${row.id}" cannot be resolved`
245
+ )
246
+ }
247
+ }
248
+
249
+ row.valid = problems.length === 0
250
+ rows.push(row)
251
+ if (!row.valid) invalid.push({ row, reason: problems.join('; ') })
252
+ }
253
+
254
+ // A file with neither a heading nor a table is not an empty ledger — it is a
255
+ // ledger nobody can read. Reported as such, because "empty" is exactly the
256
+ // conclusion that clears the gate.
257
+ const parseable = sawTable || change !== null
258
+ return { change, parseable, rows, invalid }
259
+ }
260
+
261
+ /**
262
+ * Render a ledger. Round-trips with parseLedger: serialize → parse → serialize
263
+ * is stable. Empty cells become the em dash placeholder from the §F2 template.
264
+ */
265
+ export function serializeLedger(change, rows) {
266
+ const name = typeof change === 'string' && change.trim() ? change.trim() : 'unnamed-change'
267
+ const list = Array.isArray(rows) ? rows : []
268
+ const out = []
269
+ out.push(`# Decisions — ${name}`)
270
+ out.push('')
271
+ out.push(`| ${COLUMNS.join(' | ')} |`)
272
+ out.push(`|${COLUMNS.map(() => '----').join('|')}|`)
273
+ for (const r of list) {
274
+ const row = r && typeof r === 'object' ? r : {}
275
+ out.push(
276
+ '| ' +
277
+ [
278
+ cell(row.id),
279
+ cell(row.question),
280
+ cell(normalizeClass(row.class)),
281
+ cell(row.resolution),
282
+ cell(row.evidence)
283
+ ].join(' | ') +
284
+ ' |'
285
+ )
286
+ }
287
+ out.push('')
288
+ return out.join('\n')
289
+ }
290
+
291
+ // A cell never breaks the table: pipes are escaped, newlines collapse, and an
292
+ // absent value renders as the placeholder the template uses.
293
+ function cell(value) {
294
+ const s = String(value ?? '').replace(/\r?\n/g, ' ').replace(/\s+/g, ' ').trim()
295
+ if (isEmptyCell(s)) return '—'
296
+ return s.replace(/\|/g, '\\|')
297
+ }
298
+
299
+ /**
300
+ * Roll a parsed ledger up into the numbers a gate acts on.
301
+ *
302
+ * `blocking` is true when anything remains that a human must touch: a
303
+ * `needs_human` row, OR any invalid row — an unreadable or unsubstantiated row
304
+ * is treated as unresolved, never as absent.
305
+ */
306
+ export function summarize(parsed) {
307
+ const p = parsed && typeof parsed === 'object' ? parsed : {}
308
+ const rows = Array.isArray(p.rows) ? p.rows : []
309
+ const invalidList = Array.isArray(p.invalid) ? p.invalid : []
310
+
311
+ const needsHuman = rows.filter(r => r.valid && r.class === NEEDS_HUMAN).length
312
+ const agentResolved = rows.filter(r => r.valid && r.class === AGENT_RESOLVED).length
313
+ const invalidCount = invalidList.length
314
+
315
+ // `changeExists === false` means the change directory itself is absent, which
316
+ // is never "nothing to decide" — it is a caller error, and clearing on it
317
+ // would be the one fail-open this module cannot afford.
318
+ const missingChange = p.changeExists === false
319
+
320
+ // Three outcomes that must never collapse into each other, because two of
321
+ // them read as "nothing needs a human" and only one of them means it:
322
+ // missing — no decisions.md at all. A failure of the audit.
323
+ // unparseable — a file is there and nobody can read it.
324
+ // present and empty — a ledger with a heading and no rows.
325
+ const exists = p.exists !== false
326
+ const missing = !exists
327
+ const unparseable = exists && p.parseable === false
328
+
329
+ return {
330
+ total: rows.length,
331
+ needsHuman,
332
+ agentResolved,
333
+ // Count, not rows. `parseLedger`/`readLedger` return `invalid` as the rows
334
+ // themselves; keeping both under one key made a merged object ambiguous.
335
+ invalidCount,
336
+ invalid: invalidCount,
337
+ exists,
338
+ missing,
339
+ unparseable,
340
+ changeExists: !missingChange,
341
+ blocking: needsHuman > 0 || invalidCount > 0 || missingChange || missing || unparseable
342
+ }
343
+ }
344
+
345
+ // --- filesystem wrappers -------------------------------------------------
346
+
347
+ /**
348
+ * Absolute-from-root path of a change's ledger. Path resolution for the change
349
+ * directory itself belongs to artifacts.mjs — do not rebuild it here.
350
+ */
351
+ export function ledgerPath(root = '.', change) {
352
+ if (typeof change !== 'string' || !change.trim()) fail('ledger requires a change name')
353
+ return join(inspectChange(root, change).path, LEDGER_FILE)
354
+ }
355
+
356
+ /**
357
+ * Read and parse a change's ledger. A missing file is `exists: false`, not a
358
+ * throw — "no ledger yet" is a normal state early in a change.
359
+ */
360
+ export function readLedger(root = '.', change) {
361
+ const path = ledgerPath(root, change)
362
+ // The design document is read HERE rather than inside parseLedger, which is
363
+ // pure by contract. The path comes from artifacts.mjs (via ledgerPath) so
364
+ // there is still one definition of where a change lives. `null` when it is
365
+ // absent or unreadable — which makes every reference unresolvable, rather
366
+ // than treating a document nobody could read as one containing every id.
367
+ const designPath = join(dirname(path), 'design.md')
368
+ let designText = null
369
+ if (existsSync(designPath)) {
370
+ try {
371
+ designText = readFileSync(designPath, 'utf8')
372
+ } catch {
373
+ designText = null
374
+ }
375
+ }
376
+ // A ledger missing because the change directory does not exist is a different
377
+ // fact from a ledger missing because nobody wrote one, and the difference is
378
+ // load-bearing: the readiness gate can skip a human, so a typo'd change name
379
+ // must not read as "no decisions outstanding". `changeExists` lets the caller
380
+ // tell the two apart; `summarize` refuses to clear a ledger for a change that
381
+ // is not there.
382
+ const changeExists = existsSync(dirname(path))
383
+ if (!existsSync(path)) {
384
+ return { exists: false, parseable: false, changeExists, path, change, rows: [], invalid: [] }
385
+ }
386
+ const parsed = parseLedger(readFileSync(path, 'utf8'), { designText })
387
+ return {
388
+ exists: true,
389
+ parseable: parsed.parseable,
390
+ changeExists: true,
391
+ path,
392
+ designPath,
393
+ designExists: designText !== null,
394
+ change: parsed.change || change,
395
+ rows: parsed.rows,
396
+ invalid: parsed.invalid
397
+ }
398
+ }
399
+
400
+ /** Write (or replace) a change's ledger. Creates the change directory if needed. */
401
+ export function writeLedger(root = '.', change, rows) {
402
+ const path = ledgerPath(root, change)
403
+ if (!Array.isArray(rows)) fail('writeLedger requires a rows array')
404
+ mkdirSync(dirname(path), { recursive: true })
405
+ writeFileSync(path, serializeLedger(change, rows), 'utf8')
406
+ return { path }
407
+ }
408
+
409
+ // --- formatting ----------------------------------------------------------
410
+
411
+ /** Human-readable ledger status, for a skill to echo at a checkpoint. */
412
+ export function formatLedger(parsed) {
413
+ const s = summarize(parsed)
414
+ const lines = []
415
+ // "MISSING" and "0 row(s)" are different facts and are printed differently.
416
+ // An absent ledger reads as "nothing needs a human" only if you assume
417
+ // explore and spec had nothing to record; under fail-closed it reads as
418
+ // "unknown", and the audit says so rather than reporting an empty result.
419
+ if (parsed && parsed.exists === false) {
420
+ lines.push(
421
+ `DECISIONS MISSING — no ${LEDGER_FILE}${parsed.path ? ` at ${parsed.path}` : ''} ` +
422
+ `(missing, not empty — nothing here proves no human is needed)`
423
+ )
424
+ return lines.join('\n') + '\n'
425
+ }
426
+ if (s.unparseable) {
427
+ lines.push(
428
+ `DECISIONS UNPARSEABLE — ${LEDGER_FILE} is present but carries no readable decision table ` +
429
+ `(unparseable, not empty)`
430
+ )
431
+ return lines.join('\n') + '\n'
432
+ }
433
+ lines.push(
434
+ `DECISIONS ${s.blocking ? 'BLOCKING' : 'CLEAR'} — ${s.total} row(s): ` +
435
+ `${s.needsHuman} needs_human, ${s.agentResolved} agent_resolved, ${s.invalid} invalid`
436
+ )
437
+ for (const r of parsed?.rows ?? []) {
438
+ if (r.valid && r.class === NEEDS_HUMAN) lines.push(` [needs_human] ${r.id}: ${r.question}`)
439
+ }
440
+ for (const { row, reason } of parsed?.invalid ?? []) {
441
+ lines.push(` [invalid] ${row.id || `line ${row.line}`}: ${reason}`)
442
+ }
443
+ return lines.join('\n') + '\n'
444
+ }