@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.
- package/.claude-plugin/icon.png +0 -0
- package/.claude-plugin/plugin.json +109 -1
- package/CHANGELOG.md +150 -11
- package/bin/interlock +421 -53
- package/bin/interlock-run +119 -7
- package/hooks/guard-relaunch.mjs +115 -0
- package/hooks/guard-tests.mjs +51 -3
- package/hooks/hooks.json +1 -0
- package/hooks/mod.mjs +655 -0
- package/hooks/preflight.mjs +124 -13
- package/hooks/recorder.mjs +189 -0
- package/lib/agent-usage.mjs +524 -0
- package/lib/doctor.mjs +511 -46
- package/lib/eval-history.mjs +233 -0
- package/lib/evals-capture.mjs +5 -2
- package/lib/evals-triage.mjs +1 -1
- package/lib/graph/cli.mjs +28 -5
- package/lib/graph/retrieve/context.mjs +8 -3
- package/lib/host/acp.mjs +77 -14
- package/lib/host/claude-cli.mjs +391 -21
- package/lib/host/claude-env.mjs +179 -0
- package/lib/host/codex.mjs +9 -1
- package/lib/host/effort.mjs +163 -0
- package/lib/host/model-map.mjs +43 -0
- package/lib/host/qwen.mjs +9 -1
- package/lib/host/registry.mjs +44 -10
- package/lib/host.mjs +46 -5
- package/lib/interrupted.mjs +167 -0
- package/lib/launch-ledger.mjs +198 -0
- package/lib/launch-rule.mjs +174 -0
- package/lib/limits.mjs +149 -41
- package/lib/meter-quiet.mjs +45 -0
- package/lib/notify-defaults.mjs +6 -0
- package/lib/notify.mjs +41 -25
- package/lib/plan-fingerprint.mjs +80 -11
- package/lib/prompts/implementer.mjs +1 -1
- package/lib/prompts/verify.mjs +24 -1
- package/lib/receipt.mjs +59 -8
- package/lib/report-html.mjs +89 -6
- package/lib/report.mjs +313 -12
- package/lib/resume-card.mjs +90 -2
- package/lib/run-log.mjs +192 -5
- package/lib/run.mjs +1177 -84
- package/lib/state-home.mjs +298 -0
- package/lib/verify.mjs +37 -0
- package/lib/waves.mjs +53 -16
- package/package.json +4 -2
- package/shared/TEST-PROFILE.md +10 -5
- package/skills/evals/SKILL.md +3 -3
- package/skills/fix-tests/SKILL.md +3 -3
- package/skills/spec/SKILL.md +1 -6
- package/skills/spec/scripts/compact-instructions.mjs +30 -0
- package/types/index.d.ts +28 -0
- package/workflows/ship.js +75 -51
- package/.claude-plugin/marketplace.json +0 -19
package/hooks/preflight.mjs
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|