@renzrollon/interlock 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +58 -0
- package/CHANGELOG.md +380 -0
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/agents/ping.md +8 -0
- package/agents/worker.md +8 -0
- package/bin/interlock +2655 -0
- package/bin/interlock-graph +6 -0
- package/bin/interlock-run +508 -0
- package/bin/interlock-ship-acp +39 -0
- package/hooks/_shared.mjs +162 -0
- package/hooks/guard-commit.mjs +77 -0
- package/hooks/guard-tasks.mjs +107 -0
- package/hooks/guard-tests.mjs +63 -0
- package/hooks/preflight.mjs +107 -0
- package/lib/artifacts.mjs +316 -0
- package/lib/autonomy.mjs +149 -0
- package/lib/conformance.mjs +132 -0
- package/lib/doctor.mjs +1176 -0
- package/lib/drift.mjs +409 -0
- package/lib/eval-history.mjs +304 -0
- package/lib/evals-calibrate.mjs +317 -0
- package/lib/evals-capture.mjs +437 -0
- package/lib/evals-promote.mjs +257 -0
- package/lib/evals-triage.mjs +216 -0
- package/lib/findings.mjs +340 -0
- package/lib/graph/cli.mjs +266 -0
- package/lib/graph/extract/config-json.mjs +74 -0
- package/lib/graph/extract/create-extractor.mjs +35 -0
- package/lib/graph/extract/memory.mjs +99 -0
- package/lib/graph/extract/openspec.mjs +80 -0
- package/lib/graph/extract/python.mjs +137 -0
- package/lib/graph/extract/regex-fallback.mjs +187 -0
- package/lib/graph/extract/shell.mjs +71 -0
- package/lib/graph/extract/typescript.mjs +217 -0
- package/lib/graph/extract/walk.mjs +272 -0
- package/lib/graph/format.mjs +51 -0
- package/lib/graph/graph/build.mjs +374 -0
- package/lib/graph/graph/query.mjs +249 -0
- package/lib/graph/graph/report.mjs +122 -0
- package/lib/graph/graph/update.mjs +47 -0
- package/lib/graph/paths.mjs +66 -0
- package/lib/graph/retrieve/context.mjs +86 -0
- package/lib/graph/retrieve/docs-index.mjs +116 -0
- package/lib/graph/retrieve/markdown.mjs +246 -0
- package/lib/host/acp.mjs +551 -0
- package/lib/host/claude-cli.mjs +285 -0
- package/lib/host/codex.mjs +231 -0
- package/lib/host/model-map.mjs +129 -0
- package/lib/host/qwen.mjs +176 -0
- package/lib/host/registry.mjs +213 -0
- package/lib/host.mjs +393 -0
- package/lib/ledger.mjs +444 -0
- package/lib/limits.mjs +525 -0
- package/lib/locators.mjs +99 -0
- package/lib/merge-lanes.mjs +148 -0
- package/lib/metrics.mjs +99 -0
- package/lib/notify.mjs +229 -0
- package/lib/outcomes.mjs +560 -0
- package/lib/plan-fingerprint.mjs +570 -0
- package/lib/project-slug.mjs +18 -0
- package/lib/prompts/commit.mjs +26 -0
- package/lib/prompts/dimensions.mjs +70 -0
- package/lib/prompts/handoff.mjs +86 -0
- package/lib/prompts/implementer.mjs +150 -0
- package/lib/prompts/index.mjs +250 -0
- package/lib/prompts/planner.mjs +70 -0
- package/lib/prompts/remediate.mjs +137 -0
- package/lib/prompts/replan.mjs +23 -0
- package/lib/prompts/review.mjs +107 -0
- package/lib/prompts/schemas.mjs +206 -0
- package/lib/prompts/stage.mjs +48 -0
- package/lib/prompts/verify.mjs +88 -0
- package/lib/ready.mjs +812 -0
- package/lib/receipt.mjs +434 -0
- package/lib/remediate.mjs +275 -0
- package/lib/report-html.mjs +623 -0
- package/lib/report.mjs +1124 -0
- package/lib/review-core.mjs +713 -0
- package/lib/risk.mjs +370 -0
- package/lib/run-log.mjs +1004 -0
- package/lib/run-paths.mjs +239 -0
- package/lib/run.mjs +2921 -0
- package/lib/ship-stage.mjs +223 -0
- package/lib/spill.mjs +150 -0
- package/lib/surface.mjs +195 -0
- package/lib/verify.mjs +1074 -0
- package/lib/waves.mjs +2661 -0
- package/package.json +40 -0
- package/shared/CONTEXT-HYGIENE.md +84 -0
- package/shared/DECISION-LEDGER.md +141 -0
- package/shared/EXPLORE-BRIEF.md +96 -0
- package/shared/INVARIANT-SWEEP.md +57 -0
- package/shared/SESSION-SHAPES.md +261 -0
- package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
- package/shared/TEST-PROFILE.md +204 -0
- package/shared/TOOL-ECONOMY.md +155 -0
- package/skills/bootstrap/SKILL.md +226 -0
- package/skills/commit/SKILL.md +99 -0
- package/skills/dispatch/SKILL.md +78 -0
- package/skills/docs-digest/SKILL.md +109 -0
- package/skills/evals/SKILL.md +160 -0
- package/skills/explain-code/SKILL.md +42 -0
- package/skills/explain-code/commit-teachin.md +136 -0
- package/skills/explain-code/module-walkthrough.md +358 -0
- package/skills/explore/SKILL.md +159 -0
- package/skills/fix-tests/SKILL.md +120 -0
- package/skills/graph/SKILL.md +136 -0
- package/skills/manual-test-plan/SKILL.md +121 -0
- package/skills/mr/SKILL.md +170 -0
- package/skills/report/SKILL.md +121 -0
- package/skills/review-artifacts/SKILL.md +127 -0
- package/skills/review-code/REVIEW.template.md +49 -0
- package/skills/review-code/SKILL.md +156 -0
- package/skills/review-code/dimensions/architecture.md +10 -0
- package/skills/review-code/dimensions/devops.md +12 -0
- package/skills/review-code/dimensions/language.md +12 -0
- package/skills/review-code/dimensions/qa.md +12 -0
- package/skills/review-code/dimensions/security.md +12 -0
- package/skills/review-code/dimensions/technical-lead.md +19 -0
- package/skills/ship/SKILL.md +67 -0
- package/skills/spec/SKILL.md +264 -0
- package/skills/spec/continuity.md +66 -0
- package/workflows/ship.js +565 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// The `claude` host adapter (design D3).
|
|
2
|
+
//
|
|
3
|
+
// Interlock's default host is the Claude Code Workflow runtime, reached through
|
|
4
|
+
// `/interlock:ship`. This adapter is the other way to reach the same model: the
|
|
5
|
+
// installed `claude` binary in headless print mode, one process per spawn.
|
|
6
|
+
//
|
|
7
|
+
// The contract, verified against Claude Code's own `--help` and one live probe
|
|
8
|
+
// on 2026-09-05:
|
|
9
|
+
//
|
|
10
|
+
// claude -p --output-format json --json-schema <schema> envelope on stdout
|
|
11
|
+
// envelope.structured_output the schema-validated object (schemaEnforced)
|
|
12
|
+
// envelope.usage { input_tokens, output_tokens, ... }
|
|
13
|
+
// --agent <id> --model <slug> --permission-mode <mode> --allowedTools <list>
|
|
14
|
+
// prompt on stdin
|
|
15
|
+
//
|
|
16
|
+
// Two things are worth stating because they are not obvious:
|
|
17
|
+
//
|
|
18
|
+
// 1. `--permission-mode bypassPermissions` does NOT disarm this repository's
|
|
19
|
+
// guards. A live probe (a PreToolUse hook returning `permissionDecision:
|
|
20
|
+
// "deny"` under that mode) confirmed the tool call was blocked and recorded
|
|
21
|
+
// in `permission_denials`. So the unattended default stays
|
|
22
|
+
// `bypassPermissions` and `hooks` stays declared `true`; the fallback D3
|
|
23
|
+
// named — `dontAsk` plus `--allowedTools` — was not needed. The finding is
|
|
24
|
+
// recorded in the change's design.md.
|
|
25
|
+
//
|
|
26
|
+
// 2. The plugin's agents (`interlock:worker`, `interlock:ping`) and its hooks
|
|
27
|
+
// only exist in a session that loaded the plugin. A headless `claude` in a
|
|
28
|
+
// lane worktree has loaded nothing, so the adapter passes `--plugin-dir`
|
|
29
|
+
// naming this checkout. That is what makes `--agent` resolvable and `hooks:
|
|
30
|
+
// true` an honest declaration rather than an assumption about the operator's
|
|
31
|
+
// installation. If the checkout does not look like the plugin, no `--agent`
|
|
32
|
+
// is passed and the host declares `hooks: false` — spoken, never silent.
|
|
33
|
+
//
|
|
34
|
+
// Billing: this is `claude -p`, which is the programmatic path Anthropic flagged
|
|
35
|
+
// for separate metered credit. The runner banners it (design D8); the adapter
|
|
36
|
+
// only declares which path it is on.
|
|
37
|
+
|
|
38
|
+
import { existsSync } from 'node:fs'
|
|
39
|
+
import { dirname, join } from 'node:path'
|
|
40
|
+
import { fileURLToPath } from 'node:url'
|
|
41
|
+
import { assertWorkflowHost, execCapture, mapPipeline, runCli } from '../host.mjs'
|
|
42
|
+
import { parseModelMap, resolveModel } from './model-map.mjs'
|
|
43
|
+
|
|
44
|
+
/** Override the binary, for a wrapper or a fixture. */
|
|
45
|
+
export const CLAUDE_COMMAND_ENV = 'INTERLOCK_CLAUDE_COMMAND'
|
|
46
|
+
|
|
47
|
+
/** Override the unattended permission mode. */
|
|
48
|
+
export const CLAUDE_PERMISSION_MODE_ENV = 'INTERLOCK_CLAUDE_PERMISSION_MODE'
|
|
49
|
+
|
|
50
|
+
/** Optional per-spawn wall clock, in ms. 0 disables it. */
|
|
51
|
+
export const CLAUDE_TIMEOUT_ENV = 'INTERLOCK_CLAUDE_TIMEOUT_MS'
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A transport timeout, not a loop cap — the same distinction the ACP adapter
|
|
55
|
+
* draws. Every number the LOOP obeys is published by `interlock limits`.
|
|
56
|
+
*/
|
|
57
|
+
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The unattended default, kept on the strength of the live probe above: a
|
|
61
|
+
* PreToolUse `deny` still blocks under it, so the guards this repository ships
|
|
62
|
+
* are in force and the run does not stall on a prompt nobody can answer.
|
|
63
|
+
*/
|
|
64
|
+
export const DEFAULT_PERMISSION_MODE = 'bypassPermissions'
|
|
65
|
+
|
|
66
|
+
/** This checkout, if it is the Interlock plugin — the directory `--plugin-dir` names. */
|
|
67
|
+
export function pluginRoot() {
|
|
68
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
|
69
|
+
return existsSync(join(root, '.claude-plugin', 'plugin.json')) && existsSync(join(root, 'agents'))
|
|
70
|
+
? root
|
|
71
|
+
: null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The exact argv one spawn sends. One function, so a drifted vendor flag is one
|
|
76
|
+
* edit and one pinned test rather than a scattered rewrite.
|
|
77
|
+
*
|
|
78
|
+
* @param {object} req the step's spawn request
|
|
79
|
+
* @param {object} opts
|
|
80
|
+
* @param {string|null} opts.model the resolved model id, or null
|
|
81
|
+
* @param {string} opts.permissionMode
|
|
82
|
+
* @param {string|null} opts.plugin `--plugin-dir` target, or null
|
|
83
|
+
* @returns {string[]}
|
|
84
|
+
*/
|
|
85
|
+
export function claudeArgs(req, { model, permissionMode, plugin } = {}) {
|
|
86
|
+
const request = req && typeof req === 'object' ? req : {}
|
|
87
|
+
const args = ['-p', '--output-format', 'json']
|
|
88
|
+
if (request.schema) args.push('--json-schema', JSON.stringify(request.schema))
|
|
89
|
+
if (plugin) args.push('--plugin-dir', plugin)
|
|
90
|
+
// Only with the plugin loaded: an `--agent` naming a type the session cannot
|
|
91
|
+
// resolve is an invocation error, and an invocation error reads as a model
|
|
92
|
+
// that failed rather than as a host that was misconfigured.
|
|
93
|
+
if (plugin && typeof request.type === 'string' && request.type.trim()) {
|
|
94
|
+
args.push('--agent', request.type.trim())
|
|
95
|
+
}
|
|
96
|
+
if (typeof model === 'string' && model) args.push('--model', model)
|
|
97
|
+
args.push('--permission-mode', permissionMode || DEFAULT_PERMISSION_MODE)
|
|
98
|
+
if (Array.isArray(request.tools) && request.tools.length) {
|
|
99
|
+
args.push('--allowedTools', request.tools.join(','))
|
|
100
|
+
}
|
|
101
|
+
return args
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The prompt-cache lifetime tiers the vendor's usage envelope reports cache
|
|
106
|
+
* creation under, and the envelope field each one arrives in.
|
|
107
|
+
*
|
|
108
|
+
* Facts about the host's wire format, not Interlock thresholds, so they are
|
|
109
|
+
* named constants in the module that reads them — the convention every
|
|
110
|
+
* `INTERLOCK_*` reader above already follows.
|
|
111
|
+
*
|
|
112
|
+
* They are kept APART, never summed (design D7). A prefix written for five
|
|
113
|
+
* minutes and one written for an hour are different prices; a single total
|
|
114
|
+
* cannot be priced and the flattening cannot be undone by any later reader.
|
|
115
|
+
*/
|
|
116
|
+
export const CACHE_TIER_FIELDS = Object.freeze({
|
|
117
|
+
ephemeral_5m: 'ephemeral_5m_input_tokens',
|
|
118
|
+
ephemeral_1h: 'ephemeral_1h_input_tokens'
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
/** A finite non-negative count from the envelope, or `undefined` for a field it omitted. */
|
|
122
|
+
function envelopeCount(value) {
|
|
123
|
+
return Number.isFinite(value) && value >= 0 ? value : undefined
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The cache half of a usage envelope, or `null` where it carries none.
|
|
128
|
+
*
|
|
129
|
+
* `undefined` for an omitted field, NEVER `0`: a host that did not report a
|
|
130
|
+
* figure and a host that measured none are different facts, and substituting a
|
|
131
|
+
* zero would make the second out of the first everywhere downstream. A tier the
|
|
132
|
+
* envelope omits is absent; a tier it reports as zero is a measured zero.
|
|
133
|
+
*/
|
|
134
|
+
function readCacheUsage(usage) {
|
|
135
|
+
const read = envelopeCount(usage.cache_read_input_tokens)
|
|
136
|
+
const creation =
|
|
137
|
+
usage.cache_creation && typeof usage.cache_creation === 'object' ? usage.cache_creation : null
|
|
138
|
+
const write = {}
|
|
139
|
+
for (const [tier, field] of Object.entries(CACHE_TIER_FIELDS)) {
|
|
140
|
+
const value = creation ? envelopeCount(creation[field]) : undefined
|
|
141
|
+
if (value !== undefined) write[tier] = value
|
|
142
|
+
}
|
|
143
|
+
const hasWrite = Object.keys(write).length > 0
|
|
144
|
+
if (read === undefined && !hasWrite) return null
|
|
145
|
+
return {
|
|
146
|
+
...(read === undefined ? {} : { cacheReadInputTokens: read }),
|
|
147
|
+
...(hasWrite ? { cacheCreationInputTokens: write } : {})
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The result and the usage out of the CLI's JSON envelope.
|
|
153
|
+
*
|
|
154
|
+
* `structured_output` is the whole result: the CLI validated it against the
|
|
155
|
+
* schema, so there is nothing to recover from prose the way the ACP adapter has
|
|
156
|
+
* to. An envelope without it is a spawn that produced no result — `null`, and
|
|
157
|
+
* the CLI decides what a null costs.
|
|
158
|
+
*
|
|
159
|
+
* An envelope that is PRESENT AND UNPARSEABLE is a host that did not report,
|
|
160
|
+
* not a host that reported nothing: both halves come back `null` and every
|
|
161
|
+
* downstream figure is absent rather than zero.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} stdout
|
|
164
|
+
* @returns {{result: object|null, usage: object|null}}
|
|
165
|
+
*/
|
|
166
|
+
export function readClaudeEnvelope(stdout) {
|
|
167
|
+
let envelope
|
|
168
|
+
try {
|
|
169
|
+
envelope = JSON.parse(typeof stdout === 'string' ? stdout : '')
|
|
170
|
+
} catch {
|
|
171
|
+
return { result: null, usage: null }
|
|
172
|
+
}
|
|
173
|
+
if (!envelope || typeof envelope !== 'object') return { result: null, usage: null }
|
|
174
|
+
const structured = envelope.structured_output
|
|
175
|
+
const result =
|
|
176
|
+
structured && typeof structured === 'object' && !Array.isArray(structured) ? structured : null
|
|
177
|
+
const usage = envelope.usage && typeof envelope.usage === 'object' ? envelope.usage : null
|
|
178
|
+
if (!usage) return { result, usage: null }
|
|
179
|
+
const cache = readCacheUsage(usage)
|
|
180
|
+
return {
|
|
181
|
+
result,
|
|
182
|
+
usage: {
|
|
183
|
+
inputTokens: envelopeCount(usage.input_tokens),
|
|
184
|
+
outputTokens: envelopeCount(usage.output_tokens),
|
|
185
|
+
// Spread, so an envelope with no cache fields carries no cache keys at all
|
|
186
|
+
// — absent, which is what "this spawn was not measured for cache" means.
|
|
187
|
+
...(cache || {})
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Build a {@link import('../host.mjs').WorkflowHost} backed by `claude -p`.
|
|
194
|
+
*
|
|
195
|
+
* @param {object} [opts]
|
|
196
|
+
* @param {string} [opts.command] defaults to `$INTERLOCK_CLAUDE_COMMAND` or `claude`
|
|
197
|
+
* @param {string} [opts.cwd] repo root; a spawn's own `cwd` overrides it per lane
|
|
198
|
+
* @param {object} [opts.env]
|
|
199
|
+
* @param {number} [opts.timeoutMs]
|
|
200
|
+
* @param {(event: object) => void} [opts.onEvent]
|
|
201
|
+
*/
|
|
202
|
+
export function createClaudeHost({ command, cwd = process.cwd(), env = process.env, timeoutMs, onEvent } = {}) {
|
|
203
|
+
const source = env && typeof env === 'object' ? env : {}
|
|
204
|
+
const raw = (typeof command === 'string' && command.trim()) || source[CLAUDE_COMMAND_ENV] || 'claude'
|
|
205
|
+
const argv = String(raw).trim().split(/\s+/)
|
|
206
|
+
const bin = argv[0]
|
|
207
|
+
const fixedArgs = argv.slice(1)
|
|
208
|
+
const permissionMode = source[CLAUDE_PERMISSION_MODE_ENV] || DEFAULT_PERMISSION_MODE
|
|
209
|
+
const budget = Number.isInteger(timeoutMs)
|
|
210
|
+
? timeoutMs
|
|
211
|
+
: Number.parseInt(source[CLAUDE_TIMEOUT_ENV] || '', 10) || DEFAULT_TIMEOUT_MS
|
|
212
|
+
// Parsed once, here: a malformed map fails host creation rather than a wave.
|
|
213
|
+
const modelMap = parseModelMap(source)
|
|
214
|
+
const plugin = pluginRoot()
|
|
215
|
+
|
|
216
|
+
const host = {
|
|
217
|
+
command: [bin, ...fixedArgs].join(' '),
|
|
218
|
+
// The only capability this adapter can observe about itself. Everything else
|
|
219
|
+
// is the registry's static declaration.
|
|
220
|
+
capabilities: plugin ? {} : { hooks: false },
|
|
221
|
+
|
|
222
|
+
async spawn(req) {
|
|
223
|
+
const request = req && typeof req === 'object' ? req : {}
|
|
224
|
+
const label = typeof request.label === 'string' ? request.label : 'agent'
|
|
225
|
+
const started = Date.now()
|
|
226
|
+
const routed = resolveModel('claude', request.model, modelMap, { modelSelect: 'flag' })
|
|
227
|
+
if (typeof request.model === 'string' && request.model && onEvent) {
|
|
228
|
+
onEvent({
|
|
229
|
+
type: 'model-routing',
|
|
230
|
+
label,
|
|
231
|
+
requested: request.model,
|
|
232
|
+
applied: routed.applied,
|
|
233
|
+
value: routed.value,
|
|
234
|
+
via: routed.applied ? 'flag' : null,
|
|
235
|
+
reason: routed.reason
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
if (!plugin && onEvent) {
|
|
239
|
+
onEvent({ type: 'agent-prefix-skipped', label, reason: 'this checkout is not the interlock plugin' })
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const { code, stdout, stderr, timedOut } = await execCapture(
|
|
243
|
+
bin,
|
|
244
|
+
[...fixedArgs, ...claudeArgs(request, { model: routed.value, permissionMode, plugin })],
|
|
245
|
+
{
|
|
246
|
+
cwd: typeof request.cwd === 'string' && request.cwd ? request.cwd : cwd,
|
|
247
|
+
env: source,
|
|
248
|
+
input: request.prompt,
|
|
249
|
+
timeoutMs: budget
|
|
250
|
+
}
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if (code !== 0) {
|
|
254
|
+
if (onEvent) {
|
|
255
|
+
onEvent({
|
|
256
|
+
type: 'spawn-failed',
|
|
257
|
+
label,
|
|
258
|
+
code,
|
|
259
|
+
timedOut,
|
|
260
|
+
error: stderr.trim().slice(-500) || `claude exited ${code}`
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
return null
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const { result, usage } = readClaudeEnvelope(stdout)
|
|
267
|
+
if (onEvent) {
|
|
268
|
+
onEvent({ type: 'spawn-done', label, ms: Date.now() - started, parsed: Boolean(result), usage })
|
|
269
|
+
}
|
|
270
|
+
if (!result) return null
|
|
271
|
+
// Usage rides beside the result rather than inside it: the result's shape
|
|
272
|
+
// is the step's schema, and an extra field there would fail a strict
|
|
273
|
+
// consumer. `run close` reads this key by name (design D9).
|
|
274
|
+
return usage ? { ...result, usage } : result
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
mapPipeline,
|
|
278
|
+
|
|
279
|
+
runCli(argv2, opts = {}) {
|
|
280
|
+
return runCli(argv2, { cwd, env: source, ...opts })
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return assertWorkflowHost(host, 'claude host')
|
|
285
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// The `codex` host adapter (design D4).
|
|
2
|
+
//
|
|
3
|
+
// OpenAI's Codex CLI in its non-interactive mode. One `codex exec` per spawn,
|
|
4
|
+
// prompt on stdin, result in a file the CLI writes.
|
|
5
|
+
//
|
|
6
|
+
// The contract, verified against `codex exec --help` on codex-cli 0.145.0:
|
|
7
|
+
//
|
|
8
|
+
// --output-schema <FILE> JSON Schema the final response must satisfy
|
|
9
|
+
// -o, --output-last-message <FILE> where the final message is written
|
|
10
|
+
// --json events to stdout as JSONL
|
|
11
|
+
// -C, --cd <DIR> the agent's working root
|
|
12
|
+
// -s, --sandbox <MODE> read-only | workspace-write | danger-full-access
|
|
13
|
+
// -m, --model <MODEL>
|
|
14
|
+
// prompt on stdin (or `-`)
|
|
15
|
+
//
|
|
16
|
+
// One flag design D4 named does NOT exist on this version: `--ask-for-approval`
|
|
17
|
+
// is rejected by `codex exec` with "unexpected argument". `exec` is the
|
|
18
|
+
// non-interactive mode and never prompts, so the flag was redundant as well as
|
|
19
|
+
// wrong; it is dropped and the finding is recorded in the change's design.md.
|
|
20
|
+
// `--dangerously-bypass-approvals-and-sandbox` is deliberately NOT used in its
|
|
21
|
+
// place — it removes the sandbox, and `workspace-write` is the posture a ship
|
|
22
|
+
// lane wants.
|
|
23
|
+
//
|
|
24
|
+
// Two degradations this host carries, both declared and both bannered by the
|
|
25
|
+
// runner rather than discovered later:
|
|
26
|
+
//
|
|
27
|
+
// hooks: false Codex has no equivalent of this repository's PreToolUse
|
|
28
|
+
// guards, so nothing stops a repair step from weakening a test
|
|
29
|
+
// except the CLI's own unit-suite shrink check.
|
|
30
|
+
// modelSelect: 'map-only' the planner's slugs (`haiku`/`sonnet`/`opus`) mean
|
|
31
|
+
// nothing here, so a spawn with no INTERLOCK_MODEL_MAP entry
|
|
32
|
+
// runs on whatever the operator configured, and says so.
|
|
33
|
+
|
|
34
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
35
|
+
import { tmpdir } from 'node:os'
|
|
36
|
+
import { join } from 'node:path'
|
|
37
|
+
import { assertWorkflowHost, execCapture, mapPipeline, runCli } from '../host.mjs'
|
|
38
|
+
import { parseModelMap, resolveModel } from './model-map.mjs'
|
|
39
|
+
|
|
40
|
+
/** Override the binary, for a wrapper or a fixture. */
|
|
41
|
+
export const CODEX_COMMAND_ENV = 'INTERLOCK_CODEX_COMMAND'
|
|
42
|
+
|
|
43
|
+
/** Optional per-spawn wall clock, in ms. 0 disables it. */
|
|
44
|
+
export const CODEX_TIMEOUT_ENV = 'INTERLOCK_CODEX_TIMEOUT_MS'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The two variables whose ABSENCE means the run is spending a ChatGPT plan's
|
|
48
|
+
* device-auth session rather than an API key. Named here because the runner
|
|
49
|
+
* banners the condition and the docs explain it (design D8).
|
|
50
|
+
*/
|
|
51
|
+
export const CODEX_KEY_ENVS = Object.freeze(['CODEX_API_KEY', 'OPENAI_API_KEY'])
|
|
52
|
+
|
|
53
|
+
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000
|
|
54
|
+
|
|
55
|
+
/** True when neither key is set, i.e. the run is on a ChatGPT login. */
|
|
56
|
+
export function onChatGptPlan(env = {}) {
|
|
57
|
+
return CODEX_KEY_ENVS.every(name => !(env && typeof env[name] === 'string' && env[name].trim()))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The exact argv one spawn sends. One function, pinned by a test against the
|
|
62
|
+
* fixture, so a vendor flag change is one edit.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} opts
|
|
65
|
+
* @param {string} opts.cwd the lane's worktree, or the repo root
|
|
66
|
+
* @param {string} opts.schemaPath the `--output-schema` file
|
|
67
|
+
* @param {string} opts.outputPath the `-o` file
|
|
68
|
+
* @param {string|null} opts.model resolved model id, or null for unrouted
|
|
69
|
+
* @returns {string[]}
|
|
70
|
+
*/
|
|
71
|
+
export function codexArgs({ cwd, schemaPath, outputPath, model } = {}) {
|
|
72
|
+
const args = ['exec', '--json']
|
|
73
|
+
if (schemaPath) args.push('--output-schema', schemaPath)
|
|
74
|
+
args.push('-o', outputPath, '-C', cwd, '--sandbox', 'workspace-write')
|
|
75
|
+
if (typeof model === 'string' && model) args.push('--model', model)
|
|
76
|
+
return args
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The last token count the JSONL event stream reported, in the receipt's units.
|
|
81
|
+
*
|
|
82
|
+
* Best-effort by construction: the event stream is a vendor surface with no
|
|
83
|
+
* stability promise, so an unrecognised stream yields `null` — unknown — rather
|
|
84
|
+
* than a zero that would read as a run that produced no tokens.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} stdout the JSONL on stdout
|
|
87
|
+
* @returns {{inputTokens?: number, outputTokens?: number}|null}
|
|
88
|
+
*/
|
|
89
|
+
export function readCodexUsage(stdout) {
|
|
90
|
+
const lines = (typeof stdout === 'string' ? stdout : '').split('\n')
|
|
91
|
+
let latest = null
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
const text = line.trim()
|
|
94
|
+
if (!text || text[0] !== '{') continue
|
|
95
|
+
let event
|
|
96
|
+
try {
|
|
97
|
+
event = JSON.parse(text)
|
|
98
|
+
} catch {
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
const found = findUsage(event, 0)
|
|
102
|
+
if (found) latest = found
|
|
103
|
+
}
|
|
104
|
+
return latest
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The first `{input_tokens, output_tokens}`-shaped object anywhere in an event. */
|
|
108
|
+
function findUsage(value, depth) {
|
|
109
|
+
if (!value || typeof value !== 'object' || depth > 6) return null
|
|
110
|
+
if (Number.isFinite(value.output_tokens) || Number.isFinite(value.input_tokens)) {
|
|
111
|
+
return {
|
|
112
|
+
inputTokens: Number.isFinite(value.input_tokens) ? value.input_tokens : undefined,
|
|
113
|
+
outputTokens: Number.isFinite(value.output_tokens) ? value.output_tokens : undefined
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const nested of Object.values(value)) {
|
|
117
|
+
const found = findUsage(nested, depth + 1)
|
|
118
|
+
if (found) return found
|
|
119
|
+
}
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Build a {@link import('../host.mjs').WorkflowHost} backed by `codex exec`.
|
|
125
|
+
*
|
|
126
|
+
* @param {object} [opts]
|
|
127
|
+
* @param {string} [opts.command] defaults to `$INTERLOCK_CODEX_COMMAND` or `codex`
|
|
128
|
+
* @param {string} [opts.cwd]
|
|
129
|
+
* @param {object} [opts.env]
|
|
130
|
+
* @param {number} [opts.timeoutMs]
|
|
131
|
+
* @param {(event: object) => void} [opts.onEvent]
|
|
132
|
+
*/
|
|
133
|
+
export function createCodexHost({ command, cwd = process.cwd(), env = process.env, timeoutMs, onEvent } = {}) {
|
|
134
|
+
const source = env && typeof env === 'object' ? env : {}
|
|
135
|
+
const raw = (typeof command === 'string' && command.trim()) || source[CODEX_COMMAND_ENV] || 'codex'
|
|
136
|
+
const argv = String(raw).trim().split(/\s+/)
|
|
137
|
+
const bin = argv[0]
|
|
138
|
+
const fixedArgs = argv.slice(1)
|
|
139
|
+
const budget = Number.isInteger(timeoutMs)
|
|
140
|
+
? timeoutMs
|
|
141
|
+
: Number.parseInt(source[CODEX_TIMEOUT_ENV] || '', 10) || DEFAULT_TIMEOUT_MS
|
|
142
|
+
const modelMap = parseModelMap(source)
|
|
143
|
+
|
|
144
|
+
const host = {
|
|
145
|
+
command: [bin, ...fixedArgs].join(' '),
|
|
146
|
+
chatGptPlan: onChatGptPlan(source),
|
|
147
|
+
|
|
148
|
+
async spawn(req) {
|
|
149
|
+
const request = req && typeof req === 'object' ? req : {}
|
|
150
|
+
const label = typeof request.label === 'string' ? request.label : 'agent'
|
|
151
|
+
const started = Date.now()
|
|
152
|
+
const routed = resolveModel('codex', request.model, modelMap, { modelSelect: 'map-only' })
|
|
153
|
+
if (typeof request.model === 'string' && request.model && onEvent) {
|
|
154
|
+
onEvent({
|
|
155
|
+
type: 'model-routing',
|
|
156
|
+
label,
|
|
157
|
+
requested: request.model,
|
|
158
|
+
applied: routed.applied,
|
|
159
|
+
value: routed.value,
|
|
160
|
+
via: routed.applied ? 'flag' : null,
|
|
161
|
+
reason: routed.reason
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const scratch = mkdtempSync(join(tmpdir(), 'interlock-codex-'))
|
|
166
|
+
const outputPath = join(scratch, 'last-message.json')
|
|
167
|
+
const schemaPath = request.schema ? join(scratch, 'schema.json') : null
|
|
168
|
+
try {
|
|
169
|
+
// Written rather than passed inline: `--output-schema` takes a path.
|
|
170
|
+
if (schemaPath) writeFileSync(schemaPath, JSON.stringify(request.schema))
|
|
171
|
+
const { code, stdout, stderr, timedOut } = await execCapture(
|
|
172
|
+
bin,
|
|
173
|
+
[
|
|
174
|
+
...fixedArgs,
|
|
175
|
+
...codexArgs({
|
|
176
|
+
cwd: typeof request.cwd === 'string' && request.cwd ? request.cwd : cwd,
|
|
177
|
+
schemaPath,
|
|
178
|
+
outputPath,
|
|
179
|
+
model: routed.value
|
|
180
|
+
})
|
|
181
|
+
],
|
|
182
|
+
{
|
|
183
|
+
cwd: typeof request.cwd === 'string' && request.cwd ? request.cwd : cwd,
|
|
184
|
+
env: source,
|
|
185
|
+
input: request.prompt,
|
|
186
|
+
timeoutMs: budget
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if (code !== 0) {
|
|
191
|
+
if (onEvent) {
|
|
192
|
+
onEvent({
|
|
193
|
+
type: 'spawn-failed',
|
|
194
|
+
label,
|
|
195
|
+
code,
|
|
196
|
+
timedOut,
|
|
197
|
+
// The stderr tail is the whole diagnostic on this host: a stale
|
|
198
|
+
// device-auth token surfaces here and nowhere else.
|
|
199
|
+
error: stderr.trim().slice(-500) || `codex exited ${code}`
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let result = null
|
|
206
|
+
try {
|
|
207
|
+
const parsed = JSON.parse(readFileSync(outputPath, 'utf8'))
|
|
208
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) result = parsed
|
|
209
|
+
} catch {
|
|
210
|
+
result = null
|
|
211
|
+
}
|
|
212
|
+
const usage = readCodexUsage(stdout)
|
|
213
|
+
if (onEvent) {
|
|
214
|
+
onEvent({ type: 'spawn-done', label, ms: Date.now() - started, parsed: Boolean(result), usage })
|
|
215
|
+
}
|
|
216
|
+
if (!result) return null
|
|
217
|
+
return usage ? { ...result, usage } : result
|
|
218
|
+
} finally {
|
|
219
|
+
rmSync(scratch, { recursive: true, force: true })
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
mapPipeline,
|
|
224
|
+
|
|
225
|
+
runCli(argv2, opts = {}) {
|
|
226
|
+
return runCli(argv2, { cwd, env: source, ...opts })
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return assertWorkflowHost(host, 'codex host')
|
|
231
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// One published model map, read once, for every host (design D6).
|
|
2
|
+
//
|
|
3
|
+
// The planner assigns a tier slug — `haiku`, `sonnet`, `opus` — and that slug is
|
|
4
|
+
// a statement about how much judgement a lane needs, not about a vendor's
|
|
5
|
+
// catalogue. Every host has to answer the same question from it: which model id
|
|
6
|
+
// does this slug mean HERE. Four adapters answering it four ways is four places
|
|
7
|
+
// for a tier ladder to quietly stop applying, and a run whose cost profile was
|
|
8
|
+
// never applied looks exactly like one where it was.
|
|
9
|
+
//
|
|
10
|
+
// So the mapping is one environment variable, parsed once at startup, and the
|
|
11
|
+
// resolution is one function whose answer carries its own reason:
|
|
12
|
+
//
|
|
13
|
+
// INTERLOCK_MODEL_MAP={"codex":{"sonnet":"gpt-5-codex"},"qwen":{"opus":"qwen3-max"}}
|
|
14
|
+
//
|
|
15
|
+
// `INTERLOCK_ACP_MODEL_MAP` predates it (`close-acp-model-routing`) and is
|
|
16
|
+
// folded in as the `acp` entry, so an operator who set it keeps working.
|
|
17
|
+
//
|
|
18
|
+
// Malformed is fatal at creation, never at wave three: a typo that surfaced
|
|
19
|
+
// halfway through a run would have already spent the tier ladder it broke.
|
|
20
|
+
|
|
21
|
+
/** The published map: host id → slug → that host's model id. */
|
|
22
|
+
export const MODEL_MAP_ENV = 'INTERLOCK_MODEL_MAP'
|
|
23
|
+
|
|
24
|
+
/** The ACP-only predecessor, folded in as the `acp` entry. */
|
|
25
|
+
export const ACP_MODEL_MAP_ENV = 'INTERLOCK_ACP_MODEL_MAP'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse one host's slug → value object, from an already-parsed JSON value.
|
|
29
|
+
*
|
|
30
|
+
* @param {unknown} value
|
|
31
|
+
* @param {string} where the variable path to name in an error
|
|
32
|
+
* @returns {Record<string, string>}
|
|
33
|
+
*/
|
|
34
|
+
function parseHostEntry(value, where) {
|
|
35
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
36
|
+
throw new Error(`${where} must be a JSON object of model slug to that host's model id`)
|
|
37
|
+
}
|
|
38
|
+
for (const [slug, model] of Object.entries(value)) {
|
|
39
|
+
if (typeof model !== 'string' || !model.trim()) {
|
|
40
|
+
throw new Error(`${where}.${slug} must be a non-empty string`)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { ...value }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse {@link MODEL_MAP_ENV} (and the ACP alias) into a host → slug → value map.
|
|
48
|
+
*
|
|
49
|
+
* Throws on anything that is not a JSON object of objects of non-empty strings.
|
|
50
|
+
* The throw is the point: the runner parses this before its first step, so a
|
|
51
|
+
* typo fails the invocation rather than three waves in.
|
|
52
|
+
*
|
|
53
|
+
* @param {Record<string, string|undefined>} [env]
|
|
54
|
+
* @returns {Record<string, Record<string, string>>}
|
|
55
|
+
*/
|
|
56
|
+
export function parseModelMap(env = {}) {
|
|
57
|
+
const source = env && typeof env === 'object' ? env : {}
|
|
58
|
+
const out = {}
|
|
59
|
+
|
|
60
|
+
const raw = typeof source[MODEL_MAP_ENV] === 'string' ? source[MODEL_MAP_ENV].trim() : ''
|
|
61
|
+
if (raw) {
|
|
62
|
+
let parsed
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(raw)
|
|
65
|
+
} catch (err) {
|
|
66
|
+
throw new Error(`${MODEL_MAP_ENV} is not valid JSON: ${err.message}`)
|
|
67
|
+
}
|
|
68
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
69
|
+
throw new Error(`${MODEL_MAP_ENV} must be a JSON object keyed by host id`)
|
|
70
|
+
}
|
|
71
|
+
for (const [hostId, entry] of Object.entries(parsed)) {
|
|
72
|
+
out[hostId] = parseHostEntry(entry, `${MODEL_MAP_ENV}.${hostId}`)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The alias wins for `acp` only where the newer variable said nothing about
|
|
77
|
+
// that host: an operator mid-migration has both set, and silently discarding
|
|
78
|
+
// the one they most recently wrote is the confusing half of either order.
|
|
79
|
+
const legacy = typeof source[ACP_MODEL_MAP_ENV] === 'string' ? source[ACP_MODEL_MAP_ENV].trim() : ''
|
|
80
|
+
if (legacy) {
|
|
81
|
+
let parsed
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(legacy)
|
|
84
|
+
} catch (err) {
|
|
85
|
+
throw new Error(`${ACP_MODEL_MAP_ENV} is not valid JSON: ${err.message}`)
|
|
86
|
+
}
|
|
87
|
+
const entry = parseHostEntry(parsed, ACP_MODEL_MAP_ENV)
|
|
88
|
+
out.acp = { ...entry, ...(out.acp || {}) }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* What one host should do with one planner slug.
|
|
96
|
+
*
|
|
97
|
+
* Never guesses a nearest model and never substitutes a default: a wrong model
|
|
98
|
+
* that ran is invisible in a summary, and an unapplied one that was bannered is
|
|
99
|
+
* not. `applied: false` always carries the reason the runner prints.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} hostId
|
|
102
|
+
* @param {string} [slug] the planner's model, e.g. `sonnet`
|
|
103
|
+
* @param {Record<string, Record<string, string>>} [map] {@link parseModelMap}'s output
|
|
104
|
+
* @param {{modelSelect?: string}} [capabilities]
|
|
105
|
+
* @returns {{value: string|null, applied: boolean, reason: string|null}}
|
|
106
|
+
*/
|
|
107
|
+
export function resolveModel(hostId, slug, map = {}, capabilities = {}) {
|
|
108
|
+
const wanted = typeof slug === 'string' ? slug.trim() : ''
|
|
109
|
+
if (!wanted) return { value: null, applied: false, reason: 'no model requested' }
|
|
110
|
+
|
|
111
|
+
const forHost = map && typeof map === 'object' ? map[hostId] : undefined
|
|
112
|
+
const mapped = forHost && typeof forHost === 'object' ? forHost[wanted] : undefined
|
|
113
|
+
if (typeof mapped === 'string' && mapped) return { value: mapped, applied: true, reason: null }
|
|
114
|
+
|
|
115
|
+
const kind = capabilities && typeof capabilities.modelSelect === 'string' ? capabilities.modelSelect : ''
|
|
116
|
+
|
|
117
|
+
// The host's own CLI accepts the slug (Claude's `--model haiku`), so an
|
|
118
|
+
// unmapped slug is routed rather than dropped.
|
|
119
|
+
if (kind === 'flag') return { value: wanted, applied: true, reason: null }
|
|
120
|
+
|
|
121
|
+
// The agent advertises its models and the adapter negotiates on the wire; the
|
|
122
|
+
// map only supplies a preference. Whether it landed is the adapter's to say,
|
|
123
|
+
// so nothing is claimed here.
|
|
124
|
+
if (kind === 'negotiated') {
|
|
125
|
+
return { value: null, applied: false, reason: 'negotiated with the agent at session creation' }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { value: null, applied: false, reason: `no mapping for ${wanted}` }
|
|
129
|
+
}
|