@renzrollon/interlock 1.2.1 → 1.3.1
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 -2
- package/CHANGELOG.md +64 -5
- package/bin/interlock +142 -14
- package/hooks/mod.mjs +1013 -85
- package/hooks/preflight.mjs +168 -34
- package/lib/draw-plan.mjs +516 -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 +2 -2
- package/skills/review-artifacts/SKILL.md +3 -3
- package/skills/spec/SKILL.md +5 -5
- /package/{.claude-plugin → assets}/icon.png +0 -0
package/hooks/preflight.mjs
CHANGED
|
@@ -15,16 +15,34 @@
|
|
|
15
15
|
//
|
|
16
16
|
// It also surfaces any interrupted-run note a previous session left — a run
|
|
17
17
|
// that ended with its session and never reached its close (lib/interrupted.mjs).
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
18
|
+
// A linked worktree's run writes its note into the state home — the main
|
|
19
|
+
// checkout — so the notes are read from the home the doctor resolved AND from
|
|
20
|
+
// the working directory (a run whose manifest recorded no home wrote there),
|
|
21
|
+
// each run spoken once. When the doctor gave no home — it could not run, or
|
|
22
|
+
// predates the field — the working directory alone, as before.
|
|
23
|
+
//
|
|
24
|
+
// It marks each note it speaks, in every directory the note was found in
|
|
25
|
+
// (show-preflight-and-interrupted-runs-at-session-start design D3). A session
|
|
26
|
+
// start now reaches the person as well as the model, through the hooks
|
|
27
|
+
// module's band, and a note nobody clears must not be said at every session
|
|
28
|
+
// start. The mark is `markSpoken`, the run program's own, and it fails toward
|
|
29
|
+
// speaking again: a note that cannot be marked stays unspoken on disk, so the
|
|
30
|
+
// next session start and the next `run start` say it again, and the report
|
|
31
|
+
// file below records why.
|
|
32
|
+
//
|
|
33
|
+
// In a project where a ship run can start — one whose working root holds
|
|
34
|
+
// `openspec/` — it leaves what it computed in `.claude/ship/preflight.json`
|
|
35
|
+
// (lib/preflight-file.mjs, design D1, D2): the doctor's verdict and checks,
|
|
36
|
+
// the notes it spoke and whether each mark landed, and the halt resume cards
|
|
37
|
+
// of open changes, listed from each card's first-line stamp and never read
|
|
38
|
+
// past it. The previous session's file is removed before the new one is
|
|
39
|
+
// written, so a failed write leaves no stale report; the failure is one line
|
|
40
|
+
// on stderr. Every other repository gets no file, no directory, and exactly
|
|
41
|
+
// the output it got before. The file is outcome-class: losing it costs the
|
|
42
|
+
// band, never the session.
|
|
25
43
|
|
|
26
44
|
import { execFileSync } from 'node:child_process'
|
|
27
|
-
import { appendFileSync, existsSync, realpathSync } from 'node:fs'
|
|
45
|
+
import { appendFileSync, existsSync, mkdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
28
46
|
import { dirname, join, resolve } from 'node:path'
|
|
29
47
|
import { fileURLToPath } from 'node:url'
|
|
30
48
|
|
|
@@ -38,10 +56,11 @@ function interlockBin() {
|
|
|
38
56
|
}
|
|
39
57
|
|
|
40
58
|
/**
|
|
41
|
-
* The preflight's own report, as one message
|
|
42
|
-
* resolved — `null` when its output carried none or could not be read
|
|
59
|
+
* The preflight's own report, as one message; the state home and surface the
|
|
60
|
+
* doctor resolved — `null` when its output carried none or could not be read;
|
|
61
|
+
* and the doctor's verdict as the report file carries it (design D1).
|
|
43
62
|
*
|
|
44
|
-
* @returns {{ message: string, stateHome: string|null }}
|
|
63
|
+
* @returns {{ message: string, stateHome: string|null, surface: string|null, doctor: object }}
|
|
45
64
|
*/
|
|
46
65
|
function run() {
|
|
47
66
|
let raw
|
|
@@ -65,7 +84,9 @@ function run() {
|
|
|
65
84
|
`The \`interlock\` binary is not resolvable from this session. Inside Claude Code the ` +
|
|
66
85
|
`plugin's bin/ is normally injected; if you see this, reinstall the plugin. The session ` +
|
|
67
86
|
`continues — this preflight is advisory.`,
|
|
68
|
-
stateHome: null
|
|
87
|
+
stateHome: null,
|
|
88
|
+
surface: null,
|
|
89
|
+
doctor: { ran: false, parsed: false, ok: null, counts: null, checks: [] }
|
|
69
90
|
}
|
|
70
91
|
}
|
|
71
92
|
}
|
|
@@ -79,18 +100,35 @@ function run() {
|
|
|
79
100
|
if (!doctor || typeof doctor !== 'object') {
|
|
80
101
|
return {
|
|
81
102
|
message: 'interlock preflight ran but its output could not be parsed; skipping. The session continues.',
|
|
82
|
-
stateHome: null
|
|
103
|
+
stateHome: null,
|
|
104
|
+
surface: null,
|
|
105
|
+
doctor: { ran: true, parsed: false, ok: null, counts: null, checks: [] }
|
|
83
106
|
}
|
|
84
107
|
}
|
|
85
108
|
const stateHome =
|
|
86
109
|
typeof doctor.stateHome === 'string' && doctor.stateHome ? resolve(process.cwd(), doctor.stateHome) : null
|
|
87
110
|
|
|
111
|
+
const surface = typeof doctor.surface === 'string' && doctor.surface ? doctor.surface : null
|
|
112
|
+
|
|
88
113
|
const checks = Array.isArray(doctor.checks) ? doctor.checks : []
|
|
114
|
+
// The checks as the file carries them: the fix travels, the evidence does not (design D1).
|
|
115
|
+
const verdict = {
|
|
116
|
+
ran: true,
|
|
117
|
+
parsed: true,
|
|
118
|
+
ok: typeof doctor.ok === 'boolean' ? doctor.ok : null,
|
|
119
|
+
counts: doctor.counts && typeof doctor.counts === 'object' ? doctor.counts : null,
|
|
120
|
+
checks: checks.filter(c => c && typeof c === 'object').map(c => ({ id: c.id, status: c.status, detail: c.detail, fix: c.fix ?? null }))
|
|
121
|
+
}
|
|
89
122
|
const failures = checks.filter(c => c && c.status === 'fail')
|
|
90
123
|
if (!failures.length) {
|
|
91
124
|
const warns = (doctor.counts && doctor.counts.warn) || 0
|
|
92
125
|
// A clean preflight is quiet, not a wall of output — one confirming line.
|
|
93
|
-
return {
|
|
126
|
+
return {
|
|
127
|
+
message: `interlock preflight OK${warns ? ` (${warns} warning${warns === 1 ? '' : 's'})` : ''}.`,
|
|
128
|
+
stateHome,
|
|
129
|
+
surface,
|
|
130
|
+
doctor: verdict
|
|
131
|
+
}
|
|
94
132
|
}
|
|
95
133
|
|
|
96
134
|
const lines = ['interlock preflight found issues that can stall an unattended ship run:']
|
|
@@ -103,7 +141,7 @@ function run() {
|
|
|
103
141
|
}
|
|
104
142
|
}
|
|
105
143
|
lines.push('The session still starts — apply the fixes above before /interlock:ship.')
|
|
106
|
-
return { message: lines.join('\n'), stateHome }
|
|
144
|
+
return { message: lines.join('\n'), stateHome, surface, doctor: verdict }
|
|
107
145
|
}
|
|
108
146
|
|
|
109
147
|
/** Two paths naming one directory — through a symlink too — so one directory is read once. */
|
|
@@ -120,35 +158,114 @@ function sameDirectory(a, b) {
|
|
|
120
158
|
|
|
121
159
|
/**
|
|
122
160
|
* One line per interrupted-run note not yet spoken, and one per note that
|
|
123
|
-
* could not be read, from the state home and then the working directory
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
161
|
+
* could not be read, from the state home and then the working directory, each
|
|
162
|
+
* spoken note marked in every directory it was found in (design D3). A run
|
|
163
|
+
* whose note is in both is one interrupted run, spoken once and marked in
|
|
164
|
+
* both. Loaded on demand rather than imported, so a hook that finds its
|
|
165
|
+
* library missing still exits 0 — and says so instead of falling silent.
|
|
166
|
+
*
|
|
167
|
+
* @returns {Promise<{lines: string[], spoken: object[], unreadable: Array<{file: string, reason: string}>}>}
|
|
127
168
|
*/
|
|
128
|
-
async function
|
|
169
|
+
async function interruptedNotes(cwd, stateHome) {
|
|
129
170
|
let lib
|
|
130
171
|
try {
|
|
131
172
|
lib = await import(new URL('../lib/interrupted.mjs', import.meta.url).href)
|
|
132
173
|
} catch (err) {
|
|
133
|
-
|
|
174
|
+
const reason = (err && err.message) || String(err)
|
|
175
|
+
return { lines: [`interrupted-run notes could not be read: ${reason}`], spoken: [], unreadable: [{ file: 'lib/interrupted.mjs', reason }] }
|
|
134
176
|
}
|
|
135
177
|
const roots = stateHome && !sameDirectory(stateHome, cwd) ? [stateHome, cwd] : [cwd]
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
const unreadableLines = []
|
|
178
|
+
const found = new Map()
|
|
179
|
+
const unreadable = []
|
|
139
180
|
for (const root of roots) {
|
|
140
|
-
const { notes, unreadable } = lib.readInterruptedNotes(root)
|
|
181
|
+
const { notes, unreadable: bad } = lib.readInterruptedNotes(root)
|
|
141
182
|
for (const note of notes.filter(lib.isUnspoken)) {
|
|
142
|
-
if (
|
|
143
|
-
|
|
144
|
-
banners.push(lib.formatInterruptedBanner(note))
|
|
183
|
+
if (!found.has(note.runId)) found.set(note.runId, { note, roots: [] })
|
|
184
|
+
found.get(note.runId).roots.push(root)
|
|
145
185
|
}
|
|
146
186
|
// The working directory's notes keep their root-relative names; the home's are named in full.
|
|
147
|
-
for (const u of unreadable)
|
|
148
|
-
|
|
149
|
-
|
|
187
|
+
for (const u of bad) unreadable.push({ file: root === cwd ? u.file : join(root, u.file), reason: u.reason })
|
|
188
|
+
}
|
|
189
|
+
const banners = []
|
|
190
|
+
const spoken = []
|
|
191
|
+
for (const { note, roots: where } of found.values()) {
|
|
192
|
+
const banner = lib.formatInterruptedBanner(note)
|
|
193
|
+
banners.push(banner)
|
|
194
|
+
// Spoken, then marked: a mark that fails leaves the note to be said again.
|
|
195
|
+
const at = new Date().toISOString()
|
|
196
|
+
const marks = where.map(root => {
|
|
197
|
+
const m = lib.markSpoken(root, note.runId, at)
|
|
198
|
+
return { root, marked: m.marked === true, reason: m.marked === true ? null : m.reason }
|
|
199
|
+
})
|
|
200
|
+
spoken.push({ runId: note.runId, change: note.change, stage: note.stage, banner, marked: marks.every(m => m.marked), marks })
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
lines: [...banners, ...unreadable.map(u => `An interrupted-run note could not be read: ${u.file}: ${u.reason}`)],
|
|
204
|
+
spoken,
|
|
205
|
+
unreadable
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The halt resume cards of open changes under the state home and the working
|
|
211
|
+
* directory, listed from each card's stamp (design D4). The listing reads no
|
|
212
|
+
* card past its first line, and the advisory output never mentions a card.
|
|
213
|
+
*/
|
|
214
|
+
async function resumeCards(cwd, stateHome) {
|
|
215
|
+
const roots = stateHome && !sameDirectory(stateHome, cwd) ? [stateHome, cwd] : [cwd]
|
|
216
|
+
try {
|
|
217
|
+
const lib = await import(new URL('../lib/resume-card.mjs', import.meta.url).href)
|
|
218
|
+
return lib.listResumeCards({ roots, changesDir: join(cwd, 'openspec', 'changes') })
|
|
219
|
+
} catch (err) {
|
|
220
|
+
const reason = `halt resume cards could not be listed: ${(err && err.message) || String(err)}`
|
|
221
|
+
return { listed: [], archived: 0, unreadable: [{ path: join(cwd, '.claude', 'handoff'), reason }], lookedIn: [] }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A project where a ship run can start: the working root holds an `openspec/` directory (design D2). */
|
|
226
|
+
function isOpenSpecProject(cwd) {
|
|
227
|
+
try {
|
|
228
|
+
return statSync(join(cwd, 'openspec')).isDirectory()
|
|
229
|
+
} catch {
|
|
230
|
+
return false
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Leave this session's report in `.claude/ship/preflight.json` (design D1,
|
|
236
|
+
* D2): the previous file removed first, so a write that fails leaves no stale
|
|
237
|
+
* report, then the new one written whole. A failure is one stderr line, and
|
|
238
|
+
* the session starts as before.
|
|
239
|
+
*/
|
|
240
|
+
async function writeReport(cwd, input) {
|
|
241
|
+
let path = join(cwd, '.claude', 'ship', 'preflight.json')
|
|
242
|
+
try {
|
|
243
|
+
const lib = await import(new URL('../lib/preflight-file.mjs', import.meta.url).href)
|
|
244
|
+
path = join(cwd, lib.PREFLIGHT_FILE)
|
|
245
|
+
rmSync(path, { force: true })
|
|
246
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
247
|
+
writeFileSync(path, JSON.stringify(lib.buildPreflightReport(input), null, 2) + '\n')
|
|
248
|
+
} catch (err) {
|
|
249
|
+
process.stderr.write(`interlock preflight: report not written: ${path}: ${(err && err.message) || String(err)}\n`)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The session-start source from the host's event on stdin (`startup`,
|
|
255
|
+
* `resume`, `clear`, `compact`), or `null`: the report records it so the
|
|
256
|
+
* hooks module keeps a band hidden across a compaction (design D5).
|
|
257
|
+
*/
|
|
258
|
+
async function sessionSource() {
|
|
259
|
+
try {
|
|
260
|
+
if (process.stdin.isTTY) return null
|
|
261
|
+
const chunks = []
|
|
262
|
+
for await (const chunk of process.stdin) chunks.push(chunk)
|
|
263
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim()
|
|
264
|
+
const event = raw ? JSON.parse(raw) : null
|
|
265
|
+
return event && typeof event.source === 'string' ? event.source : null
|
|
266
|
+
} catch {
|
|
267
|
+
return null
|
|
150
268
|
}
|
|
151
|
-
return [...banners, ...unreadableLines]
|
|
152
269
|
}
|
|
153
270
|
|
|
154
271
|
/**
|
|
@@ -208,8 +325,25 @@ try {
|
|
|
208
325
|
`interlock preflight could not publish notify options (ignored): ${(err && err.message) || err}\n`
|
|
209
326
|
)
|
|
210
327
|
}
|
|
211
|
-
const
|
|
212
|
-
const
|
|
328
|
+
const cwd = process.cwd()
|
|
329
|
+
const source = await sessionSource()
|
|
330
|
+
const { message, stateHome, surface, doctor } = run()
|
|
331
|
+
const notes = await interruptedNotes(cwd, stateHome)
|
|
332
|
+
const lines = [message, ...notes.lines].filter(Boolean)
|
|
333
|
+
// Written before stdout, so a session that reads the context has the file on disk already (design D10).
|
|
334
|
+
if (isOpenSpecProject(cwd)) {
|
|
335
|
+
await writeReport(cwd, {
|
|
336
|
+
writtenAt: new Date().toISOString(),
|
|
337
|
+
source,
|
|
338
|
+
root: cwd,
|
|
339
|
+
stateHome,
|
|
340
|
+
surface,
|
|
341
|
+
message,
|
|
342
|
+
doctor,
|
|
343
|
+
notes: { spoken: notes.spoken, unreadable: notes.unreadable },
|
|
344
|
+
cards: await resumeCards(cwd, stateHome)
|
|
345
|
+
})
|
|
346
|
+
}
|
|
213
347
|
if (lines.length) report(lines.join('\n'))
|
|
214
348
|
} catch (err) {
|
|
215
349
|
// Belt and suspenders: nothing this hook does may abort the session.
|