@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.
- package/.claude-plugin/icon.png +0 -0
- package/.claude-plugin/plugin.json +109 -1
- package/CHANGELOG.md +150 -11
- package/bin/interlock +421 -53
- package/bin/interlock-run +119 -7
- package/hooks/guard-relaunch.mjs +115 -0
- package/hooks/guard-tests.mjs +51 -3
- package/hooks/hooks.json +1 -0
- package/hooks/mod.mjs +655 -0
- package/hooks/preflight.mjs +124 -13
- package/hooks/recorder.mjs +189 -0
- package/lib/agent-usage.mjs +524 -0
- package/lib/doctor.mjs +511 -46
- package/lib/eval-history.mjs +233 -0
- package/lib/evals-capture.mjs +5 -2
- package/lib/evals-triage.mjs +1 -1
- package/lib/graph/cli.mjs +28 -5
- package/lib/graph/retrieve/context.mjs +8 -3
- package/lib/host/acp.mjs +77 -14
- package/lib/host/claude-cli.mjs +391 -21
- package/lib/host/claude-env.mjs +179 -0
- package/lib/host/codex.mjs +9 -1
- package/lib/host/effort.mjs +163 -0
- package/lib/host/model-map.mjs +43 -0
- package/lib/host/qwen.mjs +9 -1
- package/lib/host/registry.mjs +44 -10
- package/lib/host.mjs +46 -5
- package/lib/interrupted.mjs +167 -0
- package/lib/launch-ledger.mjs +198 -0
- package/lib/launch-rule.mjs +174 -0
- package/lib/limits.mjs +149 -41
- package/lib/meter-quiet.mjs +45 -0
- package/lib/notify-defaults.mjs +6 -0
- package/lib/notify.mjs +41 -25
- package/lib/plan-fingerprint.mjs +80 -11
- package/lib/prompts/implementer.mjs +1 -1
- package/lib/prompts/verify.mjs +24 -1
- package/lib/receipt.mjs +59 -8
- package/lib/report-html.mjs +89 -6
- package/lib/report.mjs +313 -12
- package/lib/resume-card.mjs +90 -2
- package/lib/run-log.mjs +192 -5
- package/lib/run.mjs +1177 -84
- package/lib/state-home.mjs +298 -0
- package/lib/verify.mjs +37 -0
- package/lib/waves.mjs +53 -16
- package/package.json +4 -2
- package/shared/TEST-PROFILE.md +10 -5
- package/skills/evals/SKILL.md +3 -3
- package/skills/fix-tests/SKILL.md +3 -3
- package/skills/spec/SKILL.md +1 -6
- package/skills/spec/scripts/compact-instructions.mjs +30 -0
- package/types/index.d.ts +28 -0
- package/workflows/ship.js +75 -51
- 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
|
+
}
|
package/lib/host/codex.mjs
CHANGED
|
@@ -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
|
+
}
|
package/lib/host/model-map.mjs
CHANGED
|
@@ -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) {
|
package/lib/host/registry.mjs
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
92
|
-
//
|
|
93
|
-
// run that IS spending a
|
|
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
|
|
193
|
-
*
|
|
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
|
|
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.
|