@renzrollon/interlock 1.2.1 → 1.3.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.
@@ -0,0 +1,591 @@
1
+ // The spec meter's text rules (observe-the-spec-run-live design D1-D9).
2
+ //
3
+ // `/interlock:spec` has no CLI spine to draw from: it is a prose skill that
4
+ // drives `openspec` and `interlock` through the Bash tool and writes its
5
+ // artifacts with the Write tool. The hooks module draws it anyway, from what
6
+ // already crossed the engine, and this module holds every rule it draws by:
7
+ // which load starts a run (D1), which command lines are the flow's keyed lines
8
+ // and how a compound line is cut (D3), where a line names its change (D2), the
9
+ // fields each line's JSON must carry (D3), which writes are counted (D4), and
10
+ // every word the status line and the pane say (D6). The module keeps the
11
+ // record and makes the engine calls; nothing here does either.
12
+ //
13
+ // Every word drawn is the CLI's: a field it printed, composed from the field
14
+ // and never from its text, a count of events the engine raised, or a time the
15
+ // engine's clock gave. A line that cannot be read is said to be unread, with
16
+ // its first line. Nothing here compares a count against a number, names a
17
+ // threshold, or reads OpenSpec's own roll-up of the artifact ladder: the table
18
+ // is the ladder as printed, and the record is the artifacts on disk, the
19
+ // findings file and the gate's exit, which the pane says.
20
+ //
21
+ // It runs inside the engine through `hooks/mod.mjs`, so it is pure and imports
22
+ // nothing (`test/spine/mod-pins.test.mjs` walks it). It does not import the
23
+ // CLI's own modules for their shapes: they reach `node:fs`, and re-deriving a
24
+ // verdict from them is what the meter must not do (D9).
25
+
26
+ export const SPEC_PANE = 'interlock-spec'
27
+ export const SPEC_TITLE = 'Interlock spec'
28
+ export const NO_SPEC_LINE = 'no spec run is live in this session'
29
+ export const SPEC_RECORD_LINE = "the artifacts on disk, the findings file and the gate's exit are the record"
30
+ export const SPEC_UNPLACED_TOAST = '/interlock-spec opens the spec meter'
31
+ export const UNKNOWN_CHANGE = 'change: unknown until named'
32
+ export const SPEC_REPLACED_LINE = 'interlock spec: a new spec run replaced the previous one'
33
+
34
+ /**
35
+ * The flow's keyed lines, by their command words, in the flow's order: the
36
+ * spec skill's drift, change, ladder, ledger, validate, autonomy and checkpoint
37
+ * lines, with review-artifacts' gate and continuity's readiness where they
38
+ * fall (D3). The skill-lines pin reads the three skills against this list.
39
+ */
40
+ export const KEYED_LINES = Object.freeze([
41
+ Object.freeze({ kind: 'drift', words: Object.freeze(['interlock', 'drift']) }),
42
+ Object.freeze({ kind: 'new-change', words: Object.freeze(['openspec', 'new', 'change']) }),
43
+ Object.freeze({ kind: 'status', words: Object.freeze(['openspec', 'status']) }),
44
+ Object.freeze({ kind: 'ledger', words: Object.freeze(['interlock', 'ledger']) }),
45
+ Object.freeze({ kind: 'validate', words: Object.freeze(['interlock', 'validate']) }),
46
+ Object.freeze({ kind: 'gate', words: Object.freeze(['interlock', 'gate']) }),
47
+ Object.freeze({ kind: 'autonomy', words: Object.freeze(['interlock', 'autonomy', 'record']) }),
48
+ Object.freeze({ kind: 'autonomy', words: Object.freeze(['interlock', 'autonomy', 'clean']) }),
49
+ Object.freeze({ kind: 'ready', words: Object.freeze(['interlock', 'ready']) }),
50
+ Object.freeze({ kind: 'checkpoint', words: Object.freeze(['interlock', 'notify', 'checkpoint']) })
51
+ ])
52
+
53
+ /** Lines whose argv may name the change and whose result is never read (D2). */
54
+ export const NAMING_ONLY = Object.freeze([Object.freeze(['openspec', 'instructions'])])
55
+
56
+ /** The kinds whose result text is read; the rest are read by their argv alone (D3). */
57
+ export const TEXT_KINDS = Object.freeze(['status', 'drift', 'ledger', 'validate', 'gate', 'ready'])
58
+
59
+ /** The skills a load can be, by the name the engine carries (D1, as probe 1 left it). */
60
+ const SKILL_ROLES = new Map([
61
+ ['interlock:spec', 'spec'],
62
+ ['interlock:explore', 'explore'],
63
+ ['interlock:review-artifacts', 'review-artifacts']
64
+ ])
65
+
66
+ /** The line the engine puts before a failed command's output (probe 3). */
67
+ const ENGINE_PREFIX = /^Exit code \d+$/
68
+
69
+ const UNPARSED_CUT = 200
70
+
71
+ const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v)
72
+ const isString = v => typeof v === 'string' && v !== ''
73
+ const countOf = v => (Array.isArray(v) ? v.length : null)
74
+ const shown = v => (v === null || v === undefined ? '?' : String(v))
75
+
76
+ /**
77
+ * What a skill name loads: `spec`, `explore`, `review-artifacts`, or `null`.
78
+ * Only the plugin's namespaced names count, a leading `/` dropped: neither
79
+ * event that carries a load carries the skill's text, so a bare `spec` could
80
+ * be anyone's (D1).
81
+ */
82
+ export function skillRole(name) {
83
+ if (typeof name !== 'string') return null
84
+ return SKILL_ROLES.get(name.trim().replace(/^\//, '')) || null
85
+ }
86
+
87
+ /** The skill a submitted prompt names by its first word (`/interlock:spec …`), or `null`. Nothing else is read. */
88
+ export function promptSkill(text) {
89
+ if (typeof text !== 'string') return null
90
+ const m = text.match(/^\s*\/([A-Za-z0-9_.-]+(?::[A-Za-z0-9_.-]+)?)(?=\s|$)/)
91
+ return m ? m[1] : null
92
+ }
93
+
94
+ /**
95
+ * A shell command's words, unquoted, up to the first unquoted `|`, `;`, `&`,
96
+ * `>` or `<`. Each word is `{ value, plain }`, `plain` when no part of it was
97
+ * quoted. Never throws.
98
+ */
99
+ export function argvOf(command) {
100
+ if (typeof command !== 'string') return []
101
+ const words = []
102
+ let value = ''
103
+ let plain = true
104
+ let open = false
105
+ let quote = null
106
+ const end = () => {
107
+ if (open) words.push({ value, plain })
108
+ value = ''
109
+ plain = true
110
+ open = false
111
+ }
112
+ for (let i = 0; i < command.length; i++) {
113
+ const c = command[i]
114
+ if (quote === "'") {
115
+ if (c === "'") quote = null
116
+ else value += c
117
+ continue
118
+ }
119
+ if (quote === '"') {
120
+ if (c === '"') quote = null
121
+ else if (c === '\\' && i + 1 < command.length && '"\\$`'.includes(command[i + 1])) value += command[++i]
122
+ else value += c
123
+ continue
124
+ }
125
+ if (c === "'" || c === '"') {
126
+ quote = c
127
+ plain = false
128
+ open = true
129
+ } else if (c === '\\' && i + 1 < command.length) {
130
+ value += command[++i]
131
+ open = true
132
+ } else if ('|;&><'.includes(c)) {
133
+ break
134
+ } else if (/\s/.test(c)) {
135
+ end()
136
+ } else {
137
+ value += c
138
+ open = true
139
+ }
140
+ }
141
+ end()
142
+ return words
143
+ }
144
+
145
+ /** Whether `words` begins with the plain words `prefix`. */
146
+ const startsWith = (words, prefix) => prefix.every((w, i) => words[i] && words[i].plain && words[i].value === w)
147
+
148
+ /**
149
+ * The kind of one command line by its first words, after leading whitespace:
150
+ * a keyed kind, `naming-only`, or `null`. It keys on the command words and not
151
+ * on `--json`, so a line on the previous skill text is classified and shown
152
+ * unparsed rather than ignored (D3).
153
+ */
154
+ export function lineKind(command) {
155
+ const words = argvOf(command)
156
+ for (const { kind, words: prefix } of KEYED_LINES) if (startsWith(words, prefix)) return kind
157
+ for (const prefix of NAMING_ONLY) if (startsWith(words, prefix)) return 'naming-only'
158
+ return null
159
+ }
160
+
161
+ /**
162
+ * A command cut at every unquoted `&&`, `||`, `;` and line break, each segment
163
+ * trimmed; past an unquoted `<<` the rest is one segment, so a heredoc's body
164
+ * is never read as commands. Never throws.
165
+ */
166
+ export function segmentsOf(command) {
167
+ if (typeof command !== 'string') return []
168
+ const out = []
169
+ let start = 0
170
+ let quote = null
171
+ for (let i = 0; i < command.length; i++) {
172
+ const c = command[i]
173
+ if (quote) {
174
+ if (c === quote) quote = null
175
+ else if (c === '\\' && quote === '"') i++
176
+ continue
177
+ }
178
+ if (c === "'" || c === '"') quote = c
179
+ else if (c === '\\') i++
180
+ else if (c === '<' && command[i + 1] === '<') break
181
+ else if (c === ';' || c === '\n') {
182
+ out.push(command.slice(start, i))
183
+ start = i + 1
184
+ } else if ((c === '&' && command[i + 1] === '&') || (c === '|' && command[i + 1] === '|')) {
185
+ out.push(command.slice(start, i))
186
+ start = i + 2
187
+ i++
188
+ }
189
+ }
190
+ out.push(command.slice(start))
191
+ return out.map(s => s.trim()).filter(Boolean)
192
+ }
193
+
194
+ /**
195
+ * The keyed segments of a command, in order: `{ kind, segment }` for each
196
+ * segment `lineKind` classifies, `naming-only` included (D3, the compound
197
+ * split probe 1 asked for).
198
+ */
199
+ export function specLines(command) {
200
+ return segmentsOf(command)
201
+ .map(segment => ({ kind: lineKind(segment), segment }))
202
+ .filter(line => line.kind !== null)
203
+ }
204
+
205
+ /** A name the CLI could have meant: a non-empty string with no unexpanded `$`, backtick or `<`. */
206
+ const nameOf = v => (isString(v) && !/[$`<]/.test(v) ? v : null)
207
+
208
+ /** The value of `--<flag> v` or `--<flag>=v` in `words`, or `null`. */
209
+ function flagValue(words, flag) {
210
+ for (let i = 0; i < words.length; i++) {
211
+ const w = words[i].value
212
+ if (w === `--${flag}`) return words[i + 1] ? words[i + 1].value : null
213
+ if (w.startsWith(`--${flag}=`)) return w.slice(flag.length + 3)
214
+ }
215
+ return null
216
+ }
217
+
218
+ /** The positional at `index` when it is not a flag. */
219
+ const positional = (words, index) => (words[index] && !words[index].value.startsWith('-') ? words[index].value : null)
220
+
221
+ /**
222
+ * The change a line names (D2): from its argv first, the positional after
223
+ * `openspec new change`, `--change` on any `openspec` line, the name after
224
+ * `interlock ledger|validate|ready` and `interlock notify checkpoint`,
225
+ * `--metrics` on `interlock gate`; then a non-empty `changeName` or `change`
226
+ * in the parsed result. Never a path, a prompt or another tool's input.
227
+ */
228
+ export function changeOf(kind, command, value) {
229
+ const words = argvOf(command)
230
+ let named = null
231
+ if (kind === 'new-change') named = positional(words, 3)
232
+ else if (words[0] && words[0].value === 'openspec') named = flagValue(words, 'change')
233
+ else if (kind === 'ledger' || kind === 'validate' || kind === 'ready') named = positional(words, 2)
234
+ else if (kind === 'checkpoint') named = positional(words, 3)
235
+ else if (kind === 'gate') named = flagValue(words, 'metrics')
236
+ if (nameOf(named)) return named
237
+ if (isObject(value)) return nameOf(value.changeName) || nameOf(value.change)
238
+ return null
239
+ }
240
+
241
+ /**
242
+ * The one JSON object a command printed, as `emit` prints it: from the first
243
+ * line that is exactly `{` to the last that is exactly `}`, so an engine
244
+ * prefix line and a stderr tail do not defeat it. `{ value }` or `{ problem }`.
245
+ */
246
+ export function findJson(text) {
247
+ if (typeof text !== 'string') return { problem: 'no result text' }
248
+ const lines = text.split('\n').map(l => l.replace(/\r$/, ''))
249
+ const first = lines.indexOf('{')
250
+ const last = lines.lastIndexOf('}')
251
+ if (first === -1 || last < first) return { problem: 'output not JSON' }
252
+ try {
253
+ const value = JSON.parse(lines.slice(first, last + 1).join('\n'))
254
+ return isObject(value) ? { value } : { problem: 'output not JSON' }
255
+ } catch {
256
+ return { problem: 'output not JSON' }
257
+ }
258
+ }
259
+
260
+ /** The text a result carries: the command's own stdout when the engine kept it, else what the model reads. */
261
+ function resultText(r) {
262
+ if (!isObject(r)) return null
263
+ if (isObject(r.result) && typeof r.result.stdout === 'string') return r.result.stdout
264
+ return typeof r.text === 'string' ? r.text : null
265
+ }
266
+
267
+ /** The first non-empty line of a result, past the engine's failed-command prefix, cut. */
268
+ function firstLine(text) {
269
+ const lines = (typeof text === 'string' ? text : '').split('\n').map(l => l.trim())
270
+ const line = lines.find(l => l && !ENGINE_PREFIX.test(l)) || ''
271
+ return line.length > UNPARSED_CUT ? line.slice(0, UNPARSED_CUT) : line
272
+ }
273
+
274
+ /** An entry for a line that ran and was not read: its first line and why (D3). */
275
+ export const unparsedEntry = (kind, text, reason) => ({ kind, unparsed: firstLine(text), reason })
276
+
277
+ const isBool = v => typeof v === 'boolean'
278
+ const isCount = v => Number.isInteger(v)
279
+
280
+ /** Each text kind's needed fields, and what it keeps of them (D3). */
281
+ const READERS = {
282
+ status(v) {
283
+ const ok = Array.isArray(v.artifacts) && v.artifacts.every(a => isObject(a) && isString(a.id) && isString(a.status))
284
+ if (!ok) return { missing: ['artifacts'] }
285
+ return {
286
+ entry: {
287
+ artifacts: v.artifacts.map(a => ({
288
+ id: a.id,
289
+ status: a.status,
290
+ outputPath: isString(a.outputPath) ? a.outputPath : null,
291
+ requires: Array.isArray(a.requires) ? a.requires.filter(isString) : []
292
+ })),
293
+ applyRequires: Array.isArray(v.applyRequires) ? v.applyRequires.filter(isString) : null,
294
+ changeName: isString(v.changeName) ? v.changeName : null,
295
+ schemaName: isString(v.schemaName) ? v.schemaName : null
296
+ }
297
+ }
298
+ },
299
+ drift(v) {
300
+ const stale = isObject(v.stale) ? v.stale : {}
301
+ const missing = [
302
+ ...(Array.isArray(v.unarchived) ? [] : ['unarchived']),
303
+ ...(Array.isArray(stale.broken) ? [] : ['stale.broken']),
304
+ ...(Array.isArray(stale.aging) ? [] : ['stale.aging'])
305
+ ]
306
+ if (missing.length) return { missing }
307
+ return { entry: { unarchived: v.unarchived.length, broken: stale.broken.length, aging: stale.aging.length } }
308
+ },
309
+ ledger(v) {
310
+ if (!isBool(v.blocking)) return { missing: ['blocking'] }
311
+ const entry = { blocking: v.blocking }
312
+ for (const f of ['needsHuman', 'invalidCount']) if (isCount(v[f])) entry[f] = v[f]
313
+ for (const f of ['missing', 'unparseable']) if (isBool(v[f])) entry[f] = v[f]
314
+ return { entry }
315
+ },
316
+ validate(v) {
317
+ const missing = [...(isBool(v.ready) ? [] : ['ready']), ...(Array.isArray(v.problems) ? [] : ['problems'])]
318
+ if (missing.length) return { missing }
319
+ return { entry: { ready: v.ready, problems: v.problems.map(p => (typeof p === 'string' ? p : JSON.stringify(p))) } }
320
+ },
321
+ gate(v) {
322
+ const counts = isObject(v.counts) ? v.counts : {}
323
+ const missing = [
324
+ ...(isBool(v.passed) ? [] : ['passed']),
325
+ ...['blocker', 'warning', 'suggestion'].filter(s => !isCount(counts[s])).map(s => `counts.${s}`),
326
+ ...(Array.isArray(v.malformed) ? [] : ['malformed'])
327
+ ]
328
+ if (missing.length) return { missing }
329
+ const entry = {
330
+ passed: v.passed,
331
+ counts: { blocker: counts.blocker, warning: counts.warning, suggestion: counts.suggestion },
332
+ malformed: v.malformed.length,
333
+ metrics: null
334
+ }
335
+ if (isObject(v.metrics)) {
336
+ entry.metrics = {
337
+ written: isBool(v.metrics.written) ? v.metrics.written : null,
338
+ path: isString(v.metrics.path) ? v.metrics.path : null,
339
+ reason: isString(v.metrics.reason) ? v.metrics.reason : null
340
+ }
341
+ }
342
+ return { entry }
343
+ },
344
+ ready(v) {
345
+ const missing = [...(isBool(v.ready) ? [] : ['ready']), ...(Array.isArray(v.blockers) ? [] : ['blockers'])]
346
+ if (missing.length) return { missing }
347
+ return { entry: { ready: v.ready, blockers: v.blockers.length } }
348
+ }
349
+ }
350
+
351
+ /** The kinds a name that does not resolve answers `{ error, candidates }` for (`requireChange`). */
352
+ const RESOLVING = new Set(['ledger', 'validate', 'ready'])
353
+ const VERDICT = { ledger: 'blocking', validate: 'ready', ready: 'ready' }
354
+
355
+ /**
356
+ * What one keyed line's result says, as an entry the record keeps, with the
357
+ * change it names as `name` (D2, D3). Only the fields a kind needs are kept:
358
+ * a result lacking them, or not JSON, or too large to have been kept inline,
359
+ * is an unparsed entry with its first line and the reason. `new-change`,
360
+ * `autonomy` and `checkpoint` read no text; `autonomy` keeps only `record` or
361
+ * `clean`. OpenSpec's own roll-up of the ladder is never read.
362
+ */
363
+ export function readResult(kind, command, r) {
364
+ if (kind === 'new-change' || kind === 'checkpoint') return { kind, name: changeOf(kind, command, null) }
365
+ if (kind === 'autonomy') {
366
+ const words = argvOf(command)
367
+ return { kind, command: words[2] ? words[2].value : null, name: null }
368
+ }
369
+ if (!READERS[kind]) return null
370
+ if (isObject(r) && isObject(r.result) && isString(r.result.persistedOutputPath)) {
371
+ return { ...unparsedEntry(kind, resultText(r), 'output too large to read inline'), name: changeOf(kind, command, null) }
372
+ }
373
+ const text = resultText(r)
374
+ if (text === null) return { ...unparsedEntry(kind, '', 'no result text'), name: changeOf(kind, command, null) }
375
+ const { value, problem } = findJson(text)
376
+ if (!value) return { ...unparsedEntry(kind, text, problem), name: changeOf(kind, command, null) }
377
+ const name = changeOf(kind, command, value)
378
+ if (RESOLVING.has(kind) && isString(value.error) && Array.isArray(value.candidates) && !(VERDICT[kind] in value)) {
379
+ return { kind, error: value.error, candidates: value.candidates.filter(isString), name }
380
+ }
381
+ const { entry, missing } = READERS[kind](value)
382
+ if (!entry) return { ...unparsedEntry(kind, text, `fields missing: ${missing.join(', ')}`), name }
383
+ return { kind, ...entry, name }
384
+ }
385
+
386
+ /**
387
+ * Which write a path is (D4): an artifact under `openspec/changes/<dir>/`
388
+ * (`archive` excluded), the explore brief, or a review-artifacts findings
389
+ * file; otherwise `null`. A path files a write under its directory and never
390
+ * names the change.
391
+ */
392
+ export function writeOf(path) {
393
+ if (typeof path !== 'string' || !path) return null
394
+ const p = path.replace(/\\/g, '/')
395
+ const artifact = p.match(/(?:^|\/)openspec\/changes\/([^/]+)\/(.+)$/)
396
+ if (artifact) return artifact[1] === 'archive' ? null : { kind: 'artifact', change: artifact[1], file: artifact[2] }
397
+ if (/(?:^|\/)\.claude\/handoff\/explore-[^/]*\.md$/.test(p)) return { kind: 'brief', path }
398
+ const findings = p.match(/(?:^|\/)\.claude\/metrics\/review-artifacts-(.+)-\d{8}-\d{6}\.json$/)
399
+ if (findings) return { kind: 'findings', change: findings[1], path }
400
+ return null
401
+ }
402
+
403
+ /** The status line's stage for an entry, in the CLI's words (D6's table). */
404
+ export function stageText(entry, spawned = 0) {
405
+ if (!isObject(entry)) return null
406
+ const { kind } = entry
407
+ if (typeof entry.unparsed === 'string') return `${kind} (unparsed)`
408
+ if (isString(entry.error)) return `${kind}: change not resolved (candidates ${countOf(entry.candidates) ?? 0})`
409
+ switch (kind) {
410
+ case 'explore':
411
+ return spawned ? `explore (${spawned} investigators)` : 'explore'
412
+ case 'review-artifacts':
413
+ return 'review-artifacts'
414
+ case 'new-change':
415
+ return 'new change'
416
+ case 'status': {
417
+ const done = entry.artifacts.filter(a => a.status === 'done').length
418
+ const next = entry.artifacts.find(a => a.status === 'ready')
419
+ return `status ${done}/${entry.artifacts.length} done${next ? ` · next: ${next.id}` : ''}`
420
+ }
421
+ case 'drift':
422
+ return `drift (unarchived ${entry.unarchived} · broken ${entry.broken} · aging ${entry.aging})`
423
+ case 'ledger':
424
+ if (entry.missing === true) return 'ledger missing'
425
+ if (entry.unparseable === true) return 'ledger unparseable'
426
+ return `ledger ${entry.blocking ? 'blocking' : 'clear'} (needs_human ${shown(entry.needsHuman)} · invalid ${shown(entry.invalidCount)})`
427
+ case 'validate':
428
+ return entry.ready ? 'validate READY' : `validate NOT READY (problems ${entry.problems.length})`
429
+ case 'gate': {
430
+ const notes = [
431
+ ...(entry.malformed ? [`malformed ${entry.malformed}`] : []),
432
+ ...(entry.metrics && entry.metrics.written === false ? ['metrics not written'] : [])
433
+ ]
434
+ if (entry.passed) return `gate PASS${notes.length ? ` (${notes.join(' · ')})` : ''}`
435
+ const { blocker, warning } = entry.counts
436
+ return `gate BLOCKED (${[`blocker ${blocker}`, `warning ${warning}`, ...notes].join(' · ')})`
437
+ }
438
+ case 'autonomy':
439
+ return `autonomy ${entry.command || '?'}`
440
+ case 'ready':
441
+ return entry.ready ? 'ready true' : `ready false (blockers ${entry.blockers})`
442
+ case 'checkpoint':
443
+ return 'checkpoint'
444
+ default:
445
+ return null
446
+ }
447
+ }
448
+
449
+ /** `interlock spec: <change> · <stage>`, the stage omitted until there is one (D6). */
450
+ export function specStatusText(spec) {
451
+ const stage = stageText(spec.stage, spec.explore.spawned)
452
+ return `interlock spec: ${spec.current || UNKNOWN_CHANGE}${stage ? ` · ${stage}` : ''}`
453
+ }
454
+
455
+ const iso = at => (Number.isFinite(at) ? new Date(at).toISOString() : null)
456
+
457
+ /** A result line on the pane, by its JSON names, or why it is absent (D6). */
458
+ function resultLine(kind, entry) {
459
+ if (!entry) return `${kind}: not run yet`
460
+ if (typeof entry.unparsed === 'string') return `${kind}: not read (${entry.reason}) · ${entry.unparsed}`
461
+ if (isString(entry.error)) return `${kind}: change not resolved · candidates ${entry.candidates.join(', ') || 'none'}`
462
+ switch (kind) {
463
+ case 'drift':
464
+ return `drift: unarchived ${entry.unarchived} · broken ${entry.broken} · aging ${entry.aging}`
465
+ case 'ledger':
466
+ return `ledger: ${['blocking', 'needsHuman', 'invalidCount', 'missing', 'unparseable']
467
+ .filter(f => f in entry)
468
+ .map(f => `${f} ${entry[f]}`)
469
+ .join(' · ')}`
470
+ case 'validate':
471
+ return `validate: ready ${entry.ready} · problems ${entry.problems.length}`
472
+ case 'gate': {
473
+ const { blocker, warning, suggestion } = entry.counts
474
+ const m = entry.metrics
475
+ const metrics = !m
476
+ ? ''
477
+ : m.written === false
478
+ ? ` · metrics not written (${m.reason || 'no reason given'})`
479
+ : m.path
480
+ ? ` · metrics ${m.path}`
481
+ : ''
482
+ return `gate: passed ${entry.passed} · blocker ${blocker} · warning ${warning} · suggestion ${suggestion} · malformed ${entry.malformed}${metrics}`
483
+ }
484
+ case 'ready':
485
+ return `ready: ready ${entry.ready} · blockers ${entry.blockers}`
486
+ default:
487
+ return `${kind}: not run yet`
488
+ }
489
+ }
490
+
491
+ /**
492
+ * The `/interlock-spec` pane as keyed lines, `{ key, text, bold?, dim? }`
493
+ * (D6): the module maps them onto the engine's `Box` and `Text` and nothing
494
+ * else. No row carries a tick, a colour or a threshold.
495
+ */
496
+ export function specPaneLines(spec) {
497
+ if (!spec || spec.phase === 'idle') return [{ key: 'spec-none', text: NO_SPEC_LINE, dim: true }]
498
+ const change = spec.current ? spec.changes.get(spec.current) || {} : {}
499
+ const status = change.status
500
+ const out = []
501
+ const schema = status && !status.unparsed && !status.error && status.schemaName
502
+ out.push({ key: 'spec-header', text: `${spec.current || UNKNOWN_CHANGE}${schema ? ` · schema ${schema}` : ''}`, bold: true })
503
+ if (!status || typeof status.unparsed === 'string' || isString(status.error)) {
504
+ out.push({ key: 'spec-status', text: resultLine('status', status) })
505
+ } else {
506
+ for (const a of status.artifacts) out.push({ key: `spec-artifact-${a.id}`, text: `${a.id} · ${a.status} · ${a.outputPath || '?'}` })
507
+ if (status.applyRequires) out.push({ key: 'spec-apply-requires', text: `applyRequires: ${status.applyRequires.join(', ') || 'none'}` })
508
+ }
509
+ const spawned = spec.explore.spawned
510
+ out.push({
511
+ key: 'spec-explore',
512
+ text: `explore: ${spawned ? `${spawned} investigators spawned` : 'none reported by this host'} · ${
513
+ spec.explore.brief ? `brief ${spec.explore.brief}` : 'no brief written yet'
514
+ }`
515
+ })
516
+ out.push({ key: 'spec-drift', text: resultLine('drift', spec.drift) })
517
+ for (const kind of ['ledger', 'validate', 'gate', 'ready']) {
518
+ out.push({ key: `spec-${kind}`, text: resultLine(kind, change[kind]) })
519
+ if (kind === 'validate' && change.validate && Array.isArray(change.validate.problems)) {
520
+ change.validate.problems.forEach((p, i) => out.push({ key: `spec-validate-problem-${i}`, text: p }))
521
+ }
522
+ }
523
+ out.push({ key: 'spec-autonomy', text: `autonomy: ${spec.autonomy || 'not run yet'}` })
524
+ out.push({ key: 'spec-writes', text: 'writes', bold: true })
525
+ const writes = spec.current ? spec.writes.get(spec.current) : null
526
+ if (!writes || !writes.size) out.push({ key: 'spec-writes-none', text: 'no artifact writes yet', dim: true })
527
+ for (const [file, w] of writes || []) {
528
+ out.push({ key: `spec-write-${file}`, text: `${file} · written ${w.count}× · ${iso(w.at) ? `last ${iso(w.at)}` : 'last time unknown'}` })
529
+ }
530
+ const findings = spec.current ? spec.findings.get(spec.current) : null
531
+ if (findings) out.push({ key: 'spec-findings', text: `findings file: ${findings}` })
532
+ out.push({ key: 'spec-last-activity', text: iso(spec.lastActivityAt) ? `last activity ${iso(spec.lastActivityAt)}` : 'last activity unknown' })
533
+ if (spec.checkpointCrossed) {
534
+ out.push({
535
+ key: 'spec-checkpoint',
536
+ text: iso(spec.checkpointAt) ? `checkpoint reached ${iso(spec.checkpointAt)}` : 'checkpoint reached, time unknown'
537
+ })
538
+ }
539
+ if (spec.handedOver) {
540
+ out.push({
541
+ key: 'spec-handover',
542
+ text: iso(spec.handedOverAt) ? `handed over to ship at ${iso(spec.handedOverAt)}` : 'handed over to ship, time unknown'
543
+ })
544
+ }
545
+ const others = spec.order.filter(name => name !== spec.current)
546
+ if (others.length) out.push({ key: 'spec-other-changes', text: `other changes named this run: ${others.join(', ')}` })
547
+ out.push({ key: 'spec-record', text: SPEC_RECORD_LINE, dim: true })
548
+ return out
549
+ }
550
+
551
+ /** The debug line for a keyed line that crossed when no spec run could draw it (D3). */
552
+ export function lateLineText(kind, phase) {
553
+ return phase === 'checkpoint'
554
+ ? `interlock spec: a ${kind} line crossed after the checkpoint; not drawn`
555
+ : `interlock spec: a ${kind} line crossed with no live spec run; not drawn`
556
+ }
557
+
558
+ /** The debug line for a keyed line's result that was not read (D3). */
559
+ export function unreadLineText(entry, command) {
560
+ return `interlock spec: ${entry.kind} result not read (${entry.reason}): ${String(command).slice(0, 80)}`
561
+ }
562
+
563
+ /**
564
+ * A spec run record (D7): `phase` is `idle`, `live`, `checkpoint` or
565
+ * `handed-over`. `changes` holds each named change's results; the unnamed
566
+ * slot is `null`'s, never drawn. Writes and findings are filed by directory.
567
+ */
568
+ export function freshSpec(flags = {}) {
569
+ return {
570
+ phase: 'idle',
571
+ current: null,
572
+ changes: new Map(),
573
+ order: [],
574
+ explore: { spawned: 0, brief: null },
575
+ drift: null,
576
+ autonomy: null,
577
+ stage: null,
578
+ writes: new Map(),
579
+ findings: new Map(),
580
+ startedAt: null,
581
+ lastActivityAt: null,
582
+ checkpointCrossed: false,
583
+ checkpointAt: null,
584
+ handedOver: false,
585
+ handedOverAt: null,
586
+ clockFailed: false,
587
+ afterBoundary: flags.afterBoundary === true,
588
+ // Whether a keyed line with no run to draw it has been named on the debug log.
589
+ lateNamed: false
590
+ }
591
+ }