@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.
Files changed (125) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +58 -0
  3. package/CHANGELOG.md +380 -0
  4. package/LICENSE +21 -0
  5. package/README.md +358 -0
  6. package/agents/ping.md +8 -0
  7. package/agents/worker.md +8 -0
  8. package/bin/interlock +2655 -0
  9. package/bin/interlock-graph +6 -0
  10. package/bin/interlock-run +508 -0
  11. package/bin/interlock-ship-acp +39 -0
  12. package/hooks/_shared.mjs +162 -0
  13. package/hooks/guard-commit.mjs +77 -0
  14. package/hooks/guard-tasks.mjs +107 -0
  15. package/hooks/guard-tests.mjs +63 -0
  16. package/hooks/preflight.mjs +107 -0
  17. package/lib/artifacts.mjs +316 -0
  18. package/lib/autonomy.mjs +149 -0
  19. package/lib/conformance.mjs +132 -0
  20. package/lib/doctor.mjs +1176 -0
  21. package/lib/drift.mjs +409 -0
  22. package/lib/eval-history.mjs +304 -0
  23. package/lib/evals-calibrate.mjs +317 -0
  24. package/lib/evals-capture.mjs +437 -0
  25. package/lib/evals-promote.mjs +257 -0
  26. package/lib/evals-triage.mjs +216 -0
  27. package/lib/findings.mjs +340 -0
  28. package/lib/graph/cli.mjs +266 -0
  29. package/lib/graph/extract/config-json.mjs +74 -0
  30. package/lib/graph/extract/create-extractor.mjs +35 -0
  31. package/lib/graph/extract/memory.mjs +99 -0
  32. package/lib/graph/extract/openspec.mjs +80 -0
  33. package/lib/graph/extract/python.mjs +137 -0
  34. package/lib/graph/extract/regex-fallback.mjs +187 -0
  35. package/lib/graph/extract/shell.mjs +71 -0
  36. package/lib/graph/extract/typescript.mjs +217 -0
  37. package/lib/graph/extract/walk.mjs +272 -0
  38. package/lib/graph/format.mjs +51 -0
  39. package/lib/graph/graph/build.mjs +374 -0
  40. package/lib/graph/graph/query.mjs +249 -0
  41. package/lib/graph/graph/report.mjs +122 -0
  42. package/lib/graph/graph/update.mjs +47 -0
  43. package/lib/graph/paths.mjs +66 -0
  44. package/lib/graph/retrieve/context.mjs +86 -0
  45. package/lib/graph/retrieve/docs-index.mjs +116 -0
  46. package/lib/graph/retrieve/markdown.mjs +246 -0
  47. package/lib/host/acp.mjs +551 -0
  48. package/lib/host/claude-cli.mjs +285 -0
  49. package/lib/host/codex.mjs +231 -0
  50. package/lib/host/model-map.mjs +129 -0
  51. package/lib/host/qwen.mjs +176 -0
  52. package/lib/host/registry.mjs +213 -0
  53. package/lib/host.mjs +393 -0
  54. package/lib/ledger.mjs +444 -0
  55. package/lib/limits.mjs +525 -0
  56. package/lib/locators.mjs +99 -0
  57. package/lib/merge-lanes.mjs +148 -0
  58. package/lib/metrics.mjs +99 -0
  59. package/lib/notify.mjs +229 -0
  60. package/lib/outcomes.mjs +560 -0
  61. package/lib/plan-fingerprint.mjs +570 -0
  62. package/lib/project-slug.mjs +18 -0
  63. package/lib/prompts/commit.mjs +26 -0
  64. package/lib/prompts/dimensions.mjs +70 -0
  65. package/lib/prompts/handoff.mjs +86 -0
  66. package/lib/prompts/implementer.mjs +150 -0
  67. package/lib/prompts/index.mjs +250 -0
  68. package/lib/prompts/planner.mjs +70 -0
  69. package/lib/prompts/remediate.mjs +137 -0
  70. package/lib/prompts/replan.mjs +23 -0
  71. package/lib/prompts/review.mjs +107 -0
  72. package/lib/prompts/schemas.mjs +206 -0
  73. package/lib/prompts/stage.mjs +48 -0
  74. package/lib/prompts/verify.mjs +88 -0
  75. package/lib/ready.mjs +812 -0
  76. package/lib/receipt.mjs +434 -0
  77. package/lib/remediate.mjs +275 -0
  78. package/lib/report-html.mjs +623 -0
  79. package/lib/report.mjs +1124 -0
  80. package/lib/review-core.mjs +713 -0
  81. package/lib/risk.mjs +370 -0
  82. package/lib/run-log.mjs +1004 -0
  83. package/lib/run-paths.mjs +239 -0
  84. package/lib/run.mjs +2921 -0
  85. package/lib/ship-stage.mjs +223 -0
  86. package/lib/spill.mjs +150 -0
  87. package/lib/surface.mjs +195 -0
  88. package/lib/verify.mjs +1074 -0
  89. package/lib/waves.mjs +2661 -0
  90. package/package.json +40 -0
  91. package/shared/CONTEXT-HYGIENE.md +84 -0
  92. package/shared/DECISION-LEDGER.md +141 -0
  93. package/shared/EXPLORE-BRIEF.md +96 -0
  94. package/shared/INVARIANT-SWEEP.md +57 -0
  95. package/shared/SESSION-SHAPES.md +261 -0
  96. package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
  97. package/shared/TEST-PROFILE.md +204 -0
  98. package/shared/TOOL-ECONOMY.md +155 -0
  99. package/skills/bootstrap/SKILL.md +226 -0
  100. package/skills/commit/SKILL.md +99 -0
  101. package/skills/dispatch/SKILL.md +78 -0
  102. package/skills/docs-digest/SKILL.md +109 -0
  103. package/skills/evals/SKILL.md +160 -0
  104. package/skills/explain-code/SKILL.md +42 -0
  105. package/skills/explain-code/commit-teachin.md +136 -0
  106. package/skills/explain-code/module-walkthrough.md +358 -0
  107. package/skills/explore/SKILL.md +159 -0
  108. package/skills/fix-tests/SKILL.md +120 -0
  109. package/skills/graph/SKILL.md +136 -0
  110. package/skills/manual-test-plan/SKILL.md +121 -0
  111. package/skills/mr/SKILL.md +170 -0
  112. package/skills/report/SKILL.md +121 -0
  113. package/skills/review-artifacts/SKILL.md +127 -0
  114. package/skills/review-code/REVIEW.template.md +49 -0
  115. package/skills/review-code/SKILL.md +156 -0
  116. package/skills/review-code/dimensions/architecture.md +10 -0
  117. package/skills/review-code/dimensions/devops.md +12 -0
  118. package/skills/review-code/dimensions/language.md +12 -0
  119. package/skills/review-code/dimensions/qa.md +12 -0
  120. package/skills/review-code/dimensions/security.md +12 -0
  121. package/skills/review-code/dimensions/technical-lead.md +19 -0
  122. package/skills/ship/SKILL.md +67 -0
  123. package/skills/spec/SKILL.md +264 -0
  124. package/skills/spec/continuity.md +66 -0
  125. 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
+ }