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