@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +94 -1
- package/CHANGELOG.md +135 -1
- package/bin/interlock +409 -49
- 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 +83 -13
- package/hooks/recorder.mjs +189 -0
- package/lib/agent-usage.mjs +524 -0
- package/lib/doctor.mjs +488 -28
- package/lib/eval-history.mjs +233 -0
- package/lib/evals-capture.mjs +5 -2
- 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/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 +2 -1
- package/shared/TEST-PROFILE.md +10 -5
- package/skills/fix-tests/SKILL.md +1 -1
- package/types/index.d.ts +28 -0
- 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({
|
|
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
|
+
}
|