@renzrollon/interlock 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +94 -1
- package/CHANGELOG.md +135 -1
- package/bin/interlock +409 -49
- package/bin/interlock-run +119 -7
- package/hooks/guard-relaunch.mjs +115 -0
- package/hooks/guard-tests.mjs +51 -3
- package/hooks/hooks.json +1 -0
- package/hooks/mod.mjs +655 -0
- package/hooks/preflight.mjs +83 -13
- package/hooks/recorder.mjs +189 -0
- package/lib/agent-usage.mjs +524 -0
- package/lib/doctor.mjs +488 -28
- package/lib/eval-history.mjs +233 -0
- package/lib/evals-capture.mjs +5 -2
- package/lib/graph/cli.mjs +28 -5
- package/lib/graph/retrieve/context.mjs +8 -3
- package/lib/host/acp.mjs +77 -14
- package/lib/host/claude-cli.mjs +391 -21
- package/lib/host/claude-env.mjs +179 -0
- package/lib/host/codex.mjs +9 -1
- package/lib/host/effort.mjs +163 -0
- package/lib/host/model-map.mjs +43 -0
- package/lib/host/qwen.mjs +9 -1
- package/lib/host/registry.mjs +44 -10
- package/lib/host.mjs +46 -5
- package/lib/interrupted.mjs +167 -0
- package/lib/launch-ledger.mjs +198 -0
- package/lib/launch-rule.mjs +174 -0
- package/lib/limits.mjs +149 -41
- package/lib/meter-quiet.mjs +45 -0
- package/lib/plan-fingerprint.mjs +80 -11
- package/lib/prompts/implementer.mjs +1 -1
- package/lib/prompts/verify.mjs +24 -1
- package/lib/receipt.mjs +59 -8
- package/lib/report-html.mjs +89 -6
- package/lib/report.mjs +313 -12
- package/lib/resume-card.mjs +90 -2
- package/lib/run-log.mjs +192 -5
- package/lib/run.mjs +1177 -84
- package/lib/state-home.mjs +298 -0
- package/lib/verify.mjs +37 -0
- package/lib/waves.mjs +53 -16
- package/package.json +2 -1
- package/shared/TEST-PROFILE.md +10 -5
- package/skills/fix-tests/SKILL.md +1 -1
- package/types/index.d.ts +28 -0
- package/workflows/ship.js +75 -51
package/lib/doctor.mjs
CHANGED
|
@@ -39,13 +39,46 @@
|
|
|
39
39
|
// report that checked the developer's checkout of Interlock while the run used
|
|
40
40
|
// the installed one.
|
|
41
41
|
//
|
|
42
|
+
// ROOT vs STATE HOME. A third place, and the same mistake one level down. From
|
|
43
|
+
// a linked worktree the run's append-only corpora go to the main checkout
|
|
44
|
+
// (`lib/state-home.mjs`), so the doctor resolves that home once and probes
|
|
45
|
+
// each state directory under the home it is actually written to — a read-only
|
|
46
|
+
// main checkout is found from the worktree session, not on wave three.
|
|
47
|
+
//
|
|
42
48
|
// Exposed to skills and to `/interlock:ship` as `interlock doctor [--json]`.
|
|
43
49
|
|
|
44
50
|
import { execFileSync } from 'node:child_process'
|
|
45
|
-
import {
|
|
46
|
-
|
|
51
|
+
import {
|
|
52
|
+
accessSync,
|
|
53
|
+
constants,
|
|
54
|
+
existsSync,
|
|
55
|
+
readdirSync,
|
|
56
|
+
readFileSync,
|
|
57
|
+
realpathSync,
|
|
58
|
+
rmSync,
|
|
59
|
+
statSync,
|
|
60
|
+
writeFileSync
|
|
61
|
+
} from 'node:fs'
|
|
62
|
+
import { cpus, homedir, platform } from 'node:os'
|
|
47
63
|
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
48
64
|
import { fileURLToPath } from 'node:url'
|
|
65
|
+
import {
|
|
66
|
+
AGENT_TEAMS_ENV,
|
|
67
|
+
SUBAGENT_MODEL_DEFAULT_ONLY_MIN_VERSION,
|
|
68
|
+
SUBAGENT_MODEL_ENV,
|
|
69
|
+
SUBAGENT_MODEL_FORCE_ENV,
|
|
70
|
+
SUBAGENT_MODEL_FORCE_MIN_VERSION,
|
|
71
|
+
MODS_MIN_HOST_VERSION,
|
|
72
|
+
compareVersions,
|
|
73
|
+
WORKFLOW_MAX_CONCURRENT_ENV,
|
|
74
|
+
WORKFLOW_MAX_CONCURRENT_RANGE,
|
|
75
|
+
claudeCommandArgv,
|
|
76
|
+
observeClaudeEnv,
|
|
77
|
+
parseClaudeVersion,
|
|
78
|
+
readModelRouting
|
|
79
|
+
} from './host/claude-env.mjs'
|
|
80
|
+
import { RUNTIME } from './limits.mjs'
|
|
81
|
+
import { STATE_HOME_ENV, resolveStateHome } from './state-home.mjs'
|
|
49
82
|
|
|
50
83
|
/** The installed copy of the plugin — the one whose workflow and agents run. */
|
|
51
84
|
export const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
@@ -101,16 +134,45 @@ export const PROMPT_CACHE_MIN_HOST_VERSION = '2.1.242'
|
|
|
101
134
|
* `wave-state` call whose trajectory append fails now exits 1 rather than
|
|
102
135
|
* warning on stderr. `.claude/ship/spill` is fatal for a different reason —
|
|
103
136
|
* `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
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
137
|
+
* oversized suite log has no legal way to report it. The rest are explicitly
|
|
138
|
+
* never-fatal in their own modules ("losing a corpus line must never fail the
|
|
139
|
+
* run that made it"; metrics "report their own failure and never touch the exit
|
|
140
|
+
* code"), so an unwritable one is a warning here too.
|
|
141
|
+
*
|
|
142
|
+
* Each entry also names its `home`, because from a linked worktree the two are
|
|
143
|
+
* different directories (design D15). `state`: the append-only corpora a report
|
|
144
|
+
* reads across every run the project has had — the trajectory, the outcome
|
|
145
|
+
* corpus, the metrics, the resume cards, the interrupted-run notes — which go to
|
|
146
|
+
* the state home so they outlive a worktree that is deleted when its session is
|
|
147
|
+
* archived. `root`: per-run working state, which stays with the run — the spill,
|
|
148
|
+
* whose locators are root-relative and which `verify spill` writes under the
|
|
149
|
+
* root, and the agent-usage sidecar the SubagentStop recorder keys by run.
|
|
150
|
+
* Probing a directory under the wrong home tests a directory nothing writes to,
|
|
151
|
+
* and misses the one the run will fail on.
|
|
108
152
|
*/
|
|
109
153
|
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' }
|
|
154
|
+
{ path: join('.claude', 'ship', 'runs'), home: 'state', fatal: true, why: 'run trajectory; a failed append exits 1' },
|
|
155
|
+
{ path: join('.claude', 'ship', 'spill'), home: 'root', fatal: true, why: 'spilled suite output; no fallback path' },
|
|
156
|
+
{ path: join('.claude', 'learning'), home: 'state', fatal: false, why: 'outcome corpus; never fatal at runtime' },
|
|
157
|
+
{ path: join('.claude', 'metrics'), home: 'state', fatal: false, why: 'review metrics; never fatal at runtime' },
|
|
158
|
+
{
|
|
159
|
+
path: join('.claude', 'handoff'),
|
|
160
|
+
home: 'state',
|
|
161
|
+
fatal: false,
|
|
162
|
+
why: 'resume cards; outcome-class, a failed write is reported and never fails the run'
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
path: join('.claude', 'ship', 'interrupted'),
|
|
166
|
+
home: 'state',
|
|
167
|
+
fatal: false,
|
|
168
|
+
why: 'interrupted-run notes; outcome-class, a failed write is a stderr line from the SessionEnd hook'
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
path: join('.claude', 'ship', 'agent-usage'),
|
|
172
|
+
home: 'root',
|
|
173
|
+
fatal: false,
|
|
174
|
+
why: 'per-agent usage sidecar; outcome-class, a failed write is a stderr line from the SubagentStop recorder'
|
|
175
|
+
}
|
|
114
176
|
])
|
|
115
177
|
|
|
116
178
|
/**
|
|
@@ -127,12 +189,12 @@ export const REQUIRED_COMMANDS = Object.freeze([
|
|
|
127
189
|
{
|
|
128
190
|
tokens: ['test'],
|
|
129
191
|
open: true,
|
|
130
|
-
why: 'the
|
|
192
|
+
why: 'the bootstrap skill checks for openspec/config.yaml (skills/bootstrap)'
|
|
131
193
|
},
|
|
132
194
|
{
|
|
133
195
|
tokens: ['printenv'],
|
|
134
196
|
open: true,
|
|
135
|
-
why: 'the environment probe reads
|
|
197
|
+
why: 'the environment probe reads CLAUDE_CODE_EFFORT_LEVEL'
|
|
136
198
|
},
|
|
137
199
|
{ tokens: ['mkdir'], open: true, why: 'the environment probe creates .claude/ship/' }
|
|
138
200
|
])
|
|
@@ -561,8 +623,15 @@ function checkGit(root, env, probe) {
|
|
|
561
623
|
return check('git', 'ok', `${version || 'git'} in a work tree`, { evidence: [`git → ${bin}`] })
|
|
562
624
|
}
|
|
563
625
|
|
|
564
|
-
function checkTestProfile(root) {
|
|
565
|
-
|
|
626
|
+
function checkTestProfile(root, home = root) {
|
|
627
|
+
// Root first, then the state home: the same read-through `run start` performs
|
|
628
|
+
// (ship/state-home), so a linked worktree with no profile of its own is not
|
|
629
|
+
// failed for a profile its run will read from the main checkout.
|
|
630
|
+
const atRoot = join(root, TEST_PROFILE_PATH)
|
|
631
|
+
const atHome = join(home, TEST_PROFILE_PATH)
|
|
632
|
+
const readThrough = !existsSync(atRoot) && resolve(home) !== resolve(root) && existsSync(atHome)
|
|
633
|
+
const path = readThrough ? atHome : atRoot
|
|
634
|
+
const via = readThrough ? ` (read through from the state home: ${atHome})` : ''
|
|
566
635
|
if (!existsSync(path)) {
|
|
567
636
|
return check('test-profile', 'fail', `${TEST_PROFILE_PATH} is missing, so verification has no command to run and continuity refuses to start`, {
|
|
568
637
|
fix: 'Run /interlock:fix-tests once to discover and persist the project test setup.'
|
|
@@ -581,7 +650,7 @@ function checkTestProfile(root) {
|
|
|
581
650
|
fix: 'Add a "unit": { "command": "..." } section, or re-run /interlock:fix-tests.'
|
|
582
651
|
})
|
|
583
652
|
}
|
|
584
|
-
return check('test-profile', 'ok', `unit suite: ${command}`, {
|
|
653
|
+
return check('test-profile', 'ok', `unit suite: ${command}${via}`, {
|
|
585
654
|
evidence: [parsed.value.e2e ? 'e2e configured' : 'no e2e configured'],
|
|
586
655
|
profile: parsed.value
|
|
587
656
|
})
|
|
@@ -597,19 +666,33 @@ function checkTestProfile(root) {
|
|
|
597
666
|
* again. A directory that does not exist yet is judged by the writability of
|
|
598
667
|
* its nearest existing ancestor, because that is what `mkdirSync(recursive)`
|
|
599
668
|
* will need — nothing is created here.
|
|
669
|
+
*
|
|
670
|
+
* Each directory is probed under the home it is written to (`STATE_DIRS[].home`):
|
|
671
|
+
* `state` under the resolved state home, `root` under the working root. The
|
|
672
|
+
* evidence names every absolute path probed, so a reader of a worktree
|
|
673
|
+
* session's report can see that the trajectory was tested in the main checkout.
|
|
674
|
+
*
|
|
675
|
+
* @param {string} root the working root
|
|
676
|
+
* @param {string} [stateHome] the resolved state home; the root when absent
|
|
600
677
|
*/
|
|
601
|
-
function checkStateDirs(root) {
|
|
678
|
+
export function checkStateDirs(root, stateHome = root) {
|
|
679
|
+
// Real paths on both sides, as the resolver compares them: macOS keeps `/tmp`
|
|
680
|
+
// behind a symlink, and one directory under two spellings is still one.
|
|
681
|
+
const workingRoot = realOrSelf(resolve(root))
|
|
682
|
+
const home = typeof stateHome === 'string' && stateHome ? realOrSelf(resolve(stateHome)) : workingRoot
|
|
602
683
|
const blocked = []
|
|
603
684
|
const evidence = []
|
|
604
685
|
for (const dir of STATE_DIRS) {
|
|
605
|
-
const
|
|
686
|
+
const base = dir.home === 'state' ? home : workingRoot
|
|
687
|
+
const where = dir.home === 'state' ? 'state home' : 'working root'
|
|
688
|
+
const abs = join(base, dir.path)
|
|
606
689
|
let reason = null
|
|
607
690
|
if (existsSync(abs)) {
|
|
608
691
|
const probe = join(abs, `.interlock-doctor-${process.pid}.tmp`)
|
|
609
692
|
try {
|
|
610
693
|
writeFileSync(probe, '')
|
|
611
694
|
rmSync(probe, { force: true })
|
|
612
|
-
evidence.push(`${
|
|
695
|
+
evidence.push(`${abs}: writable (${where})`)
|
|
613
696
|
} catch (err) {
|
|
614
697
|
reason = messageOf(err)
|
|
615
698
|
}
|
|
@@ -618,35 +701,127 @@ function checkStateDirs(root) {
|
|
|
618
701
|
while (ancestor && !existsSync(ancestor) && ancestor !== dirname(ancestor)) ancestor = dirname(ancestor)
|
|
619
702
|
try {
|
|
620
703
|
accessSync(ancestor, constants.W_OK)
|
|
621
|
-
evidence.push(`${
|
|
704
|
+
evidence.push(`${abs}: absent, creatable under ${ancestor} (${where})`)
|
|
622
705
|
} catch (err) {
|
|
623
706
|
reason = `cannot be created under ${ancestor}: ${messageOf(err)}`
|
|
624
707
|
}
|
|
625
708
|
}
|
|
626
|
-
if (reason) blocked.push({ ...dir, reason })
|
|
709
|
+
if (reason) blocked.push({ ...dir, abs, base, reason })
|
|
627
710
|
}
|
|
628
711
|
|
|
629
712
|
if (!blocked.length) {
|
|
630
713
|
return check('state-dirs', 'ok', 'every run-state directory is writable or creatable', { evidence })
|
|
631
714
|
}
|
|
632
715
|
|
|
633
|
-
const lines = blocked.map(b => `${b.
|
|
716
|
+
const lines = blocked.map(b => `${b.abs}: ${b.reason} — ${b.why}`)
|
|
717
|
+
// Name the directory to repair. From a worktree session the one that blocks
|
|
718
|
+
// may be the main checkout's, which is not the `.claude/` the reader is in.
|
|
719
|
+
const bases = [...new Set(blocked.map(b => b.base))]
|
|
720
|
+
const target = bases.map(b => join(b, '.claude')).join(' and ')
|
|
721
|
+
const elsewhere =
|
|
722
|
+
home !== workingRoot && blocked.some(b => b.home === 'state')
|
|
723
|
+
? ` — ${join(home, '.claude')} is the state home this root's corpora are written to; pin another with --state-home or ${STATE_HOME_ENV}`
|
|
724
|
+
: ''
|
|
634
725
|
const fatal = blocked.filter(b => b.fatal)
|
|
635
726
|
if (!fatal.length) {
|
|
636
|
-
// Bookkeeping only. The modules behind these
|
|
727
|
+
// Bookkeeping only. The modules behind these say so themselves, and a
|
|
637
728
|
// preflight that failed the run over a lost metrics line would be applying
|
|
638
729
|
// a rule the runtime does not.
|
|
639
730
|
return check('state-dirs', 'warn', `${blocked.length} bookkeeping director${blocked.length === 1 ? 'y is' : 'ies are'} not writable; the run continues without them`, {
|
|
640
731
|
evidence: lines,
|
|
641
|
-
fix:
|
|
732
|
+
fix: `Make ${target} writable to keep the outcome corpus and review metrics${elsewhere}.`
|
|
642
733
|
})
|
|
643
734
|
}
|
|
644
735
|
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
736
|
evidence: lines,
|
|
646
|
-
fix:
|
|
737
|
+
fix: `Make ${target} writable, or run from a checkout that is not read-only${elsewhere}. A failed trajectory append exits 1 mid-run.`
|
|
647
738
|
})
|
|
648
739
|
}
|
|
649
740
|
|
|
741
|
+
/**
|
|
742
|
+
* Advice, never a gate: where this root's corpora go, and what the root is.
|
|
743
|
+
*
|
|
744
|
+
* `checkNotify`'s posture — `ok` or `skip`, NEVER `fail`, its own try/catch,
|
|
745
|
+
* outside `diagnose`'s `run()`. The writability of the home is `state-dirs`'
|
|
746
|
+
* question and that row can fail; this one only says which home was resolved,
|
|
747
|
+
* so a worktree session's reader learns before a run that its trajectory, its
|
|
748
|
+
* outcome line and its resume card land in the main checkout. A fallback is
|
|
749
|
+
* `skip` with the resolver's reason, in the same words the run banners, because
|
|
750
|
+
* a fallback is exactly today's behaviour — a degradation to speak, not a block.
|
|
751
|
+
*
|
|
752
|
+
* @param {string} root the working root
|
|
753
|
+
* @param {{home: string, surface: string, reason: string|null, resolvedFrom: string}} resolved
|
|
754
|
+
* what `resolveStateHome` returned for it
|
|
755
|
+
*/
|
|
756
|
+
export function checkStateHome(root, resolved) {
|
|
757
|
+
try {
|
|
758
|
+
const home = resolved && typeof resolved.home === 'string' && resolved.home ? resolved.home : null
|
|
759
|
+
if (!home) {
|
|
760
|
+
return check(
|
|
761
|
+
'state-home',
|
|
762
|
+
'skip',
|
|
763
|
+
'STATE HOME UNRESOLVED: the resolver returned no home; corpora of this run go to the working root'
|
|
764
|
+
)
|
|
765
|
+
}
|
|
766
|
+
const surface = typeof resolved.surface === 'string' && resolved.surface ? resolved.surface : 'unknown'
|
|
767
|
+
const from = typeof resolved.resolvedFrom === 'string' ? resolved.resolvedFrom : null
|
|
768
|
+
const reason = typeof resolved.reason === 'string' && resolved.reason ? resolved.reason : null
|
|
769
|
+
const workingRoot = realOrSelf(resolve(root))
|
|
770
|
+
const evidence = [
|
|
771
|
+
`working root: ${workingRoot}`,
|
|
772
|
+
`state home: ${home}`,
|
|
773
|
+
`surface: ${surface}`,
|
|
774
|
+
`resolved from: ${from || 'unknown'}`
|
|
775
|
+
]
|
|
776
|
+
const corpora = `corpora of this run go to ${home}; the test profile and graph are read from there when this root has none`
|
|
777
|
+
|
|
778
|
+
if (from === 'fallback') {
|
|
779
|
+
return check(
|
|
780
|
+
'state-home',
|
|
781
|
+
'skip',
|
|
782
|
+
`STATE HOME UNRESOLVED: ${reason || 'the resolver gave no reason'}; corpora of this run go to the working root ${home}`,
|
|
783
|
+
{ evidence }
|
|
784
|
+
)
|
|
785
|
+
}
|
|
786
|
+
if (from === 'explicit') {
|
|
787
|
+
const parts = [`state home ${home}, set explicitly (--state-home or ${STATE_HOME_ENV}); surface ${surface}`]
|
|
788
|
+
if (reason) parts.push(`the surface could not be read: ${reason}`)
|
|
789
|
+
if (realOrSelf(home) !== workingRoot) parts.push(corpora)
|
|
790
|
+
return check('state-home', 'ok', parts.join('; '), { evidence })
|
|
791
|
+
}
|
|
792
|
+
if (surface === 'linked-worktree') {
|
|
793
|
+
return check('state-home', 'ok', `state home ${home}, surface linked-worktree; ${corpora}`, { evidence })
|
|
794
|
+
}
|
|
795
|
+
if (surface === 'main') {
|
|
796
|
+
return check('state-home', 'ok', `state home is the root itself, ${home}, surface main`, { evidence })
|
|
797
|
+
}
|
|
798
|
+
if (surface === 'lane-worktree') {
|
|
799
|
+
return check(
|
|
800
|
+
'state-home',
|
|
801
|
+
'ok',
|
|
802
|
+
`state home is the root itself, ${home}, surface lane-worktree: an Interlock lane worktree keeps its own state`,
|
|
803
|
+
{ evidence }
|
|
804
|
+
)
|
|
805
|
+
}
|
|
806
|
+
return check('state-home', 'skip', `the state home ${home} was resolved with an unrecognised surface "${surface}"`, {
|
|
807
|
+
evidence
|
|
808
|
+
})
|
|
809
|
+
} catch (err) {
|
|
810
|
+
// Fail-open, and never `fail`: a resolution this row cannot read is a
|
|
811
|
+
// reason to withhold the advice, not a reason to block a run.
|
|
812
|
+
return check('state-home', 'skip', `the state-home check could not run: ${messageOf(err)}`)
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** A path made real when it can be, left as given when it cannot. Never throws. */
|
|
817
|
+
function realOrSelf(path) {
|
|
818
|
+
try {
|
|
819
|
+
return realpathSync(path)
|
|
820
|
+
} catch {
|
|
821
|
+
return path
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
650
825
|
/**
|
|
651
826
|
* The allowlist preflight. A required command with no covering rule is a `fail`,
|
|
652
827
|
* not a warning: an approval prompt in the middle of a zero-touch run is not a
|
|
@@ -770,6 +945,227 @@ function checkNotify(env) {
|
|
|
770
945
|
}
|
|
771
946
|
}
|
|
772
947
|
|
|
948
|
+
/**
|
|
949
|
+
* Advice, never a gate: what the Claude Code environment does to a run's model
|
|
950
|
+
* routing, read the way `interlock run start --host workflow` reads it.
|
|
951
|
+
*
|
|
952
|
+
* `checkNotify`'s posture: `ok` only when none of the four variables is set AND
|
|
953
|
+
* the host version was read — an unread version is not a clean reading, because
|
|
954
|
+
* what the plain subagent-model variable means depends on it — otherwise `skip`,
|
|
955
|
+
* and never `fail`. The variables are the operator's environment; a preflight
|
|
956
|
+
* that failed on them would fail every session that set one on purpose.
|
|
957
|
+
*
|
|
958
|
+
* The values printed are model slugs and small integers. Bedrock is named by
|
|
959
|
+
* variable and never by value, under the same presence-only rule as the
|
|
960
|
+
* credential rows: this report is written to disk and pasted into issues.
|
|
961
|
+
*
|
|
962
|
+
* @param {Record<string, string|undefined>} env
|
|
963
|
+
* @param {{version: string|null, cpuCount: number|null}} host
|
|
964
|
+
*/
|
|
965
|
+
function checkClaudeEnv(env, { version, cpuCount }) {
|
|
966
|
+
try {
|
|
967
|
+
const o = observeClaudeEnv(env, { version, cpuCount })
|
|
968
|
+
const routing = readModelRouting(o)
|
|
969
|
+
const shown = (name, value) => `${name}=${value === null ? 'unset' : value}`
|
|
970
|
+
const four = [
|
|
971
|
+
shown(SUBAGENT_MODEL_ENV, o.subagentModel),
|
|
972
|
+
shown(SUBAGENT_MODEL_FORCE_ENV, o.subagentModelForce),
|
|
973
|
+
shown(WORKFLOW_MAX_CONCURRENT_ENV, o.maxConcurrent ? o.maxConcurrent.raw : null),
|
|
974
|
+
shown(AGENT_TEAMS_ENV, o.agentTeams)
|
|
975
|
+
]
|
|
976
|
+
const anySet = [o.subagentModel, o.subagentModelForce, o.maxConcurrent, o.agentTeams].some(v => v !== null)
|
|
977
|
+
|
|
978
|
+
const detail = [`Claude Code ${o.version || 'unknown (the version probe did not answer)'}`, ...four]
|
|
979
|
+
if (o.subagentModel !== null) {
|
|
980
|
+
detail.push(
|
|
981
|
+
routing.plainDefaultOnly
|
|
982
|
+
? `${SUBAGENT_MODEL_ENV} sets only the default subagent model on this host (>= ${SUBAGENT_MODEL_DEFAULT_ONLY_MIN_VERSION}); a model a spawn names takes precedence`
|
|
983
|
+
: routing.forceEffective
|
|
984
|
+
? `${SUBAGENT_MODEL_ENV} names the model FORCE applies`
|
|
985
|
+
: `${SUBAGENT_MODEL_ENV} replaces every agent's model on ` +
|
|
986
|
+
(routing.versionKnown
|
|
987
|
+
? `this host (below ${SUBAGENT_MODEL_DEFAULT_ONLY_MIN_VERSION})`
|
|
988
|
+
: 'a host whose version is unknown, the conservative reading') +
|
|
989
|
+
', so the plan\'s per-tier assignment is not in effect'
|
|
990
|
+
)
|
|
991
|
+
}
|
|
992
|
+
if (o.subagentModelForce !== null) {
|
|
993
|
+
detail.push(
|
|
994
|
+
routing.forceInert
|
|
995
|
+
? `${SUBAGENT_MODEL_FORCE_ENV} has no effect on this host (below ${SUBAGENT_MODEL_FORCE_MIN_VERSION})`
|
|
996
|
+
: o.subagentModelForceEnabled
|
|
997
|
+
? `${SUBAGENT_MODEL_FORCE_ENV} forces one model: every agent runs on one model`
|
|
998
|
+
: `${SUBAGENT_MODEL_FORCE_ENV} is set to an off value`
|
|
999
|
+
)
|
|
1000
|
+
}
|
|
1001
|
+
if (o.maxConcurrent && !o.maxConcurrent.valid) {
|
|
1002
|
+
const { min, max } = WORKFLOW_MAX_CONCURRENT_RANGE
|
|
1003
|
+
detail.push(`${WORKFLOW_MAX_CONCURRENT_ENV} is not an integer from ${min} to ${max}, so a run reads it as unset`)
|
|
1004
|
+
}
|
|
1005
|
+
if (o.agentTeams !== null) detail.push(`${AGENT_TEAMS_ENV} does not affect Workflow agents`)
|
|
1006
|
+
detail.push(
|
|
1007
|
+
o.bedrock.length
|
|
1008
|
+
? `Bedrock: ${o.bedrock.join(', ')} set, so control-plane pings inherit the session model`
|
|
1009
|
+
: 'Bedrock: not set'
|
|
1010
|
+
)
|
|
1011
|
+
detail.push(
|
|
1012
|
+
`${o.cpuCount === null ? 'CPU count unknown' : `${o.cpuCount} CPUs`} beside the vendor default of ` +
|
|
1013
|
+
`${RUNTIME.maxConcurrentAgents} concurrent agents, which the runtime may reduce on a small machine`
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
return check('claude-env', !anySet && o.version !== null ? 'ok' : 'skip', detail.join('; '))
|
|
1017
|
+
} catch (err) {
|
|
1018
|
+
// Fail-open, and never `fail`: an unreadable environment is a reason to
|
|
1019
|
+
// withhold the advice, not a reason to block a run.
|
|
1020
|
+
return check('claude-env', 'skip', `the claude-env check could not run: ${messageOf(err)}`)
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* Whether the host can load the plugin's hooks module (the ship meter), as
|
|
1026
|
+
* advice: `ok` at or above the mods floor, `skip` below it or with the version
|
|
1027
|
+
* unknown, never `fail`. It reads the version the `claude-env` row already
|
|
1028
|
+
* probed, so the preflight spawns nothing new. The binary it names is the one
|
|
1029
|
+
* on PATH (or `INTERLOCK_CLAUDE_COMMAND`); a Desktop session's engine is not
|
|
1030
|
+
* that binary, which is why the module logs the engine it actually loaded in.
|
|
1031
|
+
*
|
|
1032
|
+
* @param {Record<string, string|undefined>} env
|
|
1033
|
+
* @param {string|null} version
|
|
1034
|
+
*/
|
|
1035
|
+
export function checkMods(env, version) {
|
|
1036
|
+
try {
|
|
1037
|
+
const bin = claudeCommandArgv(env)[0]
|
|
1038
|
+
const desktop = 'a Desktop session\'s engine may differ from this binary; the meter logs the engine it loaded in at session start'
|
|
1039
|
+
const unchanged = 'a host that cannot load mods runs exactly today\'s run and prints every banner at close'
|
|
1040
|
+
if (version === null || version === undefined) {
|
|
1041
|
+
return check('mods', 'skip', `${bin}: version unknown (the version probe did not answer); mods need ${MODS_MIN_HOST_VERSION}+; ${unchanged}; ${desktop}`)
|
|
1042
|
+
}
|
|
1043
|
+
if (compareVersions(version, MODS_MIN_HOST_VERSION) >= 0) {
|
|
1044
|
+
return check('mods', 'ok', `${bin} ${version} loads the hooks module (mods ${MODS_MIN_HOST_VERSION}+): /interlock-meter draws a ship run live; ${desktop}`)
|
|
1045
|
+
}
|
|
1046
|
+
return check(
|
|
1047
|
+
'mods',
|
|
1048
|
+
'skip',
|
|
1049
|
+
`${bin} ${version} is below the mods floor ${MODS_MIN_HOST_VERSION}: the meter draws nothing here; ${unchanged}; ${desktop}`
|
|
1050
|
+
)
|
|
1051
|
+
} catch (err) {
|
|
1052
|
+
return check('mods', 'skip', `the mods check could not run: ${messageOf(err)}`)
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** The installed Claude CLI's version through the doctor's probe, or null. Never throws. */
|
|
1057
|
+
function claudeVersionOf(env, probe) {
|
|
1058
|
+
try {
|
|
1059
|
+
const [bin, ...fixedArgs] = claudeCommandArgv(env)
|
|
1060
|
+
return parseClaudeVersion(probe(bin, [...fixedArgs, '--version']))
|
|
1061
|
+
} catch {
|
|
1062
|
+
return null
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** This machine's CPU count, or null. Never throws. */
|
|
1067
|
+
function cpuCountOf() {
|
|
1068
|
+
try {
|
|
1069
|
+
const n = cpus().length
|
|
1070
|
+
return Number.isInteger(n) && n > 0 ? n : null
|
|
1071
|
+
} catch {
|
|
1072
|
+
return null
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** The credential a bare print-mode run reads from the environment, by name. */
|
|
1077
|
+
const BARE_KEY_ENV = 'ANTHROPIC_API_KEY'
|
|
1078
|
+
/** The settings key a bare print-mode run can take a credential from instead. */
|
|
1079
|
+
const BARE_HELPER_KEY = 'apiKeyHelper'
|
|
1080
|
+
/** What `--bare` sets, and what a session already in simple mode carries. */
|
|
1081
|
+
const CLAUDE_SIMPLE_ENV = 'CLAUDE_CODE_SIMPLE'
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* The vendor's bare-mode facts this row repeats. A host claim this module
|
|
1085
|
+
* cannot verify, so it is stated as what the documentation says, not as a
|
|
1086
|
+
* measurement of the session.
|
|
1087
|
+
*/
|
|
1088
|
+
const BARE_MODE_FACT =
|
|
1089
|
+
'the headless docs recommend --bare and say it will become the default for -p; under it OAuth and the keychain ' +
|
|
1090
|
+
'are never read, so --host claude lanes need a key or a helper, and a plugin loads only through --plugin-dir'
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Advice, never a gate: would a bare print-mode run have a credential?
|
|
1094
|
+
*
|
|
1095
|
+
* `checkNotify`'s posture — `ok` or `skip`, NEVER `fail`, its own try/catch,
|
|
1096
|
+
* outside `diagnose`'s `run()`. A session that authenticates with OAuth runs
|
|
1097
|
+
* every lane today; the row exists because the vendor intends `--bare` to
|
|
1098
|
+
* become the print-mode default, and under it that same session's `--host
|
|
1099
|
+
* claude` lanes would start with nothing to authenticate with. A preflight that
|
|
1100
|
+
* failed on a default that has not changed yet would block working runs.
|
|
1101
|
+
*
|
|
1102
|
+
* Read by NAME and never by value: `ANTHROPIC_API_KEY` is reported as set or
|
|
1103
|
+
* not, and an `apiKeyHelper` by the settings scope that configures it, never by
|
|
1104
|
+
* the command it runs. This report is written to disk and pasted into issues.
|
|
1105
|
+
*
|
|
1106
|
+
* A settings file that is absent, unreadable or not JSON is skipped here and
|
|
1107
|
+
* not reported — `checkPermissions` owns that finding, and an unparseable file
|
|
1108
|
+
* is not evidence that a helper is unset.
|
|
1109
|
+
*
|
|
1110
|
+
* @param {Record<string, string|undefined>} env
|
|
1111
|
+
* @param {{root?: string, home?: string, sources?: Array<{scope: string, path: string}>}} [opts]
|
|
1112
|
+
*/
|
|
1113
|
+
export function checkClaudeBare(env, { root = '.', home, sources } = {}) {
|
|
1114
|
+
try {
|
|
1115
|
+
const key = isSet(env, BARE_KEY_ENV)
|
|
1116
|
+
const simple = isSet(env, CLAUDE_SIMPLE_ENV)
|
|
1117
|
+
const helperScopes = []
|
|
1118
|
+
const evidence = []
|
|
1119
|
+
for (const source of sources || settingsSources(resolve(root), home)) {
|
|
1120
|
+
if (!source || typeof source.path !== 'string' || !existsSync(source.path)) continue
|
|
1121
|
+
const parsed = readJson(source.path)
|
|
1122
|
+
if (!parsed.value || typeof parsed.value !== 'object') continue
|
|
1123
|
+
const helper = parsed.value[BARE_HELPER_KEY]
|
|
1124
|
+
if (typeof helper === 'string' && helper.trim()) {
|
|
1125
|
+
helperScopes.push(source.scope)
|
|
1126
|
+
evidence.push(`${BARE_HELPER_KEY}: ${source.scope} (${source.path})`)
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const helpers = helperScopes.length
|
|
1131
|
+
? `${BARE_HELPER_KEY} is configured in ${helperScopes.join(', ')} settings`
|
|
1132
|
+
: null
|
|
1133
|
+
const detail = []
|
|
1134
|
+
if (key && helpers) {
|
|
1135
|
+
detail.push(`${BARE_KEY_ENV} is set and ${helpers}, so a bare print-mode run has a credential`)
|
|
1136
|
+
} else if (key) {
|
|
1137
|
+
detail.push(`${BARE_KEY_ENV} is set, so a bare print-mode run has a credential`)
|
|
1138
|
+
} else if (helpers) {
|
|
1139
|
+
detail.push(
|
|
1140
|
+
`${helpers}, so a bare print-mode run has a credential`,
|
|
1141
|
+
`\`claude --help\` words bare-mode auth as an ${BARE_HELPER_KEY} "via --settings", so a helper configured only in a settings file may need passing with --settings`
|
|
1142
|
+
)
|
|
1143
|
+
} else {
|
|
1144
|
+
detail.push(
|
|
1145
|
+
`neither ${BARE_KEY_ENV} nor an ${BARE_HELPER_KEY} is configured, so a bare print-mode run would have no credential — set ${BARE_KEY_ENV} or configure ${BARE_HELPER_KEY}`
|
|
1146
|
+
)
|
|
1147
|
+
}
|
|
1148
|
+
detail.push(
|
|
1149
|
+
simple
|
|
1150
|
+
? `${CLAUDE_SIMPLE_ENV} is set (the simple mode --bare turns on)`
|
|
1151
|
+
: `${CLAUDE_SIMPLE_ENV} is not set`,
|
|
1152
|
+
BARE_MODE_FACT
|
|
1153
|
+
)
|
|
1154
|
+
|
|
1155
|
+
const ok = key || Boolean(helpers)
|
|
1156
|
+
return check('claude-bare', ok ? 'ok' : 'skip', detail.join('; '), {
|
|
1157
|
+
evidence,
|
|
1158
|
+
fix: ok
|
|
1159
|
+
? undefined
|
|
1160
|
+
: `Set ${BARE_KEY_ENV}, or configure "${BARE_HELPER_KEY}" in a settings file (passed with --settings under --bare), before --host claude lanes run bare. Nothing changes for a run today.`
|
|
1161
|
+
})
|
|
1162
|
+
} catch (err) {
|
|
1163
|
+
// Fail-open, and never `fail`: an unreadable environment or settings tree
|
|
1164
|
+
// is a reason to withhold the advice, not a reason to block a run.
|
|
1165
|
+
return check('claude-bare', 'skip', `the claude-bare check could not run: ${messageOf(err)}`)
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
773
1169
|
/**
|
|
774
1170
|
* Advice, never a gate, and never a measurement.
|
|
775
1171
|
*
|
|
@@ -1074,9 +1470,12 @@ export function evalPrerequisiteChecks(pluginRoot, env = process.env) {
|
|
|
1074
1470
|
* @param {object} [opts.env] environment PATH is read from
|
|
1075
1471
|
* @param {string} [opts.home] home directory user settings are read from
|
|
1076
1472
|
* @param {string} [opts.nodeVersion] overrides process.version, for tests
|
|
1077
|
-
* @param {(bin: string) => string|null} [opts.probeVersion] version probe; injected in tests
|
|
1473
|
+
* @param {(bin: string, args?: string[]) => string|null} [opts.probeVersion] version probe; injected in tests
|
|
1474
|
+
* @param {number} [opts.cpuCount] overrides the machine's CPU count, for tests
|
|
1078
1475
|
* @param {Array<{scope:string,path:string}>} [opts.settingsSources] overrides the merge list
|
|
1079
|
-
* @
|
|
1476
|
+
* @param {string} [opts.stateHome] an explicit state home (`--state-home`); when absent,
|
|
1477
|
+
* `INTERLOCK_STATE_HOME` in `opts.env` and then git decide
|
|
1478
|
+
* @returns {object} report, carrying `stateHome` (absolute) and `surface`
|
|
1080
1479
|
*/
|
|
1081
1480
|
export function diagnose(root = '.', opts = {}) {
|
|
1082
1481
|
const projectRoot = resolve(root)
|
|
@@ -1084,6 +1483,11 @@ export function diagnose(root = '.', opts = {}) {
|
|
|
1084
1483
|
const env = opts.env || process.env
|
|
1085
1484
|
const probe = typeof opts.probeVersion === 'function' ? opts.probeVersion : probeVersion
|
|
1086
1485
|
|
|
1486
|
+
// Resolved ONCE, here, and handed to every row that needs it — and to the
|
|
1487
|
+
// SessionStart preflight through the report, so it reads interrupted-run
|
|
1488
|
+
// notes from this home without resolving a second time.
|
|
1489
|
+
const resolved = resolveHomeFor(projectRoot, opts.stateHome, env)
|
|
1490
|
+
|
|
1087
1491
|
const run = (id, fn) => {
|
|
1088
1492
|
try {
|
|
1089
1493
|
return fn()
|
|
@@ -1101,7 +1505,7 @@ export function diagnose(root = '.', opts = {}) {
|
|
|
1101
1505
|
checks.push(run('openspec', () => checkOpenSpec(projectRoot, env, probe)))
|
|
1102
1506
|
checks.push(run('git', () => checkGit(projectRoot, env, probe)))
|
|
1103
1507
|
|
|
1104
|
-
const profileCheck = run('test-profile', () => checkTestProfile(projectRoot))
|
|
1508
|
+
const profileCheck = run('test-profile', () => checkTestProfile(projectRoot, resolved.home || projectRoot))
|
|
1105
1509
|
const profile = profileCheck.profile
|
|
1106
1510
|
delete profileCheck.profile
|
|
1107
1511
|
checks.push(profileCheck)
|
|
@@ -1115,7 +1519,10 @@ export function diagnose(root = '.', opts = {}) {
|
|
|
1115
1519
|
checkPermissions(projectRoot, required, { home: opts.home, sources: opts.settingsSources })
|
|
1116
1520
|
)
|
|
1117
1521
|
)
|
|
1118
|
-
|
|
1522
|
+
// Not behind `run()`: which home was resolved is advice and may never be a
|
|
1523
|
+
// `fail`; whether that home is writable is `state-dirs`' question, which can.
|
|
1524
|
+
checks.push(checkStateHome(projectRoot, resolved))
|
|
1525
|
+
checks.push(run('state-dirs', () => checkStateDirs(projectRoot, resolved.home)))
|
|
1119
1526
|
|
|
1120
1527
|
// Not behind `run()`: that wrapper's thrown-probe verdict is `fail`, and
|
|
1121
1528
|
// this row may never carry one — push is optional, so `checkNotify` owns
|
|
@@ -1128,6 +1535,24 @@ export function diagnose(root = '.', opts = {}) {
|
|
|
1128
1535
|
// try/catch. See `checkPromptCache`.
|
|
1129
1536
|
checks.push(checkPromptCache(projectRoot, { home: opts.home, sources: opts.settingsSources, env }))
|
|
1130
1537
|
|
|
1538
|
+
// And again: the Claude Code environment a run's routing reads is the
|
|
1539
|
+
// operator's, so this row is advice. One `claude --version` spawn, bounded by
|
|
1540
|
+
// the probe's own timeout. See `checkClaudeEnv`.
|
|
1541
|
+
const claudeVersion = claudeVersionOf(env, probe)
|
|
1542
|
+
checks.push(
|
|
1543
|
+
checkClaudeEnv(env, {
|
|
1544
|
+
version: claudeVersion,
|
|
1545
|
+
cpuCount: Number.isInteger(opts.cpuCount) ? opts.cpuCount : cpuCountOf()
|
|
1546
|
+
})
|
|
1547
|
+
)
|
|
1548
|
+
// The same reading, no second spawn: whether the ship meter can draw here.
|
|
1549
|
+
// Advice, `ok`/`skip`, its own try/catch. See `checkMods`.
|
|
1550
|
+
checks.push(checkMods(env, claudeVersion))
|
|
1551
|
+
|
|
1552
|
+
// And again: whether a bare print-mode run would have a credential is advice
|
|
1553
|
+
// about the operator's environment and settings. See `checkClaudeBare`.
|
|
1554
|
+
checks.push(checkClaudeBare(env, { root: projectRoot, home: opts.home, sources: opts.settingsSources }))
|
|
1555
|
+
|
|
1131
1556
|
// Not behind `run()`: that wrapper's thrown-probe verdict is `fail`, and
|
|
1132
1557
|
// these rows may never carry one. See `evalPrerequisiteChecks`.
|
|
1133
1558
|
checks.push(...evalPrerequisiteChecks(pluginRoot, env))
|
|
@@ -1138,6 +1563,8 @@ export function diagnose(root = '.', opts = {}) {
|
|
|
1138
1563
|
return {
|
|
1139
1564
|
ok: counts.fail === 0,
|
|
1140
1565
|
root: projectRoot,
|
|
1566
|
+
stateHome: resolved.home,
|
|
1567
|
+
surface: resolved.surface,
|
|
1141
1568
|
pluginRoot,
|
|
1142
1569
|
counts,
|
|
1143
1570
|
checks,
|
|
@@ -1147,6 +1574,30 @@ export function diagnose(root = '.', opts = {}) {
|
|
|
1147
1574
|
}
|
|
1148
1575
|
}
|
|
1149
1576
|
|
|
1577
|
+
/**
|
|
1578
|
+
* The state home for `root`, never throwing. `resolveStateHome` already holds
|
|
1579
|
+
* that contract; the guard is here because a doctor that crashed resolving
|
|
1580
|
+
* where to look would produce no report at all, and the fallback carries its
|
|
1581
|
+
* reason so the `state-home` row can speak it.
|
|
1582
|
+
*/
|
|
1583
|
+
function resolveHomeFor(root, explicit, env) {
|
|
1584
|
+
try {
|
|
1585
|
+
const resolved = resolveStateHome(root, {
|
|
1586
|
+
explicit: typeof explicit === 'string' && explicit.trim() ? explicit : undefined,
|
|
1587
|
+
env
|
|
1588
|
+
})
|
|
1589
|
+
if (resolved && typeof resolved.home === 'string' && resolved.home) return resolved
|
|
1590
|
+
return { home: root, surface: 'unknown', reason: 'the state home resolver returned no home', resolvedFrom: 'fallback' }
|
|
1591
|
+
} catch (err) {
|
|
1592
|
+
return {
|
|
1593
|
+
home: root,
|
|
1594
|
+
surface: 'unknown',
|
|
1595
|
+
reason: `the state home could not be resolved: ${messageOf(err)}`,
|
|
1596
|
+
resolvedFrom: 'fallback'
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1150
1601
|
/** Human-readable preflight, in the shape `interlock ready` already prints. */
|
|
1151
1602
|
export function formatDoctor(report) {
|
|
1152
1603
|
const lines = []
|
|
@@ -1158,6 +1609,15 @@ export function formatDoctor(report) {
|
|
|
1158
1609
|
(report.counts.warn ? `, ${report.counts.warn} warning(s)` : '')
|
|
1159
1610
|
)
|
|
1160
1611
|
lines.push(` root: ${report.root}`)
|
|
1612
|
+
// Under `root:` only when the two differ, so a main checkout's report reads as
|
|
1613
|
+
// it always did and a worktree session's names where its corpora go.
|
|
1614
|
+
if (
|
|
1615
|
+
typeof report.stateHome === 'string' &&
|
|
1616
|
+
report.stateHome &&
|
|
1617
|
+
realOrSelf(report.stateHome) !== realOrSelf(report.root)
|
|
1618
|
+
) {
|
|
1619
|
+
lines.push(` state home: ${report.stateHome}`)
|
|
1620
|
+
}
|
|
1161
1621
|
if (report.pluginRoot !== report.root) lines.push(` plugin: ${report.pluginRoot}`)
|
|
1162
1622
|
for (const c of report.checks) {
|
|
1163
1623
|
lines.push(` [${STATUS_MARK[c.status] || c.status}] ${c.id}: ${c.detail}`)
|