@renzrollon/interlock 1.1.1 → 1.2.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.
Files changed (55) hide show
  1. package/.claude-plugin/icon.png +0 -0
  2. package/.claude-plugin/plugin.json +109 -1
  3. package/CHANGELOG.md +150 -11
  4. package/bin/interlock +421 -53
  5. package/bin/interlock-run +119 -7
  6. package/hooks/guard-relaunch.mjs +115 -0
  7. package/hooks/guard-tests.mjs +51 -3
  8. package/hooks/hooks.json +1 -0
  9. package/hooks/mod.mjs +655 -0
  10. package/hooks/preflight.mjs +124 -13
  11. package/hooks/recorder.mjs +189 -0
  12. package/lib/agent-usage.mjs +524 -0
  13. package/lib/doctor.mjs +511 -46
  14. package/lib/eval-history.mjs +233 -0
  15. package/lib/evals-capture.mjs +5 -2
  16. package/lib/evals-triage.mjs +1 -1
  17. package/lib/graph/cli.mjs +28 -5
  18. package/lib/graph/retrieve/context.mjs +8 -3
  19. package/lib/host/acp.mjs +77 -14
  20. package/lib/host/claude-cli.mjs +391 -21
  21. package/lib/host/claude-env.mjs +179 -0
  22. package/lib/host/codex.mjs +9 -1
  23. package/lib/host/effort.mjs +163 -0
  24. package/lib/host/model-map.mjs +43 -0
  25. package/lib/host/qwen.mjs +9 -1
  26. package/lib/host/registry.mjs +44 -10
  27. package/lib/host.mjs +46 -5
  28. package/lib/interrupted.mjs +167 -0
  29. package/lib/launch-ledger.mjs +198 -0
  30. package/lib/launch-rule.mjs +174 -0
  31. package/lib/limits.mjs +149 -41
  32. package/lib/meter-quiet.mjs +45 -0
  33. package/lib/notify-defaults.mjs +6 -0
  34. package/lib/notify.mjs +41 -25
  35. package/lib/plan-fingerprint.mjs +80 -11
  36. package/lib/prompts/implementer.mjs +1 -1
  37. package/lib/prompts/verify.mjs +24 -1
  38. package/lib/receipt.mjs +59 -8
  39. package/lib/report-html.mjs +89 -6
  40. package/lib/report.mjs +313 -12
  41. package/lib/resume-card.mjs +90 -2
  42. package/lib/run-log.mjs +192 -5
  43. package/lib/run.mjs +1177 -84
  44. package/lib/state-home.mjs +298 -0
  45. package/lib/verify.mjs +37 -0
  46. package/lib/waves.mjs +53 -16
  47. package/package.json +4 -2
  48. package/shared/TEST-PROFILE.md +10 -5
  49. package/skills/evals/SKILL.md +3 -3
  50. package/skills/fix-tests/SKILL.md +3 -3
  51. package/skills/spec/SKILL.md +1 -6
  52. package/skills/spec/scripts/compact-instructions.mjs +30 -0
  53. package/types/index.d.ts +28 -0
  54. package/workflows/ship.js +75 -51
  55. package/.claude-plugin/marketplace.json +0 -19
@@ -12,10 +12,20 @@
12
12
  // It NEVER blocks. `interlock doctor` is non-mutating, and a preflight that
13
13
  // aborted the session because its own binary was missing would be worse than no
14
14
  // preflight. Every path here exits 0.
15
+ //
16
+ // It also surfaces any interrupted-run note a previous session left — a run
17
+ // that ended with its session and never reached its close (lib/interrupted.mjs).
18
+ // It marks nothing: the next `interlock run start`, where the reader is about to
19
+ // spend agents, is the moment that marks a note spoken. A linked worktree's run
20
+ // writes its note into the state home — the main checkout — so the notes are
21
+ // read from the home the doctor resolved AND from the working directory (a run
22
+ // whose manifest recorded no home wrote there), each run spoken once. When the
23
+ // doctor gave no home — it could not run, or predates the field — the working
24
+ // directory alone, as before.
15
25
 
16
26
  import { execFileSync } from 'node:child_process'
17
- import { existsSync } from 'node:fs'
18
- import { dirname, join } from 'node:path'
27
+ import { appendFileSync, existsSync, realpathSync } from 'node:fs'
28
+ import { dirname, join, resolve } from 'node:path'
19
29
  import { fileURLToPath } from 'node:url'
20
30
 
21
31
  const PLUGIN_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
@@ -27,6 +37,12 @@ function interlockBin() {
27
37
  return 'interlock' // let PATH resolution decide; a miss throws ENOENT below
28
38
  }
29
39
 
40
+ /**
41
+ * The preflight's own report, as one message, and the state home the doctor
42
+ * resolved — `null` when its output carried none or could not be read.
43
+ *
44
+ * @returns {{ message: string, stateHome: string|null }}
45
+ */
30
46
  function run() {
31
47
  let raw
32
48
  try {
@@ -43,13 +59,14 @@ function run() {
43
59
  if (err && typeof err.stdout === 'string' && err.stdout.trim()) {
44
60
  raw = err.stdout
45
61
  } else {
46
- report(
47
- `interlock preflight could not run: ${(err && err.message) || String(err)}\n` +
62
+ return {
63
+ message:
64
+ `interlock preflight could not run: ${(err && err.message) || String(err)}\n` +
48
65
  `The \`interlock\` binary is not resolvable from this session. Inside Claude Code the ` +
49
66
  `plugin's bin/ is normally injected; if you see this, reinstall the plugin. The session ` +
50
- `continues — this preflight is advisory.`
51
- )
52
- return
67
+ `continues — this preflight is advisory.`,
68
+ stateHome: null
69
+ }
53
70
  }
54
71
  }
55
72
 
@@ -57,17 +74,23 @@ function run() {
57
74
  try {
58
75
  doctor = JSON.parse(raw)
59
76
  } catch {
60
- report('interlock preflight ran but its output could not be parsed; skipping. The session continues.')
61
- return
77
+ doctor = null
78
+ }
79
+ if (!doctor || typeof doctor !== 'object') {
80
+ return {
81
+ message: 'interlock preflight ran but its output could not be parsed; skipping. The session continues.',
82
+ stateHome: null
83
+ }
62
84
  }
85
+ const stateHome =
86
+ typeof doctor.stateHome === 'string' && doctor.stateHome ? resolve(process.cwd(), doctor.stateHome) : null
63
87
 
64
88
  const checks = Array.isArray(doctor.checks) ? doctor.checks : []
65
89
  const failures = checks.filter(c => c && c.status === 'fail')
66
90
  if (!failures.length) {
67
91
  const warns = (doctor.counts && doctor.counts.warn) || 0
68
92
  // A clean preflight is quiet, not a wall of output — one confirming line.
69
- report(`interlock preflight OK${warns ? ` (${warns} warning${warns === 1 ? '' : 's'})` : ''}.`)
70
- return
93
+ return { message: `interlock preflight OK${warns ? ` (${warns} warning${warns === 1 ? '' : 's'})` : ''}.`, stateHome }
71
94
  }
72
95
 
73
96
  const lines = ['interlock preflight found issues that can stall an unattended ship run:']
@@ -80,7 +103,52 @@ function run() {
80
103
  }
81
104
  }
82
105
  lines.push('The session still starts — apply the fixes above before /interlock:ship.')
83
- report(lines.join('\n'))
106
+ return { message: lines.join('\n'), stateHome }
107
+ }
108
+
109
+ /** Two paths naming one directory — through a symlink too — so one directory is read once. */
110
+ function sameDirectory(a, b) {
111
+ const canonical = p => {
112
+ try {
113
+ return realpathSync(p)
114
+ } catch {
115
+ return resolve(p)
116
+ }
117
+ }
118
+ return canonical(a) === canonical(b)
119
+ }
120
+
121
+ /**
122
+ * 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. A run
124
+ * whose note is in both is one interrupted run, spoken once. Loaded on demand
125
+ * rather than imported, so a hook that finds its library missing still exits 0
126
+ * — and says so instead of falling silent.
127
+ */
128
+ async function interruptedLines(cwd, stateHome) {
129
+ let lib
130
+ try {
131
+ lib = await import(new URL('../lib/interrupted.mjs', import.meta.url).href)
132
+ } catch (err) {
133
+ return [`interrupted-run notes could not be read: ${(err && err.message) || String(err)}`]
134
+ }
135
+ const roots = stateHome && !sameDirectory(stateHome, cwd) ? [stateHome, cwd] : [cwd]
136
+ const spoken = new Set()
137
+ const banners = []
138
+ const unreadableLines = []
139
+ for (const root of roots) {
140
+ const { notes, unreadable } = lib.readInterruptedNotes(root)
141
+ for (const note of notes.filter(lib.isUnspoken)) {
142
+ if (spoken.has(note.runId)) continue
143
+ spoken.add(note.runId)
144
+ banners.push(lib.formatInterruptedBanner(note))
145
+ }
146
+ // The working directory's notes keep their root-relative names; the home's are named in full.
147
+ for (const u of unreadable) {
148
+ unreadableLines.push(`An interrupted-run note could not be read: ${root === cwd ? u.file : join(root, u.file)}: ${u.reason}`)
149
+ }
150
+ }
151
+ return [...banners, ...unreadableLines]
84
152
  }
85
153
 
86
154
  /**
@@ -98,8 +166,51 @@ function report(message) {
98
166
  process.stdout.write(JSON.stringify(out) + '\n')
99
167
  }
100
168
 
169
+ /** A single-quoted shell word. A value with a newline or a NUL is refused. */
170
+ function shellQuote(value) {
171
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
172
+ }
173
+
174
+ function publishable(value) {
175
+ return typeof value === 'string' && value.trim() && !/[\0\r\n]/.test(value)
176
+ }
177
+
178
+ /**
179
+ * Copy a plugin option into the session so `interlock notify` can see it.
180
+ *
181
+ * The host injects `userConfig` into hook processes only. A ship run's notify
182
+ * call is not a hook, so without this copy a topic typed into the plugin
183
+ * options would never reach the CLI. An operator who already provided the
184
+ * shell variable keeps that value. Nothing is printed, and a failure here
185
+ * does not change the preflight.
186
+ */
187
+ function publishNotifyOptions() {
188
+ const file = process.env.CLAUDE_ENV_FILE
189
+ if (typeof file !== 'string' || !file) return
190
+ const lines = []
191
+ const topic = process.env.CLAUDE_PLUGIN_OPTION_NTFY_TOPIC
192
+ const url = process.env.CLAUDE_PLUGIN_OPTION_NTFY_URL
193
+ if (publishable(topic) && !publishable(process.env.INTERLOCK_NTFY_TOPIC)) {
194
+ lines.push(`export INTERLOCK_NTFY_TOPIC=${shellQuote(topic.trim())}`)
195
+ }
196
+ if (publishable(url) && !publishable(process.env.INTERLOCK_NTFY_URL)) {
197
+ lines.push(`export INTERLOCK_NTFY_URL=${shellQuote(url.trim())}`)
198
+ }
199
+ if (!lines.length) return
200
+ appendFileSync(file, lines.join('\n') + '\n')
201
+ }
202
+
101
203
  try {
102
- run()
204
+ try {
205
+ publishNotifyOptions()
206
+ } catch (err) {
207
+ process.stderr.write(
208
+ `interlock preflight could not publish notify options (ignored): ${(err && err.message) || err}\n`
209
+ )
210
+ }
211
+ const { message, stateHome } = run()
212
+ const lines = [message, ...(await interruptedLines(process.cwd(), stateHome))].filter(Boolean)
213
+ if (lines.length) report(lines.join('\n'))
103
214
  } catch (err) {
104
215
  // Belt and suspenders: nothing this hook does may abort the session.
105
216
  process.stderr.write(`interlock preflight hook error (ignored): ${(err && err.message) || String(err)}\n`)
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+ // The recorder: a hook that REPORTS what a ship run cannot see about itself, and
3
+ // never decides anything.
4
+ //
5
+ // The guards beside it return a decision; this returns none. It prints nothing
6
+ // on stdout on any path, and it exits 0 on every path — a valid event, malformed
7
+ // input, an event it has no branch for, and its own crash. A recorder that could
8
+ // block would be a guard nobody reviewed as one. A subagent hook that exited
9
+ // non-zero could keep the agent it reports on running.
10
+ //
11
+ // ONE FILE FOR EVERY REPORTING BRANCH. It dispatches on `hook_event_name`; a new
12
+ // reporting branch joins this file rather than adding a second recorder. Five
13
+ // branches today:
14
+ //
15
+ // SessionEnd — a session that ends while its own ship run is live kills the
16
+ // background workflow before `run close`: no receipt, no resume card, no
17
+ // terminal trajectory event. This writes one small note under the state
18
+ // home's `.claude/ship/interrupted/` so the next `interlock run start`, the
19
+ // next SessionStart preflight and `interlock report` can say so
20
+ // (lib/interrupted.mjs). The home is the one the manifest records — a linked
21
+ // worktree's run writes into its main checkout — else the working root. It is
22
+ // read from the manifest, never found by running version control.
23
+ //
24
+ // SubagentStart, SubagentStop — a Workflow script's `agent()` returns its
25
+ // result and nothing else. The start records the agent once (the resume and
26
+ // teammate-message firings change nothing); the stop sums the agent's own
27
+ // transcript — usage per turn, the models that served it, the briefing sha it
28
+ // was spawned with — into the same file (lib/agent-usage.mjs). `run close`
29
+ // joins those files to the spawns it dispatched.
30
+ //
31
+ // PermissionDenied, PermissionRequest — a permission event that interrupted
32
+ // the run, one file per event: its kind, the time, the agent, the tool name and
33
+ // the host's reason. Never the tool input, never the host's suggestions.
34
+ //
35
+ // EVERY PLUGIN HOOK RUNS IN EVERY SESSION, in every repository. So every branch
36
+ // passes one gate, in this order, and otherwise creates no file and no
37
+ // directory:
38
+ //
39
+ // - a stage marker is live: its pid — the session process, which is still
40
+ // alive while its own hooks run — is a running process. A marker whose
41
+ // process is gone is an orphan and means nothing;
42
+ // - the run manifest names that marker's change and carries a run id;
43
+ // - SessionEnd only: the manifest's session is the one ending. A session that
44
+ // does not own the run, or a run with no session (the runner host), never
45
+ // matches — the runner's own signal trap is its receipt instead;
46
+ // - the two subagent branches only: the event's agent type is exactly the one
47
+ // the host reports for every agent a Workflow script spawns, whatever type
48
+ // the script named. The registration's matcher says the same, and the
49
+ // branch checks again: an internal agent of a `claude --agent
50
+ // interlock:worker` session reports `interlock:worker`, and writes nothing.
51
+ //
52
+ // It never reads or writes the run trajectory, and it starts no process: one
53
+ // `mkdirSync` and one write per record. A failed write is a line on stderr, never
54
+ // an exit code (the note and the agent-usage sidecar are outcome-class;
55
+ // docs/13-the-guards.md says why that differs from the trajectory's rule).
56
+
57
+ import { readFileSync } from 'node:fs'
58
+ import { join, resolve } from 'node:path'
59
+ import { readStage, SHIP_DIR } from '../lib/ship-stage.mjs'
60
+ import { writeInterruptedNote } from '../lib/interrupted.mjs'
61
+ import { WORKFLOW_AGENT_TYPE, writeAgentStart, writeAgentStop, writePermissionEvent } from '../lib/agent-usage.mjs'
62
+ import { projectRoot, readEvent } from './_shared.mjs'
63
+
64
+ /** The run manifest, read raw: the run program is not loaded into a hook. */
65
+ function readRunManifest(root) {
66
+ try {
67
+ const value = JSON.parse(readFileSync(join(root, SHIP_DIR, 'run.json'), 'utf8'))
68
+ return value && typeof value === 'object' ? value : null
69
+ } catch {
70
+ return null
71
+ }
72
+ }
73
+
74
+ /**
75
+ * The gate every branch passes before it touches the filesystem: a live marker,
76
+ * and a manifest for that marker's change carrying a run id. `null` otherwise.
77
+ */
78
+ function liveRun(root) {
79
+ const marker = readStage({ root })
80
+ if (marker.stage === 'unknown' || typeof marker.change !== 'string') return null
81
+
82
+ const manifest = readRunManifest(root)
83
+ if (!manifest) return null
84
+ if (manifest.change !== marker.change) return null
85
+ if (typeof manifest.runId !== 'string' || !manifest.runId) return null
86
+ return { marker, manifest, runId: manifest.runId }
87
+ }
88
+
89
+ /** The gate, plus the agent-type re-check the two subagent branches add. */
90
+ function liveRunForAgent(root, event) {
91
+ const run = liveRun(root)
92
+ if (!run) return null
93
+ if (event.agent_type !== WORKFLOW_AGENT_TYPE) return null
94
+ return run
95
+ }
96
+
97
+ /** A record that did not land is one line on stderr, and nothing more. */
98
+ function speak(what, result) {
99
+ if (!result.written) process.stderr.write(`interlock recorder: ${what} not written: ${result.reason}\n`)
100
+ }
101
+
102
+ /** A session that ended while its own run was live leaves a note. Anything else, nothing. */
103
+ function onSessionEnd(event) {
104
+ const root = projectRoot(event)
105
+ const sessionId = typeof event.session_id === 'string' ? event.session_id.trim() : ''
106
+ if (!sessionId) return
107
+
108
+ const run = liveRun(root)
109
+ if (!run) return
110
+ const { marker, manifest } = run
111
+ if (typeof manifest.sessionId !== 'string' || manifest.sessionId !== sessionId) return
112
+
113
+ // The home `run start` resolved and recorded; a manifest without one is a run
114
+ // in the main checkout, or one written before the field existed.
115
+ const home = typeof manifest.stateHome === 'string' && manifest.stateHome ? resolve(root, manifest.stateHome) : root
116
+ speak(
117
+ 'interrupted-run note',
118
+ writeInterruptedNote(home, {
119
+ runId: run.runId,
120
+ change: manifest.change,
121
+ sessionId,
122
+ reason: event.reason,
123
+ stage: marker.stage
124
+ })
125
+ )
126
+ }
127
+
128
+ /** A Workflow agent started: its record, once. */
129
+ function onSubagentStart(event) {
130
+ const root = projectRoot(event)
131
+ const run = liveRunForAgent(root, event)
132
+ if (!run) return
133
+ speak('agent start', writeAgentStart(root, { runId: run.runId, agentId: event.agent_id, agentType: event.agent_type }))
134
+ }
135
+
136
+ /** A Workflow agent stopped: its transcript, summed into the same record. */
137
+ function onSubagentStop(event) {
138
+ const root = projectRoot(event)
139
+ const run = liveRunForAgent(root, event)
140
+ if (!run) return
141
+ speak(
142
+ 'agent stop',
143
+ writeAgentStop(root, {
144
+ runId: run.runId,
145
+ agentId: event.agent_id,
146
+ agentType: event.agent_type,
147
+ transcriptPath: event.agent_transcript_path
148
+ })
149
+ )
150
+ }
151
+
152
+ /** One permission event, one file — the fields by name, and nothing the tool was given. */
153
+ function permissionBranch(kind) {
154
+ return event => {
155
+ const root = projectRoot(event)
156
+ const run = liveRun(root)
157
+ if (!run) return
158
+ speak(
159
+ `permission ${kind} event`,
160
+ writePermissionEvent(root, {
161
+ runId: run.runId,
162
+ kind,
163
+ agentId: event.agent_id ?? null,
164
+ tool: event.tool_name,
165
+ reason: event.reason ?? null
166
+ })
167
+ )
168
+ }
169
+ }
170
+
171
+ const BRANCHES = {
172
+ SessionEnd: onSessionEnd,
173
+ SubagentStart: onSubagentStart,
174
+ SubagentStop: onSubagentStop,
175
+ PermissionDenied: permissionBranch('denied'),
176
+ PermissionRequest: permissionBranch('request')
177
+ }
178
+
179
+ try {
180
+ const event = await readEvent()
181
+ const name = event && event.hook_event_name
182
+ // Own keys only: an event named after an Object.prototype member is no branch.
183
+ if (typeof name === 'string' && Object.hasOwn(BRANCHES, name)) BRANCHES[name](event)
184
+ } catch (err) {
185
+ // Nothing this hook does may stop a session from ending, keep an agent
186
+ // running, or speak a decision.
187
+ process.stderr.write(`interlock recorder hook error (ignored): ${(err && err.message) || String(err)}\n`)
188
+ }
189
+ process.exit(0)