@renzrollon/interlock 1.1.1 → 1.2.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.
Files changed (48) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +94 -1
  3. package/CHANGELOG.md +135 -1
  4. package/bin/interlock +409 -49
  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 +83 -13
  11. package/hooks/recorder.mjs +189 -0
  12. package/lib/agent-usage.mjs +524 -0
  13. package/lib/doctor.mjs +488 -28
  14. package/lib/eval-history.mjs +233 -0
  15. package/lib/evals-capture.mjs +5 -2
  16. package/lib/graph/cli.mjs +28 -5
  17. package/lib/graph/retrieve/context.mjs +8 -3
  18. package/lib/host/acp.mjs +77 -14
  19. package/lib/host/claude-cli.mjs +391 -21
  20. package/lib/host/claude-env.mjs +179 -0
  21. package/lib/host/codex.mjs +9 -1
  22. package/lib/host/effort.mjs +163 -0
  23. package/lib/host/model-map.mjs +43 -0
  24. package/lib/host/qwen.mjs +9 -1
  25. package/lib/host/registry.mjs +44 -10
  26. package/lib/host.mjs +46 -5
  27. package/lib/interrupted.mjs +167 -0
  28. package/lib/launch-ledger.mjs +198 -0
  29. package/lib/launch-rule.mjs +174 -0
  30. package/lib/limits.mjs +149 -41
  31. package/lib/meter-quiet.mjs +45 -0
  32. package/lib/plan-fingerprint.mjs +80 -11
  33. package/lib/prompts/implementer.mjs +1 -1
  34. package/lib/prompts/verify.mjs +24 -1
  35. package/lib/receipt.mjs +59 -8
  36. package/lib/report-html.mjs +89 -6
  37. package/lib/report.mjs +313 -12
  38. package/lib/resume-card.mjs +90 -2
  39. package/lib/run-log.mjs +192 -5
  40. package/lib/run.mjs +1177 -84
  41. package/lib/state-home.mjs +298 -0
  42. package/lib/verify.mjs +37 -0
  43. package/lib/waves.mjs +53 -16
  44. package/package.json +2 -1
  45. package/shared/TEST-PROFILE.md +10 -5
  46. package/skills/fix-tests/SKILL.md +1 -1
  47. package/types/index.d.ts +28 -0
  48. package/workflows/ship.js +75 -51
package/lib/host.mjs CHANGED
@@ -26,7 +26,7 @@
26
26
  // CLI, which is the same split — script versus policy — the repo already had.
27
27
 
28
28
  import { spawn as spawnProcess } from 'node:child_process'
29
- import { dirname, join } from 'node:path'
29
+ import { delimiter, dirname, join } from 'node:path'
30
30
  import { fileURLToPath } from 'node:url'
31
31
 
32
32
  /**
@@ -37,6 +37,35 @@ import { fileURLToPath } from 'node:url'
37
37
  */
38
38
  export const INTERLOCK_BIN = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'interlock')
39
39
 
40
+ /**
41
+ * Environment for a runner lane.
42
+ *
43
+ * A briefing names `interlock` bare — `interlock verify exec`, and the same
44
+ * bare name in the implementer — while `runCli` executes `INTERLOCK_BIN` by
45
+ * absolute path, so the runner and the CLI that wrote the briefing are one
46
+ * binary whatever PATH says. A lane is not. A headless `-p --plugin-dir`
47
+ * session appends each plugin `bin/` after the PATH it inherited (probed
48
+ * 2026-10-05: this checkout's `bin/` was the last entry), so the directory
49
+ * placed first is the one the bare name resolves to. This copies `env` with
50
+ * `dirname(INTERLOCK_BIN)` first and any later copy of that directory removed,
51
+ * and writes the path back under the key the caller spelled (`Path` on
52
+ * Windows, `PATH` when the caller named none). Nothing else moves:
53
+ * `CLAUDE_CODE_EFFORT_LEVEL` stays the operator's.
54
+ *
55
+ * @param {NodeJS.ProcessEnv} [env]
56
+ * @returns {NodeJS.ProcessEnv}
57
+ */
58
+ export function laneEnv(env = process.env) {
59
+ const source = env && typeof env === 'object' ? env : {}
60
+ const key = Object.prototype.hasOwnProperty.call(source, 'PATH')
61
+ ? 'PATH'
62
+ : Object.keys(source).find(name => name.toLowerCase() === 'path') || 'PATH'
63
+ const binDir = dirname(INTERLOCK_BIN)
64
+ const current = source[key]
65
+ const rest = typeof current === 'string' ? current.split(delimiter).filter(entry => entry !== binDir) : []
66
+ return { ...source, [key]: [binDir, ...rest].join(delimiter) }
67
+ }
68
+
40
69
  /** The three ports a host must provide. Read by `assertWorkflowHost`. */
41
70
  export const HOST_PORTS = ['spawn', 'mapPipeline', 'runCli']
42
71
 
@@ -70,6 +99,7 @@ export function spawnPrefix(kind) {
70
99
  * @property {string} label stable name for the agent (task id, step name)
71
100
  * @property {string} prompt the full prompt text
72
101
  * @property {string} [model] model slug the planner assigned, if the host can honour it
102
+ * @property {string} [effort] effort level the step named; absent means inherit the host's default
73
103
  * @property {object} [schema] JSON Schema the result must satisfy
74
104
  * @property {string} [type] plugin agent id (`interlock:ping` / `interlock:worker`)
75
105
  * @property {string[]} [tools] tools allowlist; omitting it inherits the parent catalog
@@ -200,7 +230,7 @@ export function runCli(argv, { cwd = process.cwd(), env = process.env, input, ti
200
230
  * @param {object} [opts.env]
201
231
  * @param {string} [opts.input] the prompt, written to stdin and closed
202
232
  * @param {number} [opts.timeoutMs] 0 or absent disables the wall clock
203
- * @returns {Promise<{code: number|null, stdout: string, stderr: string, signal: string|null, timedOut: boolean}>}
233
+ * @returns {Promise<{code: number|null, stdout: string, stderr: string, signal: string|null, timedOut: boolean, started: boolean}>}
204
234
  */
205
235
  export function execCapture(command, args = [], { cwd, env, input, timeoutMs } = {}) {
206
236
  return new Promise(resolve => {
@@ -212,7 +242,7 @@ export function execCapture(command, args = [], { cwd, env, input, timeoutMs } =
212
242
  stdio: ['pipe', 'pipe', 'pipe']
213
243
  })
214
244
  } catch (err) {
215
- resolve({ code: null, stdout: '', stderr: `${command}: ${(err && err.message) || err}\n`, signal: null, timedOut: false })
245
+ resolve({ code: null, stdout: '', stderr: `${command}: ${(err && err.message) || err}\n`, signal: null, timedOut: false, started: false })
216
246
  return
217
247
  }
218
248
 
@@ -243,8 +273,18 @@ export function execCapture(command, args = [], { cwd, env, input, timeoutMs } =
243
273
  child.stderr.on('data', chunk => {
244
274
  stderr += chunk
245
275
  })
276
+ // `started` is whether a process ever existed: a spawn that failed (a
277
+ // missing binary, a missing cwd) has no pid. An adapter reports what it did
278
+ // with a spawn's effort only for one that started (run-host-adapters spec).
246
279
  child.on('error', err => {
247
- done({ code: null, stdout, stderr: `${stderr}${command}: ${(err && err.message) || err}\n`, signal: null, timedOut })
280
+ done({
281
+ code: null,
282
+ stdout,
283
+ stderr: `${stderr}${command}: ${(err && err.message) || err}\n`,
284
+ signal: null,
285
+ timedOut,
286
+ started: typeof child.pid === 'number'
287
+ })
248
288
  })
249
289
  child.on('close', (code, signal) => {
250
290
  done({
@@ -252,7 +292,8 @@ export function execCapture(command, args = [], { cwd, env, input, timeoutMs } =
252
292
  stdout,
253
293
  stderr: timedOut ? `${stderr}${command}: killed after ${budget}ms\n` : stderr,
254
294
  signal: signal || null,
255
- timedOut
295
+ timedOut,
296
+ started: typeof child.pid === 'number'
256
297
  })
257
298
  })
258
299
 
@@ -0,0 +1,167 @@
1
+ // The interrupted-run note (design D7) — what a session that ended mid-run
2
+ // leaves behind, since it never reached `run close`.
3
+ //
4
+ // A session that ends while a ship run is live kills the background workflow
5
+ // before its close: no receipt, no resume card, no terminal trajectory event, in
6
+ // exactly the place a reader most needs one. The SessionEnd recorder
7
+ // (`hooks/recorder.mjs`) writes one small note here instead, and three readers
8
+ // speak it from the one text below: `interlock run start`, the SessionStart
9
+ // preflight, and `interlock report`.
10
+ //
11
+ // ONE FILE PER RUN, NEVER DELETED. `.claude/ship/interrupted/<runId>.json`. Keyed
12
+ // by run, not by change, so two interrupted runs of one change do not overwrite
13
+ // each other; marked spoken rather than deleted, so `interlock report` can still
14
+ // join it to its trajectory after `run start` has spoken it.
15
+ //
16
+ // OUTCOME-CLASS, NOT FATAL. A failed write is a stderr line from the hook; an
17
+ // unreadable note or a failed mark is a note on the manifest. Nothing here moves
18
+ // an exit code. That is the opposite of the run trajectory (`.claude/ship/runs`),
19
+ // whose failed append exits 1 — and the difference is deliberate: the note points
20
+ // at records written elsewhere, while the trajectory IS the record. See
21
+ // docs/13-the-guards.md before making the two consistent.
22
+ //
23
+ // Every function collapses a failure to a value and never throws, because two of
24
+ // its callers are hooks, and a hook whose own crash is its output is worse than
25
+ // no hook.
26
+
27
+ import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
28
+ import { join } from 'node:path'
29
+ import { SHIP_DIR } from './ship-stage.mjs'
30
+
31
+ export const INTERRUPTED_SCHEMA = 'interlock.interrupted/1'
32
+
33
+ /** Where the notes live, under the ship directory the doctor already probes. */
34
+ export const INTERRUPTED_DIR = join(SHIP_DIR, 'interrupted')
35
+
36
+ /**
37
+ * The host's end reason is stored verbatim but bounded, to the same length the
38
+ * trajectory bounds its own free text to (`MAX_TEXT` in `lib/run-log.mjs`;
39
+ * `test/hooks.test.mjs` pins the two equal). Restated rather than imported so a
40
+ * hook loads two small modules and not the trajectory writer.
41
+ */
42
+ export const REASON_MAX = 500
43
+
44
+ function messageOf(err) {
45
+ return (err && err.message) || String(err)
46
+ }
47
+
48
+ /** The note's path for a run, rooted at `root`. */
49
+ export function interruptedPath(root, runId) {
50
+ return join(root, INTERRUPTED_DIR, `${runId}.json`)
51
+ }
52
+
53
+ /** A run id safe to name a file after: the shape `createRunState` mints, and nothing with a separator. */
54
+ function usableRunId(runId) {
55
+ return typeof runId === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(runId) && runId.length <= 200
56
+ }
57
+
58
+ /**
59
+ * Write the note for one interrupted run: one `mkdirSync`, one `writeFileSync`,
60
+ * no subprocess. The reason is the host's, stored as given and bounded; nothing
61
+ * branches on its value, because the host's enum is not verified.
62
+ *
63
+ * @param {string} root
64
+ * @param {{runId: string, change: string, sessionId: string, reason?: string, stage: string, at?: string}} note
65
+ * @returns {{written: true, path: string} | {written: false, reason: string}}
66
+ */
67
+ export function writeInterruptedNote(root, { runId, change, sessionId, reason, stage, at } = {}) {
68
+ if (!usableRunId(runId)) return { written: false, reason: `not a usable run id: ${JSON.stringify(runId)}` }
69
+ const text = typeof reason === 'string' ? reason : reason === undefined || reason === null ? null : String(reason)
70
+ const note = {
71
+ schema: INTERRUPTED_SCHEMA,
72
+ runId,
73
+ change: typeof change === 'string' ? change : null,
74
+ sessionId: typeof sessionId === 'string' ? sessionId : null,
75
+ reason: text === null ? null : text.length > REASON_MAX ? `${text.slice(0, REASON_MAX - 1)}…` : text,
76
+ stage: typeof stage === 'string' ? stage : null,
77
+ at: typeof at === 'string' ? at : new Date().toISOString(),
78
+ spokenAt: null
79
+ }
80
+ const path = interruptedPath(root, runId)
81
+ try {
82
+ mkdirSync(join(root, INTERRUPTED_DIR), { recursive: true })
83
+ writeFileSync(path, JSON.stringify(note, null, 2) + '\n')
84
+ return { written: true, path }
85
+ } catch (err) {
86
+ return { written: false, reason: `${path}: ${messageOf(err)}` }
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Every note in the root. A note that does not parse, or parses to something
92
+ * that is not a note, is returned under `unreadable` by name and reason — never
93
+ * skipped, because a note nobody can read is still a run nobody closed.
94
+ *
95
+ * `file` is the note's path relative to `root`, which is how every reader names
96
+ * it. An absent directory is no notes and no error.
97
+ *
98
+ * @returns {{notes: object[], unreadable: Array<{file: string, reason: string}>}}
99
+ */
100
+ export function readInterruptedNotes(root) {
101
+ const dir = join(root, INTERRUPTED_DIR)
102
+ let names
103
+ try {
104
+ names = readdirSync(dir).filter(name => name.endsWith('.json')).sort()
105
+ } catch (err) {
106
+ if (err && err.code === 'ENOENT') return { notes: [], unreadable: [] }
107
+ return { notes: [], unreadable: [{ file: INTERRUPTED_DIR, reason: messageOf(err) }] }
108
+ }
109
+ const notes = []
110
+ const unreadable = []
111
+ for (const name of names) {
112
+ const file = join(INTERRUPTED_DIR, name)
113
+ let value
114
+ try {
115
+ value = JSON.parse(readFileSync(join(root, file), 'utf8'))
116
+ } catch (err) {
117
+ unreadable.push({ file, reason: messageOf(err) })
118
+ continue
119
+ }
120
+ if (!value || typeof value !== 'object' || typeof value.runId !== 'string' || !value.runId) {
121
+ unreadable.push({ file, reason: 'not an interrupted-run note: no runId' })
122
+ continue
123
+ }
124
+ notes.push({ ...value, file })
125
+ }
126
+ return { notes, unreadable }
127
+ }
128
+
129
+ /** A note not yet spoken by a run start. */
130
+ export function isUnspoken(note) {
131
+ return Boolean(note) && (note.spokenAt === null || note.spokenAt === undefined)
132
+ }
133
+
134
+ /**
135
+ * Mark a note spoken, rewriting it in place with `spokenAt` set. A note that
136
+ * cannot be marked is spoken again at the next run start — the right direction
137
+ * to fail in — and the caller says so.
138
+ *
139
+ * @returns {{marked: true} | {marked: false, reason: string}}
140
+ */
141
+ export function markSpoken(root, runId, at = new Date().toISOString()) {
142
+ if (!usableRunId(runId)) return { marked: false, reason: `not a usable run id: ${JSON.stringify(runId)}` }
143
+ const path = interruptedPath(root, runId)
144
+ try {
145
+ const note = JSON.parse(readFileSync(path, 'utf8'))
146
+ writeFileSync(path, JSON.stringify({ ...note, spokenAt: at }, null, 2) + '\n')
147
+ return { marked: true }
148
+ } catch (err) {
149
+ return { marked: false, reason: `${path}: ${messageOf(err)}` }
150
+ }
151
+ }
152
+
153
+ /** The one line every reader prints for a note. */
154
+ export function formatInterruptedBanner(note) {
155
+ const n = note && typeof note === 'object' ? note : {}
156
+ const change = typeof n.change === 'string' && n.change ? n.change : '(unnamed change)'
157
+ const stage = typeof n.stage === 'string' && n.stage ? n.stage : 'unknown'
158
+ return (
159
+ `PREVIOUS RUN INTERRUPTED: ${change} run ${n.runId} ended at stage ${stage} — ` +
160
+ `no resume card was written; interlock run-log show ${n.runId}`
161
+ )
162
+ }
163
+
164
+ /** The line a reader prints for a note it could not read. */
165
+ export function formatUnreadableNote(entry) {
166
+ return `INTERRUPTED NOTE UNREADABLE: ${entry.file}: ${entry.reason}`
167
+ }
@@ -0,0 +1,198 @@
1
+ // The launch ledger: the file transport of the launch rule, which
2
+ // `hooks/guard-relaunch.mjs` reads to decide whether a ship launch is a
3
+ // relaunch nobody asked for (guard-ship-relaunch design D2, D3).
4
+ //
5
+ // The rule itself — what a ship launch is, what a human prompt is, the
6
+ // decision and its words — lives in `lib/launch-rule.mjs`, which the hooks
7
+ // module imports too (guard-ship-relaunch-in-process design D1). This module
8
+ // re-exports every name of it unchanged, so the settings hook and its tests
9
+ // import what they always imported and one function decides in both forms.
10
+ // What stays here is the file: per session, the ship launches the runtime
11
+ // accepted and the time of the last human prompt.
12
+ //
13
+ // ONE FILE PER SESSION, CREATED ONLY BY AN ACCEPTED LAUNCH.
14
+ // `.claude/ship/launch-ledger/<session_id>.json`. Only the PostToolUse branch
15
+ // creates it. A prompt event records a time only when the file already exists —
16
+ // every plugin hook runs in every session in every repository, and a prompt in a
17
+ // repository that never shipped must write nothing.
18
+ //
19
+ // THE COMPLETION WAKE IS NOT A HUMAN. The host wakes the session when a
20
+ // background workflow finishes by firing `UserPromptSubmit` with the
21
+ // notification as the prompt (captured: test/fixtures/hooks/completion-wake.json).
22
+ // Counted as a human prompt it would reset the clock in exactly the turn the
23
+ // relaunch happens in, so `isHumanPrompt` refuses it by its leading marker.
24
+ //
25
+ // EVERY UNKNOWN READS AS NO LEDGER, and no ledger allows. A missing file, an
26
+ // unsafe session id, malformed JSON, a wrong schema, a launch time that does not
27
+ // parse, a ledger older than `LIMITS.launchLedgerMaxAgeMs`: each is null here,
28
+ // never an exception. A false deny costs one typed message; a guard whose own
29
+ // parse error blocked a launch would be the worse failure.
30
+ //
31
+ // Pure apart from the ledger files themselves. No hook protocol in here — the
32
+ // hook is plumbing over these functions, the way the stage guards are plumbing
33
+ // over `lib/ship-stage.mjs`.
34
+
35
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
36
+ import { dirname, join } from 'node:path'
37
+ import { LIMITS } from './limits.mjs'
38
+ import { LEDGER_SCHEMA, emptyRecord, newestLaunch, promptMs, withLaunch, withPrompt } from './launch-rule.mjs'
39
+ import { SHIP_DIR } from './ship-stage.mjs'
40
+
41
+ export {
42
+ LEDGER_SCHEMA,
43
+ SKILL_QUOTE,
44
+ WAKE_MARKER,
45
+ decideLaunch,
46
+ denyReason,
47
+ emptyRecord,
48
+ isAcceptedLaunch,
49
+ isHumanPrompt,
50
+ isShipLaunch,
51
+ newestLaunch,
52
+ promptMs,
53
+ withLaunch,
54
+ withPrompt
55
+ } from './launch-rule.mjs'
56
+
57
+ /** Where the ledgers live, beside the stage markers and the trajectory. */
58
+ export const LEDGER_DIR = join(SHIP_DIR, 'launch-ledger')
59
+
60
+ const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
61
+ const iso = ms => new Date(ms).toISOString()
62
+ const str = value => (typeof value === 'string' && value ? value : null)
63
+ const messageOf = err => (err && err.message) || String(err)
64
+
65
+ /** Safe as a file name: the trajectory's run-id rule (`lib/run-log.mjs`). */
66
+ function isSafeSessionId(value) {
67
+ return typeof value === 'string' && /^[A-Za-z0-9._-]{1,128}$/.test(value) && !value.includes('..')
68
+ }
69
+
70
+ /** The session's ledger path, or null when the session id cannot name a file. */
71
+ export function ledgerPath(root, sessionId) {
72
+ if (typeof root !== 'string' || !isSafeSessionId(sessionId)) return null
73
+ return join(root, LEDGER_DIR, `${sessionId}.json`)
74
+ }
75
+
76
+ function isLedger(value) {
77
+ if (!isObject(value) || value.schema !== LEDGER_SCHEMA || !Array.isArray(value.launches)) return false
78
+ const prompt = promptMs(value)
79
+ return prompt === null || Number.isFinite(prompt)
80
+ }
81
+
82
+ const isStale = (ledger, now, maxAgeMs) => {
83
+ const newest = newestLaunch(ledger)
84
+ return newest !== null && now - newest.ms > maxAgeMs
85
+ }
86
+
87
+ /**
88
+ * The session's ledger, or null on every failure. A stale ledger reads as
89
+ * absent. `onProblem` hears about a ledger that exists and cannot be read — an
90
+ * absent or stale one is ordinary and says nothing — so the hook can speak the
91
+ * degradation instead of allowing silently.
92
+ */
93
+ export function readLedger(root, sessionId, { now = Date.now(), maxAgeMs = LIMITS.launchLedgerMaxAgeMs, onProblem } = {}) {
94
+ const path = ledgerPath(root, sessionId)
95
+ if (!path) return null
96
+ const problem = message => {
97
+ if (typeof onProblem === 'function') onProblem(message)
98
+ }
99
+ let raw
100
+ try {
101
+ raw = readFileSync(path, 'utf8')
102
+ } catch (err) {
103
+ if (err && err.code !== 'ENOENT') problem(`launch ledger unreadable, ignored: ${path}: ${messageOf(err)}`)
104
+ return null
105
+ }
106
+ let value
107
+ try {
108
+ value = JSON.parse(raw)
109
+ } catch {
110
+ problem(`launch ledger is not valid JSON, ignored: ${path}`)
111
+ return null
112
+ }
113
+ if (!isLedger(value)) {
114
+ problem(`launch ledger is not an ${LEDGER_SCHEMA} document, ignored: ${path}`)
115
+ return null
116
+ }
117
+ return isStale(value, now, maxAgeMs) ? null : value
118
+ }
119
+
120
+ function writeLedger(path, ledger) {
121
+ try {
122
+ mkdirSync(dirname(path), { recursive: true })
123
+ writeFileSync(path, JSON.stringify(ledger, null, 2) + '\n')
124
+ return { written: true, path, reason: null }
125
+ } catch (err) {
126
+ return { written: false, path, reason: `launch ledger not written: ${path}: ${messageOf(err)}` }
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Record one launch the runtime accepted, with the identity its response
132
+ * carried. Appends to a readable ledger and keeps its recorded prompt; over a
133
+ * missing, stale or unreadable one it starts fresh, which is the state "one
134
+ * launch, no prompt since" — exactly what just happened. The record is changed
135
+ * by `withLaunch`, the transform the hooks module applies to its state record.
136
+ */
137
+ export function recordLaunch(root, sessionId, toolResponse, { now = Date.now(), maxAgeMs = LIMITS.launchLedgerMaxAgeMs } = {}) {
138
+ const path = ledgerPath(root, sessionId)
139
+ if (!path) return { written: false, path: null, reason: 'session id is not a safe file name' }
140
+ const response = isObject(toolResponse) ? toolResponse : {}
141
+ const entry = {
142
+ at: iso(now),
143
+ taskId: str(response.taskId),
144
+ runId: str(response.runId),
145
+ workflowName: str(response.workflowName),
146
+ scriptPath: str(response.scriptPath)
147
+ }
148
+ const existing = readLedger(root, sessionId, { now, maxAgeMs })
149
+ return writeLedger(path, withLaunch({ ...(existing || emptyRecord()), sessionId }, entry, maxAgeMs))
150
+ }
151
+
152
+ /**
153
+ * Record a human prompt — only when the session already has a readable ledger.
154
+ * One existence check and out when it does not, so a prompt creates no file and
155
+ * no directory. Keeps the later of the recorded time and `now`, through
156
+ * `withPrompt`, the hooks module's transform too.
157
+ */
158
+ export function recordPrompt(root, sessionId, { now = Date.now(), maxAgeMs = LIMITS.launchLedgerMaxAgeMs } = {}) {
159
+ const path = ledgerPath(root, sessionId)
160
+ if (!path) return { written: false, path: null, reason: 'session id is not a safe file name' }
161
+ if (!existsSync(path)) return { written: false, path, reason: 'no ledger for this session' }
162
+ const ledger = readLedger(root, sessionId, { now, maxAgeMs })
163
+ if (!ledger) return { written: false, path, reason: 'ledger unreadable or stale' }
164
+ const previous = promptMs(ledger)
165
+ if (previous !== null && previous >= now) return { written: false, path, reason: 'a later prompt is already recorded' }
166
+ return writeLedger(path, withPrompt(ledger, iso(now)))
167
+ }
168
+
169
+ /**
170
+ * Remove sibling ledgers whose file is older than the published age, sparing
171
+ * `keep` (the session just written). Only `.json` files in the ledger directory
172
+ * are touched; a missing directory or a failed removal is a no-op.
173
+ */
174
+ export function sweepStale(root, { now = Date.now(), maxAgeMs = LIMITS.launchLedgerMaxAgeMs, keep } = {}) {
175
+ const removed = []
176
+ if (typeof root !== 'string') return { removed }
177
+ const dir = join(root, LEDGER_DIR)
178
+ let names
179
+ try {
180
+ names = readdirSync(dir).sort()
181
+ } catch {
182
+ return { removed }
183
+ }
184
+ for (const name of names) {
185
+ if (!name.endsWith('.json') || (keep && name === `${keep}.json`)) continue
186
+ try {
187
+ const path = join(dir, name)
188
+ const stat = statSync(path)
189
+ if (stat.isFile() && now - stat.mtimeMs > maxAgeMs) {
190
+ unlinkSync(path)
191
+ removed.push(name)
192
+ }
193
+ } catch {
194
+ // Another session may have swept or rewritten it first; nothing to say.
195
+ }
196
+ }
197
+ return { removed }
198
+ }
@@ -0,0 +1,174 @@
1
+ // The launch rule: what a ship launch is, what a human prompt is, and whether
2
+ // the next launch has a human behind it (guard-ship-relaunch design D1, D3;
3
+ // guard-ship-relaunch-in-process design D1).
4
+ //
5
+ // The one recorded twenty-agent mistake was a parent chat calling Workflow again
6
+ // over leftover checkboxes, with no human message in between. The skill forbids
7
+ // it in prose; this is the rule that lets a hook refuse it: a launch newer than
8
+ // the session's last human prompt means the next launch has no human behind it.
9
+ //
10
+ // ONE RULE, TWO TRANSPORTS. Two guards read this module: the settings hook
11
+ // (`hooks/guard-relaunch.mjs`, through `lib/launch-ledger.mjs`, which keeps the
12
+ // record in a file per session and re-exports everything here unchanged) and
13
+ // the hooks module (`hooks/mod.mjs`, which keeps the same record in the
14
+ // engine's session state). The decision and its words are defined once, so a
15
+ // reword of the skill sentence moves both guards and both pins together.
16
+ //
17
+ // NO NODE. The hooks module runs in the engine with no Node, so this file
18
+ // imports nothing but the published age, and `test/spine/launch-ledger.test.mjs`
19
+ // pins that. A record here is plain data: `{ schema, launches: [{ at, runId,
20
+ // workflowName, scriptPath }], lastHumanPromptAt }`, times as ISO strings.
21
+ //
22
+ // EVERY UNKNOWN ALLOWS. A launch time that does not parse is no launch, a
23
+ // record that is not one is empty, an age past `LIMITS.launchLedgerMaxAgeMs` is
24
+ // absent. A false refusal costs one typed message; a rule whose own parse
25
+ // error blocked a launch would be the worse failure.
26
+
27
+ import { LIMITS } from './limits.mjs'
28
+
29
+ export const LEDGER_SCHEMA = 'interlock.launch-ledger/1'
30
+
31
+ /**
32
+ * The skill sentence the deny quotes, verbatim, so one string is pinned in both
33
+ * places: `test/hooks.test.mjs` asserts `skills/ship/SKILL.md` still contains it.
34
+ */
35
+ export const SKILL_QUOTE = 'Leftover `- [ ]` boxes after a run are a report, not authorization to call Workflow again'
36
+
37
+ /** The leading marker of the host's completion wake (guard-ship-relaunch design D4, task-1 capture). */
38
+ export const WAKE_MARKER = '<task-notification>'
39
+
40
+ // The plugin's own ship workflow, by either handle the host passes: the script
41
+ // path the skill trampoline names, or the command name the manifest's
42
+ // `workflows` entry registers (`interlock:ship`, possibly marketplace-prefixed).
43
+ const SHIP_SCRIPT = /(^|\/)workflows\/ship\.js$/
44
+ const SHIP_NAME = /(^|[^A-Za-z0-9_])interlock:ship$/
45
+ const LAUNCHED = new Set(['async_launched', 'remote_launched'])
46
+
47
+ const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
48
+ const posix = path => path.replace(/\\/g, '/')
49
+ const str = value => (typeof value === 'string' && value ? value : null)
50
+ const timeOf = launch => (isObject(launch) && typeof launch.at === 'string' ? Date.parse(launch.at) : NaN)
51
+ const isRecord = value => isObject(value) && Array.isArray(value.launches)
52
+
53
+ /**
54
+ * Is this Workflow input a launch of the plugin's ship workflow? A call
55
+ * carrying `resumeFromRunId` is still a launch: a resume replays every agent
56
+ * after the failed one. With the session's record, a call that resumes a run it
57
+ * recorded — by `runId`, or by the persisted `scriptPath` the completion wake
58
+ * suggests passing back — is one too, though it names neither handle above.
59
+ */
60
+ export function isShipLaunch(toolInput, ledger = null) {
61
+ if (!isObject(toolInput)) return false
62
+ const scriptPath = typeof toolInput.scriptPath === 'string' ? posix(toolInput.scriptPath) : ''
63
+ if (scriptPath && SHIP_SCRIPT.test(scriptPath)) return true
64
+ if (typeof toolInput.name === 'string' && SHIP_NAME.test(toolInput.name)) return true
65
+ if (!isRecord(ledger)) return false
66
+ return ledger.launches.some(
67
+ launch =>
68
+ isObject(launch) &&
69
+ ((str(launch.runId) && toolInput.resumeFromRunId === launch.runId) ||
70
+ (str(launch.scriptPath) && scriptPath && scriptPath === posix(launch.scriptPath)))
71
+ )
72
+ }
73
+
74
+ /**
75
+ * Did the runtime accept this launch? Positive, not negative: only a response
76
+ * object whose `status` says it launched, carrying no error mark, counts. The
77
+ * capture found a refused launch never reaches PostToolUse at all — the engine
78
+ * rejects it at input validation — so this is the second line, and anything it
79
+ * cannot recognise records nothing, which is the allow direction.
80
+ */
81
+ export function isAcceptedLaunch(toolResponse) {
82
+ if (!isObject(toolResponse)) return false
83
+ if (toolResponse.is_error === true || toolResponse.isError === true || toolResponse.error !== undefined) return false
84
+ return LAUNCHED.has(toolResponse.status)
85
+ }
86
+
87
+ /**
88
+ * A prompt a person sent, as opposed to the host's completion wake, read off
89
+ * the prompt's text: the settings form's test, which has no origin to read. No
90
+ * text is still a prompt. The hooks module reads the engine's stamped origin
91
+ * instead (guard-ship-relaunch-in-process design D3).
92
+ */
93
+ export function isHumanPrompt(prompt) {
94
+ if (typeof prompt !== 'string') return true
95
+ return !prompt.trimStart().startsWith(WAKE_MARKER)
96
+ }
97
+
98
+ /** The newest launch with a readable time, or null. */
99
+ export function newestLaunch(ledger) {
100
+ if (!isRecord(ledger)) return null
101
+ let newest = null
102
+ for (const launch of ledger.launches) {
103
+ const ms = timeOf(launch)
104
+ if (Number.isFinite(ms) && (!newest || ms > newest.ms)) newest = { at: launch.at, ms }
105
+ }
106
+ return newest
107
+ }
108
+
109
+ /** `lastHumanPromptAt` as milliseconds: null when none was recorded, NaN when it cannot be read. */
110
+ export function promptMs(ledger) {
111
+ if (ledger.lastHumanPromptAt === null || ledger.lastHumanPromptAt === undefined) return null
112
+ return typeof ledger.lastHumanPromptAt === 'string' ? Date.parse(ledger.lastHumanPromptAt) : NaN
113
+ }
114
+
115
+ /** A record with no launch and no prompt: what a session starts from. A new object on every call. */
116
+ export function emptyRecord() {
117
+ return { schema: LEDGER_SCHEMA, launches: [], lastHumanPromptAt: null }
118
+ }
119
+
120
+ /**
121
+ * The record with one more launch. Launches older than the published age
122
+ * (measured back from this one) and launches with no readable time are
123
+ * dropped, so the record stays bounded in either transport. Anything that is
124
+ * not a record starts from `emptyRecord()`. The input is never mutated.
125
+ */
126
+ export function withLaunch(record, launch, maxAgeMs = LIMITS.launchLedgerMaxAgeMs) {
127
+ const base = isRecord(record) ? record : emptyRecord()
128
+ const ms = timeOf(launch)
129
+ const kept = base.launches.filter(prior => {
130
+ const at = timeOf(prior)
131
+ return Number.isFinite(at) && (!Number.isFinite(ms) || ms - at <= maxAgeMs)
132
+ })
133
+ return { ...base, launches: [...kept, launch] }
134
+ }
135
+
136
+ /**
137
+ * The record with a human prompt at `at` (an ISO time). Keeps the later of the
138
+ * recorded time and `at`: an earlier clock never moves the prompt back. Anything
139
+ * that is not a record starts from `emptyRecord()`. The input is never mutated.
140
+ */
141
+ export function withPrompt(record, at) {
142
+ const base = isRecord(record) ? record : emptyRecord()
143
+ const next = typeof at === 'string' ? Date.parse(at) : NaN
144
+ if (!Number.isFinite(next)) return base
145
+ const previous = promptMs(base)
146
+ if (previous !== null && Number.isFinite(previous) && previous >= next) return base
147
+ return { ...base, lastHumanPromptAt: at }
148
+ }
149
+
150
+ /** The deny's words (guard-ship-relaunch design D3): what happened, the skill's rule, and the remedy. */
151
+ export function denyReason(lastLaunchAt) {
152
+ return (
153
+ `guard-relaunch: a ship workflow was already launched in this session at ${lastLaunchAt} ` +
154
+ `and no human prompt has arrived since. skills/ship/SKILL.md: "${SKILL_QUOTE}." ` +
155
+ `Send a new message that asks to ship the leftovers, or type /interlock:ship again, ` +
156
+ `and the next launch is allowed.`
157
+ )
158
+ }
159
+
160
+ /**
161
+ * The rule. No record, or a stale one → allow. A launch newer than the last
162
+ * human prompt — including every launch when no prompt was recorded → deny.
163
+ * Anything this cannot read a time from → allow.
164
+ */
165
+ export function decideLaunch(ledger, now = Date.now(), maxAgeMs = LIMITS.launchLedgerMaxAgeMs) {
166
+ const allow = { decision: 'allow', reason: null, lastLaunchAt: null, lastHumanPromptAt: null }
167
+ const newest = newestLaunch(ledger)
168
+ if (!newest) return allow
169
+ const prompt = promptMs(ledger)
170
+ const seen = { lastLaunchAt: newest.at, lastHumanPromptAt: prompt === null ? null : ledger.lastHumanPromptAt }
171
+ if (now - newest.ms > maxAgeMs) return { ...allow, ...seen }
172
+ if (prompt !== null && (!Number.isFinite(prompt) || prompt >= newest.ms)) return { ...allow, ...seen }
173
+ return { decision: 'deny', reason: denyReason(newest.at), ...seen }
174
+ }