@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.
Files changed (55) hide show
  1. package/.claude-plugin/icon.png +0 -0
  2. package/.claude-plugin/plugin.json +109 -1
  3. package/CHANGELOG.md +150 -11
  4. package/bin/interlock +421 -53
  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 +124 -13
  11. package/hooks/recorder.mjs +189 -0
  12. package/lib/agent-usage.mjs +524 -0
  13. package/lib/doctor.mjs +511 -46
  14. package/lib/eval-history.mjs +233 -0
  15. package/lib/evals-capture.mjs +5 -2
  16. package/lib/evals-triage.mjs +1 -1
  17. package/lib/graph/cli.mjs +28 -5
  18. package/lib/graph/retrieve/context.mjs +8 -3
  19. package/lib/host/acp.mjs +77 -14
  20. package/lib/host/claude-cli.mjs +391 -21
  21. package/lib/host/claude-env.mjs +179 -0
  22. package/lib/host/codex.mjs +9 -1
  23. package/lib/host/effort.mjs +163 -0
  24. package/lib/host/model-map.mjs +43 -0
  25. package/lib/host/qwen.mjs +9 -1
  26. package/lib/host/registry.mjs +44 -10
  27. package/lib/host.mjs +46 -5
  28. package/lib/interrupted.mjs +167 -0
  29. package/lib/launch-ledger.mjs +198 -0
  30. package/lib/launch-rule.mjs +174 -0
  31. package/lib/limits.mjs +149 -41
  32. package/lib/meter-quiet.mjs +45 -0
  33. package/lib/notify-defaults.mjs +6 -0
  34. package/lib/notify.mjs +41 -25
  35. package/lib/plan-fingerprint.mjs +80 -11
  36. package/lib/prompts/implementer.mjs +1 -1
  37. package/lib/prompts/verify.mjs +24 -1
  38. package/lib/receipt.mjs +59 -8
  39. package/lib/report-html.mjs +89 -6
  40. package/lib/report.mjs +313 -12
  41. package/lib/resume-card.mjs +90 -2
  42. package/lib/run-log.mjs +192 -5
  43. package/lib/run.mjs +1177 -84
  44. package/lib/state-home.mjs +298 -0
  45. package/lib/verify.mjs +37 -0
  46. package/lib/waves.mjs +53 -16
  47. package/package.json +4 -2
  48. package/shared/TEST-PROFILE.md +10 -5
  49. package/skills/evals/SKILL.md +3 -3
  50. package/skills/fix-tests/SKILL.md +3 -3
  51. package/skills/spec/SKILL.md +1 -6
  52. package/skills/spec/scripts/compact-instructions.mjs +30 -0
  53. package/types/index.d.ts +28 -0
  54. package/workflows/ship.js +75 -51
  55. package/.claude-plugin/marketplace.json +0 -19
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
- const MAX_TEXT = 500
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: nullableBool(value.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
  }