@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.
Files changed (48) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +94 -1
  3. package/CHANGELOG.md +135 -1
  4. package/bin/interlock +409 -49
  5. package/bin/interlock-run +119 -7
  6. package/hooks/guard-relaunch.mjs +115 -0
  7. package/hooks/guard-tests.mjs +51 -3
  8. package/hooks/hooks.json +1 -0
  9. package/hooks/mod.mjs +655 -0
  10. package/hooks/preflight.mjs +83 -13
  11. package/hooks/recorder.mjs +189 -0
  12. package/lib/agent-usage.mjs +524 -0
  13. package/lib/doctor.mjs +488 -28
  14. package/lib/eval-history.mjs +233 -0
  15. package/lib/evals-capture.mjs +5 -2
  16. package/lib/graph/cli.mjs +28 -5
  17. package/lib/graph/retrieve/context.mjs +8 -3
  18. package/lib/host/acp.mjs +77 -14
  19. package/lib/host/claude-cli.mjs +391 -21
  20. package/lib/host/claude-env.mjs +179 -0
  21. package/lib/host/codex.mjs +9 -1
  22. package/lib/host/effort.mjs +163 -0
  23. package/lib/host/model-map.mjs +43 -0
  24. package/lib/host/qwen.mjs +9 -1
  25. package/lib/host/registry.mjs +44 -10
  26. package/lib/host.mjs +46 -5
  27. package/lib/interrupted.mjs +167 -0
  28. package/lib/launch-ledger.mjs +198 -0
  29. package/lib/launch-rule.mjs +174 -0
  30. package/lib/limits.mjs +149 -41
  31. package/lib/meter-quiet.mjs +45 -0
  32. package/lib/plan-fingerprint.mjs +80 -11
  33. package/lib/prompts/implementer.mjs +1 -1
  34. package/lib/prompts/verify.mjs +24 -1
  35. package/lib/receipt.mjs +59 -8
  36. package/lib/report-html.mjs +89 -6
  37. package/lib/report.mjs +313 -12
  38. package/lib/resume-card.mjs +90 -2
  39. package/lib/run-log.mjs +192 -5
  40. package/lib/run.mjs +1177 -84
  41. package/lib/state-home.mjs +298 -0
  42. package/lib/verify.mjs +37 -0
  43. package/lib/waves.mjs +53 -16
  44. package/package.json +2 -1
  45. package/shared/TEST-PROFILE.md +10 -5
  46. package/skills/fix-tests/SKILL.md +1 -1
  47. package/types/index.d.ts +28 -0
  48. 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 { accessSync, constants, existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
46
- import { homedir, platform } from 'node:os'
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 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.
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 environment probe checks for the graph and the test profile (workflows/ship.js)'
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 CLAUDE_CODE_SUBAGENT_MODEL and the Bedrock variables'
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
- const path = join(root, TEST_PROFILE_PATH)
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 abs = join(root, dir.path)
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(`${dir.path}: writable`)
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(`${dir.path}: absent, creatable under ${ancestor}`)
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.path}: ${b.reason} — ${b.why}`)
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 two say so themselves, and a
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: 'Make .claude/ writable to keep the outcome corpus and review metrics.'
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: 'Make .claude/ writable, or run from a checkout that is not read-only. A failed trajectory append exits 1 mid-run.'
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
- * @returns {object} report
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
- checks.push(run('state-dirs', () => checkStateDirs(projectRoot)))
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}`)