@renzrollon/interlock 1.1.1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/icon.png +0 -0
- package/.claude-plugin/plugin.json +109 -1
- package/CHANGELOG.md +150 -11
- package/bin/interlock +421 -53
- package/bin/interlock-run +119 -7
- package/hooks/guard-relaunch.mjs +115 -0
- package/hooks/guard-tests.mjs +51 -3
- package/hooks/hooks.json +1 -0
- package/hooks/mod.mjs +655 -0
- package/hooks/preflight.mjs +124 -13
- package/hooks/recorder.mjs +189 -0
- package/lib/agent-usage.mjs +524 -0
- package/lib/doctor.mjs +511 -46
- package/lib/eval-history.mjs +233 -0
- package/lib/evals-capture.mjs +5 -2
- package/lib/evals-triage.mjs +1 -1
- package/lib/graph/cli.mjs +28 -5
- package/lib/graph/retrieve/context.mjs +8 -3
- package/lib/host/acp.mjs +77 -14
- package/lib/host/claude-cli.mjs +391 -21
- package/lib/host/claude-env.mjs +179 -0
- package/lib/host/codex.mjs +9 -1
- package/lib/host/effort.mjs +163 -0
- package/lib/host/model-map.mjs +43 -0
- package/lib/host/qwen.mjs +9 -1
- package/lib/host/registry.mjs +44 -10
- package/lib/host.mjs +46 -5
- package/lib/interrupted.mjs +167 -0
- package/lib/launch-ledger.mjs +198 -0
- package/lib/launch-rule.mjs +174 -0
- package/lib/limits.mjs +149 -41
- package/lib/meter-quiet.mjs +45 -0
- package/lib/notify-defaults.mjs +6 -0
- package/lib/notify.mjs +41 -25
- package/lib/plan-fingerprint.mjs +80 -11
- package/lib/prompts/implementer.mjs +1 -1
- package/lib/prompts/verify.mjs +24 -1
- package/lib/receipt.mjs +59 -8
- package/lib/report-html.mjs +89 -6
- package/lib/report.mjs +313 -12
- package/lib/resume-card.mjs +90 -2
- package/lib/run-log.mjs +192 -5
- package/lib/run.mjs +1177 -84
- package/lib/state-home.mjs +298 -0
- package/lib/verify.mjs +37 -0
- package/lib/waves.mjs +53 -16
- package/package.json +4 -2
- package/shared/TEST-PROFILE.md +10 -5
- package/skills/evals/SKILL.md +3 -3
- package/skills/fix-tests/SKILL.md +3 -3
- package/skills/spec/SKILL.md +1 -6
- package/skills/spec/scripts/compact-instructions.mjs +30 -0
- package/types/index.d.ts +28 -0
- package/workflows/ship.js +75 -51
- package/.claude-plugin/marketplace.json +0 -19
package/lib/eval-history.mjs
CHANGED
|
@@ -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
|
+
}
|
package/lib/evals-capture.mjs
CHANGED
|
@@ -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(
|
|
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(
|
|
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/evals-triage.mjs
CHANGED
|
@@ -49,7 +49,7 @@ function graderName(grader, i) {
|
|
|
49
49
|
return grader && typeof grader.name === 'string' && grader.name ? grader.name : `grader-${i + 1}`
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
// Two results shapes reach triage: the one
|
|
52
|
+
// Two results shapes reach triage: the one the harness writes at
|
|
53
53
|
// schemaVersion 1 (`name`, `arms.with` / `arms['with-only']`) and the legacy
|
|
54
54
|
// `id` / `runs` shape the offline unit tests construct. Normalizing both at the
|
|
55
55
|
// case boundary keeps `classifyCase` the single classification rule — a second
|
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
|
|
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(
|
|
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(
|
|
39
|
+
if (fs.existsSync(graphPath(graphRoot))) {
|
|
35
40
|
try {
|
|
36
|
-
const graph = loadGraph(
|
|
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(
|
|
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
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
? { hooks: true
|
|
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
|
})
|