@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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +55 -5
- package/bin/interlock +142 -14
- package/hooks/mod.mjs +835 -51
- package/hooks/preflight.mjs +168 -34
- package/lib/draw-plan.mjs +496 -0
- package/lib/draw-run.mjs +466 -0
- package/lib/lane.mjs +152 -0
- package/lib/limits.mjs +37 -2
- package/lib/meter-refusals.mjs +73 -0
- package/lib/plan-summary.mjs +119 -0
- package/lib/preflight-file.mjs +266 -0
- package/lib/resume-card.mjs +126 -8
- package/lib/run.mjs +43 -9
- package/lib/spec-meter.mjs +591 -0
- package/lib/waves.mjs +6 -107
- package/package.json +1 -1
- package/skills/review-artifacts/SKILL.md +3 -3
- package/skills/spec/SKILL.md +5 -5
|
@@ -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
|
+
}
|