@renzrollon/interlock 1.1.0 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +94 -1
- package/CHANGELOG.md +142 -0
- package/bin/interlock +413 -65
- 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 +83 -13
- package/hooks/recorder.mjs +189 -0
- package/lib/agent-usage.mjs +524 -0
- package/lib/doctor.mjs +488 -28
- package/lib/eval-history.mjs +233 -0
- package/lib/evals-capture.mjs +5 -2
- 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 +153 -45
- package/lib/meter-quiet.mjs +45 -0
- package/lib/plan-fingerprint.mjs +83 -13
- 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 +1193 -85
- package/lib/state-home.mjs +298 -0
- package/lib/verify.mjs +37 -0
- package/lib/waves.mjs +285 -83
- package/package.json +2 -1
- package/shared/TEST-PROFILE.md +10 -5
- package/skills/fix-tests/SKILL.md +1 -1
- package/types/index.d.ts +28 -0
- package/workflows/ship.js +75 -51
package/lib/run-log.mjs
CHANGED
|
@@ -60,12 +60,38 @@ export const RUN_LOG_TYPES = Object.freeze([
|
|
|
60
60
|
'wave-action',
|
|
61
61
|
'cli-exit',
|
|
62
62
|
'agent-spawn',
|
|
63
|
+
// What the host observed about one agent after it returned (design D8). Added
|
|
64
|
+
// under the same schema id: the type is additive, and every reader below
|
|
65
|
+
// skips a type it does not handle rather than reporting it.
|
|
66
|
+
'agent-result',
|
|
63
67
|
'verify-judgement',
|
|
64
68
|
'run-halt',
|
|
65
69
|
'run-complete',
|
|
66
70
|
'run-receipt'
|
|
67
71
|
])
|
|
68
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Where an `agent-result` observation came from: the runner host's `claude -p`
|
|
75
|
+
* envelope, or the agent's own transcript read by the recorder hooks on the
|
|
76
|
+
* Workflow host. Kept apart because the two do not see the same models
|
|
77
|
+
* (design D6): a reader must be able to tell which evidence a banner stood on.
|
|
78
|
+
*/
|
|
79
|
+
export const AGENT_RESULT_SOURCES = Object.freeze(['envelope', 'transcript'])
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Which observation a model-substitution verdict read (design D6): the agent's
|
|
83
|
+
* own assistant turns, or the session-wide per-model breakdown that also lists
|
|
84
|
+
* the host's internal calls.
|
|
85
|
+
*/
|
|
86
|
+
export const MODEL_SCOPES = Object.freeze(['turns', 'session'])
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The surfaces a run may record on `run-start` and its receipt (design D14).
|
|
90
|
+
* Anything else is recorded as absent, so a reader partitioning by surface
|
|
91
|
+
* counts it with the runs that recorded none rather than inventing a fifth.
|
|
92
|
+
*/
|
|
93
|
+
export const RUN_SURFACES = Object.freeze(['main', 'linked-worktree', 'lane-worktree', 'unknown'])
|
|
94
|
+
|
|
69
95
|
/** The two modes a run starts in — mirrors `lib/outcomes.mjs`'s `MODES`. */
|
|
70
96
|
export const RUN_MODES = Object.freeze(['checkpoint', 'continue'])
|
|
71
97
|
|
|
@@ -97,7 +123,8 @@ export const AGENT_KINDS = Object.freeze([
|
|
|
97
123
|
/** Wave-state sources a `wave-action` event may report. */
|
|
98
124
|
export const WAVE_SOURCES = Object.freeze(['create', 'next', 'record-batch', 'record-verify', 'replan'])
|
|
99
125
|
|
|
100
|
-
|
|
126
|
+
/** The bound on one free-text field. `lib/interrupted.mjs` bounds a note's reason to the same length. */
|
|
127
|
+
export const MAX_TEXT = 500
|
|
101
128
|
const MAX_NAME = 120
|
|
102
129
|
const MAX_LABEL = 200
|
|
103
130
|
const MAX_COMMAND = 120
|
|
@@ -113,6 +140,20 @@ const MAX_HASH = 80
|
|
|
113
140
|
const MAX_PATH_SET = 200
|
|
114
141
|
const MAX_PATH = 300
|
|
115
142
|
|
|
143
|
+
/**
|
|
144
|
+
* How many error strings one `agent-result` may carry, each bounded to
|
|
145
|
+
* `MAX_TEXT`. A record-shape bound like `MAX_PATH_SET`, for the same reason it
|
|
146
|
+
* lives here rather than in `lib/limits.mjs`: nothing halts on it, it only
|
|
147
|
+
* bounds what one event writes. Exported because the runner's adapter bounds
|
|
148
|
+
* the host record to the same figure before the record reaches the CLI.
|
|
149
|
+
*/
|
|
150
|
+
export const MAX_AGENT_ERRORS = 10
|
|
151
|
+
|
|
152
|
+
// How many model ids or denied tool names one `agent-result` may carry. A
|
|
153
|
+
// record-shape bound in the same class: a transcript names eight models at most
|
|
154
|
+
// (design D3), and a denial list's whole size travels on its `count`.
|
|
155
|
+
const MAX_AGENT_LIST = 20
|
|
156
|
+
|
|
116
157
|
function messageOf(err) {
|
|
117
158
|
return (err && err.message) || String(err)
|
|
118
159
|
}
|
|
@@ -248,14 +289,83 @@ function hostBlock(value) {
|
|
|
248
289
|
// Declared separately from `usage` and recorded separately, so a reader can
|
|
249
290
|
// partition a corpus by whether cache figures were obtainable at all rather
|
|
250
291
|
// than averaging a measured figure against an absent one.
|
|
251
|
-
cacheAccounting:
|
|
292
|
+
cacheAccounting: cacheAccountingValue(value.cacheAccounting),
|
|
293
|
+
// Bounded text, not an enum: a receipt written before the effort
|
|
294
|
+
// capability existed reads `null` (not recorded), never `flag` or
|
|
295
|
+
// `unsupported`.
|
|
296
|
+
effort: nullableText(value.effort, MAX_NAME)
|
|
252
297
|
}
|
|
253
298
|
}
|
|
254
299
|
|
|
300
|
+
// `true` and `false` as a host declared them, or `'hook'` — the value only a
|
|
301
|
+
// close whose every briefed spawn joined with recorded usage may set (design
|
|
302
|
+
// D5). Tri-state like `nullableBool` for everything else, so a receipt written
|
|
303
|
+
// before `hook` existed reads its boolean exactly as it always did, and a
|
|
304
|
+
// reader partitions "figures obtainable" as `true` or `'hook'`.
|
|
305
|
+
function cacheAccountingValue(value) {
|
|
306
|
+
if (value === true || value === false || value === 'hook') return value
|
|
307
|
+
return null
|
|
308
|
+
}
|
|
309
|
+
|
|
255
310
|
function enumOf(allowed, value, fallback = null) {
|
|
256
311
|
return typeof value === 'string' && allowed.includes(value) ? value : fallback
|
|
257
312
|
}
|
|
258
313
|
|
|
314
|
+
// A cost the host estimated, in dollars. A number or nothing: `nullableCount`
|
|
315
|
+
// would truncate it to whole dollars and read a numeric string or `true` as a
|
|
316
|
+
// figure. Never added to a price table — it is the host's client-side
|
|
317
|
+
// estimate, kept beside the token figures rather than reconciled with them.
|
|
318
|
+
function nullableAmount(value) {
|
|
319
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return null
|
|
320
|
+
return value
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// The cache-creation figure of one agent, in the two shapes the sources report
|
|
324
|
+
// (design D2/D3): both lifetime tiers when the source split them, or `{ total }`
|
|
325
|
+
// when it carried only a total and the split is unknown. Tiers win when both
|
|
326
|
+
// are present, and the total is never derived from them nor they from it — the
|
|
327
|
+
// tiers are priced differently and a flattening cannot be undone. `null` for a
|
|
328
|
+
// source that reported no decomposition at all, which is not `{}` and not 0.
|
|
329
|
+
function agentCacheCreation(value) {
|
|
330
|
+
if (!isObject(value)) return null
|
|
331
|
+
if ('ephemeral_5m' in value || 'ephemeral_1h' in value) {
|
|
332
|
+
return { ephemeral_5m: nullableCount(value.ephemeral_5m), ephemeral_1h: nullableCount(value.ephemeral_1h) }
|
|
333
|
+
}
|
|
334
|
+
if ('total' in value) return { total: nullableCount(value.total) }
|
|
335
|
+
return null
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// One agent's own usage, element by element. Absent as a whole when the source
|
|
339
|
+
// carried none; a figure the source omitted is `null` inside a present block.
|
|
340
|
+
function agentUsage(value) {
|
|
341
|
+
if (!isObject(value)) return null
|
|
342
|
+
return {
|
|
343
|
+
inputTokens: nullableCount(value.inputTokens),
|
|
344
|
+
outputTokens: nullableCount(value.outputTokens),
|
|
345
|
+
cacheReadInputTokens: nullableCount(value.cacheReadInputTokens),
|
|
346
|
+
cacheCreationInputTokens: agentCacheCreation(value.cacheCreationInputTokens)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// The host's permission denials for one lane: how many, and which tools. Never
|
|
351
|
+
// the tool input — the host record carries it beside the tool name, and it is
|
|
352
|
+
// whatever command or file body the lane tried to run.
|
|
353
|
+
function permissionDenials(value) {
|
|
354
|
+
if (!isObject(value)) return null
|
|
355
|
+
return {
|
|
356
|
+
count: nullableCount(value.count),
|
|
357
|
+
tools: nullableStringArray(value.tools, MAX_AGENT_LIST, MAX_NAME)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// An agent kind that was given is coerced the way `agent-spawn` coerces it, so
|
|
362
|
+
// an unlisted one reads as `other`; one that was never given is absent, because
|
|
363
|
+
// a host record on its own does not say what kind of agent it observed.
|
|
364
|
+
function agentKind(value) {
|
|
365
|
+
if (typeof value !== 'string' || !value.trim()) return null
|
|
366
|
+
return enumOf(AGENT_KINDS, value, 'other')
|
|
367
|
+
}
|
|
368
|
+
|
|
259
369
|
function stringArray(value, max = MAX_TEXT) {
|
|
260
370
|
if (!Array.isArray(value)) return []
|
|
261
371
|
return value.filter(v => typeof v === 'string').map(v => text(v, max))
|
|
@@ -314,7 +424,18 @@ const TYPE_FIELDS = {
|
|
|
314
424
|
// session id records absent; deriving one from the runId, the pid or the
|
|
315
425
|
// timestamp would put a key in the corpus that joins to nothing and looks
|
|
316
426
|
// exactly like one that joins to something.
|
|
317
|
-
sessionId: source => nullableText(source.sessionId, MAX_NAME)
|
|
427
|
+
sessionId: source => nullableText(source.sessionId, MAX_NAME),
|
|
428
|
+
// Where the run ran and where its corpora went (design D14): the surface
|
|
429
|
+
// the state-home resolver classified, the absolute home this run's
|
|
430
|
+
// trajectory, outcome line, metrics and resume card were written under, and
|
|
431
|
+
// the absolute working directory it ran in. A run in a linked worktree
|
|
432
|
+
// writes its corpora to the main checkout, and a reader of the trajectory
|
|
433
|
+
// there would otherwise not know which tree the run operated on. Absent on
|
|
434
|
+
// a trajectory written before these existed, which a reader counts as
|
|
435
|
+
// surface-unrecorded rather than as any named surface.
|
|
436
|
+
surface: source => enumOf(RUN_SURFACES, source.surface, null),
|
|
437
|
+
stateHome: source => nullableText(source.stateHome),
|
|
438
|
+
cwd: source => nullableText(source.cwd)
|
|
318
439
|
},
|
|
319
440
|
'wave-action': {
|
|
320
441
|
action: source => nullableText(source.action, MAX_ACTION),
|
|
@@ -352,6 +473,49 @@ const TYPE_FIELDS = {
|
|
|
352
473
|
kind: source => enumOf(AGENT_KINDS, source.kind, 'other'),
|
|
353
474
|
taskId: source => nullableText(source.taskId, MAX_NAME)
|
|
354
475
|
},
|
|
476
|
+
// What the host observed about one agent after it returned (design D8): one
|
|
477
|
+
// per host record on the runner, appended at the record path before any
|
|
478
|
+
// tick, and one per joined agent on the Workflow host, appended at close
|
|
479
|
+
// before the receipt. In the trajectory's fatal class like every other event
|
|
480
|
+
// here — the caller decides that; this writer only reports.
|
|
481
|
+
//
|
|
482
|
+
// The source is a whole host record or a whole sidecar file, which carry the
|
|
483
|
+
// lane's stdout, its result body and its tool inputs beside these fields, so
|
|
484
|
+
// every field is named and the three nested groups are built field by field.
|
|
485
|
+
//
|
|
486
|
+
// Absent, never zero, throughout: each figure is something a host may or may
|
|
487
|
+
// not have said, and a reader must be able to tell a lane that was denied
|
|
488
|
+
// nothing from a lane nobody asked about.
|
|
489
|
+
'agent-result': {
|
|
490
|
+
label: source => text(source.label, MAX_LABEL),
|
|
491
|
+
kind: source => agentKind(source.kind),
|
|
492
|
+
agentId: source => nullableText(source.agentId, MAX_NAME),
|
|
493
|
+
source: source => enumOf(AGENT_RESULT_SOURCES, source.source, null),
|
|
494
|
+
subtype: source => nullableText(source.subtype, MAX_NAME),
|
|
495
|
+
isError: source => nullableBool(source.isError),
|
|
496
|
+
terminalReason: source => nullableText(source.terminalReason, MAX_NAME),
|
|
497
|
+
errors: source => nullableStringArray(source.errors, MAX_AGENT_ERRORS, MAX_TEXT),
|
|
498
|
+
permissionDenials: source => permissionDenials(source.permissionDenials),
|
|
499
|
+
sessionId: source => nullableText(source.sessionId, MAX_NAME),
|
|
500
|
+
numTurns: source => nullableCount(source.numTurns),
|
|
501
|
+
// The model the run asked for, after the host's model map — the value a
|
|
502
|
+
// substitution verdict compared against.
|
|
503
|
+
modelRouted: source => nullableText(source.modelRouted, 60),
|
|
504
|
+
// Two observations that do not mean the same thing (design D6): the models
|
|
505
|
+
// of the agent's own assistant turns, and every model the session called,
|
|
506
|
+
// the host's internal calls included. Both are kept, with the scope the
|
|
507
|
+
// verdict read, so a reader can see which evidence a banner stood on and
|
|
508
|
+
// every id the session called, not only the ones that decided.
|
|
509
|
+
servedModels: source => nullableStringArray(source.servedModels, MAX_AGENT_LIST, MAX_NAME),
|
|
510
|
+
sessionModels: source => nullableStringArray(source.sessionModels, MAX_AGENT_LIST, MAX_NAME),
|
|
511
|
+
modelScope: source => enumOf(MODEL_SCOPES, source.modelScope, null),
|
|
512
|
+
substituted: source => nullableBool(source.substituted),
|
|
513
|
+
resultMissing: source => nullableBool(source.resultMissing),
|
|
514
|
+
// The agent's own figures, not the wave's aggregate: a later per-agent
|
|
515
|
+
// reader's input, and never folded into the receipt's output tokens.
|
|
516
|
+
usage: source => agentUsage(source.usage),
|
|
517
|
+
hostCostUsd: source => nullableAmount(source.hostCostUsd)
|
|
518
|
+
},
|
|
355
519
|
'verify-judgement': {
|
|
356
520
|
context: source => enumOf(VERIFY_CONTEXTS, source.context, null),
|
|
357
521
|
halt: source => boolStrict(source.halt),
|
|
@@ -443,7 +607,24 @@ const TYPE_FIELDS = {
|
|
|
443
607
|
predictedPathsTruncated: source => pathSetTruncated(source.predictedPaths),
|
|
444
608
|
predictedPathsComplete: source => nullableBool(source.predictedPathsComplete),
|
|
445
609
|
predictedPathsReason: source => nullableText(source.predictedPathsReason),
|
|
446
|
-
degradations: source => stringArray(source.degradations)
|
|
610
|
+
degradations: source => stringArray(source.degradations),
|
|
611
|
+
// What the run observed about its agents (design D8): lanes the host
|
|
612
|
+
// stopped, success envelopes with no structured result, lanes with
|
|
613
|
+
// permission denials, model substitutions, permission prompts and
|
|
614
|
+
// auto-mode denials. `null` when no host record and no sidecar was ever
|
|
615
|
+
// seen, and a measured zero otherwise — a Workflow run with no recorder
|
|
616
|
+
// saw no agent at all, and filing it as zero would put it beside a run that
|
|
617
|
+
// watched every lane and found nothing wrong.
|
|
618
|
+
lanesStoppedByHost: source => nullableCount(source.lanesStoppedByHost),
|
|
619
|
+
schemaResultsMissing: source => nullableCount(source.schemaResultsMissing),
|
|
620
|
+
toolsDeniedInLanes: source => nullableCount(source.toolsDeniedInLanes),
|
|
621
|
+
modelSubstitutions: source => nullableCount(source.modelSubstitutions),
|
|
622
|
+
permissionPrompts: source => nullableCount(source.permissionPrompts),
|
|
623
|
+
autoModeDenials: source => nullableCount(source.autoModeDenials),
|
|
624
|
+
// Where the run ran and where its corpora went, on the same terms as
|
|
625
|
+
// `run-start` (design D14), so a receipt read on its own still says so.
|
|
626
|
+
surface: source => enumOf(RUN_SURFACES, source.surface, null),
|
|
627
|
+
stateHome: source => nullableText(source.stateHome)
|
|
447
628
|
}
|
|
448
629
|
}
|
|
449
630
|
|
|
@@ -513,7 +694,7 @@ function nextSeq(file) {
|
|
|
513
694
|
* @param {string} root repo root; `.claude/ship/runs` is created beneath it
|
|
514
695
|
* @param {{
|
|
515
696
|
* runId?: string,
|
|
516
|
-
* type?: 'run-start'|'wave-action'|'cli-exit'|'agent-spawn'|'verify-judgement'|'run-halt'|'run-complete'|'run-receipt',
|
|
697
|
+
* type?: 'run-start'|'wave-action'|'cli-exit'|'agent-spawn'|'agent-result'|'verify-judgement'|'run-halt'|'run-complete'|'run-receipt',
|
|
517
698
|
* change?: string,
|
|
518
699
|
* now?: Date|string,
|
|
519
700
|
* [field: string]: unknown
|
|
@@ -762,6 +943,12 @@ export function deriveWaveElapsed(records) {
|
|
|
762
943
|
current = null
|
|
763
944
|
continue
|
|
764
945
|
}
|
|
946
|
+
// An `agent-result` is timestamped when the CLI recorded the observation,
|
|
947
|
+
// not when the agent ran: on the Workflow host that is the close, and
|
|
948
|
+
// folding it in would pad the last wave with everything between its last
|
|
949
|
+
// event and the close. On the runner it lands beside the record path's own
|
|
950
|
+
// events, which already bound the wave, so skipping it loses nothing.
|
|
951
|
+
if (r.type === 'agent-result') continue
|
|
765
952
|
if (r.type === 'wave-action' && r.wave !== null && r.wave !== undefined && String(r.wave).trim()) {
|
|
766
953
|
current = String(r.wave)
|
|
767
954
|
}
|