@renzrollon/interlock 1.1.1 → 1.2.1

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 (55) hide show
  1. package/.claude-plugin/icon.png +0 -0
  2. package/.claude-plugin/plugin.json +109 -1
  3. package/CHANGELOG.md +150 -11
  4. package/bin/interlock +421 -53
  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 +124 -13
  11. package/hooks/recorder.mjs +189 -0
  12. package/lib/agent-usage.mjs +524 -0
  13. package/lib/doctor.mjs +511 -46
  14. package/lib/eval-history.mjs +233 -0
  15. package/lib/evals-capture.mjs +5 -2
  16. package/lib/evals-triage.mjs +1 -1
  17. package/lib/graph/cli.mjs +28 -5
  18. package/lib/graph/retrieve/context.mjs +8 -3
  19. package/lib/host/acp.mjs +77 -14
  20. package/lib/host/claude-cli.mjs +391 -21
  21. package/lib/host/claude-env.mjs +179 -0
  22. package/lib/host/codex.mjs +9 -1
  23. package/lib/host/effort.mjs +163 -0
  24. package/lib/host/model-map.mjs +43 -0
  25. package/lib/host/qwen.mjs +9 -1
  26. package/lib/host/registry.mjs +44 -10
  27. package/lib/host.mjs +46 -5
  28. package/lib/interrupted.mjs +167 -0
  29. package/lib/launch-ledger.mjs +198 -0
  30. package/lib/launch-rule.mjs +174 -0
  31. package/lib/limits.mjs +149 -41
  32. package/lib/meter-quiet.mjs +45 -0
  33. package/lib/notify-defaults.mjs +6 -0
  34. package/lib/notify.mjs +41 -25
  35. package/lib/plan-fingerprint.mjs +80 -11
  36. package/lib/prompts/implementer.mjs +1 -1
  37. package/lib/prompts/verify.mjs +24 -1
  38. package/lib/receipt.mjs +59 -8
  39. package/lib/report-html.mjs +89 -6
  40. package/lib/report.mjs +313 -12
  41. package/lib/resume-card.mjs +90 -2
  42. package/lib/run-log.mjs +192 -5
  43. package/lib/run.mjs +1177 -84
  44. package/lib/state-home.mjs +298 -0
  45. package/lib/verify.mjs +37 -0
  46. package/lib/waves.mjs +53 -16
  47. package/package.json +4 -2
  48. package/shared/TEST-PROFILE.md +10 -5
  49. package/skills/evals/SKILL.md +3 -3
  50. package/skills/fix-tests/SKILL.md +3 -3
  51. package/skills/spec/SKILL.md +1 -6
  52. package/skills/spec/scripts/compact-instructions.mjs +30 -0
  53. package/types/index.d.ts +28 -0
  54. package/workflows/ship.js +75 -51
  55. package/.claude-plugin/marketplace.json +0 -19
package/lib/doctor.mjs CHANGED
@@ -39,13 +39,47 @@
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 { DEFAULT_NTFY_URL } from './notify-defaults.mjs'
82
+ import { STATE_HOME_ENV, resolveStateHome } from './state-home.mjs'
49
83
 
50
84
  /** The installed copy of the plugin — the one whose workflow and agents run. */
51
85
  export const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
@@ -101,16 +135,45 @@ export const PROMPT_CACHE_MIN_HOST_VERSION = '2.1.242'
101
135
  * `wave-state` call whose trajectory append fails now exits 1 rather than
102
136
  * warning on stderr. `.claude/ship/spill` is fatal for a different reason —
103
137
  * `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.
138
+ * oversized suite log has no legal way to report it. The rest are explicitly
139
+ * never-fatal in their own modules ("losing a corpus line must never fail the
140
+ * run that made it"; metrics "report their own failure and never touch the exit
141
+ * code"), so an unwritable one is a warning here too.
142
+ *
143
+ * Each entry also names its `home`, because from a linked worktree the two are
144
+ * different directories (design D15). `state`: the append-only corpora a report
145
+ * reads across every run the project has had — the trajectory, the outcome
146
+ * corpus, the metrics, the resume cards, the interrupted-run notes — which go to
147
+ * the state home so they outlive a worktree that is deleted when its session is
148
+ * archived. `root`: per-run working state, which stays with the run — the spill,
149
+ * whose locators are root-relative and which `verify spill` writes under the
150
+ * root, and the agent-usage sidecar the SubagentStop recorder keys by run.
151
+ * Probing a directory under the wrong home tests a directory nothing writes to,
152
+ * and misses the one the run will fail on.
108
153
  */
109
154
  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' }
155
+ { path: join('.claude', 'ship', 'runs'), home: 'state', fatal: true, why: 'run trajectory; a failed append exits 1' },
156
+ { path: join('.claude', 'ship', 'spill'), home: 'root', fatal: true, why: 'spilled suite output; no fallback path' },
157
+ { path: join('.claude', 'learning'), home: 'state', fatal: false, why: 'outcome corpus; never fatal at runtime' },
158
+ { path: join('.claude', 'metrics'), home: 'state', fatal: false, why: 'review metrics; never fatal at runtime' },
159
+ {
160
+ path: join('.claude', 'handoff'),
161
+ home: 'state',
162
+ fatal: false,
163
+ why: 'resume cards; outcome-class, a failed write is reported and never fails the run'
164
+ },
165
+ {
166
+ path: join('.claude', 'ship', 'interrupted'),
167
+ home: 'state',
168
+ fatal: false,
169
+ why: 'interrupted-run notes; outcome-class, a failed write is a stderr line from the SessionEnd hook'
170
+ },
171
+ {
172
+ path: join('.claude', 'ship', 'agent-usage'),
173
+ home: 'root',
174
+ fatal: false,
175
+ why: 'per-agent usage sidecar; outcome-class, a failed write is a stderr line from the SubagentStop recorder'
176
+ }
114
177
  ])
115
178
 
116
179
  /**
@@ -127,12 +190,12 @@ export const REQUIRED_COMMANDS = Object.freeze([
127
190
  {
128
191
  tokens: ['test'],
129
192
  open: true,
130
- why: 'the environment probe checks for the graph and the test profile (workflows/ship.js)'
193
+ why: 'the bootstrap skill checks for openspec/config.yaml (skills/bootstrap)'
131
194
  },
132
195
  {
133
196
  tokens: ['printenv'],
134
197
  open: true,
135
- why: 'the environment probe reads CLAUDE_CODE_SUBAGENT_MODEL and the Bedrock variables'
198
+ why: 'the environment probe reads CLAUDE_CODE_EFFORT_LEVEL'
136
199
  },
137
200
  { tokens: ['mkdir'], open: true, why: 'the environment probe creates .claude/ship/' }
138
201
  ])
@@ -141,8 +204,7 @@ export const REQUIRED_COMMANDS = Object.freeze([
141
204
  * Commands Claude Code auto-approves as read-only no matter what the
142
205
  * project's own allowlist says, so a briefing that instructs one of these
143
206
  * 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
207
+ * Source: the Claude Code permissions documentation, checked
146
208
  * 2026-09-07 — a host claim this module cannot itself verify, so it is dated
147
209
  * and cited rather than asserted bare. If the host narrows this list the
148
210
  * failure is loud: the drift test below goes red naming the command, never a
@@ -524,11 +586,11 @@ function checkOpenSpec(root, env, probe) {
524
586
  if (configured) {
525
587
  return check('openspec', 'warn', 'the openspec CLI is not on PATH; ship reads artifacts from disk, but /interlock:spec and /interlock:bootstrap need it', {
526
588
  evidence: ['openspec/config.yaml is present, so existing changes are still readable'],
527
- fix: 'npm install -g openspec (requires Node 20.19.0+).'
589
+ fix: 'Install the openspec command globally. It needs Node.js 20.19.0 or newer.'
528
590
  })
529
591
  }
530
592
  return check('openspec', 'fail', 'no openspec CLI on PATH and no openspec/ project in this repo — neither authoring nor shipping a change can start', {
531
- fix: 'npm install -g openspec && openspec init'
593
+ fix: 'Install the openspec command globally (Node.js 20.19.0 or newer), then run `openspec init` in the project root.'
532
594
  })
533
595
  }
534
596
 
@@ -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
@@ -740,17 +915,22 @@ function checkPermissions(root, required, { home, sources } = {}) {
740
915
  /**
741
916
  * Advice, never a gate: push notification is an optional feature, so this row
742
917
  * 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.
918
+ * not degraded their run. Reads the same names `lib/notify.mjs` reads — the
919
+ * plugin options `CLAUDE_PLUGIN_OPTION_NTFY_TOPIC` / `CLAUDE_PLUGIN_OPTION_NTFY_URL`
920
+ * and the shell variables `INTERLOCK_NTFY_TOPIC` / `INTERLOCK_NTFY_URL` — by
921
+ * literal string rather than by importing the transport. The default server
922
+ * comes from `notify-defaults.mjs`, so this row cannot name a different one.
923
+ * The topic value is never read into a string that could reach the report;
924
+ * only the server URL, which is not a capability, is echoed.
750
925
  */
926
+ function configuredValue(env, name) {
927
+ return env && typeof env[name] === 'string' && env[name].trim() ? env[name].trim() : ''
928
+ }
929
+
751
930
  function checkNotify(env) {
752
931
  try {
753
- const topic = env && typeof env.INTERLOCK_NTFY_TOPIC === 'string' ? env.INTERLOCK_NTFY_TOPIC.trim() : ''
932
+ const topic =
933
+ configuredValue(env, 'CLAUDE_PLUGIN_OPTION_NTFY_TOPIC') || configuredValue(env, 'INTERLOCK_NTFY_TOPIC')
754
934
  if (!topic) {
755
935
  return check(
756
936
  'notify',
@@ -759,9 +939,9 @@ function checkNotify(env) {
759
939
  )
760
940
  }
761
941
  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'
942
+ configuredValue(env, 'CLAUDE_PLUGIN_OPTION_NTFY_URL') ||
943
+ configuredValue(env, 'INTERLOCK_NTFY_URL') ||
944
+ DEFAULT_NTFY_URL
765
945
  return check('notify', 'ok', `push configured: ${url}`)
766
946
  } catch (err) {
767
947
  // Fail-open, and never `fail`: an unreadable env is a reason to skip the
@@ -770,6 +950,227 @@ function checkNotify(env) {
770
950
  }
771
951
  }
772
952
 
953
+ /**
954
+ * Advice, never a gate: what the Claude Code environment does to a run's model
955
+ * routing, read the way `interlock run start --host workflow` reads it.
956
+ *
957
+ * `checkNotify`'s posture: `ok` only when none of the four variables is set AND
958
+ * the host version was read — an unread version is not a clean reading, because
959
+ * what the plain subagent-model variable means depends on it — otherwise `skip`,
960
+ * and never `fail`. The variables are the operator's environment; a preflight
961
+ * that failed on them would fail every session that set one on purpose.
962
+ *
963
+ * The values printed are model slugs and small integers. Bedrock is named by
964
+ * variable and never by value, under the same presence-only rule as the
965
+ * credential rows: this report is written to disk and pasted into issues.
966
+ *
967
+ * @param {Record<string, string|undefined>} env
968
+ * @param {{version: string|null, cpuCount: number|null}} host
969
+ */
970
+ function checkClaudeEnv(env, { version, cpuCount }) {
971
+ try {
972
+ const o = observeClaudeEnv(env, { version, cpuCount })
973
+ const routing = readModelRouting(o)
974
+ const shown = (name, value) => `${name}=${value === null ? 'unset' : value}`
975
+ const four = [
976
+ shown(SUBAGENT_MODEL_ENV, o.subagentModel),
977
+ shown(SUBAGENT_MODEL_FORCE_ENV, o.subagentModelForce),
978
+ shown(WORKFLOW_MAX_CONCURRENT_ENV, o.maxConcurrent ? o.maxConcurrent.raw : null),
979
+ shown(AGENT_TEAMS_ENV, o.agentTeams)
980
+ ]
981
+ const anySet = [o.subagentModel, o.subagentModelForce, o.maxConcurrent, o.agentTeams].some(v => v !== null)
982
+
983
+ const detail = [`Claude Code ${o.version || 'unknown (the version probe did not answer)'}`, ...four]
984
+ if (o.subagentModel !== null) {
985
+ detail.push(
986
+ routing.plainDefaultOnly
987
+ ? `${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`
988
+ : routing.forceEffective
989
+ ? `${SUBAGENT_MODEL_ENV} names the model FORCE applies`
990
+ : `${SUBAGENT_MODEL_ENV} replaces every agent's model on ` +
991
+ (routing.versionKnown
992
+ ? `this host (below ${SUBAGENT_MODEL_DEFAULT_ONLY_MIN_VERSION})`
993
+ : 'a host whose version is unknown, the conservative reading') +
994
+ ', so the plan\'s per-tier assignment is not in effect'
995
+ )
996
+ }
997
+ if (o.subagentModelForce !== null) {
998
+ detail.push(
999
+ routing.forceInert
1000
+ ? `${SUBAGENT_MODEL_FORCE_ENV} has no effect on this host (below ${SUBAGENT_MODEL_FORCE_MIN_VERSION})`
1001
+ : o.subagentModelForceEnabled
1002
+ ? `${SUBAGENT_MODEL_FORCE_ENV} forces one model: every agent runs on one model`
1003
+ : `${SUBAGENT_MODEL_FORCE_ENV} is set to an off value`
1004
+ )
1005
+ }
1006
+ if (o.maxConcurrent && !o.maxConcurrent.valid) {
1007
+ const { min, max } = WORKFLOW_MAX_CONCURRENT_RANGE
1008
+ detail.push(`${WORKFLOW_MAX_CONCURRENT_ENV} is not an integer from ${min} to ${max}, so a run reads it as unset`)
1009
+ }
1010
+ if (o.agentTeams !== null) detail.push(`${AGENT_TEAMS_ENV} does not affect Workflow agents`)
1011
+ detail.push(
1012
+ o.bedrock.length
1013
+ ? `Bedrock: ${o.bedrock.join(', ')} set, so control-plane pings inherit the session model`
1014
+ : 'Bedrock: not set'
1015
+ )
1016
+ detail.push(
1017
+ `${o.cpuCount === null ? 'CPU count unknown' : `${o.cpuCount} CPUs`} beside the vendor default of ` +
1018
+ `${RUNTIME.maxConcurrentAgents} concurrent agents, which the runtime may reduce on a small machine`
1019
+ )
1020
+
1021
+ return check('claude-env', !anySet && o.version !== null ? 'ok' : 'skip', detail.join('; '))
1022
+ } catch (err) {
1023
+ // Fail-open, and never `fail`: an unreadable environment is a reason to
1024
+ // withhold the advice, not a reason to block a run.
1025
+ return check('claude-env', 'skip', `the claude-env check could not run: ${messageOf(err)}`)
1026
+ }
1027
+ }
1028
+
1029
+ /**
1030
+ * Whether the host can load the plugin's hooks module (the ship meter), as
1031
+ * advice: `ok` at or above the mods floor, `skip` below it or with the version
1032
+ * unknown, never `fail`. It reads the version the `claude-env` row already
1033
+ * probed, so the preflight spawns nothing new. The binary it names is the one
1034
+ * on PATH (or `INTERLOCK_CLAUDE_COMMAND`); a Desktop session's engine is not
1035
+ * that binary, which is why the module logs the engine it actually loaded in.
1036
+ *
1037
+ * @param {Record<string, string|undefined>} env
1038
+ * @param {string|null} version
1039
+ */
1040
+ export function checkMods(env, version) {
1041
+ try {
1042
+ const bin = claudeCommandArgv(env)[0]
1043
+ const desktop = 'a Desktop session\'s engine may differ from this binary; the meter logs the engine it loaded in at session start'
1044
+ const unchanged = 'a host that cannot load mods runs exactly today\'s run and prints every banner at close'
1045
+ if (version === null || version === undefined) {
1046
+ return check('mods', 'skip', `${bin}: version unknown (the version probe did not answer); mods need ${MODS_MIN_HOST_VERSION}+; ${unchanged}; ${desktop}`)
1047
+ }
1048
+ if (compareVersions(version, MODS_MIN_HOST_VERSION) >= 0) {
1049
+ return check('mods', 'ok', `${bin} ${version} loads the hooks module (mods ${MODS_MIN_HOST_VERSION}+): /interlock-meter draws a ship run live; ${desktop}`)
1050
+ }
1051
+ return check(
1052
+ 'mods',
1053
+ 'skip',
1054
+ `${bin} ${version} is below the mods floor ${MODS_MIN_HOST_VERSION}: the meter draws nothing here; ${unchanged}; ${desktop}`
1055
+ )
1056
+ } catch (err) {
1057
+ return check('mods', 'skip', `the mods check could not run: ${messageOf(err)}`)
1058
+ }
1059
+ }
1060
+
1061
+ /** The installed Claude CLI's version through the doctor's probe, or null. Never throws. */
1062
+ function claudeVersionOf(env, probe) {
1063
+ try {
1064
+ const [bin, ...fixedArgs] = claudeCommandArgv(env)
1065
+ return parseClaudeVersion(probe(bin, [...fixedArgs, '--version']))
1066
+ } catch {
1067
+ return null
1068
+ }
1069
+ }
1070
+
1071
+ /** This machine's CPU count, or null. Never throws. */
1072
+ function cpuCountOf() {
1073
+ try {
1074
+ const n = cpus().length
1075
+ return Number.isInteger(n) && n > 0 ? n : null
1076
+ } catch {
1077
+ return null
1078
+ }
1079
+ }
1080
+
1081
+ /** The credential a bare print-mode run reads from the environment, by name. */
1082
+ const BARE_KEY_ENV = 'ANTHROPIC_API_KEY'
1083
+ /** The settings key a bare print-mode run can take a credential from instead. */
1084
+ const BARE_HELPER_KEY = 'apiKeyHelper'
1085
+ /** What `--bare` sets, and what a session already in simple mode carries. */
1086
+ const CLAUDE_SIMPLE_ENV = 'CLAUDE_CODE_SIMPLE'
1087
+
1088
+ /**
1089
+ * The vendor's bare-mode facts this row repeats. A host claim this module
1090
+ * cannot verify, so it is stated as what the documentation says, not as a
1091
+ * measurement of the session.
1092
+ */
1093
+ const BARE_MODE_FACT =
1094
+ 'the headless docs recommend --bare and say it will become the default for -p; under it OAuth and the keychain ' +
1095
+ 'are never read, so --host claude lanes need a key or a helper, and a plugin loads only through --plugin-dir'
1096
+
1097
+ /**
1098
+ * Advice, never a gate: would a bare print-mode run have a credential?
1099
+ *
1100
+ * `checkNotify`'s posture — `ok` or `skip`, NEVER `fail`, its own try/catch,
1101
+ * outside `diagnose`'s `run()`. A session that authenticates with OAuth runs
1102
+ * every lane today; the row exists because the vendor intends `--bare` to
1103
+ * become the print-mode default, and under it that same session's `--host
1104
+ * claude` lanes would start with nothing to authenticate with. A preflight that
1105
+ * failed on a default that has not changed yet would block working runs.
1106
+ *
1107
+ * Read by NAME and never by value: `ANTHROPIC_API_KEY` is reported as set or
1108
+ * not, and an `apiKeyHelper` by the settings scope that configures it, never by
1109
+ * the command it runs. This report is written to disk and pasted into issues.
1110
+ *
1111
+ * A settings file that is absent, unreadable or not JSON is skipped here and
1112
+ * not reported — `checkPermissions` owns that finding, and an unparseable file
1113
+ * is not evidence that a helper is unset.
1114
+ *
1115
+ * @param {Record<string, string|undefined>} env
1116
+ * @param {{root?: string, home?: string, sources?: Array<{scope: string, path: string}>}} [opts]
1117
+ */
1118
+ export function checkClaudeBare(env, { root = '.', home, sources } = {}) {
1119
+ try {
1120
+ const key = isSet(env, BARE_KEY_ENV)
1121
+ const simple = isSet(env, CLAUDE_SIMPLE_ENV)
1122
+ const helperScopes = []
1123
+ const evidence = []
1124
+ for (const source of sources || settingsSources(resolve(root), home)) {
1125
+ if (!source || typeof source.path !== 'string' || !existsSync(source.path)) continue
1126
+ const parsed = readJson(source.path)
1127
+ if (!parsed.value || typeof parsed.value !== 'object') continue
1128
+ const helper = parsed.value[BARE_HELPER_KEY]
1129
+ if (typeof helper === 'string' && helper.trim()) {
1130
+ helperScopes.push(source.scope)
1131
+ evidence.push(`${BARE_HELPER_KEY}: ${source.scope} (${source.path})`)
1132
+ }
1133
+ }
1134
+
1135
+ const helpers = helperScopes.length
1136
+ ? `${BARE_HELPER_KEY} is configured in ${helperScopes.join(', ')} settings`
1137
+ : null
1138
+ const detail = []
1139
+ if (key && helpers) {
1140
+ detail.push(`${BARE_KEY_ENV} is set and ${helpers}, so a bare print-mode run has a credential`)
1141
+ } else if (key) {
1142
+ detail.push(`${BARE_KEY_ENV} is set, so a bare print-mode run has a credential`)
1143
+ } else if (helpers) {
1144
+ detail.push(
1145
+ `${helpers}, so a bare print-mode run has a credential`,
1146
+ `\`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`
1147
+ )
1148
+ } else {
1149
+ detail.push(
1150
+ `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}`
1151
+ )
1152
+ }
1153
+ detail.push(
1154
+ simple
1155
+ ? `${CLAUDE_SIMPLE_ENV} is set (the simple mode --bare turns on)`
1156
+ : `${CLAUDE_SIMPLE_ENV} is not set`,
1157
+ BARE_MODE_FACT
1158
+ )
1159
+
1160
+ const ok = key || Boolean(helpers)
1161
+ return check('claude-bare', ok ? 'ok' : 'skip', detail.join('; '), {
1162
+ evidence,
1163
+ fix: ok
1164
+ ? undefined
1165
+ : `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.`
1166
+ })
1167
+ } catch (err) {
1168
+ // Fail-open, and never `fail`: an unreadable environment or settings tree
1169
+ // is a reason to withhold the advice, not a reason to block a run.
1170
+ return check('claude-bare', 'skip', `the claude-bare check could not run: ${messageOf(err)}`)
1171
+ }
1172
+ }
1173
+
773
1174
  /**
774
1175
  * Advice, never a gate, and never a measurement.
775
1176
  *
@@ -1031,12 +1432,12 @@ export function evalPrerequisiteChecks(pluginRoot, env = process.env) {
1031
1432
 
1032
1433
  return [
1033
1434
  enabled
1034
- ? check('evals-harness', 'ok', `${EVAL_ENABLEMENT_VAR} is set, so \`claude plugin eval\` is enabled`)
1435
+ ? check('evals-harness', 'ok', `${EVAL_ENABLEMENT_VAR} is set, so the early-access harness is enabled`)
1035
1436
  : check(
1036
1437
  'evals-harness',
1037
1438
  '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.` }
1439
+ `${EVAL_ENABLEMENT_VAR} is not set, so the early-access harness is unavailable — no ship run needs it`,
1440
+ { fix: `Set one of the harness prerequisites by assigning ${EVAL_ENABLEMENT_VAR} the value 1. Nothing else changes.` }
1040
1441
  ),
1041
1442
  credential
1042
1443
  ? // The NAME of the variable, never its value. Reporting which one is
@@ -1074,9 +1475,12 @@ export function evalPrerequisiteChecks(pluginRoot, env = process.env) {
1074
1475
  * @param {object} [opts.env] environment PATH is read from
1075
1476
  * @param {string} [opts.home] home directory user settings are read from
1076
1477
  * @param {string} [opts.nodeVersion] overrides process.version, for tests
1077
- * @param {(bin: string) => string|null} [opts.probeVersion] version probe; injected in tests
1478
+ * @param {(bin: string, args?: string[]) => string|null} [opts.probeVersion] version probe; injected in tests
1479
+ * @param {number} [opts.cpuCount] overrides the machine's CPU count, for tests
1078
1480
  * @param {Array<{scope:string,path:string}>} [opts.settingsSources] overrides the merge list
1079
- * @returns {object} report
1481
+ * @param {string} [opts.stateHome] an explicit state home (`--state-home`); when absent,
1482
+ * `INTERLOCK_STATE_HOME` in `opts.env` and then git decide
1483
+ * @returns {object} report, carrying `stateHome` (absolute) and `surface`
1080
1484
  */
1081
1485
  export function diagnose(root = '.', opts = {}) {
1082
1486
  const projectRoot = resolve(root)
@@ -1084,6 +1488,11 @@ export function diagnose(root = '.', opts = {}) {
1084
1488
  const env = opts.env || process.env
1085
1489
  const probe = typeof opts.probeVersion === 'function' ? opts.probeVersion : probeVersion
1086
1490
 
1491
+ // Resolved ONCE, here, and handed to every row that needs it — and to the
1492
+ // SessionStart preflight through the report, so it reads interrupted-run
1493
+ // notes from this home without resolving a second time.
1494
+ const resolved = resolveHomeFor(projectRoot, opts.stateHome, env)
1495
+
1087
1496
  const run = (id, fn) => {
1088
1497
  try {
1089
1498
  return fn()
@@ -1101,7 +1510,7 @@ export function diagnose(root = '.', opts = {}) {
1101
1510
  checks.push(run('openspec', () => checkOpenSpec(projectRoot, env, probe)))
1102
1511
  checks.push(run('git', () => checkGit(projectRoot, env, probe)))
1103
1512
 
1104
- const profileCheck = run('test-profile', () => checkTestProfile(projectRoot))
1513
+ const profileCheck = run('test-profile', () => checkTestProfile(projectRoot, resolved.home || projectRoot))
1105
1514
  const profile = profileCheck.profile
1106
1515
  delete profileCheck.profile
1107
1516
  checks.push(profileCheck)
@@ -1115,7 +1524,10 @@ export function diagnose(root = '.', opts = {}) {
1115
1524
  checkPermissions(projectRoot, required, { home: opts.home, sources: opts.settingsSources })
1116
1525
  )
1117
1526
  )
1118
- checks.push(run('state-dirs', () => checkStateDirs(projectRoot)))
1527
+ // Not behind `run()`: which home was resolved is advice and may never be a
1528
+ // `fail`; whether that home is writable is `state-dirs`' question, which can.
1529
+ checks.push(checkStateHome(projectRoot, resolved))
1530
+ checks.push(run('state-dirs', () => checkStateDirs(projectRoot, resolved.home)))
1119
1531
 
1120
1532
  // Not behind `run()`: that wrapper's thrown-probe verdict is `fail`, and
1121
1533
  // this row may never carry one — push is optional, so `checkNotify` owns
@@ -1128,6 +1540,24 @@ export function diagnose(root = '.', opts = {}) {
1128
1540
  // try/catch. See `checkPromptCache`.
1129
1541
  checks.push(checkPromptCache(projectRoot, { home: opts.home, sources: opts.settingsSources, env }))
1130
1542
 
1543
+ // And again: the Claude Code environment a run's routing reads is the
1544
+ // operator's, so this row is advice. One `claude --version` spawn, bounded by
1545
+ // the probe's own timeout. See `checkClaudeEnv`.
1546
+ const claudeVersion = claudeVersionOf(env, probe)
1547
+ checks.push(
1548
+ checkClaudeEnv(env, {
1549
+ version: claudeVersion,
1550
+ cpuCount: Number.isInteger(opts.cpuCount) ? opts.cpuCount : cpuCountOf()
1551
+ })
1552
+ )
1553
+ // The same reading, no second spawn: whether the ship meter can draw here.
1554
+ // Advice, `ok`/`skip`, its own try/catch. See `checkMods`.
1555
+ checks.push(checkMods(env, claudeVersion))
1556
+
1557
+ // And again: whether a bare print-mode run would have a credential is advice
1558
+ // about the operator's environment and settings. See `checkClaudeBare`.
1559
+ checks.push(checkClaudeBare(env, { root: projectRoot, home: opts.home, sources: opts.settingsSources }))
1560
+
1131
1561
  // Not behind `run()`: that wrapper's thrown-probe verdict is `fail`, and
1132
1562
  // these rows may never carry one. See `evalPrerequisiteChecks`.
1133
1563
  checks.push(...evalPrerequisiteChecks(pluginRoot, env))
@@ -1138,6 +1568,8 @@ export function diagnose(root = '.', opts = {}) {
1138
1568
  return {
1139
1569
  ok: counts.fail === 0,
1140
1570
  root: projectRoot,
1571
+ stateHome: resolved.home,
1572
+ surface: resolved.surface,
1141
1573
  pluginRoot,
1142
1574
  counts,
1143
1575
  checks,
@@ -1147,6 +1579,30 @@ export function diagnose(root = '.', opts = {}) {
1147
1579
  }
1148
1580
  }
1149
1581
 
1582
+ /**
1583
+ * The state home for `root`, never throwing. `resolveStateHome` already holds
1584
+ * that contract; the guard is here because a doctor that crashed resolving
1585
+ * where to look would produce no report at all, and the fallback carries its
1586
+ * reason so the `state-home` row can speak it.
1587
+ */
1588
+ function resolveHomeFor(root, explicit, env) {
1589
+ try {
1590
+ const resolved = resolveStateHome(root, {
1591
+ explicit: typeof explicit === 'string' && explicit.trim() ? explicit : undefined,
1592
+ env
1593
+ })
1594
+ if (resolved && typeof resolved.home === 'string' && resolved.home) return resolved
1595
+ return { home: root, surface: 'unknown', reason: 'the state home resolver returned no home', resolvedFrom: 'fallback' }
1596
+ } catch (err) {
1597
+ return {
1598
+ home: root,
1599
+ surface: 'unknown',
1600
+ reason: `the state home could not be resolved: ${messageOf(err)}`,
1601
+ resolvedFrom: 'fallback'
1602
+ }
1603
+ }
1604
+ }
1605
+
1150
1606
  /** Human-readable preflight, in the shape `interlock ready` already prints. */
1151
1607
  export function formatDoctor(report) {
1152
1608
  const lines = []
@@ -1158,6 +1614,15 @@ export function formatDoctor(report) {
1158
1614
  (report.counts.warn ? `, ${report.counts.warn} warning(s)` : '')
1159
1615
  )
1160
1616
  lines.push(` root: ${report.root}`)
1617
+ // Under `root:` only when the two differ, so a main checkout's report reads as
1618
+ // it always did and a worktree session's names where its corpora go.
1619
+ if (
1620
+ typeof report.stateHome === 'string' &&
1621
+ report.stateHome &&
1622
+ realOrSelf(report.stateHome) !== realOrSelf(report.root)
1623
+ ) {
1624
+ lines.push(` state home: ${report.stateHome}`)
1625
+ }
1161
1626
  if (report.pluginRoot !== report.root) lines.push(` plugin: ${report.pluginRoot}`)
1162
1627
  for (const c of report.checks) {
1163
1628
  lines.push(` [${STATUS_MARK[c.status] || c.status}] ${c.id}: ${c.detail}`)