@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.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +58 -0
- package/CHANGELOG.md +380 -0
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/agents/ping.md +8 -0
- package/agents/worker.md +8 -0
- package/bin/interlock +2655 -0
- package/bin/interlock-graph +6 -0
- package/bin/interlock-run +508 -0
- package/bin/interlock-ship-acp +39 -0
- package/hooks/_shared.mjs +162 -0
- package/hooks/guard-commit.mjs +77 -0
- package/hooks/guard-tasks.mjs +107 -0
- package/hooks/guard-tests.mjs +63 -0
- package/hooks/preflight.mjs +107 -0
- package/lib/artifacts.mjs +316 -0
- package/lib/autonomy.mjs +149 -0
- package/lib/conformance.mjs +132 -0
- package/lib/doctor.mjs +1176 -0
- package/lib/drift.mjs +409 -0
- package/lib/eval-history.mjs +304 -0
- package/lib/evals-calibrate.mjs +317 -0
- package/lib/evals-capture.mjs +437 -0
- package/lib/evals-promote.mjs +257 -0
- package/lib/evals-triage.mjs +216 -0
- package/lib/findings.mjs +340 -0
- package/lib/graph/cli.mjs +266 -0
- package/lib/graph/extract/config-json.mjs +74 -0
- package/lib/graph/extract/create-extractor.mjs +35 -0
- package/lib/graph/extract/memory.mjs +99 -0
- package/lib/graph/extract/openspec.mjs +80 -0
- package/lib/graph/extract/python.mjs +137 -0
- package/lib/graph/extract/regex-fallback.mjs +187 -0
- package/lib/graph/extract/shell.mjs +71 -0
- package/lib/graph/extract/typescript.mjs +217 -0
- package/lib/graph/extract/walk.mjs +272 -0
- package/lib/graph/format.mjs +51 -0
- package/lib/graph/graph/build.mjs +374 -0
- package/lib/graph/graph/query.mjs +249 -0
- package/lib/graph/graph/report.mjs +122 -0
- package/lib/graph/graph/update.mjs +47 -0
- package/lib/graph/paths.mjs +66 -0
- package/lib/graph/retrieve/context.mjs +86 -0
- package/lib/graph/retrieve/docs-index.mjs +116 -0
- package/lib/graph/retrieve/markdown.mjs +246 -0
- package/lib/host/acp.mjs +551 -0
- package/lib/host/claude-cli.mjs +285 -0
- package/lib/host/codex.mjs +231 -0
- package/lib/host/model-map.mjs +129 -0
- package/lib/host/qwen.mjs +176 -0
- package/lib/host/registry.mjs +213 -0
- package/lib/host.mjs +393 -0
- package/lib/ledger.mjs +444 -0
- package/lib/limits.mjs +525 -0
- package/lib/locators.mjs +99 -0
- package/lib/merge-lanes.mjs +148 -0
- package/lib/metrics.mjs +99 -0
- package/lib/notify.mjs +229 -0
- package/lib/outcomes.mjs +560 -0
- package/lib/plan-fingerprint.mjs +570 -0
- package/lib/project-slug.mjs +18 -0
- package/lib/prompts/commit.mjs +26 -0
- package/lib/prompts/dimensions.mjs +70 -0
- package/lib/prompts/handoff.mjs +86 -0
- package/lib/prompts/implementer.mjs +150 -0
- package/lib/prompts/index.mjs +250 -0
- package/lib/prompts/planner.mjs +70 -0
- package/lib/prompts/remediate.mjs +137 -0
- package/lib/prompts/replan.mjs +23 -0
- package/lib/prompts/review.mjs +107 -0
- package/lib/prompts/schemas.mjs +206 -0
- package/lib/prompts/stage.mjs +48 -0
- package/lib/prompts/verify.mjs +88 -0
- package/lib/ready.mjs +812 -0
- package/lib/receipt.mjs +434 -0
- package/lib/remediate.mjs +275 -0
- package/lib/report-html.mjs +623 -0
- package/lib/report.mjs +1124 -0
- package/lib/review-core.mjs +713 -0
- package/lib/risk.mjs +370 -0
- package/lib/run-log.mjs +1004 -0
- package/lib/run-paths.mjs +239 -0
- package/lib/run.mjs +2921 -0
- package/lib/ship-stage.mjs +223 -0
- package/lib/spill.mjs +150 -0
- package/lib/surface.mjs +195 -0
- package/lib/verify.mjs +1074 -0
- package/lib/waves.mjs +2661 -0
- package/package.json +40 -0
- package/shared/CONTEXT-HYGIENE.md +84 -0
- package/shared/DECISION-LEDGER.md +141 -0
- package/shared/EXPLORE-BRIEF.md +96 -0
- package/shared/INVARIANT-SWEEP.md +57 -0
- package/shared/SESSION-SHAPES.md +261 -0
- package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
- package/shared/TEST-PROFILE.md +204 -0
- package/shared/TOOL-ECONOMY.md +155 -0
- package/skills/bootstrap/SKILL.md +226 -0
- package/skills/commit/SKILL.md +99 -0
- package/skills/dispatch/SKILL.md +78 -0
- package/skills/docs-digest/SKILL.md +109 -0
- package/skills/evals/SKILL.md +160 -0
- package/skills/explain-code/SKILL.md +42 -0
- package/skills/explain-code/commit-teachin.md +136 -0
- package/skills/explain-code/module-walkthrough.md +358 -0
- package/skills/explore/SKILL.md +159 -0
- package/skills/fix-tests/SKILL.md +120 -0
- package/skills/graph/SKILL.md +136 -0
- package/skills/manual-test-plan/SKILL.md +121 -0
- package/skills/mr/SKILL.md +170 -0
- package/skills/report/SKILL.md +121 -0
- package/skills/review-artifacts/SKILL.md +127 -0
- package/skills/review-code/REVIEW.template.md +49 -0
- package/skills/review-code/SKILL.md +156 -0
- package/skills/review-code/dimensions/architecture.md +10 -0
- package/skills/review-code/dimensions/devops.md +12 -0
- package/skills/review-code/dimensions/language.md +12 -0
- package/skills/review-code/dimensions/qa.md +12 -0
- package/skills/review-code/dimensions/security.md +12 -0
- package/skills/review-code/dimensions/technical-lead.md +19 -0
- package/skills/ship/SKILL.md +67 -0
- package/skills/spec/SKILL.md +264 -0
- package/skills/spec/continuity.md +66 -0
- 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
|
+
}
|