@renzrollon/interlock 1.2.0 → 1.3.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/icon.png +0 -0
- package/.claude-plugin/plugin.json +16 -1
- package/CHANGELOG.md +68 -13
- package/bin/interlock +154 -18
- package/hooks/mod.mjs +835 -51
- package/hooks/preflight.mjs +209 -34
- package/lib/doctor.mjs +23 -18
- package/lib/draw-plan.mjs +496 -0
- package/lib/draw-run.mjs +466 -0
- package/lib/evals-triage.mjs +1 -1
- package/lib/lane.mjs +152 -0
- package/lib/limits.mjs +37 -2
- package/lib/meter-refusals.mjs +73 -0
- package/lib/notify-defaults.mjs +6 -0
- package/lib/notify.mjs +41 -25
- package/lib/plan-summary.mjs +119 -0
- package/lib/preflight-file.mjs +266 -0
- package/lib/resume-card.mjs +126 -8
- package/lib/run.mjs +43 -9
- package/lib/spec-meter.mjs +591 -0
- package/lib/waves.mjs +6 -107
- package/package.json +3 -2
- package/skills/evals/SKILL.md +3 -3
- package/skills/fix-tests/SKILL.md +2 -2
- package/skills/review-artifacts/SKILL.md +3 -3
- package/skills/spec/SKILL.md +6 -11
- package/skills/spec/scripts/compact-instructions.mjs +30 -0
- package/.claude-plugin/marketplace.json +0 -19
package/hooks/preflight.mjs
CHANGED
|
@@ -15,16 +15,34 @@
|
|
|
15
15
|
//
|
|
16
16
|
// It also surfaces any interrupted-run note a previous session left — a run
|
|
17
17
|
// that ended with its session and never reached its close (lib/interrupted.mjs).
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
18
|
+
// A linked worktree's run writes its note into the state home — the main
|
|
19
|
+
// checkout — so the notes are read from the home the doctor resolved AND from
|
|
20
|
+
// the working directory (a run whose manifest recorded no home wrote there),
|
|
21
|
+
// each run spoken once. When the doctor gave no home — it could not run, or
|
|
22
|
+
// predates the field — the working directory alone, as before.
|
|
23
|
+
//
|
|
24
|
+
// It marks each note it speaks, in every directory the note was found in
|
|
25
|
+
// (show-preflight-and-interrupted-runs-at-session-start design D3). A session
|
|
26
|
+
// start now reaches the person as well as the model, through the hooks
|
|
27
|
+
// module's band, and a note nobody clears must not be said at every session
|
|
28
|
+
// start. The mark is `markSpoken`, the run program's own, and it fails toward
|
|
29
|
+
// speaking again: a note that cannot be marked stays unspoken on disk, so the
|
|
30
|
+
// next session start and the next `run start` say it again, and the report
|
|
31
|
+
// file below records why.
|
|
32
|
+
//
|
|
33
|
+
// In a project where a ship run can start — one whose working root holds
|
|
34
|
+
// `openspec/` — it leaves what it computed in `.claude/ship/preflight.json`
|
|
35
|
+
// (lib/preflight-file.mjs, design D1, D2): the doctor's verdict and checks,
|
|
36
|
+
// the notes it spoke and whether each mark landed, and the halt resume cards
|
|
37
|
+
// of open changes, listed from each card's first-line stamp and never read
|
|
38
|
+
// past it. The previous session's file is removed before the new one is
|
|
39
|
+
// written, so a failed write leaves no stale report; the failure is one line
|
|
40
|
+
// on stderr. Every other repository gets no file, no directory, and exactly
|
|
41
|
+
// the output it got before. The file is outcome-class: losing it costs the
|
|
42
|
+
// band, never the session.
|
|
25
43
|
|
|
26
44
|
import { execFileSync } from 'node:child_process'
|
|
27
|
-
import { existsSync, realpathSync } from 'node:fs'
|
|
45
|
+
import { appendFileSync, existsSync, mkdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
28
46
|
import { dirname, join, resolve } from 'node:path'
|
|
29
47
|
import { fileURLToPath } from 'node:url'
|
|
30
48
|
|
|
@@ -38,10 +56,11 @@ function interlockBin() {
|
|
|
38
56
|
}
|
|
39
57
|
|
|
40
58
|
/**
|
|
41
|
-
* The preflight's own report, as one message
|
|
42
|
-
* resolved — `null` when its output carried none or could not be read
|
|
59
|
+
* The preflight's own report, as one message; the state home and surface the
|
|
60
|
+
* doctor resolved — `null` when its output carried none or could not be read;
|
|
61
|
+
* and the doctor's verdict as the report file carries it (design D1).
|
|
43
62
|
*
|
|
44
|
-
* @returns {{ message: string, stateHome: string|null }}
|
|
63
|
+
* @returns {{ message: string, stateHome: string|null, surface: string|null, doctor: object }}
|
|
45
64
|
*/
|
|
46
65
|
function run() {
|
|
47
66
|
let raw
|
|
@@ -65,7 +84,9 @@ function run() {
|
|
|
65
84
|
`The \`interlock\` binary is not resolvable from this session. Inside Claude Code the ` +
|
|
66
85
|
`plugin's bin/ is normally injected; if you see this, reinstall the plugin. The session ` +
|
|
67
86
|
`continues — this preflight is advisory.`,
|
|
68
|
-
stateHome: null
|
|
87
|
+
stateHome: null,
|
|
88
|
+
surface: null,
|
|
89
|
+
doctor: { ran: false, parsed: false, ok: null, counts: null, checks: [] }
|
|
69
90
|
}
|
|
70
91
|
}
|
|
71
92
|
}
|
|
@@ -79,18 +100,35 @@ function run() {
|
|
|
79
100
|
if (!doctor || typeof doctor !== 'object') {
|
|
80
101
|
return {
|
|
81
102
|
message: 'interlock preflight ran but its output could not be parsed; skipping. The session continues.',
|
|
82
|
-
stateHome: null
|
|
103
|
+
stateHome: null,
|
|
104
|
+
surface: null,
|
|
105
|
+
doctor: { ran: true, parsed: false, ok: null, counts: null, checks: [] }
|
|
83
106
|
}
|
|
84
107
|
}
|
|
85
108
|
const stateHome =
|
|
86
109
|
typeof doctor.stateHome === 'string' && doctor.stateHome ? resolve(process.cwd(), doctor.stateHome) : null
|
|
87
110
|
|
|
111
|
+
const surface = typeof doctor.surface === 'string' && doctor.surface ? doctor.surface : null
|
|
112
|
+
|
|
88
113
|
const checks = Array.isArray(doctor.checks) ? doctor.checks : []
|
|
114
|
+
// The checks as the file carries them: the fix travels, the evidence does not (design D1).
|
|
115
|
+
const verdict = {
|
|
116
|
+
ran: true,
|
|
117
|
+
parsed: true,
|
|
118
|
+
ok: typeof doctor.ok === 'boolean' ? doctor.ok : null,
|
|
119
|
+
counts: doctor.counts && typeof doctor.counts === 'object' ? doctor.counts : null,
|
|
120
|
+
checks: checks.filter(c => c && typeof c === 'object').map(c => ({ id: c.id, status: c.status, detail: c.detail, fix: c.fix ?? null }))
|
|
121
|
+
}
|
|
89
122
|
const failures = checks.filter(c => c && c.status === 'fail')
|
|
90
123
|
if (!failures.length) {
|
|
91
124
|
const warns = (doctor.counts && doctor.counts.warn) || 0
|
|
92
125
|
// A clean preflight is quiet, not a wall of output — one confirming line.
|
|
93
|
-
return {
|
|
126
|
+
return {
|
|
127
|
+
message: `interlock preflight OK${warns ? ` (${warns} warning${warns === 1 ? '' : 's'})` : ''}.`,
|
|
128
|
+
stateHome,
|
|
129
|
+
surface,
|
|
130
|
+
doctor: verdict
|
|
131
|
+
}
|
|
94
132
|
}
|
|
95
133
|
|
|
96
134
|
const lines = ['interlock preflight found issues that can stall an unattended ship run:']
|
|
@@ -103,7 +141,7 @@ function run() {
|
|
|
103
141
|
}
|
|
104
142
|
}
|
|
105
143
|
lines.push('The session still starts — apply the fixes above before /interlock:ship.')
|
|
106
|
-
return { message: lines.join('\n'), stateHome }
|
|
144
|
+
return { message: lines.join('\n'), stateHome, surface, doctor: verdict }
|
|
107
145
|
}
|
|
108
146
|
|
|
109
147
|
/** Two paths naming one directory — through a symlink too — so one directory is read once. */
|
|
@@ -120,35 +158,114 @@ function sameDirectory(a, b) {
|
|
|
120
158
|
|
|
121
159
|
/**
|
|
122
160
|
* One line per interrupted-run note not yet spoken, and one per note that
|
|
123
|
-
* could not be read, from the state home and then the working directory
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
161
|
+
* could not be read, from the state home and then the working directory, each
|
|
162
|
+
* spoken note marked in every directory it was found in (design D3). A run
|
|
163
|
+
* whose note is in both is one interrupted run, spoken once and marked in
|
|
164
|
+
* both. Loaded on demand rather than imported, so a hook that finds its
|
|
165
|
+
* library missing still exits 0 — and says so instead of falling silent.
|
|
166
|
+
*
|
|
167
|
+
* @returns {Promise<{lines: string[], spoken: object[], unreadable: Array<{file: string, reason: string}>}>}
|
|
127
168
|
*/
|
|
128
|
-
async function
|
|
169
|
+
async function interruptedNotes(cwd, stateHome) {
|
|
129
170
|
let lib
|
|
130
171
|
try {
|
|
131
172
|
lib = await import(new URL('../lib/interrupted.mjs', import.meta.url).href)
|
|
132
173
|
} catch (err) {
|
|
133
|
-
|
|
174
|
+
const reason = (err && err.message) || String(err)
|
|
175
|
+
return { lines: [`interrupted-run notes could not be read: ${reason}`], spoken: [], unreadable: [{ file: 'lib/interrupted.mjs', reason }] }
|
|
134
176
|
}
|
|
135
177
|
const roots = stateHome && !sameDirectory(stateHome, cwd) ? [stateHome, cwd] : [cwd]
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
const unreadableLines = []
|
|
178
|
+
const found = new Map()
|
|
179
|
+
const unreadable = []
|
|
139
180
|
for (const root of roots) {
|
|
140
|
-
const { notes, unreadable } = lib.readInterruptedNotes(root)
|
|
181
|
+
const { notes, unreadable: bad } = lib.readInterruptedNotes(root)
|
|
141
182
|
for (const note of notes.filter(lib.isUnspoken)) {
|
|
142
|
-
if (
|
|
143
|
-
|
|
144
|
-
banners.push(lib.formatInterruptedBanner(note))
|
|
183
|
+
if (!found.has(note.runId)) found.set(note.runId, { note, roots: [] })
|
|
184
|
+
found.get(note.runId).roots.push(root)
|
|
145
185
|
}
|
|
146
186
|
// The working directory's notes keep their root-relative names; the home's are named in full.
|
|
147
|
-
for (const u of unreadable)
|
|
148
|
-
|
|
149
|
-
|
|
187
|
+
for (const u of bad) unreadable.push({ file: root === cwd ? u.file : join(root, u.file), reason: u.reason })
|
|
188
|
+
}
|
|
189
|
+
const banners = []
|
|
190
|
+
const spoken = []
|
|
191
|
+
for (const { note, roots: where } of found.values()) {
|
|
192
|
+
const banner = lib.formatInterruptedBanner(note)
|
|
193
|
+
banners.push(banner)
|
|
194
|
+
// Spoken, then marked: a mark that fails leaves the note to be said again.
|
|
195
|
+
const at = new Date().toISOString()
|
|
196
|
+
const marks = where.map(root => {
|
|
197
|
+
const m = lib.markSpoken(root, note.runId, at)
|
|
198
|
+
return { root, marked: m.marked === true, reason: m.marked === true ? null : m.reason }
|
|
199
|
+
})
|
|
200
|
+
spoken.push({ runId: note.runId, change: note.change, stage: note.stage, banner, marked: marks.every(m => m.marked), marks })
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
lines: [...banners, ...unreadable.map(u => `An interrupted-run note could not be read: ${u.file}: ${u.reason}`)],
|
|
204
|
+
spoken,
|
|
205
|
+
unreadable
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The halt resume cards of open changes under the state home and the working
|
|
211
|
+
* directory, listed from each card's stamp (design D4). The listing reads no
|
|
212
|
+
* card past its first line, and the advisory output never mentions a card.
|
|
213
|
+
*/
|
|
214
|
+
async function resumeCards(cwd, stateHome) {
|
|
215
|
+
const roots = stateHome && !sameDirectory(stateHome, cwd) ? [stateHome, cwd] : [cwd]
|
|
216
|
+
try {
|
|
217
|
+
const lib = await import(new URL('../lib/resume-card.mjs', import.meta.url).href)
|
|
218
|
+
return lib.listResumeCards({ roots, changesDir: join(cwd, 'openspec', 'changes') })
|
|
219
|
+
} catch (err) {
|
|
220
|
+
const reason = `halt resume cards could not be listed: ${(err && err.message) || String(err)}`
|
|
221
|
+
return { listed: [], archived: 0, unreadable: [{ path: join(cwd, '.claude', 'handoff'), reason }], lookedIn: [] }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A project where a ship run can start: the working root holds an `openspec/` directory (design D2). */
|
|
226
|
+
function isOpenSpecProject(cwd) {
|
|
227
|
+
try {
|
|
228
|
+
return statSync(join(cwd, 'openspec')).isDirectory()
|
|
229
|
+
} catch {
|
|
230
|
+
return false
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Leave this session's report in `.claude/ship/preflight.json` (design D1,
|
|
236
|
+
* D2): the previous file removed first, so a write that fails leaves no stale
|
|
237
|
+
* report, then the new one written whole. A failure is one stderr line, and
|
|
238
|
+
* the session starts as before.
|
|
239
|
+
*/
|
|
240
|
+
async function writeReport(cwd, input) {
|
|
241
|
+
let path = join(cwd, '.claude', 'ship', 'preflight.json')
|
|
242
|
+
try {
|
|
243
|
+
const lib = await import(new URL('../lib/preflight-file.mjs', import.meta.url).href)
|
|
244
|
+
path = join(cwd, lib.PREFLIGHT_FILE)
|
|
245
|
+
rmSync(path, { force: true })
|
|
246
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
247
|
+
writeFileSync(path, JSON.stringify(lib.buildPreflightReport(input), null, 2) + '\n')
|
|
248
|
+
} catch (err) {
|
|
249
|
+
process.stderr.write(`interlock preflight: report not written: ${path}: ${(err && err.message) || String(err)}\n`)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The session-start source from the host's event on stdin (`startup`,
|
|
255
|
+
* `resume`, `clear`, `compact`), or `null`: the report records it so the
|
|
256
|
+
* hooks module keeps a band hidden across a compaction (design D5).
|
|
257
|
+
*/
|
|
258
|
+
async function sessionSource() {
|
|
259
|
+
try {
|
|
260
|
+
if (process.stdin.isTTY) return null
|
|
261
|
+
const chunks = []
|
|
262
|
+
for await (const chunk of process.stdin) chunks.push(chunk)
|
|
263
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim()
|
|
264
|
+
const event = raw ? JSON.parse(raw) : null
|
|
265
|
+
return event && typeof event.source === 'string' ? event.source : null
|
|
266
|
+
} catch {
|
|
267
|
+
return null
|
|
150
268
|
}
|
|
151
|
-
return [...banners, ...unreadableLines]
|
|
152
269
|
}
|
|
153
270
|
|
|
154
271
|
/**
|
|
@@ -166,9 +283,67 @@ function report(message) {
|
|
|
166
283
|
process.stdout.write(JSON.stringify(out) + '\n')
|
|
167
284
|
}
|
|
168
285
|
|
|
286
|
+
/** A single-quoted shell word. A value with a newline or a NUL is refused. */
|
|
287
|
+
function shellQuote(value) {
|
|
288
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function publishable(value) {
|
|
292
|
+
return typeof value === 'string' && value.trim() && !/[\0\r\n]/.test(value)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Copy a plugin option into the session so `interlock notify` can see it.
|
|
297
|
+
*
|
|
298
|
+
* The host injects `userConfig` into hook processes only. A ship run's notify
|
|
299
|
+
* call is not a hook, so without this copy a topic typed into the plugin
|
|
300
|
+
* options would never reach the CLI. An operator who already provided the
|
|
301
|
+
* shell variable keeps that value. Nothing is printed, and a failure here
|
|
302
|
+
* does not change the preflight.
|
|
303
|
+
*/
|
|
304
|
+
function publishNotifyOptions() {
|
|
305
|
+
const file = process.env.CLAUDE_ENV_FILE
|
|
306
|
+
if (typeof file !== 'string' || !file) return
|
|
307
|
+
const lines = []
|
|
308
|
+
const topic = process.env.CLAUDE_PLUGIN_OPTION_NTFY_TOPIC
|
|
309
|
+
const url = process.env.CLAUDE_PLUGIN_OPTION_NTFY_URL
|
|
310
|
+
if (publishable(topic) && !publishable(process.env.INTERLOCK_NTFY_TOPIC)) {
|
|
311
|
+
lines.push(`export INTERLOCK_NTFY_TOPIC=${shellQuote(topic.trim())}`)
|
|
312
|
+
}
|
|
313
|
+
if (publishable(url) && !publishable(process.env.INTERLOCK_NTFY_URL)) {
|
|
314
|
+
lines.push(`export INTERLOCK_NTFY_URL=${shellQuote(url.trim())}`)
|
|
315
|
+
}
|
|
316
|
+
if (!lines.length) return
|
|
317
|
+
appendFileSync(file, lines.join('\n') + '\n')
|
|
318
|
+
}
|
|
319
|
+
|
|
169
320
|
try {
|
|
170
|
-
|
|
171
|
-
|
|
321
|
+
try {
|
|
322
|
+
publishNotifyOptions()
|
|
323
|
+
} catch (err) {
|
|
324
|
+
process.stderr.write(
|
|
325
|
+
`interlock preflight could not publish notify options (ignored): ${(err && err.message) || err}\n`
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
const cwd = process.cwd()
|
|
329
|
+
const source = await sessionSource()
|
|
330
|
+
const { message, stateHome, surface, doctor } = run()
|
|
331
|
+
const notes = await interruptedNotes(cwd, stateHome)
|
|
332
|
+
const lines = [message, ...notes.lines].filter(Boolean)
|
|
333
|
+
// Written before stdout, so a session that reads the context has the file on disk already (design D10).
|
|
334
|
+
if (isOpenSpecProject(cwd)) {
|
|
335
|
+
await writeReport(cwd, {
|
|
336
|
+
writtenAt: new Date().toISOString(),
|
|
337
|
+
source,
|
|
338
|
+
root: cwd,
|
|
339
|
+
stateHome,
|
|
340
|
+
surface,
|
|
341
|
+
message,
|
|
342
|
+
doctor,
|
|
343
|
+
notes: { spoken: notes.spoken, unreadable: notes.unreadable },
|
|
344
|
+
cards: await resumeCards(cwd, stateHome)
|
|
345
|
+
})
|
|
346
|
+
}
|
|
172
347
|
if (lines.length) report(lines.join('\n'))
|
|
173
348
|
} catch (err) {
|
|
174
349
|
// Belt and suspenders: nothing this hook does may abort the session.
|
package/lib/doctor.mjs
CHANGED
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
readModelRouting
|
|
79
79
|
} from './host/claude-env.mjs'
|
|
80
80
|
import { RUNTIME } from './limits.mjs'
|
|
81
|
+
import { DEFAULT_NTFY_URL } from './notify-defaults.mjs'
|
|
81
82
|
import { STATE_HOME_ENV, resolveStateHome } from './state-home.mjs'
|
|
82
83
|
|
|
83
84
|
/** The installed copy of the plugin — the one whose workflow and agents run. */
|
|
@@ -203,8 +204,7 @@ export const REQUIRED_COMMANDS = Object.freeze([
|
|
|
203
204
|
* Commands Claude Code auto-approves as read-only no matter what the
|
|
204
205
|
* project's own allowlist says, so a briefing that instructs one of these
|
|
205
206
|
* needs no rule of its own and the drift test below must not demand one.
|
|
206
|
-
* Source: the Claude Code permissions documentation
|
|
207
|
-
* (https://docs.claude.com/en/docs/claude-code/permissions), checked
|
|
207
|
+
* Source: the Claude Code permissions documentation, checked
|
|
208
208
|
* 2026-09-07 — a host claim this module cannot itself verify, so it is dated
|
|
209
209
|
* and cited rather than asserted bare. If the host narrows this list the
|
|
210
210
|
* failure is loud: the drift test below goes red naming the command, never a
|
|
@@ -586,11 +586,11 @@ function checkOpenSpec(root, env, probe) {
|
|
|
586
586
|
if (configured) {
|
|
587
587
|
return check('openspec', 'warn', 'the openspec CLI is not on PATH; ship reads artifacts from disk, but /interlock:spec and /interlock:bootstrap need it', {
|
|
588
588
|
evidence: ['openspec/config.yaml is present, so existing changes are still readable'],
|
|
589
|
-
fix: '
|
|
589
|
+
fix: 'Install the openspec command globally. It needs Node.js 20.19.0 or newer.'
|
|
590
590
|
})
|
|
591
591
|
}
|
|
592
592
|
return check('openspec', 'fail', 'no openspec CLI on PATH and no openspec/ project in this repo — neither authoring nor shipping a change can start', {
|
|
593
|
-
fix: '
|
|
593
|
+
fix: 'Install the openspec command globally (Node.js 20.19.0 or newer), then run `openspec init` in the project root.'
|
|
594
594
|
})
|
|
595
595
|
}
|
|
596
596
|
|
|
@@ -915,17 +915,22 @@ function checkPermissions(root, required, { home, sources } = {}) {
|
|
|
915
915
|
/**
|
|
916
916
|
* Advice, never a gate: push notification is an optional feature, so this row
|
|
917
917
|
* is `ok` or `skip` and NEVER `fail` — an operator who never configured it has
|
|
918
|
-
* not degraded their run. Reads the same
|
|
919
|
-
* `
|
|
920
|
-
*
|
|
921
|
-
*
|
|
922
|
-
*
|
|
923
|
-
* never read into a string that could reach the report;
|
|
924
|
-
* which is not a capability, is echoed.
|
|
918
|
+
* not degraded their run. Reads the same names `lib/notify.mjs` reads — the
|
|
919
|
+
* plugin options `CLAUDE_PLUGIN_OPTION_NTFY_TOPIC` / `CLAUDE_PLUGIN_OPTION_NTFY_URL`
|
|
920
|
+
* and the shell variables `INTERLOCK_NTFY_TOPIC` / `INTERLOCK_NTFY_URL` — by
|
|
921
|
+
* literal string rather than by importing the transport. The default server
|
|
922
|
+
* comes from `notify-defaults.mjs`, so this row cannot name a different one.
|
|
923
|
+
* The topic value is never read into a string that could reach the report;
|
|
924
|
+
* only the server URL, which is not a capability, is echoed.
|
|
925
925
|
*/
|
|
926
|
+
function configuredValue(env, name) {
|
|
927
|
+
return env && typeof env[name] === 'string' && env[name].trim() ? env[name].trim() : ''
|
|
928
|
+
}
|
|
929
|
+
|
|
926
930
|
function checkNotify(env) {
|
|
927
931
|
try {
|
|
928
|
-
const topic =
|
|
932
|
+
const topic =
|
|
933
|
+
configuredValue(env, 'CLAUDE_PLUGIN_OPTION_NTFY_TOPIC') || configuredValue(env, 'INTERLOCK_NTFY_TOPIC')
|
|
929
934
|
if (!topic) {
|
|
930
935
|
return check(
|
|
931
936
|
'notify',
|
|
@@ -934,9 +939,9 @@ function checkNotify(env) {
|
|
|
934
939
|
)
|
|
935
940
|
}
|
|
936
941
|
const url =
|
|
937
|
-
env
|
|
938
|
-
|
|
939
|
-
|
|
942
|
+
configuredValue(env, 'CLAUDE_PLUGIN_OPTION_NTFY_URL') ||
|
|
943
|
+
configuredValue(env, 'INTERLOCK_NTFY_URL') ||
|
|
944
|
+
DEFAULT_NTFY_URL
|
|
940
945
|
return check('notify', 'ok', `push configured: ${url}`)
|
|
941
946
|
} catch (err) {
|
|
942
947
|
// Fail-open, and never `fail`: an unreadable env is a reason to skip the
|
|
@@ -1427,12 +1432,12 @@ export function evalPrerequisiteChecks(pluginRoot, env = process.env) {
|
|
|
1427
1432
|
|
|
1428
1433
|
return [
|
|
1429
1434
|
enabled
|
|
1430
|
-
? check('evals-harness', 'ok', `${EVAL_ENABLEMENT_VAR} is set, so
|
|
1435
|
+
? check('evals-harness', 'ok', `${EVAL_ENABLEMENT_VAR} is set, so the early-access harness is enabled`)
|
|
1431
1436
|
: check(
|
|
1432
1437
|
'evals-harness',
|
|
1433
1438
|
'skip',
|
|
1434
|
-
`${EVAL_ENABLEMENT_VAR} is not set, so the
|
|
1435
|
-
{ fix: `
|
|
1439
|
+
`${EVAL_ENABLEMENT_VAR} is not set, so the early-access harness is unavailable — no ship run needs it`,
|
|
1440
|
+
{ fix: `Set one of the harness prerequisites by assigning ${EVAL_ENABLEMENT_VAR} the value 1. Nothing else changes.` }
|
|
1436
1441
|
),
|
|
1437
1442
|
credential
|
|
1438
1443
|
? // The NAME of the variable, never its value. Reporting which one is
|