@renzrollon/interlock 1.0.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 (125) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +58 -0
  3. package/CHANGELOG.md +380 -0
  4. package/LICENSE +21 -0
  5. package/README.md +358 -0
  6. package/agents/ping.md +8 -0
  7. package/agents/worker.md +8 -0
  8. package/bin/interlock +2655 -0
  9. package/bin/interlock-graph +6 -0
  10. package/bin/interlock-run +508 -0
  11. package/bin/interlock-ship-acp +39 -0
  12. package/hooks/_shared.mjs +162 -0
  13. package/hooks/guard-commit.mjs +77 -0
  14. package/hooks/guard-tasks.mjs +107 -0
  15. package/hooks/guard-tests.mjs +63 -0
  16. package/hooks/preflight.mjs +107 -0
  17. package/lib/artifacts.mjs +316 -0
  18. package/lib/autonomy.mjs +149 -0
  19. package/lib/conformance.mjs +132 -0
  20. package/lib/doctor.mjs +1176 -0
  21. package/lib/drift.mjs +409 -0
  22. package/lib/eval-history.mjs +304 -0
  23. package/lib/evals-calibrate.mjs +317 -0
  24. package/lib/evals-capture.mjs +437 -0
  25. package/lib/evals-promote.mjs +257 -0
  26. package/lib/evals-triage.mjs +216 -0
  27. package/lib/findings.mjs +340 -0
  28. package/lib/graph/cli.mjs +266 -0
  29. package/lib/graph/extract/config-json.mjs +74 -0
  30. package/lib/graph/extract/create-extractor.mjs +35 -0
  31. package/lib/graph/extract/memory.mjs +99 -0
  32. package/lib/graph/extract/openspec.mjs +80 -0
  33. package/lib/graph/extract/python.mjs +137 -0
  34. package/lib/graph/extract/regex-fallback.mjs +187 -0
  35. package/lib/graph/extract/shell.mjs +71 -0
  36. package/lib/graph/extract/typescript.mjs +217 -0
  37. package/lib/graph/extract/walk.mjs +272 -0
  38. package/lib/graph/format.mjs +51 -0
  39. package/lib/graph/graph/build.mjs +374 -0
  40. package/lib/graph/graph/query.mjs +249 -0
  41. package/lib/graph/graph/report.mjs +122 -0
  42. package/lib/graph/graph/update.mjs +47 -0
  43. package/lib/graph/paths.mjs +66 -0
  44. package/lib/graph/retrieve/context.mjs +86 -0
  45. package/lib/graph/retrieve/docs-index.mjs +116 -0
  46. package/lib/graph/retrieve/markdown.mjs +246 -0
  47. package/lib/host/acp.mjs +551 -0
  48. package/lib/host/claude-cli.mjs +285 -0
  49. package/lib/host/codex.mjs +231 -0
  50. package/lib/host/model-map.mjs +129 -0
  51. package/lib/host/qwen.mjs +176 -0
  52. package/lib/host/registry.mjs +213 -0
  53. package/lib/host.mjs +393 -0
  54. package/lib/ledger.mjs +444 -0
  55. package/lib/limits.mjs +525 -0
  56. package/lib/locators.mjs +99 -0
  57. package/lib/merge-lanes.mjs +148 -0
  58. package/lib/metrics.mjs +99 -0
  59. package/lib/notify.mjs +229 -0
  60. package/lib/outcomes.mjs +560 -0
  61. package/lib/plan-fingerprint.mjs +570 -0
  62. package/lib/project-slug.mjs +18 -0
  63. package/lib/prompts/commit.mjs +26 -0
  64. package/lib/prompts/dimensions.mjs +70 -0
  65. package/lib/prompts/handoff.mjs +86 -0
  66. package/lib/prompts/implementer.mjs +150 -0
  67. package/lib/prompts/index.mjs +250 -0
  68. package/lib/prompts/planner.mjs +70 -0
  69. package/lib/prompts/remediate.mjs +137 -0
  70. package/lib/prompts/replan.mjs +23 -0
  71. package/lib/prompts/review.mjs +107 -0
  72. package/lib/prompts/schemas.mjs +206 -0
  73. package/lib/prompts/stage.mjs +48 -0
  74. package/lib/prompts/verify.mjs +88 -0
  75. package/lib/ready.mjs +812 -0
  76. package/lib/receipt.mjs +434 -0
  77. package/lib/remediate.mjs +275 -0
  78. package/lib/report-html.mjs +623 -0
  79. package/lib/report.mjs +1124 -0
  80. package/lib/review-core.mjs +713 -0
  81. package/lib/risk.mjs +370 -0
  82. package/lib/run-log.mjs +1004 -0
  83. package/lib/run-paths.mjs +239 -0
  84. package/lib/run.mjs +2921 -0
  85. package/lib/ship-stage.mjs +223 -0
  86. package/lib/spill.mjs +150 -0
  87. package/lib/surface.mjs +195 -0
  88. package/lib/verify.mjs +1074 -0
  89. package/lib/waves.mjs +2661 -0
  90. package/package.json +40 -0
  91. package/shared/CONTEXT-HYGIENE.md +84 -0
  92. package/shared/DECISION-LEDGER.md +141 -0
  93. package/shared/EXPLORE-BRIEF.md +96 -0
  94. package/shared/INVARIANT-SWEEP.md +57 -0
  95. package/shared/SESSION-SHAPES.md +261 -0
  96. package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
  97. package/shared/TEST-PROFILE.md +204 -0
  98. package/shared/TOOL-ECONOMY.md +155 -0
  99. package/skills/bootstrap/SKILL.md +226 -0
  100. package/skills/commit/SKILL.md +99 -0
  101. package/skills/dispatch/SKILL.md +78 -0
  102. package/skills/docs-digest/SKILL.md +109 -0
  103. package/skills/evals/SKILL.md +160 -0
  104. package/skills/explain-code/SKILL.md +42 -0
  105. package/skills/explain-code/commit-teachin.md +136 -0
  106. package/skills/explain-code/module-walkthrough.md +358 -0
  107. package/skills/explore/SKILL.md +159 -0
  108. package/skills/fix-tests/SKILL.md +120 -0
  109. package/skills/graph/SKILL.md +136 -0
  110. package/skills/manual-test-plan/SKILL.md +121 -0
  111. package/skills/mr/SKILL.md +170 -0
  112. package/skills/report/SKILL.md +121 -0
  113. package/skills/review-artifacts/SKILL.md +127 -0
  114. package/skills/review-code/REVIEW.template.md +49 -0
  115. package/skills/review-code/SKILL.md +156 -0
  116. package/skills/review-code/dimensions/architecture.md +10 -0
  117. package/skills/review-code/dimensions/devops.md +12 -0
  118. package/skills/review-code/dimensions/language.md +12 -0
  119. package/skills/review-code/dimensions/qa.md +12 -0
  120. package/skills/review-code/dimensions/security.md +12 -0
  121. package/skills/review-code/dimensions/technical-lead.md +19 -0
  122. package/skills/ship/SKILL.md +67 -0
  123. package/skills/spec/SKILL.md +264 -0
  124. package/skills/spec/continuity.md +66 -0
  125. package/workflows/ship.js +565 -0
package/lib/doctor.mjs ADDED
@@ -0,0 +1,1176 @@
1
+ // The preflight — everything that can stop a zero-touch run, checked before it
2
+ // starts instead of three waves in.
3
+ //
4
+ // Every other gate in this repository judges the *work*: is this plan coherent,
5
+ // did the suite go red, does this finding survive a skeptic. This one judges the
6
+ // *machine the work runs on*, and it exists because those failures are the
7
+ // expensive ones. A run that halts on wave four because `openspec` is not
8
+ // installed, or because `Bash(npm test:*)` is missing from the allowlist and an
9
+ // approval prompt is waiting for a human who went to lunch, has already spent
10
+ // four waves of agents to discover something a filesystem read knew at second
11
+ // zero.
12
+ //
13
+ // Three properties are load-bearing:
14
+ //
15
+ // 1. NON-MUTATING. A doctor that fixes things is a doctor nobody can run to
16
+ // find out what is broken. The writability probe is the only thing here
17
+ // that touches the disk, and it writes a temp file into a directory that
18
+ // already exists and removes it again. Nothing is created, installed, or
19
+ // repaired — every failure carries a `fix` string the reader runs.
20
+ //
21
+ // 2. NEVER THROWS. A probe that cannot run is evidence, not an exception: a
22
+ // missing `git`, an unreadable settings file, a PATH that is not a string
23
+ // all become checks with a status and a reason. The one thing this module
24
+ // must never do is fail to produce a report.
25
+ //
26
+ // 3. AN UNKNOWN IS NOT A PASS. The same rule `lib/ready.mjs` holds. A check
27
+ // that could not run reports `skip` (we chose not to look) or `fail` (we
28
+ // looked and could not confirm) — never `ok`. The permission checker in
29
+ // particular refuses to infer coverage: a rule that is *narrower* than the
30
+ // command it would have to permit is reported as narrower, by name, rather
31
+ // than counted.
32
+ //
33
+ // PLUGIN ROOT vs PROJECT ROOT. Two different things are being diagnosed and
34
+ // they live in two different places. The *plugin* — workflows/ship.js, the
35
+ // agent definitions ship spawns by id, bin/ — is resolved from this module's own
36
+ // location, because that is the copy that will actually run. The *project* —
37
+ // settings, openspec artifacts, the test profile, the state directories — is
38
+ // resolved from `root`. Conflating them produces the worst kind of green: a
39
+ // report that checked the developer's checkout of Interlock while the run used
40
+ // the installed one.
41
+ //
42
+ // Exposed to skills and to `/interlock:ship` as `interlock doctor [--json]`.
43
+
44
+ import { execFileSync } from 'node:child_process'
45
+ import { accessSync, constants, existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
46
+ import { homedir, platform } from 'node:os'
47
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
48
+ import { fileURLToPath } from 'node:url'
49
+
50
+ /** The installed copy of the plugin — the one whose workflow and agents run. */
51
+ export const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
52
+
53
+ /** Where the test profile lives, mirroring `lib/ready.mjs`. */
54
+ export const TEST_PROFILE_PATH = join('.claude', 'testing', 'profile.json')
55
+
56
+ /**
57
+ * Settings files Claude Code merges, least specific first. Enterprise managed
58
+ * settings are read too — they can carry a `deny` a project cannot override,
59
+ * which is precisely the kind of surprise that only shows up mid-run.
60
+ */
61
+ export function settingsSources(root, home = homedir()) {
62
+ const managed =
63
+ platform() === 'darwin'
64
+ ? '/Library/Application Support/ClaudeCode/managed-settings.json'
65
+ : platform() === 'win32'
66
+ ? join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'ClaudeCode', 'managed-settings.json')
67
+ : '/etc/claude-code/managed-settings.json'
68
+ return [
69
+ { scope: 'managed', path: managed, optional: true },
70
+ { scope: 'user', path: join(home, '.claude', 'settings.json'), optional: true },
71
+ { scope: 'project', path: join(root, '.claude', 'settings.json'), optional: true },
72
+ { scope: 'local', path: join(root, '.claude', 'settings.local.json'), optional: true }
73
+ ]
74
+ }
75
+
76
+ /**
77
+ * The host's two prompt-cache lifetime settings, and the build below which it
78
+ * accepts them and silently does nothing with them.
79
+ *
80
+ * These are facts about Claude Code, not Interlock thresholds, so they are named
81
+ * constants in the module that reads them rather than `interlock limits`
82
+ * material — the convention every `INTERLOCK_*` reader already follows. They are
83
+ * two keys because they govern two different request buckets with two different
84
+ * defaults: setting one leaves the other where it was, and the one that governs
85
+ * a ship run's wave agents is the *subagent* key, not the obvious one.
86
+ *
87
+ * The version floor is load-bearing for the advice. Below it the keys are not
88
+ * rejected — they are ignored, with no error and no effect, which is the worst
89
+ * shape a misconfiguration can take.
90
+ */
91
+ export const PROMPT_CACHE_MAIN_KEY = 'promptCacheTtl'
92
+ export const PROMPT_CACHE_SUBAGENT_KEY = 'subagentPromptCacheTtl'
93
+ export const PROMPT_CACHE_MIN_HOST_VERSION = '2.1.242'
94
+
95
+ /**
96
+ * State directories a run writes to, each carrying whether losing it ends the
97
+ * run — because the CLI already answers that, differently, per directory, and a
98
+ * preflight that flattened them would fail a run over bookkeeping.
99
+ *
100
+ * `.claude/ship/runs` is fatal since the reconstructability gate landed: a
101
+ * `wave-state` call whose trajectory append fails now exits 1 rather than
102
+ * warning on stderr. `.claude/ship/spill` is fatal for a different reason —
103
+ * `verify spill` has no fallback, and a verify agent that cannot spill an
104
+ * oversized suite log has no legal way to report it. The other two are
105
+ * explicitly never-fatal in their own modules ("losing a corpus line must never
106
+ * fail the run that made it"; metrics "report their own failure and never touch
107
+ * the exit code"), so an unwritable one is a warning here too.
108
+ */
109
+ export const STATE_DIRS = Object.freeze([
110
+ { path: join('.claude', 'ship', 'runs'), fatal: true, why: 'run trajectory; a failed append exits 1' },
111
+ { path: join('.claude', 'ship', 'spill'), fatal: true, why: 'spilled suite output; no fallback path' },
112
+ { path: join('.claude', 'learning'), fatal: false, why: 'outcome corpus; never fatal at runtime' },
113
+ { path: join('.claude', 'metrics'), fatal: false, why: 'review metrics; never fatal at runtime' }
114
+ ])
115
+
116
+ /**
117
+ * Commands the flow shells out to, independent of the project's test setup.
118
+ * `open` means the rule has to permit arguments after the prefix — `interlock`
119
+ * is never invoked bare, so a rule allowing exactly `interlock` and nothing else
120
+ * permits none of the ~30 subcommands the loop calls.
121
+ */
122
+ export const REQUIRED_COMMANDS = Object.freeze([
123
+ { tokens: ['interlock'], open: true, why: 'every deterministic gate in the loop' },
124
+ { tokens: ['interlock-graph'], open: true, why: 'graph queries in explore / bootstrap' },
125
+ { tokens: ['openspec'], open: true, why: 'spec and bootstrap read and write changes through it' },
126
+ { tokens: ['git'], open: true, why: 'diffs during review, and the commit stage' },
127
+ {
128
+ tokens: ['test'],
129
+ open: true,
130
+ why: 'the environment probe checks for the graph and the test profile (workflows/ship.js)'
131
+ },
132
+ {
133
+ tokens: ['printenv'],
134
+ open: true,
135
+ why: 'the environment probe reads CLAUDE_CODE_SUBAGENT_MODEL and the Bedrock variables'
136
+ },
137
+ { tokens: ['mkdir'], open: true, why: 'the environment probe creates .claude/ship/' }
138
+ ])
139
+
140
+ /**
141
+ * Commands Claude Code auto-approves as read-only no matter what the
142
+ * project's own allowlist says, so a briefing that instructs one of these
143
+ * needs no rule of its own and the drift test below must not demand one.
144
+ * Source: the Claude Code permissions documentation
145
+ * (https://docs.claude.com/en/docs/claude-code/permissions), checked
146
+ * 2026-09-07 — a host claim this module cannot itself verify, so it is dated
147
+ * and cited rather than asserted bare. If the host narrows this list the
148
+ * failure is loud: the drift test below goes red naming the command, never a
149
+ * silent trust of a stale copy. Read-only `git` subcommands are covered
150
+ * separately, by `REQUIRED_COMMANDS`' own open `git` entry, not by this list.
151
+ */
152
+ export const HOST_READ_ONLY = Object.freeze([
153
+ 'ls',
154
+ 'cat',
155
+ 'echo',
156
+ 'pwd',
157
+ 'head',
158
+ 'tail',
159
+ 'grep',
160
+ 'find',
161
+ 'wc',
162
+ 'which',
163
+ 'diff',
164
+ 'stat',
165
+ 'du',
166
+ 'cd'
167
+ ])
168
+
169
+ const STATUS_MARK = { ok: 'ok ', warn: 'warn', fail: 'FAIL', skip: 'skip' }
170
+ const SEVERITY = { ok: 0, skip: 1, warn: 2, fail: 3 }
171
+
172
+ function messageOf(err) {
173
+ return (err && err.message) || String(err)
174
+ }
175
+
176
+ function readJson(path) {
177
+ let raw
178
+ try {
179
+ raw = readFileSync(path, 'utf8')
180
+ } catch (err) {
181
+ return { present: false, reason: messageOf(err) }
182
+ }
183
+ try {
184
+ return { present: true, value: JSON.parse(raw) }
185
+ } catch (err) {
186
+ return { present: true, reason: `not valid JSON: ${messageOf(err)}` }
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Resolve an executable the way a shell would, by scanning PATH. Deliberately
192
+ * not `which`/`where`: shelling out to find out whether we can shell out is a
193
+ * probe that fails for the reason it is testing for.
194
+ */
195
+ export function whichSync(name, env = process.env) {
196
+ if (typeof name !== 'string' || !name) return null
197
+ if (name.includes('/') || name.includes('\\')) {
198
+ const abs = isAbsolute(name) ? name : resolve(name)
199
+ return isExecutable(abs) ? abs : null
200
+ }
201
+ const pathVar = typeof env.PATH === 'string' ? env.PATH : typeof env.Path === 'string' ? env.Path : ''
202
+ const sep = platform() === 'win32' ? ';' : ':'
203
+ const exts =
204
+ platform() === 'win32'
205
+ ? (typeof env.PATHEXT === 'string' ? env.PATHEXT : '.COM;.EXE;.BAT;.CMD').split(';')
206
+ : ['']
207
+ for (const dir of pathVar.split(sep)) {
208
+ if (!dir) continue
209
+ for (const ext of exts) {
210
+ const candidate = join(dir, name + ext)
211
+ if (isExecutable(candidate)) return candidate
212
+ }
213
+ }
214
+ return null
215
+ }
216
+
217
+ function isExecutable(path) {
218
+ try {
219
+ if (!statSync(path).isFile()) return false
220
+ } catch {
221
+ return false
222
+ }
223
+ if (platform() === 'win32') return true
224
+ try {
225
+ accessSync(path, constants.X_OK)
226
+ return true
227
+ } catch {
228
+ return false
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Best-effort version string. Always non-gating: presence is what decides, a
234
+ * version is what the report says out loud. Follows `gitLastCommit`'s
235
+ * precedent in lib/drift.mjs — every failure mode collapses to `null`.
236
+ */
237
+ function probeVersion(bin, args = ['--version']) {
238
+ try {
239
+ const out = execFileSync(bin, args, {
240
+ encoding: 'utf8',
241
+ stdio: ['ignore', 'pipe', 'ignore'],
242
+ timeout: 5000
243
+ })
244
+ return out.trim().split('\n')[0].trim() || null
245
+ } catch {
246
+ return null
247
+ }
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Permission rules
252
+ // ---------------------------------------------------------------------------
253
+
254
+ /**
255
+ * Parse one Claude Code permission rule into something matchable.
256
+ *
257
+ * Both spellings in the wild are handled: the current `Bash(git status:*)`
258
+ * colon form and the older `Bash(git *)` glob form this repository's own skill
259
+ * frontmatter still uses. A bare `Bash` and `Bash(*)` both mean "any command".
260
+ *
261
+ * @returns {{ tool: string, tokens: string[], open: boolean, rule: string }|null}
262
+ */
263
+ export function parseRule(rule) {
264
+ if (typeof rule !== 'string' || !rule.trim()) return null
265
+ const text = rule.trim()
266
+ const paren = /^([A-Za-z_][\w-]*)\((.*)\)$/s.exec(text)
267
+ if (!paren) {
268
+ // A bare tool name permits the whole tool.
269
+ if (/^[A-Za-z_][\w-]*$/.test(text)) return { tool: text, tokens: [], open: true, rule: text }
270
+ return null
271
+ }
272
+ const tool = paren[1]
273
+ let arg = paren[2].trim()
274
+ let open = false
275
+ if (arg === '' || arg === '*') return { tool, tokens: [], open: true, rule: text }
276
+ if (arg.endsWith(':*')) {
277
+ arg = arg.slice(0, -2).trim()
278
+ open = true
279
+ } else if (arg.endsWith('*')) {
280
+ // `git *` and `git*` alike: the trailing star is the open-ended marker.
281
+ arg = arg.slice(0, -1).trim()
282
+ open = true
283
+ }
284
+ const tokens = arg.split(/\s+/).filter(Boolean)
285
+ if (!tokens.length) return { tool, tokens: [], open: true, rule: text }
286
+ return { tool, tokens, open, rule: text }
287
+ }
288
+
289
+ /**
290
+ * Does `rule` permit every invocation `req` describes?
291
+ *
292
+ * Prefix semantics, and deliberately strict in one direction: a rule NARROWER
293
+ * than the requirement (`Bash(interlock waves:*)` against a requirement for
294
+ * `interlock`) does not cover it, because the loop calls thirty other
295
+ * subcommands. Reporting that as coverage would produce the exact green report
296
+ * this module exists to prevent.
297
+ */
298
+ export function ruleCovers(rule, req) {
299
+ if (!rule || rule.tool !== 'Bash') return false
300
+ if (rule.tokens.length > req.tokens.length) return false
301
+ for (let i = 0; i < rule.tokens.length; i++) {
302
+ if (rule.tokens[i] !== '*' && rule.tokens[i] !== req.tokens[i]) return false
303
+ }
304
+ if (rule.tokens.length < req.tokens.length) return rule.open
305
+ return rule.open || !req.open
306
+ }
307
+
308
+ /**
309
+ * Could `rule` ever fire on some invocation of `req`? Used for deny and ask,
310
+ * where a rule that matches only *part* of what the requirement covers is still
311
+ * a mid-run stop. Overlap in either direction counts: `Bash(git push:*)` denies
312
+ * something the `git` requirement includes even though it covers far less.
313
+ */
314
+ export function ruleOverlaps(rule, req) {
315
+ if (!rule || rule.tool !== 'Bash') return false
316
+ const shared = Math.min(rule.tokens.length, req.tokens.length)
317
+ for (let i = 0; i < shared; i++) {
318
+ if (rule.tokens[i] !== '*' && rule.tokens[i] !== req.tokens[i]) return false
319
+ }
320
+ if (rule.tokens.length > req.tokens.length) return req.open
321
+ return true
322
+ }
323
+
324
+ /** Read every settings file and collect the permission rules, with provenance. */
325
+ function collectPermissions(root, { home, sources } = {}) {
326
+ const files = sources || settingsSources(root, home)
327
+ const rules = { allow: [], deny: [], ask: [] }
328
+ const read = []
329
+ let defaultMode = null
330
+
331
+ for (const source of files) {
332
+ if (!existsSync(source.path)) {
333
+ read.push({ ...source, present: false })
334
+ continue
335
+ }
336
+ const parsed = readJson(source.path)
337
+ if (!parsed.value) {
338
+ read.push({ ...source, present: true, error: parsed.reason || 'unreadable' })
339
+ continue
340
+ }
341
+ read.push({ ...source, present: true })
342
+ const permissions =
343
+ parsed.value.permissions && typeof parsed.value.permissions === 'object'
344
+ ? parsed.value.permissions
345
+ : {}
346
+ if (typeof permissions.defaultMode === 'string') defaultMode = permissions.defaultMode
347
+ for (const kind of ['allow', 'deny', 'ask']) {
348
+ const list = Array.isArray(permissions[kind]) ? permissions[kind] : []
349
+ for (const entry of list) {
350
+ const rule = parseRule(entry)
351
+ if (rule) rules[kind].push({ ...rule, scope: source.scope })
352
+ }
353
+ }
354
+ }
355
+ return { rules, read, defaultMode }
356
+ }
357
+
358
+ /**
359
+ * Commands the *project's* test setup adds to the required set. Derived from
360
+ * `.claude/testing/profile.json` rather than guessed: the whole reason that file
361
+ * exists is that "how do I run the tests here" is not inferable.
362
+ *
363
+ * A command is truncated at its first `<placeholder>` — `node --test <path>`
364
+ * requires a rule covering `node --test`, and the path is the argument the rule
365
+ * has to leave open.
366
+ */
367
+ export function profileCommands(profile) {
368
+ const out = []
369
+ const add = (raw, why) => {
370
+ if (typeof raw !== 'string' || !raw.trim()) return
371
+ const tokens = []
372
+ for (const token of raw.trim().split(/\s+/)) {
373
+ if (token.startsWith('<')) break
374
+ tokens.push(token)
375
+ }
376
+ if (!tokens.length) return
377
+ const key = tokens.join(' ')
378
+ if (out.some(c => c.tokens.join(' ') === key)) return
379
+ out.push({ tokens, open: true, why })
380
+ }
381
+ if (!profile || typeof profile !== 'object') return out
382
+ for (const [kind, label] of [['unit', 'unit suite'], ['e2e', 'e2e suite']]) {
383
+ const section = profile[kind]
384
+ if (!section || typeof section !== 'object') continue
385
+ add(section.command, `${label} (test profile)`)
386
+ add(section.single_file, `${label}, single file (test profile)`)
387
+ }
388
+ return out
389
+ }
390
+
391
+ // ---------------------------------------------------------------------------
392
+ // Checks
393
+ // ---------------------------------------------------------------------------
394
+
395
+ function check(id, status, detail, extra = {}) {
396
+ return { id, status, detail, evidence: [], ...extra }
397
+ }
398
+
399
+ function checkNode(pluginRoot, nodeVersion) {
400
+ const pkg = readJson(join(pluginRoot, 'package.json'))
401
+ const declared =
402
+ pkg.value && pkg.value.engines && typeof pkg.value.engines.node === 'string'
403
+ ? pkg.value.engines.node
404
+ : null
405
+ const floor = declared ? Number((/(\d+)/.exec(declared) || [])[1]) : 18
406
+ const parts = String(nodeVersion).replace(/^v/, '').split('.').map(Number)
407
+ const [major, minor, patch] = [parts[0] || 0, parts[1] || 0, parts[2] || 0]
408
+
409
+ if (!Number.isFinite(major) || major === 0) {
410
+ return check('node', 'fail', `could not read a Node version from "${nodeVersion}"`, {
411
+ fix: 'Install Node.js 20.19.0 or newer.'
412
+ })
413
+ }
414
+ if (major < floor) {
415
+ return check('node', 'fail', `Node ${nodeVersion} is below the plugin floor (engines.node ${declared || '>=18'})`, {
416
+ evidence: [`plugin engines.node: ${declared || '(unset, assuming >=18)'}`],
417
+ fix: `Install Node.js ${floor}+ (20.19.0+ to also satisfy the OpenSpec CLI).`
418
+ })
419
+ }
420
+ // OpenSpec's own floor is higher than Interlock's, and a run that satisfies
421
+ // only Interlock's dies inside `openspec`, not here — so it is said here.
422
+ if (major < 20 || (major === 20 && minor < 19)) {
423
+ return check('node', 'warn', `Node ${nodeVersion} satisfies Interlock (${declared || '>=18'}) but is below the OpenSpec CLI's 20.19.0`, {
424
+ fix: 'Install Node.js 20.19.0+ if you use `openspec` from this shell.'
425
+ })
426
+ }
427
+ return check('node', 'ok', `Node ${nodeVersion} (plugin requires ${declared || '>=18'}, OpenSpec requires 20.19.0+)`, {
428
+ evidence: [`patch level ${major}.${minor}.${patch}`]
429
+ })
430
+ }
431
+
432
+ /**
433
+ * The installed plugin, not the checkout you happen to be standing in. A
434
+ * missing workflow or agent definition is the canonical mid-run halt: ship
435
+ * spawns `interlock:worker` by id, and an id that does not resolve fails on the
436
+ * first lane, after the planner has already run.
437
+ */
438
+ function checkPlugin(pluginRoot) {
439
+ const manifest = readJson(join(pluginRoot, '.claude-plugin', 'plugin.json'))
440
+ const required = [
441
+ 'workflows/ship.js',
442
+ 'agents/worker.md',
443
+ 'agents/ping.md',
444
+ 'bin/interlock',
445
+ 'bin/interlock-graph'
446
+ ]
447
+ const missing = required.filter(rel => !existsSync(join(pluginRoot, rel)))
448
+ const evidence = [`plugin root: ${pluginRoot}`]
449
+
450
+ if (!manifest.value) {
451
+ return check('plugin', 'fail', `.claude-plugin/plugin.json is ${manifest.present ? manifest.reason : 'missing'}`, {
452
+ evidence,
453
+ fix: 'Reinstall the plugin: /plugin marketplace add renzrollon/interlock, then /plugin install interlock.'
454
+ })
455
+ }
456
+ evidence.push(`version ${manifest.value.version || '(unset)'}`)
457
+ if (missing.length) {
458
+ return check('plugin', 'fail', `the installed plugin is missing ${missing.length} file(s) the run needs`, {
459
+ evidence: [...evidence, ...missing.map(m => `missing: ${m}`)],
460
+ fix: 'Reinstall the plugin — a partial install halts on the wave it first needs the missing file.'
461
+ })
462
+ }
463
+ if (typeof manifest.value.workflows !== 'string') {
464
+ return check('plugin', 'fail', 'plugin.json declares no "workflows" directory, so /interlock:ship has no workflow to launch', {
465
+ evidence,
466
+ fix: 'Add "workflows": "./workflows" to .claude-plugin/plugin.json.'
467
+ })
468
+ }
469
+ return check('plugin', 'ok', `plugin ${manifest.value.version || '(unversioned)'} complete: workflow, both agent types, both binaries`, {
470
+ evidence
471
+ })
472
+ }
473
+
474
+ /**
475
+ * `interlock` and `interlock-graph` are on PATH inside a Claude Code session
476
+ * because the host injects the plugin's `bin/`. Outside one they are not, and
477
+ * that is not a fault — so an absence resolves to `warn` with the reason, never
478
+ * to a failure that would make this command unusable from a plain shell.
479
+ */
480
+ function checkBinaries(pluginRoot, env) {
481
+ const found = []
482
+ const absent = []
483
+ for (const name of ['interlock', 'interlock-graph']) {
484
+ const bin = whichSync(name, env)
485
+ if (bin) found.push(`${name} → ${bin}`)
486
+ else absent.push(name)
487
+ }
488
+ if (!absent.length) {
489
+ return check('binaries', 'ok', 'interlock and interlock-graph resolve on PATH', { evidence: found })
490
+ }
491
+ const bundled = absent.filter(name => existsSync(join(pluginRoot, 'bin', name)))
492
+ if (bundled.length === absent.length) {
493
+ return check('binaries', 'warn', `${absent.join(', ')} not on PATH — present in the plugin's bin/, which Claude Code injects inside a session`, {
494
+ evidence: [...found, ...bundled.map(n => `bundled: ${join(pluginRoot, 'bin', n)}`)],
495
+ fix: `Nothing to do for a run inside Claude Code. For a plain shell: export PATH="${join(pluginRoot, 'bin')}:$PATH"`
496
+ })
497
+ }
498
+ return check('binaries', 'fail', `${absent.join(', ')} resolve neither on PATH nor in the plugin's bin/`, {
499
+ evidence: found,
500
+ fix: 'Reinstall the plugin.'
501
+ })
502
+ }
503
+
504
+ function checkOpenSpec(root, env, probe) {
505
+ const bin = whichSync('openspec', env)
506
+ const configured = existsSync(join(root, 'openspec', 'config.yaml'))
507
+ const changesDir = existsSync(join(root, 'openspec', 'changes'))
508
+
509
+ if (bin) {
510
+ const version = probe(bin)
511
+ const evidence = [`openspec → ${bin}`, version ? `version ${version}` : 'version probe returned nothing']
512
+ if (!configured) {
513
+ return check('openspec', 'warn', 'the openspec CLI is installed, but this project has no openspec/config.yaml', {
514
+ evidence,
515
+ fix: 'Run `openspec init` in the project root, or /interlock:bootstrap for a brownfield repo.'
516
+ })
517
+ }
518
+ return check('openspec', 'ok', `openspec CLI available and this project is initialised${changesDir ? '' : ' (no changes/ yet)'}`, { evidence })
519
+ }
520
+
521
+ // No CLI. Whether that stops you depends on which half of the flow you use:
522
+ // `ship` reads change artifacts off disk through lib/artifacts.mjs and never
523
+ // shells out, while `spec` and `bootstrap` cannot run without it.
524
+ if (configured) {
525
+ return check('openspec', 'warn', 'the openspec CLI is not on PATH; ship reads artifacts from disk, but /interlock:spec and /interlock:bootstrap need it', {
526
+ evidence: ['openspec/config.yaml is present, so existing changes are still readable'],
527
+ fix: 'npm install -g openspec (requires Node 20.19.0+).'
528
+ })
529
+ }
530
+ return check('openspec', 'fail', 'no openspec CLI on PATH and no openspec/ project in this repo — neither authoring nor shipping a change can start', {
531
+ fix: 'npm install -g openspec && openspec init'
532
+ })
533
+ }
534
+
535
+ function checkGit(root, env, probe) {
536
+ const bin = whichSync('git', env)
537
+ if (!bin) {
538
+ return check('git', 'fail', 'git is not on PATH; review diffs and the commit stage both shell out to it', {
539
+ fix: 'Install git.'
540
+ })
541
+ }
542
+ const version = probe(bin)
543
+ let insideWorkTree = false
544
+ try {
545
+ insideWorkTree =
546
+ execFileSync(bin, ['rev-parse', '--is-inside-work-tree'], {
547
+ cwd: root,
548
+ encoding: 'utf8',
549
+ stdio: ['ignore', 'pipe', 'ignore'],
550
+ timeout: 5000
551
+ }).trim() === 'true'
552
+ } catch {
553
+ insideWorkTree = false
554
+ }
555
+ if (!insideWorkTree) {
556
+ return check('git', 'fail', `${root} is not inside a git work tree, so the run has nothing to diff or commit`, {
557
+ evidence: [version || `git → ${bin}`],
558
+ fix: 'Run from a git repository, or `git init` this directory.'
559
+ })
560
+ }
561
+ return check('git', 'ok', `${version || 'git'} in a work tree`, { evidence: [`git → ${bin}`] })
562
+ }
563
+
564
+ function checkTestProfile(root) {
565
+ const path = join(root, TEST_PROFILE_PATH)
566
+ if (!existsSync(path)) {
567
+ return check('test-profile', 'fail', `${TEST_PROFILE_PATH} is missing, so verification has no command to run and continuity refuses to start`, {
568
+ fix: 'Run /interlock:fix-tests once to discover and persist the project test setup.'
569
+ })
570
+ }
571
+ const parsed = readJson(path)
572
+ if (!parsed.value) {
573
+ return check('test-profile', 'fail', `${TEST_PROFILE_PATH} is ${parsed.reason}`, {
574
+ fix: 'Delete it and re-run /interlock:fix-tests, or repair the JSON by hand.'
575
+ })
576
+ }
577
+ const unit = parsed.value.unit && typeof parsed.value.unit === 'object' ? parsed.value.unit : null
578
+ const command = unit && typeof unit.command === 'string' ? unit.command.trim() : ''
579
+ if (!command) {
580
+ return check('test-profile', 'fail', `${TEST_PROFILE_PATH} declares no unit.command; \`verify plan\` will emit no unit step and the commit gate has nothing to judge`, {
581
+ fix: 'Add a "unit": { "command": "..." } section, or re-run /interlock:fix-tests.'
582
+ })
583
+ }
584
+ return check('test-profile', 'ok', `unit suite: ${command}`, {
585
+ evidence: [parsed.value.e2e ? 'e2e configured' : 'no e2e configured'],
586
+ profile: parsed.value
587
+ })
588
+ }
589
+
590
+ /**
591
+ * The check this command was worth writing for on its own. Since the
592
+ * reconstructability gate landed, a trajectory append that fails exits 1 —
593
+ * so a read-only or otherwise unwritable `.claude/` no longer degrades the run,
594
+ * it ends it. Cheap to detect here; expensive to discover on wave three.
595
+ *
596
+ * Probes by writing into a directory that already exists and removing the file
597
+ * again. A directory that does not exist yet is judged by the writability of
598
+ * its nearest existing ancestor, because that is what `mkdirSync(recursive)`
599
+ * will need — nothing is created here.
600
+ */
601
+ function checkStateDirs(root) {
602
+ const blocked = []
603
+ const evidence = []
604
+ for (const dir of STATE_DIRS) {
605
+ const abs = join(root, dir.path)
606
+ let reason = null
607
+ if (existsSync(abs)) {
608
+ const probe = join(abs, `.interlock-doctor-${process.pid}.tmp`)
609
+ try {
610
+ writeFileSync(probe, '')
611
+ rmSync(probe, { force: true })
612
+ evidence.push(`${dir.path}: writable`)
613
+ } catch (err) {
614
+ reason = messageOf(err)
615
+ }
616
+ } else {
617
+ let ancestor = dirname(abs)
618
+ while (ancestor && !existsSync(ancestor) && ancestor !== dirname(ancestor)) ancestor = dirname(ancestor)
619
+ try {
620
+ accessSync(ancestor, constants.W_OK)
621
+ evidence.push(`${dir.path}: absent, creatable under ${ancestor}`)
622
+ } catch (err) {
623
+ reason = `cannot be created under ${ancestor}: ${messageOf(err)}`
624
+ }
625
+ }
626
+ if (reason) blocked.push({ ...dir, reason })
627
+ }
628
+
629
+ if (!blocked.length) {
630
+ return check('state-dirs', 'ok', 'every run-state directory is writable or creatable', { evidence })
631
+ }
632
+
633
+ const lines = blocked.map(b => `${b.path}: ${b.reason} — ${b.why}`)
634
+ const fatal = blocked.filter(b => b.fatal)
635
+ if (!fatal.length) {
636
+ // Bookkeeping only. The modules behind these two say so themselves, and a
637
+ // preflight that failed the run over a lost metrics line would be applying
638
+ // a rule the runtime does not.
639
+ return check('state-dirs', 'warn', `${blocked.length} bookkeeping director${blocked.length === 1 ? 'y is' : 'ies are'} not writable; the run continues without them`, {
640
+ evidence: lines,
641
+ fix: 'Make .claude/ writable to keep the outcome corpus and review metrics.'
642
+ })
643
+ }
644
+ return check('state-dirs', 'fail', `${fatal.length} run-state director${fatal.length === 1 ? 'y is' : 'ies are'} not writable, and losing ${fatal.length === 1 ? 'it' : 'them'} ends the run`, {
645
+ evidence: lines,
646
+ fix: 'Make .claude/ writable, or run from a checkout that is not read-only. A failed trajectory append exits 1 mid-run.'
647
+ })
648
+ }
649
+
650
+ /**
651
+ * The allowlist preflight. A required command with no covering rule is a `fail`,
652
+ * not a warning: an approval prompt in the middle of a zero-touch run is not a
653
+ * degraded run, it is a stopped one, and there is no human watching by
654
+ * construction.
655
+ */
656
+ function checkPermissions(root, required, { home, sources } = {}) {
657
+ const { rules, read, defaultMode } = collectPermissions(root, { home, sources })
658
+ const unreadable = read.filter(f => f.error)
659
+ const evidence = read.filter(f => f.present && !f.error).map(f => `${f.scope}: ${f.path}`)
660
+ for (const f of unreadable) evidence.push(`${f.scope}: ${f.path} — ${f.error}`)
661
+
662
+ const denied = []
663
+ const asked = []
664
+ const uncovered = []
665
+ for (const req of required) {
666
+ const name = req.tokens.join(' ')
667
+ const deny = rules.deny.filter(r => ruleOverlaps(r, req))
668
+ if (deny.length) {
669
+ denied.push(`${name}: denied by ${deny.map(d => `${d.rule} (${d.scope})`).join(', ')}`)
670
+ continue
671
+ }
672
+ const covering = rules.allow.filter(r => ruleCovers(r, req))
673
+ if (covering.length) {
674
+ const ask = rules.ask.filter(r => ruleOverlaps(r, req))
675
+ if (ask.length) asked.push(`${name}: ${ask.map(a => `${a.rule} (${a.scope})`).join(', ')} prompts`)
676
+ continue
677
+ }
678
+ // Say *why* it is uncovered. "You have a rule, it is just too narrow" and
679
+ // "you have no rule at all" are different mistakes with different fixes.
680
+ const narrower = rules.allow.filter(r => r.tool === 'Bash' && ruleOverlaps(r, req))
681
+ uncovered.push({
682
+ command: name,
683
+ why: req.why,
684
+ suggestion: `Bash(${name}:*)`,
685
+ narrower: narrower.map(r => `${r.rule} (${r.scope})`)
686
+ })
687
+ }
688
+
689
+ if (defaultMode === 'bypassPermissions') {
690
+ return check('permissions', 'warn', 'permissions.defaultMode is "bypassPermissions": nothing will prompt, and nothing is checked', {
691
+ evidence: [
692
+ ...evidence,
693
+ ...uncovered.map(u => `would otherwise be uncovered: ${u.command}`)
694
+ ],
695
+ fix: 'Intentional for a sandboxed run. Set an explicit allowlist if this repo is not disposable.'
696
+ })
697
+ }
698
+
699
+ if (denied.length) {
700
+ return check('permissions', 'fail', `${denied.length} required command(s) are denied outright; the run cannot proceed past them`, {
701
+ evidence: [...evidence, ...denied],
702
+ fix: 'Remove the deny rule, or accept that this repo cannot run ship unattended.'
703
+ })
704
+ }
705
+
706
+ if (uncovered.length) {
707
+ const lines = uncovered.map(u =>
708
+ `${u.command} — ${u.why}` + (u.narrower.length ? ` (found only narrower: ${u.narrower.join(', ')})` : '')
709
+ )
710
+ return check('permissions', 'fail', `${uncovered.length} command(s) the run shells out to have no allow rule; each one stops the run on an approval prompt`, {
711
+ evidence: [...evidence, ...lines],
712
+ uncovered,
713
+ fix:
714
+ `Add to ${join('.claude', 'settings.json')} → permissions.allow:\n` +
715
+ uncovered.map(u => ` "${u.suggestion}"`).join(',\n')
716
+ })
717
+ }
718
+
719
+ if (unreadable.length) {
720
+ return check('permissions', 'warn', `every required command is allowed, but ${unreadable.length} settings file(s) could not be read`, {
721
+ evidence,
722
+ fix: 'Repair the JSON: Claude Code ignores a settings file it cannot parse, so its rules are not in effect.'
723
+ })
724
+ }
725
+
726
+ const status = asked.length ? 'warn' : 'ok'
727
+ return check(
728
+ 'permissions',
729
+ status,
730
+ asked.length
731
+ ? `every required command is allowed, but ${asked.length} also match an "ask" rule that will prompt`
732
+ : `all ${required.length} required commands are allowed`,
733
+ {
734
+ evidence: [...evidence, ...asked, ...required.map(r => `${r.tokens.join(' ')} — ${r.why}`)],
735
+ fix: asked.length ? 'Remove the ask rule, or expect a prompt mid-run.' : undefined
736
+ }
737
+ )
738
+ }
739
+
740
+ /**
741
+ * Advice, never a gate: push notification is an optional feature, so this row
742
+ * is `ok` or `skip` and NEVER `fail` — an operator who never configured it has
743
+ * not degraded their run. Reads the same two environment variable NAMES
744
+ * `lib/notify.mjs` reads (`INTERLOCK_NTFY_TOPIC`, `INTERLOCK_NTFY_URL`) by
745
+ * literal string rather than by importing that module: this file has no
746
+ * other reason to depend on the network transport, and the two spellings are
747
+ * pinned identically in both modules' own test suites. The topic value is
748
+ * never read into a string that could reach the report; only the server URL,
749
+ * which is not a capability, is echoed.
750
+ */
751
+ function checkNotify(env) {
752
+ try {
753
+ const topic = env && typeof env.INTERLOCK_NTFY_TOPIC === 'string' ? env.INTERLOCK_NTFY_TOPIC.trim() : ''
754
+ if (!topic) {
755
+ return check(
756
+ 'notify',
757
+ 'skip',
758
+ 'INTERLOCK_NTFY_TOPIC is not set — optional; an unattended run that stops will not reach you'
759
+ )
760
+ }
761
+ const url =
762
+ env && typeof env.INTERLOCK_NTFY_URL === 'string' && env.INTERLOCK_NTFY_URL.trim()
763
+ ? env.INTERLOCK_NTFY_URL.trim()
764
+ : 'https://ntfy.sh'
765
+ return check('notify', 'ok', `push configured: ${url}`)
766
+ } catch (err) {
767
+ // Fail-open, and never `fail`: an unreadable env is a reason to skip the
768
+ // advice, not a reason to block a run that never asked for it.
769
+ return check('notify', 'skip', `the notify check could not run: ${messageOf(err)}`)
770
+ }
771
+ }
772
+
773
+ /**
774
+ * Advice, never a gate, and never a measurement.
775
+ *
776
+ * An unset prompt-cache lifetime costs money and never costs correctness, so
777
+ * this row takes `checkNotify`'s posture exactly — `ok` or `skip`, never `fail`
778
+ * — rather than `checkPermissions`'s. Taking the other shape would emit a
779
+ * failing preflight on every session that has not set two keys Interlock cannot
780
+ * set for them: a plugin has no settings component, so this row exists to *tell*
781
+ * an operator, which is the whole of what is available here.
782
+ *
783
+ * WHAT IT MAY NOT SAY. Nothing exposes the lifetime a session is actually
784
+ * running under to a hook, a script, or any command's output. The only observable
785
+ * is the post-hoc usage envelope, which this command does not have. So the row
786
+ * reads the four settings scopes and states what is *configured*, plus the
787
+ * authentication mode — because the documented defaults depend on it — and
788
+ * asserts no effective lifetime. An effective lifetime stated here would be a
789
+ * fabricated measurement, the exact failure the eval requirement in this same
790
+ * change forbids.
791
+ *
792
+ * Values are never printed and never resolved across scopes. Claude Code's own
793
+ * merge decides which scope wins; a preflight that picked one and printed it
794
+ * would be recommending a value it did not compute.
795
+ */
796
+ function checkPromptCache(root, { home, sources, env } = {}) {
797
+ try {
798
+ const keys = [PROMPT_CACHE_MAIN_KEY, PROMPT_CACHE_SUBAGENT_KEY]
799
+ const scopes = { [PROMPT_CACHE_MAIN_KEY]: [], [PROMPT_CACHE_SUBAGENT_KEY]: [] }
800
+
801
+ for (const source of sources || settingsSources(root, home)) {
802
+ if (!existsSync(source.path)) continue
803
+ const parsed = readJson(source.path)
804
+ // An unreadable or unparseable settings file is not evidence that a key is
805
+ // unset — Claude Code ignores such a file too, and `checkPermissions`
806
+ // already reports the parse failure. Silence here, not a claim.
807
+ if (!parsed.value || typeof parsed.value !== 'object') continue
808
+ for (const key of keys) {
809
+ if (isConfiguredLifetime(parsed.value[key])) scopes[key].push(source.scope)
810
+ }
811
+ }
812
+
813
+ const where = key =>
814
+ scopes[key].length ? `set in ${scopes[key].join(', ')}` : 'not set in any scope read here'
815
+ const mode = detectAuthMode(env)
816
+ const both = keys.every(k => scopes[k].length)
817
+
818
+ const detail = [
819
+ both
820
+ ? 'both prompt-cache lifetimes are configured'
821
+ : 'a prompt-cache lifetime is unset, so its bucket runs on the host default',
822
+ `${PROMPT_CACHE_MAIN_KEY} (main conversation) ${where(PROMPT_CACHE_MAIN_KEY)}`,
823
+ `${PROMPT_CACHE_SUBAGENT_KEY} (subagents and workflows — the bucket a ship run's wave agents fall in) ${where(PROMPT_CACHE_SUBAGENT_KEY)}`,
824
+ `auth mode: ${mode || 'not determined from the environment, so no documented default can be named'}`,
825
+ `both keys need Claude Code >= ${PROMPT_CACHE_MIN_HOST_VERSION}, below which they are accepted and silently ignored`,
826
+ 'configuration only — nothing exposes the lifetime a session is actually running under, and the defaults depend on the host build and the authentication mode'
827
+ ].join('; ')
828
+
829
+ return check('prompt-cache', both ? 'ok' : 'skip', detail, {
830
+ fix: both
831
+ ? undefined
832
+ : `Set "${PROMPT_CACHE_MAIN_KEY}" and "${PROMPT_CACHE_SUBAGENT_KEY}" in your own settings.json. Interlock cannot: a plugin has no settings component.`
833
+ })
834
+ } catch (err) {
835
+ // Fail-open, and never `fail`: an unreadable settings tree is a reason to
836
+ // withhold the advice, not a reason to block a run that works without it.
837
+ return check('prompt-cache', 'skip', `the prompt-cache check could not run: ${messageOf(err)}`)
838
+ }
839
+ }
840
+
841
+ /** A lifetime is configured when it carries a value, whatever the host makes of it. */
842
+ function isConfiguredLifetime(value) {
843
+ if (typeof value === 'string') return value.trim() !== ''
844
+ if (typeof value === 'number') return Number.isFinite(value)
845
+ return false
846
+ }
847
+
848
+ /**
849
+ * Which credential the environment carries, by NAME and never by value — the
850
+ * same presence-only rule the eval prerequisite rows hold, for the same reason:
851
+ * this report is written to disk and pasted into issues. Returns null rather
852
+ * than guessing, because a guessed mode would let the row name a default that
853
+ * does not apply.
854
+ */
855
+ function detectAuthMode(env) {
856
+ if (isSet(env, 'ANTHROPIC_API_KEY')) return 'API key (ANTHROPIC_API_KEY is set)'
857
+ if (isSet(env, 'CLAUDE_CODE_OAUTH_TOKEN')) return 'OAuth token (CLAUDE_CODE_OAUTH_TOKEN is set)'
858
+ return null
859
+ }
860
+
861
+ // ---------------------------------------------------------------------------
862
+ // Instructed-command extraction — the drift test's own probe
863
+ // ---------------------------------------------------------------------------
864
+
865
+ /**
866
+ * Backticked spans that satisfy the shape test below — a bare lowercase word
867
+ * with a space, flag or path somewhere in it — but name a field, a
868
+ * placeholder or other non-command text rather than something a shell runs.
869
+ * Checked explicitly, by exact span text, rather than filtered by a broader
870
+ * heuristic: a heuristic clever enough to know "not a command" without being
871
+ * told would also be clever enough to hide a real one. Empty today — every
872
+ * span currently found in `workflows/ship.js` and `lib/prompts/*.mjs` is
873
+ * either a real command or excluded by the shape test itself (a bare
874
+ * `` `group` `` has no space) — but the list is exported and pinned so the
875
+ * next false positive is named here instead of the shape test being loosened
876
+ * to let it through.
877
+ */
878
+ export const IGNORED_INSTRUCTION_SPANS = Object.freeze([])
879
+
880
+ const SHELL_SEPARATORS = /(?:&&|\|\||[;|])/
881
+
882
+ /**
883
+ * Reduce one shell fragment to the program name a permission rule would have
884
+ * to permit: strip a leading `VAR=value` environment assignment (repeatable —
885
+ * `A=1 B=2 cmd` is legal shell), a leading `$(` command-substitution opener
886
+ * and a leading quote character — in whatever order they actually nest —
887
+ * then take the token up to the next whitespace and drop a trailing quote or
888
+ * closing paren that rode along with it.
889
+ */
890
+ function firstToken(fragment) {
891
+ let s = fragment.trim()
892
+ for (;;) {
893
+ const before = s
894
+ s = s.replace(/^\$\(/, '')
895
+ s = s.replace(/^["'`]/, '')
896
+ s = s.replace(/^[A-Za-z_][A-Za-z0-9_]*=\S*\s+/, '')
897
+ s = s.trim()
898
+ if (s === before) break
899
+ }
900
+ const m = /^(\S+)/.exec(s)
901
+ if (!m) return ''
902
+ return m[1].replace(/["')]+$/, '')
903
+ }
904
+
905
+ /** Split one instructed line on the shell's own separators and take each subcommand's program name. */
906
+ function subcommands(text) {
907
+ return text
908
+ .split(SHELL_SEPARATORS)
909
+ .map(part => firstToken(part))
910
+ .filter(Boolean)
911
+ }
912
+
913
+ /**
914
+ * Every shell command a driver or a briefing literally instructs an agent to
915
+ * run, derived from the raw source text rather than assumed. Two independent
916
+ * sources, because the driver text mixes both styles:
917
+ *
918
+ * 1. Every `Run:` / `Then run:` line — the environment probe's own
919
+ * convention (`workflows/ship.js:368-382`) for a literal shell command,
920
+ * taken up to the line's embedded `\n` marker (two characters in the
921
+ * raw source: a backslash and an `n`, not an actual newline) or to the
922
+ * end of the line if no marker is present.
923
+ * 2. Every markdown-style inline code span in the assembled prompt — an
924
+ * ESCAPED backtick pair (two characters, a backslash and a backtick, on
925
+ * each side) in the JS source. An UNESCAPED backtick there is a
926
+ * template-literal delimiter, not a code span, and matching it produces
927
+ * exactly the false-positive flood this function exists to avoid. A
928
+ * span counts only when its first word matches `/^[a-z][a-z0-9-]*$/`
929
+ * AND the span also carries a space, a `-` flag or a `/` path — the
930
+ * shape of an invocation rather than a bare field name like `` `group` ``
931
+ * or `` `paths` `` — and it is not listed in `IGNORED_INSTRUCTION_SPANS`.
932
+ *
933
+ * Both sources are then split on `&&`, `||`, `;` and `|` and reduced to each
934
+ * subcommand's program name by `firstToken`.
935
+ *
936
+ * @param {string} text raw source text (a whole file, or a synthetic fixture)
937
+ * @returns {string[]} deduplicated program names, in first-seen order
938
+ */
939
+ export function extractInstructedCommands(text) {
940
+ if (typeof text !== 'string') return []
941
+ const found = new Set()
942
+
943
+ for (const line of text.split(/\r?\n/)) {
944
+ const m = /(?:Run|Then run):\s*(.*)/.exec(line)
945
+ if (!m) continue
946
+ let body = m[1]
947
+ const nl = body.indexOf('\\n')
948
+ if (nl !== -1) body = body.slice(0, nl)
949
+ for (const cmd of subcommands(body)) found.add(cmd)
950
+ }
951
+
952
+ const spanRe = /\\`([^`]*?)\\`/g
953
+ let sm
954
+ while ((sm = spanRe.exec(text))) {
955
+ const span = sm[1]
956
+ if (IGNORED_INSTRUCTION_SPANS.includes(span)) continue
957
+ const first = span.split(/\s+/)[0] || ''
958
+ if (!/^[a-z][a-z0-9-]*$/.test(first)) continue
959
+ const hasSpace = /\s/.test(span)
960
+ const hasFlag = /(^|\s)-/.test(span)
961
+ const hasPath = span.includes('/')
962
+ if (!hasSpace && !hasFlag && !hasPath) continue
963
+ for (const cmd of subcommands(span)) found.add(cmd)
964
+ }
965
+
966
+ return [...found]
967
+ }
968
+
969
+ // ---------------------------------------------------------------------------
970
+ // Eval prerequisites
971
+ // ---------------------------------------------------------------------------
972
+
973
+ /**
974
+ * The early-access flag that enables the eval harness, and the credentials a
975
+ * model run can use. Both are read for PRESENCE ONLY — never for value. This
976
+ * report is written to disk and pasted into issues, and a preflight that echoed
977
+ * a key into either surface would be a credential leak with a helpful UI.
978
+ */
979
+ export const EVAL_ENABLEMENT_VAR = 'CLAUDE_CODE_WALNUT_SPIRE'
980
+ export const EVAL_CREDENTIAL_VARS = Object.freeze(['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'])
981
+
982
+ /** Is a variable set to something meaningful? Its value never leaves this function. */
983
+ function isSet(env, name) {
984
+ const raw = env && typeof env[name] === 'string' ? env[name].trim() : ''
985
+ return Boolean(raw) && raw !== '0' && raw.toLowerCase() !== 'false'
986
+ }
987
+
988
+ /** Does an eval suite — at least one case directory — exist at the plugin root? */
989
+ function hasEvalSuite(pluginRoot) {
990
+ const dir = join(pluginRoot, 'evals')
991
+ if (!existsSync(dir)) return false
992
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
993
+ if (!entry.isDirectory()) continue
994
+ const caseDir = join(dir, entry.name)
995
+ if (existsSync(join(caseDir, 'case.yaml')) || existsSync(join(caseDir, 'prompt.md'))) return true
996
+ }
997
+ return false
998
+ }
999
+
1000
+ /**
1001
+ * Two rows about running the eval suite — and never a reason not to run.
1002
+ *
1003
+ * NEITHER ROW MAY EVER BE `fail`, and that is the whole design of this
1004
+ * function. Neither prerequisite stops a ship run: a repository ships changes
1005
+ * without ever running an eval, so a preflight that failed on them would block
1006
+ * unattended work over a capability the run does not use. `skip` with a reason
1007
+ * is what "we chose not to look / it is not here" means in this module's
1008
+ * vocabulary, and it leaves the exit status alone.
1009
+ *
1010
+ * It is also NOT wrapped in `diagnose`'s `run()` helper, deliberately: that
1011
+ * wrapper turns a thrown probe into a `fail` row, which is exactly the verdict
1012
+ * this check may not produce. It catches its own errors and degrades to `skip`
1013
+ * — the same fail-open rule the runtime guards follow, stated for a preflight.
1014
+ * A check that failed whenever it could not establish its own answer would
1015
+ * block precisely the hosts it was least able to inspect.
1016
+ *
1017
+ * The rows are omitted entirely when no suite exists: a repository with no
1018
+ * cases has no prerequisite, and two permanent `skip` rows on every consumer's
1019
+ * preflight would be noise.
1020
+ *
1021
+ * @param {string} pluginRoot the installed plugin, where the suite lives
1022
+ * @param {object} env environment read for presence only
1023
+ * @returns {object[]} zero or two checks
1024
+ */
1025
+ export function evalPrerequisiteChecks(pluginRoot, env = process.env) {
1026
+ try {
1027
+ if (!hasEvalSuite(pluginRoot)) return []
1028
+
1029
+ const enabled = isSet(env, EVAL_ENABLEMENT_VAR)
1030
+ const credential = EVAL_CREDENTIAL_VARS.find(name => isSet(env, name)) || null
1031
+
1032
+ return [
1033
+ enabled
1034
+ ? check('evals-harness', 'ok', `${EVAL_ENABLEMENT_VAR} is set, so \`claude plugin eval\` is enabled`)
1035
+ : check(
1036
+ 'evals-harness',
1037
+ 'skip',
1038
+ `${EVAL_ENABLEMENT_VAR} is not set, so the eval harness is unavailable — no ship run needs it`,
1039
+ { fix: `export ${EVAL_ENABLEMENT_VAR}=1 to run \`claude plugin eval\`. Nothing else changes.` }
1040
+ ),
1041
+ credential
1042
+ ? // The NAME of the variable, never its value. Reporting which one is
1043
+ // set is what makes the row actionable; reporting what it holds would
1044
+ // put a key in a file people attach to issues.
1045
+ check('evals-credential', 'ok', `a model credential is present (${credential} is set)`)
1046
+ : check(
1047
+ 'evals-credential',
1048
+ 'skip',
1049
+ `no model credential is set, so an eval run has nothing to call — no ship run needs one`,
1050
+ { fix: `Set one of ${EVAL_CREDENTIAL_VARS.join(' or ')} to run the eval suite.` }
1051
+ )
1052
+ ]
1053
+ } catch (err) {
1054
+ // The fail-open path, and the reason this function owns its own try/catch.
1055
+ return [
1056
+ check('evals-harness', 'skip', `the eval prerequisite check could not run: ${messageOf(err)}`),
1057
+ check('evals-credential', 'skip', `the eval prerequisite check could not run: ${messageOf(err)}`)
1058
+ ]
1059
+ }
1060
+ }
1061
+
1062
+ // ---------------------------------------------------------------------------
1063
+ // Report
1064
+ // ---------------------------------------------------------------------------
1065
+
1066
+ /**
1067
+ * Run every preflight check and report. Never throws: a probe that blows up
1068
+ * becomes a failed check with the error as its detail, because a doctor whose
1069
+ * own crash is the output is worse than no doctor.
1070
+ *
1071
+ * @param {string} [root] project root (default cwd)
1072
+ * @param {object} [opts]
1073
+ * @param {string} [opts.pluginRoot] installed plugin location; defaults to this checkout
1074
+ * @param {object} [opts.env] environment PATH is read from
1075
+ * @param {string} [opts.home] home directory user settings are read from
1076
+ * @param {string} [opts.nodeVersion] overrides process.version, for tests
1077
+ * @param {(bin: string) => string|null} [opts.probeVersion] version probe; injected in tests
1078
+ * @param {Array<{scope:string,path:string}>} [opts.settingsSources] overrides the merge list
1079
+ * @returns {object} report
1080
+ */
1081
+ export function diagnose(root = '.', opts = {}) {
1082
+ const projectRoot = resolve(root)
1083
+ const pluginRoot = opts.pluginRoot ? resolve(opts.pluginRoot) : PLUGIN_ROOT
1084
+ const env = opts.env || process.env
1085
+ const probe = typeof opts.probeVersion === 'function' ? opts.probeVersion : probeVersion
1086
+
1087
+ const run = (id, fn) => {
1088
+ try {
1089
+ return fn()
1090
+ } catch (err) {
1091
+ return check(id, 'fail', `check could not run: ${messageOf(err)}`, {
1092
+ fix: 'This is a bug in `interlock doctor`; the underlying condition is unknown, which is not a pass.'
1093
+ })
1094
+ }
1095
+ }
1096
+
1097
+ const checks = []
1098
+ checks.push(run('node', () => checkNode(pluginRoot, opts.nodeVersion || process.version)))
1099
+ checks.push(run('plugin', () => checkPlugin(pluginRoot)))
1100
+ checks.push(run('binaries', () => checkBinaries(pluginRoot, env)))
1101
+ checks.push(run('openspec', () => checkOpenSpec(projectRoot, env, probe)))
1102
+ checks.push(run('git', () => checkGit(projectRoot, env, probe)))
1103
+
1104
+ const profileCheck = run('test-profile', () => checkTestProfile(projectRoot))
1105
+ const profile = profileCheck.profile
1106
+ delete profileCheck.profile
1107
+ checks.push(profileCheck)
1108
+
1109
+ // The required-command set is derived, never assumed: the static four, plus
1110
+ // whatever the project's own test profile says it runs. A repo whose suite is
1111
+ // `pnpm vitest run` needs that allowlisted, and no hardcoded list knows it.
1112
+ const required = [...REQUIRED_COMMANDS, ...profileCommands(profile)]
1113
+ checks.push(
1114
+ run('permissions', () =>
1115
+ checkPermissions(projectRoot, required, { home: opts.home, sources: opts.settingsSources })
1116
+ )
1117
+ )
1118
+ checks.push(run('state-dirs', () => checkStateDirs(projectRoot)))
1119
+
1120
+ // Not behind `run()`: that wrapper's thrown-probe verdict is `fail`, and
1121
+ // this row may never carry one — push is optional, so `checkNotify` owns
1122
+ // its own try/catch and degrades to `skip`. Same reasoning as the eval
1123
+ // prerequisite rows just below.
1124
+ checks.push(checkNotify(env))
1125
+
1126
+ // Same exemption, same reason: prompt-cache lifetime is advice about the
1127
+ // operator's own settings file, so this row is `ok`/`skip` and owns its
1128
+ // try/catch. See `checkPromptCache`.
1129
+ checks.push(checkPromptCache(projectRoot, { home: opts.home, sources: opts.settingsSources, env }))
1130
+
1131
+ // Not behind `run()`: that wrapper's thrown-probe verdict is `fail`, and
1132
+ // these rows may never carry one. See `evalPrerequisiteChecks`.
1133
+ checks.push(...evalPrerequisiteChecks(pluginRoot, env))
1134
+
1135
+ const counts = { ok: 0, warn: 0, fail: 0, skip: 0 }
1136
+ for (const c of checks) counts[c.status] = (counts[c.status] || 0) + 1
1137
+
1138
+ return {
1139
+ ok: counts.fail === 0,
1140
+ root: projectRoot,
1141
+ pluginRoot,
1142
+ counts,
1143
+ checks,
1144
+ failures: checks.filter(c => c.status === 'fail').map(c => c.id),
1145
+ warnings: checks.filter(c => c.status === 'warn').map(c => c.id),
1146
+ requiredCommands: required.map(r => ({ command: r.tokens.join(' '), why: r.why }))
1147
+ }
1148
+ }
1149
+
1150
+ /** Human-readable preflight, in the shape `interlock ready` already prints. */
1151
+ export function formatDoctor(report) {
1152
+ const lines = []
1153
+ lines.push(
1154
+ report.ok
1155
+ ? `PREFLIGHT OK — ${report.counts.ok} passed` +
1156
+ (report.counts.warn ? `, ${report.counts.warn} warning(s)` : '')
1157
+ : `PREFLIGHT BLOCKED — ${report.counts.fail} check(s) would stop a zero-touch run` +
1158
+ (report.counts.warn ? `, ${report.counts.warn} warning(s)` : '')
1159
+ )
1160
+ lines.push(` root: ${report.root}`)
1161
+ if (report.pluginRoot !== report.root) lines.push(` plugin: ${report.pluginRoot}`)
1162
+ for (const c of report.checks) {
1163
+ lines.push(` [${STATUS_MARK[c.status] || c.status}] ${c.id}: ${c.detail}`)
1164
+ // Evidence for anything that is not clean; a clean run should be readable
1165
+ // in nine lines, not ninety.
1166
+ if (SEVERITY[c.status] >= SEVERITY.warn) {
1167
+ for (const e of c.evidence || []) lines.push(` ${e}`)
1168
+ if (c.fix) {
1169
+ const [first, ...rest] = c.fix.split('\n')
1170
+ lines.push(` fix: ${first}`)
1171
+ for (const line of rest) lines.push(` ${line.trim()}`)
1172
+ }
1173
+ }
1174
+ }
1175
+ return lines.join('\n') + '\n'
1176
+ }