@renzrollon/interlock 1.1.1 → 1.2.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 (48) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +94 -1
  3. package/CHANGELOG.md +135 -1
  4. package/bin/interlock +409 -49
  5. package/bin/interlock-run +119 -7
  6. package/hooks/guard-relaunch.mjs +115 -0
  7. package/hooks/guard-tests.mjs +51 -3
  8. package/hooks/hooks.json +1 -0
  9. package/hooks/mod.mjs +655 -0
  10. package/hooks/preflight.mjs +83 -13
  11. package/hooks/recorder.mjs +189 -0
  12. package/lib/agent-usage.mjs +524 -0
  13. package/lib/doctor.mjs +488 -28
  14. package/lib/eval-history.mjs +233 -0
  15. package/lib/evals-capture.mjs +5 -2
  16. package/lib/graph/cli.mjs +28 -5
  17. package/lib/graph/retrieve/context.mjs +8 -3
  18. package/lib/host/acp.mjs +77 -14
  19. package/lib/host/claude-cli.mjs +391 -21
  20. package/lib/host/claude-env.mjs +179 -0
  21. package/lib/host/codex.mjs +9 -1
  22. package/lib/host/effort.mjs +163 -0
  23. package/lib/host/model-map.mjs +43 -0
  24. package/lib/host/qwen.mjs +9 -1
  25. package/lib/host/registry.mjs +44 -10
  26. package/lib/host.mjs +46 -5
  27. package/lib/interrupted.mjs +167 -0
  28. package/lib/launch-ledger.mjs +198 -0
  29. package/lib/launch-rule.mjs +174 -0
  30. package/lib/limits.mjs +149 -41
  31. package/lib/meter-quiet.mjs +45 -0
  32. package/lib/plan-fingerprint.mjs +80 -11
  33. package/lib/prompts/implementer.mjs +1 -1
  34. package/lib/prompts/verify.mjs +24 -1
  35. package/lib/receipt.mjs +59 -8
  36. package/lib/report-html.mjs +89 -6
  37. package/lib/report.mjs +313 -12
  38. package/lib/resume-card.mjs +90 -2
  39. package/lib/run-log.mjs +192 -5
  40. package/lib/run.mjs +1177 -84
  41. package/lib/state-home.mjs +298 -0
  42. package/lib/verify.mjs +37 -0
  43. package/lib/waves.mjs +53 -16
  44. package/package.json +2 -1
  45. package/shared/TEST-PROFILE.md +10 -5
  46. package/skills/fix-tests/SKILL.md +1 -1
  47. package/types/index.d.ts +28 -0
  48. package/workflows/ship.js +75 -51
@@ -39,6 +39,10 @@
39
39
  //
40
40
  // Written by `evals/ship/run.mjs`. Read by `lib/report.mjs` as a fourth corpus,
41
41
  // under exactly the rules the other three are read by.
42
+ //
43
+ // The cost-per-task sweep's record lives at the end of this module: a separate
44
+ // file under the same directory, with its own schema, written by
45
+ // `evals/ship/matrix.mjs` and read by nothing that promotes, gates or reports.
42
46
 
43
47
  import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync } from 'node:fs'
44
48
  import { join } from 'node:path'
@@ -302,3 +306,232 @@ export function readShipOutcomes(root) {
302
306
  return { ...empty, path, exists: true, reason: messageOf(err) }
303
307
  }
304
308
  }
309
+
310
+ // --- the cost-per-task record (spec: evals/cost-per-task) -------------------
311
+ //
312
+ // A SEPARATE FILE AND A SEPARATE SCHEMA, deliberately. The outcome record above
313
+ // holds one model constant across two arms; a cost-per-task row varies model and
314
+ // effort on one arm. Appending one into the other would let a reader of
315
+ // `ship-outcomes.jsonl` compare a model change as though it were a loop change,
316
+ // so nothing below ever opens that file, and `appendShipOutcome` never sees a
317
+ // cost-per-task row.
318
+ //
319
+ // The same three properties hold: fields are copied by name, the instrument
320
+ // travels with the measurement, and a failed append comes back as
321
+ // `written: false` for the sweep to exit non-zero on.
322
+
323
+ /** The sweep's append-only log, beside the outcome record under `EVAL_HISTORY_DIR`. */
324
+ export const COST_PER_TASK_FILE = 'cost-per-task.jsonl'
325
+
326
+ /** Distinct from `SHIP_OUTCOME_SCHEMA`, so neither reader ever counts the other's row. */
327
+ export const COST_PER_TASK_SCHEMA = 'interlock.cost-per-task/1'
328
+
329
+ /** What a cell's graders concluded. A row with neither is refused: it is not a cell that ran. */
330
+ export const JUDGE_RESULTS = Object.freeze(['pass', 'fail'])
331
+
332
+ /**
333
+ * The procedure every row ran. Stamped by the writer, never copied: the sweep
334
+ * runs the control-arm procedure only, and a row must not be readable as a
335
+ * measurement of the ship loop (design D1).
336
+ */
337
+ export const COST_PER_TASK_PROCEDURE = 'control'
338
+
339
+ /**
340
+ * The prompt-cache lifetime tiers a cache-write count is kept under — the keys
341
+ * the eval agent's usage record and the price table's write multipliers use.
342
+ */
343
+ const CACHE_WRITE_TIERS = Object.freeze(['ephemeral_5m', 'ephemeral_1h'])
344
+
345
+ /**
346
+ * Every field a cost-per-task row carries, in the order it is written (design
347
+ * D4, plus the agent identity D3 requires and the procedure label). Nothing else
348
+ * reaches the file — no transcript, no prompt, no diff, however fat the input.
349
+ */
350
+ export const COST_PER_TASK_FIELDS = Object.freeze([
351
+ 'schema',
352
+ 'ts',
353
+ 'version',
354
+ 'fixture',
355
+ 'model',
356
+ 'effort',
357
+ 'procedure',
358
+ 'evalAgent',
359
+ 'judge',
360
+ 'inputTokens',
361
+ 'outputTokens',
362
+ 'cacheReadInputTokens',
363
+ 'cacheCreationInputTokens',
364
+ 'usd',
365
+ 'pricesId',
366
+ 'usdReason',
367
+ 'durationMs'
368
+ ])
369
+
370
+ /** Absolute path of the cost-per-task record, from a repository root. */
371
+ export function costPerTaskPath(root) {
372
+ return join(root, EVAL_HISTORY_DIR, COST_PER_TASK_FILE)
373
+ }
374
+
375
+ /**
376
+ * An identifier kept EXACTLY as given, or `null`.
377
+ *
378
+ * Unlike `id()`, whitespace is not collapsed: `claude-opus-5-5 ` with a trailing
379
+ * space is not the model `claude-opus-5-5`, and a writer that trimmed it would
380
+ * record a cell under a model it never called.
381
+ */
382
+ function exactId(value, max = MAX_ID) {
383
+ if (typeof value !== 'string' || !value || value.length > max) return null
384
+ return /^\S+$/.test(value) ? value : null
385
+ }
386
+
387
+ /** A finite non-negative count, or `null` — a measured zero stays zero, an absence stays absent. */
388
+ function count(value) {
389
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
390
+ }
391
+
392
+ /**
393
+ * The cache-write tier object, copied tier by tier, or `null` where none was measured.
394
+ *
395
+ * Inside a present record a tier the record omits is zero and a tier it carries
396
+ * as a non-count is `null` — the rule the usage reader and the pricer already
397
+ * hold, so the row says what the price was computed from.
398
+ */
399
+ function cacheWrite(value) {
400
+ if (!isObject(value)) return null
401
+ const write = {}
402
+ for (const tier of CACHE_WRITE_TIERS) {
403
+ write[tier] = tier in value ? count(value[tier]) : 0
404
+ }
405
+ return write
406
+ }
407
+
408
+ /**
409
+ * Append one cost-per-task row for a cell that ran.
410
+ *
411
+ * Never throws. A failure comes back as `{written: false, reason}`, and the
412
+ * sweep fails on it: the row is the only durable product of a metered cell.
413
+ *
414
+ * A previous append torn mid-line is healed at the seam by the same final-byte
415
+ * check the outcome record carries, so a torn line costs that line and never the
416
+ * row written after it.
417
+ *
418
+ * @param {string} root repository root; `evals/history/` is created beneath it
419
+ * @param {{
420
+ * version?: string, fixture?: string, model?: string, effort?: string,
421
+ * evalAgent?: string, judge?: 'pass'|'fail',
422
+ * inputTokens?: number|null, outputTokens?: number|null,
423
+ * cacheReadInputTokens?: number|null,
424
+ * cacheCreationInputTokens?: Record<string, number|null>|null,
425
+ * usd?: number|null, pricesId?: string|null, usdReason?: string|null,
426
+ * durationMs?: number|null, now?: Date|string
427
+ * }} [input] anything not named here is ignored, however fat the object is
428
+ * @returns {{written: boolean, path: string|null, reason: string|null}}
429
+ */
430
+ export function appendCostPerTask(root, input = {}) {
431
+ try {
432
+ if (typeof root !== 'string' || !root.trim()) {
433
+ return { written: false, path: null, reason: 'no root directory given' }
434
+ }
435
+ const source = isObject(input) ? input : {}
436
+
437
+ const fixture = id(source.fixture)
438
+ if (!fixture) return { written: false, path: null, reason: 'a fixture identity is required' }
439
+ const model = exactId(source.model)
440
+ if (!model) {
441
+ return { written: false, path: null, reason: 'an exact API model id is required (no whitespace)' }
442
+ }
443
+ const effort = exactId(source.effort, 16)
444
+ if (!effort) return { written: false, path: null, reason: 'an effort level is required' }
445
+ const judge = JUDGE_RESULTS.includes(source.judge) ? source.judge : null
446
+ if (!judge) {
447
+ // Not defaulted: a row is a cell that ran and was graded. A cell that did
448
+ // not run is reported by the sweep, never written here as a failure.
449
+ return { written: false, path: null, reason: `judge must be one of ${JUDGE_RESULTS.join(', ')}` }
450
+ }
451
+
452
+ const when = source.now instanceof Date ? source.now : new Date(source.now || Date.now())
453
+ const ts = Number.isNaN(when.getTime()) ? new Date().toISOString() : when.toISOString()
454
+
455
+ const usd = count(source.usd)
456
+ const payload = {
457
+ schema: COST_PER_TASK_SCHEMA,
458
+ ts,
459
+ version: id(source.version) || 'unknown',
460
+ fixture,
461
+ model,
462
+ effort,
463
+ procedure: COST_PER_TASK_PROCEDURE,
464
+ evalAgent: id(source.evalAgent) || 'unknown',
465
+ judge,
466
+ inputTokens: count(source.inputTokens),
467
+ outputTokens: count(source.outputTokens),
468
+ cacheReadInputTokens: count(source.cacheReadInputTokens),
469
+ cacheCreationInputTokens: cacheWrite(source.cacheCreationInputTokens),
470
+ usd,
471
+ pricesId: id(source.pricesId),
472
+ // A figure and its reason are exclusive: a priced row carries no reason,
473
+ // and an unpriced one always says why.
474
+ usdReason: usd === null ? reason(source.usdReason) || 'not priced' : null,
475
+ durationMs: count(source.durationMs)
476
+ }
477
+
478
+ const dir = join(root, EVAL_HISTORY_DIR)
479
+ mkdirSync(dir, { recursive: true })
480
+ const file = join(dir, COST_PER_TASK_FILE)
481
+ const prefix = existsSync(file) && !endsWithNewline(file) ? '\n' : ''
482
+ appendFileSync(file, `${prefix}${JSON.stringify(payload)}\n`)
483
+ return { written: true, path: file, reason: null }
484
+ } catch (err) {
485
+ return { written: false, path: null, reason: messageOf(err) }
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Read the cost-per-task record back.
491
+ *
492
+ * Never throws. A torn line — the tail of a crashed append — is skipped and
493
+ * counted in `skipped`, never spliced into a record and never fatal to the rows
494
+ * around it. A line under another schema is counted as unrecognized.
495
+ *
496
+ * @param {string} root repository root
497
+ * @returns {{path: string|null, exists: boolean, records: object[],
498
+ * unrecognized: Array<{line: number, schema: string|null}>,
499
+ * skipped: Array<{line: number, reason: string}>, reason: string|null}}
500
+ */
501
+ export function readCostPerTask(root) {
502
+ const empty = { path: null, exists: false, records: [], unrecognized: [], skipped: [], reason: null }
503
+ if (typeof root !== 'string' || !root.trim()) {
504
+ return { ...empty, reason: 'no root directory given' }
505
+ }
506
+ const path = costPerTaskPath(root)
507
+ try {
508
+ if (!existsSync(path)) return { ...empty, path }
509
+ const records = []
510
+ const unrecognized = []
511
+ const skipped = []
512
+ readFileSync(path, 'utf8')
513
+ .split('\n')
514
+ .forEach((line, i) => {
515
+ if (!line.trim()) return
516
+ let parsed
517
+ try {
518
+ parsed = JSON.parse(line)
519
+ } catch (err) {
520
+ skipped.push({ line: i + 1, reason: messageOf(err) })
521
+ return
522
+ }
523
+ if (!isObject(parsed)) {
524
+ skipped.push({ line: i + 1, reason: 'line is not a JSON object' })
525
+ return
526
+ }
527
+ if (parsed.schema !== COST_PER_TASK_SCHEMA) {
528
+ unrecognized.push({ line: i + 1, schema: typeof parsed.schema === 'string' ? parsed.schema : null })
529
+ return
530
+ }
531
+ records.push(parsed)
532
+ })
533
+ return { path, exists: true, records, unrecognized, skipped, reason: null }
534
+ } catch (err) {
535
+ return { ...empty, path, exists: true, reason: messageOf(err) }
536
+ }
537
+ }
@@ -227,11 +227,14 @@ export function buildSkeleton(root, opts = {}) {
227
227
  const runId = typeof opts.runId === 'string' ? opts.runId.trim() : ''
228
228
  const taskId = typeof opts.taskId === 'string' && opts.taskId.trim() ? opts.taskId.trim() : null
229
229
  if (!runId) return refuse('no run identifier was given')
230
+ // The trajectory is a corpus, read from the state home the CLI resolved
231
+ // (ship/state-home); the eval suite stays under the repository root.
232
+ const logRoot = typeof opts.stateHome === 'string' && opts.stateHome ? opts.stateHome : root
230
233
 
231
234
  // The published decision, reused rather than re-derived. Its problems are
232
235
  // reported verbatim: the caller learns why the run cannot be cited, not
233
236
  // merely that it cannot.
234
- const reconstructable = checkRunLog(root, runId)
237
+ const reconstructable = checkRunLog(logRoot, runId)
235
238
  if (!reconstructable.ok) {
236
239
  return refuse(
237
240
  `run ${runId} is not reconstructable, so nothing may cite it`,
@@ -239,7 +242,7 @@ export function buildSkeleton(root, opts = {}) {
239
242
  )
240
243
  }
241
244
 
242
- const read = readRunLog(root, runId)
245
+ const read = readRunLog(logRoot, runId)
243
246
  const cited = citedEvents(read.records, taskId)
244
247
  const seqs = cited.events.map(e => Number(e.seq)).filter(n => Number.isFinite(n))
245
248
  const first = seqs.length ? Math.min(...seqs) : 1
package/lib/graph/cli.mjs CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from './retrieve/docs-index.mjs';
15
15
  import { formatDocsResult, retrieveMarkdownSections } from './retrieve/markdown.mjs';
16
16
  import { graphPath, reportPath, resolveRoot } from './paths.mjs';
17
+ import { resolveStateHome } from '../state-home.mjs';
17
18
 
18
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
20
 
@@ -33,6 +34,9 @@ Usage:
33
34
  interlock-graph docs-index [root] Deterministic docs/ + openspec/specs TOC → docs-index.json
34
35
  interlock-graph which Print CLI path (for skills)
35
36
 
37
+ Query commands read <root>/.claude/graph/graph.json; in a linked worktree with no
38
+ graph of its own they read the main checkout's and say so on stderr.
39
+
36
40
  Query options:
37
41
  --budget <n> Token budget for output (default 1500; docs=800, context=2000)
38
42
  --depth <n> Traversal depth (default 2)
@@ -57,6 +61,23 @@ Examples:
57
61
  `;
58
62
  }
59
63
 
64
+ /**
65
+ * Where a query reads the graph from (ship/state-home): the root's own graph
66
+ * first; else, when the root is a linked worktree with none, the state home's —
67
+ * said once on stderr, because that graph was built before this worktree's
68
+ * edits. `build`, `update`, `report` and `docs-index` never come here: they
69
+ * write under the root they were given, and nothing is written into the home.
70
+ */
71
+ function graphRootFor(root) {
72
+ if (fs.existsSync(graphPath(root))) return root;
73
+ const { home } = resolveStateHome(root);
74
+ if (home && path.resolve(home) !== path.resolve(root) && fs.existsSync(graphPath(home))) {
75
+ process.stderr.write(`GRAPH FROM MAIN CHECKOUT: ${graphPath(home)}\n`);
76
+ return home;
77
+ }
78
+ return root;
79
+ }
80
+
60
81
  function parseArgs(argv) {
61
82
  const args = {
62
83
  _: [],
@@ -174,10 +195,11 @@ function main() {
174
195
  case 'query': {
175
196
  const q = args._.slice(1).join(' ');
176
197
  if (!q) throw new Error('query requires a search string');
177
- const graph = loadGraph(root);
198
+ const from = graphRootFor(root);
199
+ const graph = loadGraph(from);
178
200
  const seeds = findSeedNodes(graph, q);
179
201
  if (!seeds.length) {
180
- process.stdout.write(`No nodes matched: ${q}\n(graph: ${graphPath(root)})\n`);
202
+ process.stdout.write(`No nodes matched: ${q}\n(graph: ${graphPath(from)})\n`);
181
203
  process.exit(1);
182
204
  }
183
205
  process.stdout.write(
@@ -192,20 +214,20 @@ function main() {
192
214
  case 'consumers': {
193
215
  const target = args._.slice(1).join(' ');
194
216
  if (!target) throw new Error('consumers requires a symbol or file');
195
- process.stdout.write(consumers(loadGraph(root), target));
217
+ process.stdout.write(consumers(loadGraph(graphRootFor(root)), target));
196
218
  break;
197
219
  }
198
220
  case 'path': {
199
221
  const a = args._[1];
200
222
  const b = args._[2];
201
223
  if (!a || !b) throw new Error('path requires two endpoints');
202
- process.stdout.write(shortestPath(loadGraph(root), a, b));
224
+ process.stdout.write(shortestPath(loadGraph(graphRootFor(root)), a, b));
203
225
  break;
204
226
  }
205
227
  case 'explain': {
206
228
  const q = args._.slice(1).join(' ');
207
229
  if (!q) throw new Error('explain requires a node query');
208
- process.stdout.write(explain(loadGraph(root), q));
230
+ process.stdout.write(explain(loadGraph(graphRootFor(root)), q));
209
231
  break;
210
232
  }
211
233
  case 'docs': {
@@ -227,6 +249,7 @@ function main() {
227
249
  throw new Error('context requires a query, --changed paths, or --commit-subject');
228
250
  }
229
251
  const result = retrieveContext(root, {
252
+ graphRoot: graphRootFor(root),
230
253
  query: q,
231
254
  changedFiles: args.changed,
232
255
  commitSubject: args.commitSubject,
@@ -7,8 +7,13 @@ import { estimateTokens } from '../format.mjs';
7
7
 
8
8
  /**
9
9
  * Build a combined context bundle: structural graph subgraph + ranked doc sections.
10
+ *
11
+ * `graphRoot` is where the graph is read from, when it is not `root`: a linked
12
+ * worktree with no graph of its own reads the main checkout's (ship/state-home),
13
+ * while its documentation is still read from the worktree itself.
10
14
  */
11
15
  export function retrieveContext(root, {
16
+ graphRoot = root,
12
17
  query = '',
13
18
  changedFiles = [],
14
19
  commitSubject = '',
@@ -31,9 +36,9 @@ export function retrieveContext(root, {
31
36
  let graphText = '';
32
37
  let graphPresent = false;
33
38
 
34
- if (fs.existsSync(graphPath(root))) {
39
+ if (fs.existsSync(graphPath(graphRoot))) {
35
40
  try {
36
- const graph = loadGraph(root);
41
+ const graph = loadGraph(graphRoot);
37
42
  const seeds = findSeedNodes(graph, searchQuery);
38
43
  if (seeds.length > 0) {
39
44
  graphText = traverse(graph, seeds, { mode: 'bfs', depth: 2, budget: graphShare });
@@ -46,7 +51,7 @@ export function retrieveContext(root, {
46
51
  }
47
52
  } else {
48
53
  graphText =
49
- `Structural: no graph at ${graphPath(root)} — run \`interlock-graph build .\` for import/symbol navigation.\n`;
54
+ `Structural: no graph at ${graphPath(graphRoot)} — run \`interlock-graph build .\` for import/symbol navigation.\n`;
50
55
  }
51
56
 
52
57
  parts.push('## Structural (graph)');
package/lib/host/acp.mjs CHANGED
@@ -38,6 +38,7 @@ import { spawn as spawnProcess } from 'node:child_process'
38
38
  import { basename } from 'node:path'
39
39
  import { mapPipeline, parseAgentJson, runCli, assertWorkflowHost } from '../host.mjs'
40
40
  import { ACP_MODEL_MAP_ENV, parseModelMap } from './model-map.mjs'
41
+ import { EFFORT_REASONS, findEffortOption, pickEffortValue } from './effort.mjs'
41
42
 
42
43
  /** Where the user names their ACP agent, e.g. `claude-code-acp` or `gemini --acp`. */
43
44
  export const ACP_COMMAND_ENV = 'INTERLOCK_ACP_COMMAND'
@@ -105,17 +106,48 @@ export function parseAcpCommand(raw) {
105
106
  return { command: tokens[0], args: tokens.slice(1) }
106
107
  }
107
108
 
109
+ /** Basename of a command path, without a Windows `.exe` suffix. */
110
+ function commandBasename(command) {
111
+ if (typeof command !== 'string' || !command) return ''
112
+ return basename(command).replace(/\.exe$/i, '')
113
+ }
114
+
115
+ /** The Claude Code CLI. It accepts `--agent` and loads this plugin's hooks. */
116
+ const CLAUDE_BINARIES = new Set(['claude', 'claude-code'])
117
+
118
+ /**
119
+ * ACP adapters that are Claude Code under another argv0. They spend the
120
+ * subscription and inherit `CLAUDE_CODE_EFFORT_LEVEL`. They do not accept
121
+ * `--agent`, and this plugin's hooks do not fire in them.
122
+ *
123
+ * A launcher whose own name is `npx` is not in this set: the command name does
124
+ * not say what it launches, and guessing from an argument would treat a
125
+ * coincidental token as Claude.
126
+ */
127
+ const CLAUDE_ACP_WRAPPERS = new Set(['claude-agent-acp', 'claude-code-acp'])
128
+
108
129
  /**
109
130
  * True when `command` is a Claude Code CLI binary that accepts `--agent`.
110
- * Wrappers (`npx`, `claude-code-acp`) are other ACP agents and are left alone.
131
+ * Wrappers are other argv0s and are left alone.
111
132
  *
112
133
  * @param {string} command
113
134
  * @returns {boolean}
114
135
  */
115
136
  export function isClaudeCodeBinary(command) {
116
- if (typeof command !== 'string' || !command) return false
117
- const base = basename(command).replace(/\.exe$/i, '')
118
- return base === 'claude' || base === 'claude-code'
137
+ return CLAUDE_BINARIES.has(commandBasename(command))
138
+ }
139
+
140
+ /**
141
+ * True when `command` is the Claude CLI or a known ACP adapter that wraps it.
142
+ * The subscription and effort-override banners read this. `--agent` and hooks
143
+ * stay on {@link isClaudeCodeBinary}.
144
+ *
145
+ * @param {string} command
146
+ * @returns {boolean}
147
+ */
148
+ export function isClaudeBackedCommand(command) {
149
+ const base = commandBasename(command)
150
+ return CLAUDE_BINARIES.has(base) || CLAUDE_ACP_WRAPPERS.has(base)
119
151
  }
120
152
 
121
153
  /**
@@ -232,7 +264,7 @@ export function pickModelValue(slug, option, map = {}) {
232
264
  * process would not start, the handshake failed, the timeout fired), which the
233
265
  * caller turns into a `null` spawn result exactly as the workflow runtime does.
234
266
  */
235
- async function promptOnce({ command, args, cwd, env, prompt, meta, model, modelMap, timeoutMs, onEvent }) {
267
+ async function promptOnce({ command, args, cwd, env, prompt, meta, model, modelMap, effort, timeoutMs, onEvent }) {
236
268
  const child = spawnProcess(command, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] })
237
269
 
238
270
  let nextId = 0
@@ -375,6 +407,9 @@ async function promptOnce({ command, args, cwd, env, prompt, meta, model, modelM
375
407
  *
376
408
  * @returns {Promise<{ applied: boolean, via: string | null, value: string | null, reason: string | null }>}
377
409
  */
410
+ // The config options the agent returned most recently; effort reads these.
411
+ let latestOptions = null
412
+
378
413
  const negotiateModel = async (sessionId, session) => {
379
414
  const option = findModelOption(session && session.configOptions)
380
415
 
@@ -394,7 +429,10 @@ async function promptOnce({ command, args, cwd, env, prompt, meta, model, modelM
394
429
 
395
430
  const configId = typeof option.id === 'string' && option.id ? option.id : 'model'
396
431
  try {
397
- await request('session/set_config_option', { sessionId, configId, value })
432
+ const reply = await request('session/set_config_option', { sessionId, configId, value })
433
+ // The reply carries the full option list, and the levels a session offers
434
+ // depend on its model, so effort is read from this and not from session/new.
435
+ if (reply && Array.isArray(reply.configOptions)) latestOptions = reply.configOptions
398
436
  return { applied: true, via: 'set_config_option', value, reason: null }
399
437
  } catch {
400
438
  try {
@@ -411,6 +449,25 @@ async function promptOnce({ command, args, cwd, env, prompt, meta, model, modelM
411
449
  }
412
450
  }
413
451
 
452
+ /**
453
+ * Apply the spawn's effort through the agent's own effort option, or say why
454
+ * not. Never throws: the prompt runs either way. Exact value match only, and
455
+ * the call goes under the option's own id.
456
+ */
457
+ const negotiateEffort = async sessionId => {
458
+ const option = findEffortOption(latestOptions)
459
+ const { value, reason } = pickEffortValue(effort, option)
460
+ if (!value) return { applied: false, via: null, value: null, reason }
461
+ const configId = typeof option.id === 'string' && option.id ? option.id : 'effort'
462
+ try {
463
+ const reply = await request('session/set_config_option', { sessionId, configId, value })
464
+ if (reply && Array.isArray(reply.configOptions)) latestOptions = reply.configOptions
465
+ return { applied: true, via: 'set_config_option', value, reason: null }
466
+ } catch {
467
+ return { applied: false, via: null, value: null, reason: EFFORT_REASONS.rejected }
468
+ }
469
+ }
470
+
414
471
  const turn = async () => {
415
472
  const initialized = await request('initialize', {
416
473
  protocolVersion: ACP_PROTOCOL_VERSION,
@@ -426,11 +483,16 @@ async function promptOnce({ command, args, cwd, env, prompt, meta, model, modelM
426
483
  const sessionId = session && session.sessionId
427
484
  if (!sessionId) throw new Error('ACP session/new returned no sessionId')
428
485
  emit({ type: 'session', sessionId })
486
+ latestOptions = session.configOptions
429
487
 
430
488
  if (typeof model === 'string' && model) {
431
489
  emit({ type: 'model-routing', requested: model, ...(await negotiateModel(sessionId, session)) })
432
490
  }
433
491
 
492
+ if (typeof effort === 'string' && effort.trim()) {
493
+ emit({ type: 'effort-routing', requested: effort.trim(), ...(await negotiateEffort(sessionId)) })
494
+ }
495
+
434
496
  const result = await request('session/prompt', {
435
497
  sessionId,
436
498
  prompt: [{ type: 'text', text: prompt }],
@@ -480,14 +542,14 @@ export function createAcpHost({ command, cwd = process.cwd(), env = process.env,
480
542
  const host = {
481
543
  command: [parsed.command, ...parsed.args].join(' '),
482
544
 
483
- // What this adapter can observe about itself that the registry's static
484
- // declaration cannot: an ACP command that IS the Claude binary means the
485
- // plugin's hooks fire and the run is spending a Claude subscription through
486
- // the programmatic path. Declaring `local`/`false` there would suppress the
487
- // two banners that matter most on that path (design D8).
488
- capabilities: isClaudeCodeBinary(parsed.command)
489
- ? { hooks: true, billing: 'claude-subscription-programmatic' }
490
- : {},
545
+ // Hooks fire only in the Claude binary. A known wrapper still spends the
546
+ // subscription, so billing is recorded for both; leaving a wrapper at
547
+ // `local` would let the receipt contradict SUBSCRIPTION PATH. Hooks stay
548
+ // false on a wrapper, so HOOKS NOT IN FORCE still prints.
549
+ capabilities: {
550
+ ...(isClaudeBackedCommand(parsed.command) ? { billing: 'claude-subscription-programmatic' } : {}),
551
+ ...(isClaudeCodeBinary(parsed.command) ? { hooks: true } : {})
552
+ },
491
553
 
492
554
  // The planner's tier ladder — the haiku pings, the opus clamp — is applied
493
555
  // per session where the agent advertises a model option, and per spawn the
@@ -518,6 +580,7 @@ export function createAcpHost({ command, cwd = process.cwd(), env = process.env,
518
580
  : undefined,
519
581
  model: request.model,
520
582
  modelMap,
583
+ effort: request.effort,
521
584
  timeoutMs: budget,
522
585
  onEvent: event => onEvent && onEvent({ label, ...event })
523
586
  })