@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
@@ -0,0 +1,179 @@
1
+ // The Claude Code environment a Workflow run's routing reads (design D1, D2).
2
+ //
3
+ // Pure, and imports nothing: `lib/run.mjs` and `lib/doctor.mjs` both read it,
4
+ // and neither may load an adapter — adapters spawn vendor binaries. This module
5
+ // only parses. The spawn that learns the host's version lives beside the effort
6
+ // probe in `./claude-cli.mjs`; what the run does with the reading is decided in
7
+ // `decideHostEnvironment` in `lib/run.mjs`.
8
+ //
9
+ // Every name and number below is a fact about Claude Code, not an Interlock
10
+ // threshold, so each is a named constant in the module that reads it — the
11
+ // convention every `INTERLOCK_*` reader follows. The two version floors are the
12
+ // load-bearing ones:
13
+ //
14
+ // SUBAGENT_MODEL_DEFAULT_ONLY_MIN_VERSION from 2.1.251 `CLAUDE_CODE_SUBAGENT_MODEL`
15
+ // sets only the DEFAULT subagent model; a model named at spawn time wins.
16
+ // Below it the variable replaces every agent's model. If the task-1 probe in
17
+ // the change's design.md shows a spawn-time model does NOT win on a current
18
+ // host, this constant becomes `null`, which disables the default-only reading
19
+ // on every version — a one-constant revert, which is why it is a constant.
20
+ // SUBAGENT_MODEL_FORCE_MIN_VERSION from 2.1.257 `CLAUDE_CODE_SUBAGENT_MODEL_FORCE`
21
+ // forces one model onto every agent. Below it the variable is inert.
22
+ // MODS_MIN_HOST_VERSION from 2.1.287 a plugin's hooks module (hooks/hooks.json
23
+ // `modules`) loads, so the ship meter draws. Below it the module is not
24
+ // loaded and the run is exactly today's; doctor's `mods` row is advice only.
25
+
26
+ /** Override the binary, for a wrapper or a fixture. Read by the adapter, the doctor and the probes. */
27
+ export const CLAUDE_COMMAND_ENV = 'INTERLOCK_CLAUDE_COMMAND'
28
+
29
+ export const SUBAGENT_MODEL_ENV = 'CLAUDE_CODE_SUBAGENT_MODEL'
30
+ export const SUBAGENT_MODEL_FORCE_ENV = 'CLAUDE_CODE_SUBAGENT_MODEL_FORCE'
31
+ export const WORKFLOW_MAX_CONCURRENT_ENV = 'CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS'
32
+ /** Advice only: Workflow `agent()` calls are unaffected by agent teams. */
33
+ export const AGENT_TEAMS_ENV = 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS'
34
+ /** Either one set routes the session through Bedrock, where haiku is often unreachable. */
35
+ export const BEDROCK_ENVS = Object.freeze(['CLAUDE_CODE_USE_BEDROCK', 'AWS_BEDROCK'])
36
+
37
+ export const SUBAGENT_MODEL_DEFAULT_ONLY_MIN_VERSION = '2.1.251'
38
+ export const SUBAGENT_MODEL_FORCE_MIN_VERSION = '2.1.257'
39
+ export const MODS_MIN_HOST_VERSION = '2.1.287'
40
+
41
+ /** The vendor's accepted range for the concurrency override. Outside it, the value is not a count. */
42
+ export const WORKFLOW_MAX_CONCURRENT_RANGE = Object.freeze({ min: 1, max: 256 })
43
+
44
+ /**
45
+ * The installed CLI's command line: `$INTERLOCK_CLAUDE_COMMAND` or `claude`,
46
+ * split on whitespace — the same split the adapter applies to the same variable.
47
+ *
48
+ * @returns {string[]} `[bin, ...fixedArgs]`
49
+ */
50
+ export function claudeCommandArgv(env) {
51
+ const raw = env && typeof env[CLAUDE_COMMAND_ENV] === 'string' ? env[CLAUDE_COMMAND_ENV].trim() : ''
52
+ return (raw || 'claude').split(/\s+/)
53
+ }
54
+
55
+ /**
56
+ * The first `major.minor.patch` in the output of `claude --version`
57
+ * (`2.1.274 (Claude Code)`), or null. Unknown stays unknown: nothing here
58
+ * guesses a version from anything else.
59
+ */
60
+ export function parseClaudeVersion(text) {
61
+ if (typeof text !== 'string') return null
62
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(text)
63
+ return m ? `${Number(m[1])}.${Number(m[2])}.${Number(m[3])}` : null
64
+ }
65
+
66
+ /**
67
+ * Compare two versions numerically, part by part. Returns -1, 0 or 1, or null
68
+ * when either side does not parse — never a guess at an order.
69
+ */
70
+ export function compareVersions(a, b) {
71
+ const left = parseClaudeVersion(a)
72
+ const right = parseClaudeVersion(b)
73
+ if (!left || !right) return null
74
+ const x = left.split('.').map(Number)
75
+ const y = right.split('.').map(Number)
76
+ for (let i = 0; i < 3; i++) {
77
+ if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1
78
+ }
79
+ return 0
80
+ }
81
+
82
+ /** A variable's trimmed value, or null when it is unset or blank. */
83
+ function valueOf(env, name) {
84
+ const raw = env && typeof env[name] === 'string' ? env[name].trim() : ''
85
+ return raw || null
86
+ }
87
+
88
+ /**
89
+ * Whether a switch-like variable is on: non-empty and not `0` or `false` — the
90
+ * test the validate ping applied to the Bedrock variables, kept exactly.
91
+ */
92
+ function enabled(value) {
93
+ return value !== null && !['0', 'false'].includes(value.toLowerCase())
94
+ }
95
+
96
+ /**
97
+ * What the environment, the host version and the machine say, before anything
98
+ * is decided from them. The raw observation is what a run stores on its
99
+ * manifest, so a later reader sees exactly what the decision saw.
100
+ *
101
+ * Throws only if `env` itself throws on read; every caller that hands it an
102
+ * environment it does not own wraps the call.
103
+ *
104
+ * @param {Record<string, string|undefined>} env
105
+ * @param {{version?: string|null, cpuCount?: number|null}} [host]
106
+ */
107
+ export function observeClaudeEnv(env, { version = null, cpuCount = null } = {}) {
108
+ const force = valueOf(env, SUBAGENT_MODEL_FORCE_ENV)
109
+ const teams = valueOf(env, AGENT_TEAMS_ENV)
110
+ const rawSlots = valueOf(env, WORKFLOW_MAX_CONCURRENT_ENV)
111
+ let maxConcurrent = null
112
+ if (rawSlots !== null) {
113
+ const n = /^\d+$/.test(rawSlots) ? Number(rawSlots) : NaN
114
+ const valid =
115
+ Number.isInteger(n) && n >= WORKFLOW_MAX_CONCURRENT_RANGE.min && n <= WORKFLOW_MAX_CONCURRENT_RANGE.max
116
+ maxConcurrent = { raw: rawSlots, value: valid ? n : null, valid }
117
+ }
118
+ return {
119
+ version: parseClaudeVersion(version),
120
+ subagentModel: valueOf(env, SUBAGENT_MODEL_ENV),
121
+ subagentModelForce: force,
122
+ subagentModelForceEnabled: force !== null && enabled(force),
123
+ maxConcurrent,
124
+ agentTeams: teams,
125
+ bedrock: BEDROCK_ENVS.filter(name => {
126
+ const value = valueOf(env, name)
127
+ return value !== null && enabled(value)
128
+ }),
129
+ cpuCount: Number.isInteger(cpuCount) && cpuCount > 0 ? cpuCount : null
130
+ }
131
+ }
132
+
133
+ /**
134
+ * The version-aware reading of the two model variables (design D1's table),
135
+ * shared by the run program's decision and the doctor's advice so the two
136
+ * cannot read one environment two ways.
137
+ *
138
+ * An unreadable version takes the conservative reading on both: FORCE is
139
+ * effective and the plain variable overrides.
140
+ */
141
+ export function readModelRouting(observation) {
142
+ const o = observation && typeof observation === 'object' ? observation : {}
143
+ const version = parseClaudeVersion(o.version)
144
+ const known = version !== null
145
+ const forceEffective =
146
+ o.subagentModelForceEnabled === true && !(known && compareVersions(version, SUBAGENT_MODEL_FORCE_MIN_VERSION) < 0)
147
+ const plain = typeof o.subagentModel === 'string' && o.subagentModel ? o.subagentModel : null
148
+ const defaultOnly =
149
+ !forceEffective &&
150
+ plain !== null &&
151
+ SUBAGENT_MODEL_DEFAULT_ONLY_MIN_VERSION !== null &&
152
+ known &&
153
+ compareVersions(version, SUBAGENT_MODEL_DEFAULT_ONLY_MIN_VERSION) >= 0
154
+ return {
155
+ version,
156
+ versionKnown: known,
157
+ forceEffective,
158
+ // Set, on a host that predates the variable: it does nothing there.
159
+ forceInert: o.subagentModelForceEnabled === true && !forceEffective,
160
+ plainDefaultOnly: defaultOnly,
161
+ plainOverrides: !forceEffective && plain !== null && !defaultOnly
162
+ }
163
+ }
164
+
165
+ /**
166
+ * The concurrency the runtime will honour, as far as this process can observe
167
+ * it (design D5): the override when it is a valid count, otherwise the vendor
168
+ * default — which the runtime may reduce on a small machine by an amount the
169
+ * vendor does not publish, so the CPU count is recorded and no number is
170
+ * invented. An invalid override is recorded with its raw text and otherwise
171
+ * read as unset.
172
+ */
173
+ export function runtimeSlotsOf(observation) {
174
+ const o = observation && typeof observation === 'object' ? observation : {}
175
+ const mc = o.maxConcurrent && typeof o.maxConcurrent === 'object' ? o.maxConcurrent : null
176
+ const cpuCount = Number.isInteger(o.cpuCount) ? o.cpuCount : null
177
+ if (mc && mc.valid) return { observed: mc.value, source: 'env', cpuCount }
178
+ return { observed: null, source: 'vendor-default', cpuCount, ...(mc ? { invalid: mc.raw } : {}) }
179
+ }
@@ -35,6 +35,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
35
35
  import { tmpdir } from 'node:os'
36
36
  import { join } from 'node:path'
37
37
  import { assertWorkflowHost, execCapture, mapPipeline, runCli } from '../host.mjs'
38
+ import { EFFORT_REASONS, effortRoutingEvent, resolveEffort } from './effort.mjs'
38
39
  import { parseModelMap, resolveModel } from './model-map.mjs'
39
40
 
40
41
  /** Override the binary, for a wrapper or a fixture. */
@@ -161,6 +162,9 @@ export function createCodexHost({ command, cwd = process.cwd(), env = process.en
161
162
  reason: routed.reason
162
163
  })
163
164
  }
165
+ // No effort channel this adapter is verified to use: argv is untouched and
166
+ // the not-applied verdict is spoken (design D4, D29).
167
+ const effort = resolveEffort(request.effort, 'unsupported', EFFORT_REASONS.notRouted)
164
168
 
165
169
  const scratch = mkdtempSync(join(tmpdir(), 'interlock-codex-'))
166
170
  const outputPath = join(scratch, 'last-message.json')
@@ -168,7 +172,7 @@ export function createCodexHost({ command, cwd = process.cwd(), env = process.en
168
172
  try {
169
173
  // Written rather than passed inline: `--output-schema` takes a path.
170
174
  if (schemaPath) writeFileSync(schemaPath, JSON.stringify(request.schema))
171
- const { code, stdout, stderr, timedOut } = await execCapture(
175
+ const { code, stdout, stderr, timedOut, started: ran } = await execCapture(
172
176
  bin,
173
177
  [
174
178
  ...fixedArgs,
@@ -186,6 +190,10 @@ export function createCodexHost({ command, cwd = process.cwd(), env = process.en
186
190
  timeoutMs: budget
187
191
  }
188
192
  )
193
+ // Once the process started — a spawn that never did is a failed spawn,
194
+ // not an unapplied effort.
195
+ const routedEffort = ran ? effortRoutingEvent(label, effort) : null
196
+ if (routedEffort && onEvent) onEvent(routedEffort)
189
197
 
190
198
  if (code !== 0) {
191
199
  if (onEvent) {
@@ -0,0 +1,163 @@
1
+ // The effort resolver every adapter shares (design D18, D19, D21, D29, D32).
2
+ //
3
+ // A spawn's effort comes from the run program, which read it off the published
4
+ // table in `lib/limits.mjs`. Whether a host can apply it, and the reason when it
5
+ // cannot, are decided here, once: the runner prints these verdicts and may not
6
+ // word one of its own, and each adapter passes its own capability literal and
7
+ // reason in, because the registry imports the adapters and the reverse import is
8
+ // not available. `lib/host/model-map.mjs` is the precedent for the shape.
9
+ //
10
+ // Pure: no I/O, and no import of an adapter.
11
+
12
+ /**
13
+ * Every reason a named effort can go unapplied. Each is true of the host that
14
+ * gives it — a host with no per-spawn channel, a host whose channel the adapter
15
+ * does not route, and a CLI that lacks the flag each say a different thing.
16
+ * `docs/04-when-it-stops.md` carries each of these verbatim.
17
+ */
18
+ export const EFFORT_REASONS = Object.freeze({
19
+ // `qwen`, and any capability this module does not know.
20
+ noControl: 'host has no effort control',
21
+ // `codex` (D29): a real knob exists; sending it a Claude-derived label would
22
+ // be a cross-vendor mapping this module does not make.
23
+ notRouted: 'effort is not routed on this host',
24
+ // `claude` (D30), when its own help does not list the flag.
25
+ noFlag: 'this claude CLI has no --effort flag',
26
+ // `claude` (D30), when the help probe fails or times out.
27
+ probeFailed: 'could not establish whether this claude CLI accepts --effort',
28
+ // `acp`: the agent offered no effort option for this session.
29
+ noOption: 'no effort option advertised',
30
+ // `acp`: the option exists, and the level is not one of its flat values.
31
+ notAdvertised: 'level not among advertised values',
32
+ // `acp`: `session/set_config_option` failed.
33
+ rejected: 'agent rejected the effort option'
34
+ })
35
+
36
+ /**
37
+ * The variable the Claude CLI reads, which outranks `--effort`, `/effort`,
38
+ * settings and a subagent's own `effort` (D16). It is the operator's: it is
39
+ * bannered, never stripped (D26).
40
+ */
41
+ export const EFFORT_LEVEL_ENV = 'CLAUDE_CODE_EFFORT_LEVEL'
42
+
43
+ /**
44
+ * The operator's exported effort override, trimmed, or `''` when it is unset or
45
+ * blank.
46
+ *
47
+ * @param {Record<string, string|undefined>} [env]
48
+ * @returns {string}
49
+ */
50
+ export function effortLevelOverride(env) {
51
+ const raw = env && typeof env === 'object' ? env[EFFORT_LEVEL_ENV] : undefined
52
+ return typeof raw === 'string' ? raw.trim() : ''
53
+ }
54
+
55
+ /** A level as requested, or `''` when none was named. */
56
+ function levelOf(level) {
57
+ return typeof level === 'string' ? level.trim() : ''
58
+ }
59
+
60
+ /**
61
+ * The verdict for a flag host or a host with no effort channel.
62
+ *
63
+ * The level is passed through unvalidated (D11): the published table is the
64
+ * authority, and a vendor CLI falls back on a level its model lacks. On a flag
65
+ * host "applied" means the flag was passed (D24) — the adapter cannot see past
66
+ * its own argv and does not claim to.
67
+ *
68
+ * Only `flag` applies. `unsupported`, and any capability this module does not
69
+ * know, resolve to not applied with the reason given — fail open, and spoken. A
70
+ * `negotiated` host does not come through here: it negotiates per session with
71
+ * {@link findEffortOption} and {@link pickEffortValue}.
72
+ *
73
+ * @param {string} [level] the spawn's effort, as the step named it
74
+ * @param {string} [capability] the adapter's own `effort` capability literal
75
+ * @param {string} [reason] what this host says when it cannot apply one
76
+ * @returns {{requested: string|null, applied: boolean, via: string|null, value: string|null, reason: string|null}}
77
+ */
78
+ export function resolveEffort(level, capability, reason) {
79
+ const requested = levelOf(level)
80
+ if (!requested) return { requested: null, applied: false, via: null, value: null, reason: null }
81
+ if (capability === 'flag') return { requested, applied: true, via: 'flag', value: requested, reason: null }
82
+ return {
83
+ requested,
84
+ applied: false,
85
+ via: null,
86
+ value: null,
87
+ reason: typeof reason === 'string' && reason ? reason : EFFORT_REASONS.noControl
88
+ }
89
+ }
90
+
91
+ /**
92
+ * The effort-routing event for one spawn, or `null` when the spawn named no
93
+ * effort and so must raise none.
94
+ *
95
+ * A flag or no-channel adapter emits it once the spawn's process has started,
96
+ * never before: a spawn that fails to start is a failed spawn, counted neither
97
+ * as applied nor as unapplied.
98
+ *
99
+ * @param {string} label
100
+ * @param {ReturnType<typeof resolveEffort>} effort
101
+ * @returns {object|null}
102
+ */
103
+ export function effortRoutingEvent(label, effort) {
104
+ if (!effort || !effort.requested) return null
105
+ return {
106
+ type: 'effort-routing',
107
+ label,
108
+ requested: effort.requested,
109
+ applied: effort.applied,
110
+ via: effort.via,
111
+ value: effort.value,
112
+ reason: effort.reason
113
+ }
114
+ }
115
+
116
+ /**
117
+ * The agent's effort option, found by its conventional id or by the reserved
118
+ * `thought_level` category (D19) — the same two-way rule the model option is
119
+ * found by. A model option is never returned.
120
+ *
121
+ * @param {unknown} configOptions the config options the agent returned most recently
122
+ * @returns {object|null}
123
+ */
124
+ export function findEffortOption(configOptions) {
125
+ if (!Array.isArray(configOptions)) return null
126
+ return (
127
+ configOptions.find(
128
+ o =>
129
+ o &&
130
+ typeof o === 'object' &&
131
+ o.id !== 'model' &&
132
+ o.category !== 'model' &&
133
+ (o.id === 'effort' || o.category === 'thought_level')
134
+ ) || null
135
+ )
136
+ }
137
+
138
+ /**
139
+ * Which advertised value a level means — exactly, or not at all (D18, D32).
140
+ *
141
+ * Exact equality on a flat `options[].value` only. No substring rule, because
142
+ * `high` is inside `xhigh` and an agent rejects a wrong value outright; no
143
+ * display-name rule; no operator map, which would be the cross-vendor mapping
144
+ * D29 declines. A grouped or non-select option is not read, so a level offered
145
+ * only inside a group is not among the advertised values.
146
+ *
147
+ * @param {string} [level]
148
+ * @param {object|null} [option] {@link findEffortOption}'s result
149
+ * @returns {{value: string|null, reason: string|null}}
150
+ */
151
+ export function pickEffortValue(level, option) {
152
+ const wanted = levelOf(level)
153
+ if (!wanted) return { value: null, reason: null }
154
+ if (!option || typeof option !== 'object') return { value: null, reason: EFFORT_REASONS.noOption }
155
+ if (option.type !== undefined && option.type !== 'select') {
156
+ return { value: null, reason: EFFORT_REASONS.notAdvertised }
157
+ }
158
+ const flat = Array.isArray(option.options)
159
+ ? option.options.filter(o => o && typeof o === 'object' && typeof o.value === 'string')
160
+ : []
161
+ const hit = flat.find(o => o.value === wanted)
162
+ return hit ? { value: hit.value, reason: null } : { value: null, reason: EFFORT_REASONS.notAdvertised }
163
+ }
@@ -127,3 +127,46 @@ export function resolveModel(hostId, slug, map = {}, capabilities = {}) {
127
127
 
128
128
  return { value: null, applied: false, reason: `no mapping for ${wanted}` }
129
129
  }
130
+
131
+ /** The tier slugs a planner routes by, which match any id that carries the tier. */
132
+ const TIER_SLUGS = Object.freeze(['haiku', 'sonnet', 'opus'])
133
+
134
+ /** A model id as comparable tokens: lowercased, provider prefix and date stamp dropped. */
135
+ function modelTokens(value) {
136
+ const tokens = String(value).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
137
+ const claude = tokens.indexOf('claude')
138
+ const rest = claude > 0 ? tokens.slice(claude) : tokens
139
+ return rest.length > 1 && /^\d{8}$/.test(rest[rest.length - 1]) ? rest.slice(0, -1) : rest
140
+ }
141
+
142
+ /**
143
+ * Whether a served model is the model a spawn was routed to (design D6).
144
+ *
145
+ * THE ONLY COMPARISON of a routed model against a served one, on either host:
146
+ * `modelSubstitution` in `lib/run.mjs` calls it, and nothing else may.
147
+ * Plain equality would banner every lane, because the run routes `sonnet` and
148
+ * the host serves `claude-sonnet-5-5`; a prefix test misses `bedrock.`; a fuzzy
149
+ * one is a guess, and a wrong model that ran would be invisible, which is the
150
+ * failure the banner exists to name. So:
151
+ *
152
+ * - both are lowercased and split on runs of non-alphanumerics;
153
+ * - every token before the first `claude` token is dropped (a provider prefix);
154
+ * - a trailing token of exactly eight digits is dropped (a date stamp);
155
+ * - a bare tier slug matches any served id that carries that tier token;
156
+ * - anything else matches only the same token list.
157
+ *
158
+ * `sonnet` matches `bedrock.claude-sonnet-5-5`; `claude-sonnet-5-5` matches
159
+ * `claude-sonnet-5-5-20261001`; `claude-sonnet-5` does not match
160
+ * `claude-sonnet-5-5`; `sonnet` does not match `claude-opus-5-5`.
161
+ *
162
+ * @param {string} routed the model the spawn was sent with
163
+ * @param {string} served one model the host reports having served
164
+ * @returns {boolean|null} `null` when either side is empty: no comparison was made
165
+ */
166
+ export function modelMatches(routed, served) {
167
+ const want = typeof routed === 'string' ? modelTokens(routed) : []
168
+ const got = typeof served === 'string' ? modelTokens(served) : []
169
+ if (!want.length || !got.length) return null
170
+ if (want.length === 1 && TIER_SLUGS.includes(want[0])) return got.includes(want[0])
171
+ return want.length === got.length && want.every((token, i) => token === got[i])
172
+ }
package/lib/host/qwen.mjs CHANGED
@@ -27,6 +27,7 @@
27
27
  // the tier the planner asked for.
28
28
 
29
29
  import { assertWorkflowHost, execCapture, mapPipeline, parseAgentJson, runCli } from '../host.mjs'
30
+ import { effortRoutingEvent, resolveEffort } from './effort.mjs'
30
31
  import { parseModelMap, resolveModel } from './model-map.mjs'
31
32
 
32
33
  /** Override the binary, for a wrapper or a fixture. */
@@ -132,8 +133,11 @@ export function createQwenHost({ command, cwd = process.cwd(), env = process.env
132
133
  reason: routed.reason
133
134
  })
134
135
  }
136
+ // No effort channel this adapter is verified to use: argv is untouched and
137
+ // the not-applied verdict is spoken (design D4, D29).
138
+ const effort = resolveEffort(request.effort, 'unsupported')
135
139
 
136
- const { code, stdout, stderr, timedOut } = await execCapture(
140
+ const { code, stdout, stderr, timedOut, started: ran } = await execCapture(
137
141
  bin,
138
142
  [...fixedArgs, ...qwenArgs(request, { model: routed.value })],
139
143
  {
@@ -143,6 +147,10 @@ export function createQwenHost({ command, cwd = process.cwd(), env = process.env
143
147
  timeoutMs: budget
144
148
  }
145
149
  )
150
+ // Once the process started — a spawn that never did is a failed spawn,
151
+ // not an unapplied effort.
152
+ const routedEffort = ran ? effortRoutingEvent(label, effort) : null
153
+ if (routedEffort && onEvent) onEvent(routedEffort)
146
154
 
147
155
  if (code !== 0) {
148
156
  if (onEvent) {
@@ -7,11 +7,13 @@
7
7
  // directory implementing the three-function port in `../host.mjs`, plus a
8
8
  // declaration of what it cannot do.
9
9
  //
10
- // The declaration is the load-bearing half. Every adapter states the same seven
10
+ // The declaration is the load-bearing half. Every adapter states the same eight
11
11
  // facts, and the run program reads them rather than branching on an id:
12
12
  //
13
13
  // schemaEnforced does the CLI itself validate the result against the schema
14
14
  // modelSelect 'flag' | 'negotiated' | 'map-only' — how a slug becomes a model
15
+ // ('forced' is observed by run start, never declared)
16
+ // effort 'flag' | 'negotiated' | 'unsupported' — how a named effort is applied
15
17
  // worktree 'driver' | 'runtime' — who creates a lane's worktree
16
18
  // hooks do this repository's PreToolUse guards fire on this host
17
19
  // usage does the CLI report token usage
@@ -32,7 +34,7 @@
32
34
  // `/interlock:ship`, it owns its own worktrees (`worktree: 'runtime'`), and the
33
35
  // runner refuses its id (design D10).
34
36
 
35
- import { createAcpHost, isClaudeCodeBinary, parseAcpCommand, ACP_COMMAND_ENV } from './acp.mjs'
37
+ import { createAcpHost, isClaudeBackedCommand, parseAcpCommand, ACP_COMMAND_ENV } from './acp.mjs'
36
38
  import { createClaudeHost } from './claude-cli.mjs'
37
39
  import { createCodexHost } from './codex.mjs'
38
40
  import { createQwenHost } from './qwen.mjs'
@@ -41,6 +43,7 @@ import { createQwenHost } from './qwen.mjs'
41
43
  export const CAPABILITY_KEYS = Object.freeze([
42
44
  'schemaEnforced',
43
45
  'modelSelect',
46
+ 'effort',
44
47
  'worktree',
45
48
  'hooks',
46
49
  'usage',
@@ -51,11 +54,22 @@ export const CAPABILITY_KEYS = Object.freeze([
51
54
  /** The allowed value of each declared capability. */
52
55
  export const CAPABILITY_VALUES = Object.freeze({
53
56
  schemaEnforced: Object.freeze([true, false]),
54
- modelSelect: Object.freeze(['flag', 'negotiated', 'map-only']),
57
+ // `forced`: an environment variable forces one model onto every spawned
58
+ // agent, so the per-spawn channel is ignored. OBSERVED ONLY — no adapter may
59
+ // declare it; `interlock run start` on the Workflow host records it when
60
+ // `CLAUDE_CODE_SUBAGENT_MODEL_FORCE` is in effect.
61
+ modelSelect: Object.freeze(['flag', 'negotiated', 'map-only', 'forced']),
62
+ // `unsupported`: no effort channel the adapter is verified to use on this
63
+ // host — no per-spawn channel at all, or one the adapter does not route.
64
+ effort: Object.freeze(['flag', 'negotiated', 'unsupported']),
55
65
  worktree: Object.freeze(['driver', 'runtime']),
56
66
  hooks: Object.freeze([true, false]),
57
67
  usage: Object.freeze([true, false]),
58
- cacheAccounting: Object.freeze([true, false]),
68
+ // `hook`: the cache figures were recorded by the plugin's recorder hooks, not
69
+ // reported by the host. OBSERVED ONLY — no adapter may declare it; `run close`
70
+ // sets it when every briefed spawn of the run joined a recorded transcript
71
+ // (`ship/agent-results`).
72
+ cacheAccounting: Object.freeze([true, false, 'hook']),
59
73
  billing: Object.freeze(['claude-subscription-programmatic', 'chatgpt-plan-or-api', 'local'])
60
74
  })
61
75
 
@@ -74,6 +88,10 @@ export const HOSTS = Object.freeze({
74
88
  capabilities: Object.freeze({
75
89
  schemaEnforced: true,
76
90
  modelSelect: 'flag',
91
+ // `claude --help` on 2.1.285 lists `--effort <level>`, and it works with
92
+ // `-p`. Refined at creation: the adapter probes `--help` once and observes
93
+ // `unsupported` when the installed CLI does not list the flag (D30).
94
+ effort: 'flag',
77
95
  worktree: 'driver',
78
96
  hooks: true,
79
97
  usage: true,
@@ -88,12 +106,18 @@ export const HOSTS = Object.freeze({
88
106
  id: 'acp',
89
107
  create: opts => createAcpHost(opts),
90
108
  // Declared for the general case — an arbitrary agent behind
91
- // `INTERLOCK_ACP_COMMAND`. The adapter refines `hooks` and `billing` at
92
- // creation when that command turns out to be the Claude binary, because a
93
- // run that IS spending a Claude subscription must say so (design D8).
109
+ // `INTERLOCK_ACP_COMMAND`. The adapter refines `hooks` when that command is
110
+ // the Claude binary, and `billing` when it is the binary or a known wrapper
111
+ // (`claude-agent-acp`, `claude-code-acp`), because a run that IS spending a
112
+ // Claude subscription must say so. A wrapper does not load this plugin's
113
+ // hooks, so those stay false and HOOKS NOT IN FORCE still prints.
94
114
  capabilities: Object.freeze({
95
115
  schemaEnforced: false,
96
116
  modelSelect: 'negotiated',
117
+ // The protocol has no effort field; claude-agent-acp 0.84.0 advertises a
118
+ // config option (id `effort`, category `thought_level`) that is set with
119
+ // `session/set_config_option`, so the adapter asks the agent per session.
120
+ effort: 'negotiated',
97
121
  worktree: 'driver',
98
122
  hooks: false,
99
123
  usage: false,
@@ -109,6 +133,10 @@ export const HOSTS = Object.freeze({
109
133
  capabilities: Object.freeze({
110
134
  schemaEnforced: true,
111
135
  modelSelect: 'map-only',
136
+ // `codex exec -c model_reasoning_effort=<lvl>` exists, but it is outside
137
+ // this adapter's verified contract, and a Claude-derived label sent to it
138
+ // would be a cross-vendor mapping (D29). A channel not routed.
139
+ effort: 'unsupported',
112
140
  worktree: 'driver',
113
141
  hooks: false,
114
142
  usage: true,
@@ -124,6 +152,9 @@ export const HOSTS = Object.freeze({
124
152
  capabilities: Object.freeze({
125
153
  schemaEnforced: true,
126
154
  modelSelect: 'map-only',
155
+ // Qwen Code reads `model.reasoningEffort` from settings files only; its
156
+ // CLI has no per-spawn effort channel at all (D4).
157
+ effort: 'unsupported',
127
158
  worktree: 'driver',
128
159
  hooks: false,
129
160
  usage: false,
@@ -189,8 +220,11 @@ function effectiveCapabilities(entry, host) {
189
220
  }
190
221
 
191
222
  /**
192
- * Whether a host id, as configured, drives the Claude binary — directly or
193
- * through ACP. The `SUBSCRIPTION PATH` banner's condition (design D8).
223
+ * Whether a host id, as configured, drives Claude — the CLI directly, or an
224
+ * ACP command that is the CLI or a known wrapper around it. The `SUBSCRIPTION
225
+ * PATH` and `EFFORT ROUTING OVERRIDDEN` banners' condition. A wrapper is
226
+ * recognised by its own command name (`claude-agent-acp`, `claude-code-acp`),
227
+ * not by an argument of `npx`.
194
228
  *
195
229
  * @param {string} id
196
230
  * @param {Record<string, string|undefined>} [env]
@@ -204,7 +238,7 @@ export function drivesClaudeBinary(id, env = {}, acpCommand) {
204
238
  ? acpCommand
205
239
  : (env && env[ACP_COMMAND_ENV]) || ''
206
240
  try {
207
- return isClaudeCodeBinary(parseAcpCommand(raw).command)
241
+ return isClaudeBackedCommand(parseAcpCommand(raw).command)
208
242
  } catch {
209
243
  // An unusable command is not a Claude run; the adapter's own creation error
210
244
  // is what the operator will see.