@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/limits.mjs CHANGED
@@ -149,7 +149,43 @@ export const LIMITS = {
149
149
  * silent head would make it lie, so the truncation is always stated. Read by
150
150
  * `lib/resume-card.mjs`.
151
151
  */
152
- resumeCardListRows: 40
152
+ resumeCardListRows: 40,
153
+
154
+ /**
155
+ * Milliseconds a session's launch ledger stays readable (guard-ship-relaunch
156
+ * design D5). A ledger whose newest launch is older than this reads as absent,
157
+ * and every ledger write sweeps sibling files past it, so the directory does
158
+ * not grow one file per session forever and no stale session can deny a live
159
+ * one. 24 hours is longer than any run `maxRunSteps` and the drivers' runaway
160
+ * backstop allow, so a live session's launch is never aged out while its run
161
+ * could still be in flight; it is shorter than the host's transcript
162
+ * retention, so the directory never outlives the sessions it names. Ageing out
163
+ * only ever turns a deny into an allow. Read by `lib/launch-ledger.mjs`.
164
+ */
165
+ launchLedgerMaxAgeMs: 24 * 60 * 60 * 1000,
166
+
167
+ /**
168
+ * Milliseconds a live ship run may go with no activity the engine reports
169
+ * (a step, a run agent's request or answer, a turn end, a spawn) before the
170
+ * ship meter appends `quiet <n> min` to its status line, spinner and pane
171
+ * (show-quiet-time-and-reset-the-meter-on-clear design D3). Longer than one
172
+ * model request at the deepest effort ordinarily takes to answer, so a lane
173
+ * that is thinking is not called quiet on the strength of one slow request;
174
+ * short enough that a person who looks during a run learns within one wave's
175
+ * time that nothing has moved. A display threshold with no verdict behind it:
176
+ * a wrong value costs an early or a late word, never a halt. Read by
177
+ * `lib/meter-quiet.mjs`, which the hooks module imports.
178
+ */
179
+ meterQuietAfterMs: 5 * 60 * 1000,
180
+
181
+ /**
182
+ * Milliseconds between the ship meter's recomputations of the quiet figure: the
183
+ * period of the one interval the hooks module asks the host for while a run is
184
+ * live (design D2, D3). The word is in whole minutes, so this keeps the shown
185
+ * minute at most this stale, at a few hundred dispatches an hour, which is
186
+ * nothing beside the run's own traffic. Read by `lib/meter-quiet.mjs`.
187
+ */
188
+ meterTickMs: 15 * 1000
153
189
  }
154
190
 
155
191
  /**
@@ -205,6 +241,25 @@ export const EVAL_CAPS = {
205
241
  */
206
242
  shipEvalCostUsd: 20,
207
243
 
244
+ /**
245
+ * US dollars. Ceiling for one invocation of the **cost-per-task sweep**
246
+ * (`evals/ship/matrix.mjs`): the committed fixtures crossed with model and
247
+ * effort, each cell run by the control-arm procedure. A different run from the
248
+ * outcome eval, with its own ceiling, so neither can spend the other's budget.
249
+ *
250
+ * Its reader is the sweep, which reads it from `interlock limits --json` before
251
+ * starting each cell. It is run by hand and gates nothing: no workflow reads
252
+ * it, and no pull request starts the sweep. The number appears nowhere else.
253
+ *
254
+ * Enforced between cells only — a cell in flight is never killed, an unpriced
255
+ * cell adds nothing to the total, and a sweep that reaches it is partial.
256
+ *
257
+ * A first guess in the posture of `shipEvalCostUsd`: twice that ceiling, for a
258
+ * grid of control-arm cells rather than a handful of full loops, to be tuned
259
+ * from the dollars the first rows report.
260
+ */
261
+ matrixCostUsd: 40,
262
+
208
263
  /**
209
264
  * Agent runs per case. Passed as `--runs` by the scheduled full-suite job,
210
265
  * which overrides each case's own value: those were chosen for the fast
@@ -285,41 +340,47 @@ export const EVAL_CAPS = {
285
340
  * `formatLimits()` below.
286
341
  */
287
342
  export const MODEL_PRICES = {
288
- // A NEW id, not an edit under `anthropic-list-2026-09`. The base rates below
289
- // are unchanged, but the table now answers a question the old one could not —
290
- // what a cached prefix costs — and a row keyed to the old id was priced by a
291
- // table with no cache structure at all. The `b` suffix rather than a new month
292
- // because this is a revision of the same published list, not a later one.
293
- id: 'anthropic-list-2026-09b',
343
+ // A NEW id, not an edit under `anthropic-list-2026-09b`. These are the rates
344
+ // confirmed against the published list in October 2026 — a later list, not a
345
+ // revision of the September one — so rows recorded under either September id
346
+ // keep the id, and the meaning, they were written with. The 5.5 models replace
347
+ // `claude-opus-5` and `claude-sonnet-5` rather than sitting beside them: a row
348
+ // naming an old model is unpriced under this id, never priced at a guessed rate.
349
+ id: 'anthropic-list-2026-10',
294
350
  perMillionTokens: {
295
- 'claude-opus-5': { input: 15, output: 75 },
296
- 'claude-sonnet-5': { input: 3, output: 15 },
351
+ 'claude-opus-5-5': { input: 4, output: 20 },
352
+ 'claude-sonnet-5-5': { input: 2, output: 10 },
297
353
  'claude-haiku-4-5': { input: 1, output: 5 }
298
354
  },
299
355
  /**
300
- * What a cached prefix costs, as multipliers on the model's own `input` rate
301
- * — the same shape for every model, which is why they are multipliers rather
302
- * than a fourth and fifth column per model.
356
+ * What a cached prefix costs, as multipliers on the model's own `input` rate.
303
357
  *
304
- * `write` is keyed by LIFETIME TIER and is never flattened: a prefix written
305
- * for five minutes and one written for an hour are different prices, and a
306
- * total that added them could not be priced back apart. The keys are the tier
307
- * names the host's usage envelope reports them under, so a recorded figure
308
- * and its multiplier are looked up by the same string.
358
+ * `read` is keyed by MODEL, by the same ids as `perMillionTokens`, because the
359
+ * published read multiplier is not one figure: Opus 5.5 reads at 0.05x while
360
+ * Sonnet 5.5 and Haiku 4.5 read at 0.1x. A model in `perMillionTokens` with no
361
+ * entry here is unpriced — the pricer never falls back to some other model's
362
+ * read multiplier.
309
363
  *
310
- * A read is a tenth of base, which is the whole economic argument: a prefix
311
- * that survives to be read costs 10% of what re-sending it costs, and a write
312
- * that is never read cost 25% (or 100%) more than not caching at all.
364
+ * `write` is keyed by LIFETIME TIER, shared across every model the table
365
+ * prices, and is never flattened: a prefix written for five minutes and one
366
+ * written for an hour are different prices, and a total that added them could
367
+ * not be priced back apart. The keys are the tier names the host's usage
368
+ * envelope reports them under, so a recorded figure and its multiplier are
369
+ * looked up by the same string.
313
370
  *
314
- * NO READER IN THIS CHANGE, deliberately (design D9): the figures are recorded
315
- * so a dollar number becomes computable, and whether to compute and present one
316
- * is a later decision. They live in the table rather than beside it so the two
317
- * cannot drift — a run priced against one id and multiplied by a differently
318
- * versioned constant is attributable to nothing.
371
+ * Read by `priceUsage` in `evals/ship/arms.mjs`, by these names
372
+ * (`cacheMultipliers.read[model]`, `cacheMultipliers.write[tier]`). They live
373
+ * in the table rather than beside it so the two cannot drift — a run priced
374
+ * against one id and multiplied by a differently versioned constant is
375
+ * attributable to nothing.
319
376
  */
320
377
  cacheMultipliers: {
321
- write: { ephemeral_5m: 1.25, ephemeral_1h: 2 },
322
- read: 0.1
378
+ read: {
379
+ 'claude-opus-5-5': 0.05,
380
+ 'claude-sonnet-5-5': 0.1,
381
+ 'claude-haiku-4-5': 0.1
382
+ },
383
+ write: { ephemeral_5m: 1.25, ephemeral_1h: 2 }
323
384
  }
324
385
  }
325
386
 
@@ -337,9 +398,10 @@ export const MODEL_PRICES = {
337
398
  * `byTier` maps a lane's hardest tier to a workflow-runtime effort. `null` means
338
399
  * "inherit the session default — do not force": tiers 3–4 inherit by policy so
339
400
  * the run keeps whatever the session/model default is (xhigh for coding since
340
- * w16); an untiered lane inherits by fallback. `verify` and `skeptic` pin the
341
- * inter-wave verify and the review skeptics regardless of any lane tier — the
342
- * two steps whose whole job is catching what an implementer missed.
401
+ * w16); an untiered lane inherits by fallback. `verify` pins both verify
402
+ * checks — the inter-wave one and the final one, which one spawn site emits —
403
+ * and `skeptic` pins the review skeptics, regardless of any lane tier: the two
404
+ * steps whose whole job is catching what an implementer missed.
343
405
  */
344
406
  export const EFFORT = {
345
407
  byTier: { 1: 'low', 2: 'low', 3: null, 4: null, 5: 'xhigh' },
@@ -360,8 +422,13 @@ export const EFFORT = {
360
422
  * lane (tier 0) uses the tier-1 entry. Tier 4 keeps the old scalar's 4 —
361
423
  * cross-file pattern-following is where a fresh context per task still pays.
362
424
  * Tier 5 stays at 8. Chain lanes are bounded by this same table: the cap is the
363
- * entry for the chain's hardest tier, packed next-fit. The values were not
364
- * retuned when lanes of two or more tasks began dispatching on opus.
425
+ * entry for the chain's hardest tier, packed next-fit.
426
+ *
427
+ * `opusMinTier` is the floor at which a multi-task lane (collision, cohesion or
428
+ * chain) dispatches on opus. Below it the lane runs on sonnet. Single-task lanes
429
+ * and solo promotions are not gated by this floor — see `laneModel`. The value
430
+ * sits next to the cohesion ceiling because both answer "how hard must the work
431
+ * be before this dial moves."
365
432
  *
366
433
  * `cohesionMaxTier` is the ceiling for COHESION: path-disjoint components in one
367
434
  * dependency layer whose hardest tier is at or below it may be packed into one
@@ -379,7 +446,9 @@ export const EFFORT = {
379
446
  */
380
447
  export const LANE_CAPS = {
381
448
  byTier: { 1: 8, 2: 8, 3: 6, 4: 4, 5: 8 },
382
- cohesionMaxTier: 3
449
+ cohesionMaxTier: 3,
450
+ /** Hardest-task tier at or above which a multi-task lane dispatches on opus. */
451
+ opusMinTier: 4
383
452
  }
384
453
 
385
454
  /**
@@ -438,8 +507,18 @@ export function clampParallel(requested) {
438
507
  return { value: requested, clamped: false, reason: null }
439
508
  }
440
509
 
441
- /** Human-readable dump, so a skill can show the caps without hardcoding them. */
442
- export function formatLimits() {
510
+ /**
511
+ * Human-readable dump, so a skill can show the caps without hardcoding them.
512
+ *
513
+ * `runtimeObserved` is the concurrency this machine's runtime will honour as far
514
+ * as the caller could observe it (`runtimeSlotsOf` in `lib/host/claude-env.mjs`):
515
+ * an override from the environment, or the vendor default beside the CPU count
516
+ * that may reduce it. Printed beside the vendor fact, never in place of it, and
517
+ * read by the caller — this module still reads no environment.
518
+ *
519
+ * @param {{runtimeObserved?: {observed: number|null, source: string, cpuCount: number|null, invalid?: string}}} [opts]
520
+ */
521
+ export function formatLimits({ runtimeObserved } = {}) {
443
522
  const rows = [
444
523
  ['max parallel agents per batch', LIMITS.maxParallel],
445
524
  ['inter-wave fix attempts (per wave)', LIMITS.interWaveFixAttempts],
@@ -455,12 +534,16 @@ export function formatLimits() {
455
534
  ['wave handoff budget (chars, per task)', LIMITS.maxHandoffChars],
456
535
  ['review policy scan cap (bytes)', LIMITS.maxReviewPolicyBytes],
457
536
  ['push timeout (ms)', LIMITS.notifyTimeoutMs],
458
- ['resume card list rows (per list)', LIMITS.resumeCardListRows]
537
+ ['resume card list rows (per list)', LIMITS.resumeCardListRows],
538
+ ['launch ledger max age (ms)', LIMITS.launchLedgerMaxAgeMs],
539
+ ['meter quiet after (ms)', LIMITS.meterQuietAfterMs],
540
+ ['meter tick (ms)', LIMITS.meterTickMs]
459
541
  ]
460
542
  const evalRows = [
461
543
  ['eval smoke cost ceiling (per PR)', `$${EVAL_CAPS.smokeCostUsd}`],
462
544
  ['eval full-run cost ceiling (scheduled)', `$${EVAL_CAPS.fullRunCostUsd}`],
463
545
  ['eval outcome-run cost ceiling (scheduled)', `$${EVAL_CAPS.shipEvalCostUsd}`],
546
+ ['eval cost-per-task sweep ceiling (by hand)', `$${EVAL_CAPS.matrixCostUsd}`],
464
547
  ['eval runs per case', EVAL_CAPS.runsPerCase],
465
548
  ['eval promotion: consecutive qualifying runs', EVAL_CAPS.promotionRuns],
466
549
  ['eval promotion: trials per qualifying run', EVAL_CAPS.promotionTrialsPerRun],
@@ -480,11 +563,21 @@ export function formatLimits() {
480
563
  // The cache multipliers print beside the base rates for the same reason the
481
564
  // base rates print beside the ceiling: they are part of what turns a token
482
565
  // count into a dollar figure, and a multiplier nobody can see is a price
483
- // written in prose. One row per lifetime tier, because they are never summed.
566
+ // written in prose. One read row per model, because the read multiplier is
567
+ // not one figure; one write row per lifetime tier, because they are never
568
+ // summed. A priced model with no read multiplier prints as `unpriced`, which
569
+ // is what the pricer makes of it.
570
+ ...Object.keys(MODEL_PRICES.perMillionTokens)
571
+ .sort()
572
+ .map(model => [
573
+ `cache read multiplier: ${model} (x input)`,
574
+ typeof MODEL_PRICES.cacheMultipliers.read[model] === 'number'
575
+ ? MODEL_PRICES.cacheMultipliers.read[model]
576
+ : 'unpriced'
577
+ ]),
484
578
  ...Object.keys(MODEL_PRICES.cacheMultipliers.write)
485
579
  .sort()
486
- .map(tier => [`cache write multiplier: ${tier} (x input)`, MODEL_PRICES.cacheMultipliers.write[tier]]),
487
- ['cache read multiplier (x input)', MODEL_PRICES.cacheMultipliers.read]
580
+ .map(tier => [`cache write multiplier: ${tier} (x input)`, MODEL_PRICES.cacheMultipliers.write[tier]])
488
581
  ]
489
582
  const reportRows = [['report trajectory scan cap', REPORT_CAPS.maxRunsScanned]]
490
583
  // One row per tier, rather than a rendered object: an operator reading this
@@ -498,6 +591,7 @@ export function formatLimits() {
498
591
  ['lane cap: tier 4 lane', LANE_CAPS.byTier[4]],
499
592
  ['lane cap: tier 5 lane', LANE_CAPS.byTier[5]],
500
593
  ['cohesion tier ceiling (packs at or below)', LANE_CAPS.cohesionMaxTier],
594
+ ['multi-task opus floor (hardest tier at or above)', LANE_CAPS.opusMinTier],
501
595
  ['solo envelope (max tasks in one lane)', SOLO.maxTasks]
502
596
  ]
503
597
  // `null` in the tier table means inherit the session default; print it as
@@ -509,7 +603,7 @@ export function formatLimits() {
509
603
  ['effort: tier 3 lane', inherit(EFFORT.byTier[3])],
510
604
  ['effort: tier 4 lane', inherit(EFFORT.byTier[4])],
511
605
  ['effort: tier 5 lane', inherit(EFFORT.byTier[5])],
512
- ['effort: inter-wave verify step', EFFORT.verify],
606
+ ['effort: verify step (inter-wave and final)', EFFORT.verify],
513
607
  ['effort: review skeptic step', EFFORT.skeptic]
514
608
  ]
515
609
  const width = Math.max(
@@ -531,6 +625,20 @@ export function formatLimits() {
531
625
  '\n\n' +
532
626
  effortRows.map(fmt).join('\n') +
533
627
  `\n\nruntime ceilings: ${RUNTIME.maxConcurrentAgents} concurrent, ` +
534
- `${RUNTIME.maxAgentsPerRun} agents per run\n`
628
+ `${RUNTIME.maxAgentsPerRun} agents per run${observedSlots(runtimeObserved)}\n`
535
629
  )
536
630
  }
631
+
632
+ /** The observed clause of the runtime-ceilings line, or '' when nothing was observed. */
633
+ function observedSlots(observed) {
634
+ if (!observed || typeof observed !== 'object') return ''
635
+ if (observed.source === 'env' && Number.isInteger(observed.observed)) {
636
+ return ` — observed on this machine: CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS=${observed.observed}`
637
+ }
638
+ const machine = Number.isInteger(observed.cpuCount) ? `this ${observed.cpuCount}-CPU machine` : 'this machine'
639
+ const ignored =
640
+ typeof observed.invalid === 'string'
641
+ ? `CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS=${observed.invalid} is not a valid count and is ignored; `
642
+ : ''
643
+ return ` — observed on this machine: ${ignored}vendor default, possibly reduced on ${machine}`
644
+ }
@@ -0,0 +1,45 @@
1
+ // The ship meter's quiet figure (show-quiet-time-and-reset-the-meter-on-clear
2
+ // design D3): how long a live run has gone without activity, and the word the
3
+ // meter shows once that reaches the published threshold.
4
+ //
5
+ // Pure and Node-free: `hooks/mod.mjs` imports it inside the engine, where there
6
+ // is no Node, and `test/spine/mod-pins.test.mjs` walks the import. The two
7
+ // numbers are `interlock limits`'s; neither this file nor the hooks module
8
+ // restates them. The word names no cause: a run waiting on a plan window and a
9
+ // run that hung read the same here, and the person tells them apart from the
10
+ // windows the pane lists beside it.
11
+
12
+ import { LIMITS } from './limits.mjs'
13
+
14
+ const MINUTE_MS = 60 * 1000
15
+
16
+ /** The period of the meter's interval, in milliseconds. */
17
+ export const TICK_MS = LIMITS.meterTickMs
18
+
19
+ /**
20
+ * Milliseconds from the last activity to now, never negative (a clock that went
21
+ * backwards reads as no wait). `null` unless both are finite numbers: a time
22
+ * the module could not read is no figure, never a guessed one.
23
+ *
24
+ * @param {unknown} lastActivityAt milliseconds since the epoch, or null
25
+ * @param {unknown} now milliseconds since the epoch, or null
26
+ * @returns {number|null}
27
+ */
28
+ export function quietMs(lastActivityAt, now) {
29
+ if (!Number.isFinite(lastActivityAt) || !Number.isFinite(now)) return null
30
+ return Math.max(0, now - lastActivityAt)
31
+ }
32
+
33
+ /**
34
+ * `quiet <n> min` once `ms` reaches the threshold, `<n>` the whole minutes and
35
+ * `<1` under one; `null` below it, or for no figure.
36
+ *
37
+ * @param {number|null} ms what `quietMs` returned
38
+ * @param {number} [afterMs] the threshold; the published one unless a test names another
39
+ * @returns {string|null}
40
+ */
41
+ export function quietWord(ms, afterMs = LIMITS.meterQuietAfterMs) {
42
+ if (!Number.isFinite(ms) || ms < afterMs) return null
43
+ const minutes = Math.floor(ms / MINUTE_MS)
44
+ return `quiet ${minutes < 1 ? '<1' : minutes} min`
45
+ }
@@ -0,0 +1,6 @@
1
+ // The server used when an operator names a topic and no server.
2
+ // One spelling, imported by the transport, the doctor and the CLI help,
3
+ // so those three cannot drift.
4
+
5
+ /** The public server, used when no server was configured. */
6
+ export const DEFAULT_NTFY_URL = 'https://ntfy.sh'
package/lib/notify.mjs CHANGED
@@ -1,6 +1,6 @@
1
- // The one network call this CLI ever makes: a push to ntfy.sh (or a
2
- // self-hosted server) so a ship run that stops while nobody is watching can
3
- // still reach someone.
1
+ // The one network call this CLI ever makes: a push to the configured server
2
+ // (the public default, or a server the operator named) so a ship run that
3
+ // stops while nobody is watching can still reach someone.
4
4
  //
5
5
  // Three properties are load-bearing, and every one of them exists because
6
6
  // this module is the CLI's only exception to "no network":
@@ -11,8 +11,8 @@
11
11
  // so there is no settings file this module could misread in the first
12
12
  // place.
13
13
  //
14
- // 2. THE TOPIC NEVER LEAVES. The topic is the only auth ntfy.sh has on a
15
- // public server; it is a capability, not a label. `readNotifyConfig`
14
+ // 2. THE TOPIC NEVER LEAVES. The topic is the only auth the public server
15
+ // has; it is a capability, not a label. `readNotifyConfig`
16
16
  // never echoes it, `sanitizeReason` strips it (and the server URL) from
17
17
  // every failure reason before that reason reaches a summary line, a
18
18
  // receipt event or the trajectory, because a `fetch` failure's own
@@ -29,15 +29,23 @@
29
29
  // design D1, D2, D4, D5, D14 (add-harden-unattended-ship-runs).
30
30
 
31
31
  import { LIMITS } from './limits.mjs'
32
+ import { DEFAULT_NTFY_URL } from './notify-defaults.mjs'
32
33
 
33
- /** Where the topic is read from. Set by the operator, never by the repo. */
34
+ export { DEFAULT_NTFY_URL }
35
+
36
+ /** Where the topic is read from. Chosen by the operator, never by the repo. */
34
37
  export const NOTIFY_TOPIC_ENV = 'INTERLOCK_NTFY_TOPIC'
35
38
 
36
- /** Where a self-hosted server is read from. Unset means the public default. */
39
+ /** Where a self-hosted server is read from. Absent means the public default. */
37
40
  export const NOTIFY_URL_ENV = 'INTERLOCK_NTFY_URL'
38
41
 
39
- /** The public ntfy server, used when `NOTIFY_URL_ENV` is unset. */
40
- export const DEFAULT_NTFY_URL = 'https://ntfy.sh'
42
+ /**
43
+ * Plugin options the host injects into a hook process, from `userConfig`
44
+ * `ntfy_topic` and `ntfy_url`. Preferred over the shell variables when present,
45
+ * so a value the operator typed into the plugin is the one that is shared.
46
+ */
47
+ export const NOTIFY_TOPIC_OPTION = 'CLAUDE_PLUGIN_OPTION_NTFY_TOPIC'
48
+ export const NOTIFY_URL_OPTION = 'CLAUDE_PLUGIN_OPTION_NTFY_URL'
41
49
 
42
50
  function messageOf(err) {
43
51
  return (err && err.message) || String(err)
@@ -60,26 +68,34 @@ function urlShapeInvalid(url) {
60
68
  }
61
69
  }
62
70
 
71
+ function trimmed(source, name) {
72
+ if (!source || typeof source[name] !== 'string') return ''
73
+ return source[name].trim()
74
+ }
75
+
63
76
  /**
64
- * Read push configuration from the environment. Never the tree — `INTERLOCK_
65
- * NTFY_TOPIC` and `INTERLOCK_NTFY_URL` are the whole of it (design D2).
77
+ * Read push configuration. Never the tree. A plugin option the host injected
78
+ * wins over the shell variable of the same meaning, and an absent topic is
79
+ * unconfigured rather than an error (design D2). The shell variables remain
80
+ * the channel for a terminal, CI, and any host that does not inject options.
66
81
  *
67
- * @param {object} env environment to read (injected in tests; `process.env` at call sites)
82
+ * @param {object} source values to read (injected in tests; the process values at call sites)
68
83
  * @returns {{topic: string, url: string} | {topic: null} | {invalid: string}}
69
84
  * `{ topic: null }` when push is simply unconfigured — not an error, the
70
85
  * default and by far the common case. `{ invalid: '<VAR>' }` names the
71
86
  * offending variable and never its value.
72
87
  */
73
- export function readNotifyConfig(env) {
74
- const rawTopic = env && typeof env[NOTIFY_TOPIC_ENV] === 'string' ? env[NOTIFY_TOPIC_ENV].trim() : ''
88
+ export function readNotifyConfig(source) {
89
+ const optionTopic = trimmed(source, NOTIFY_TOPIC_OPTION)
90
+ const shellTopic = trimmed(source, NOTIFY_TOPIC_ENV)
91
+ const rawTopic = optionTopic || shellTopic
75
92
  if (!rawTopic) return { topic: null }
76
- if (topicShapeInvalid(rawTopic)) return { invalid: NOTIFY_TOPIC_ENV }
93
+ if (topicShapeInvalid(rawTopic)) return { invalid: optionTopic ? NOTIFY_TOPIC_OPTION : NOTIFY_TOPIC_ENV }
77
94
 
78
- const rawUrl =
79
- env && typeof env[NOTIFY_URL_ENV] === 'string' && env[NOTIFY_URL_ENV].trim()
80
- ? env[NOTIFY_URL_ENV].trim()
81
- : DEFAULT_NTFY_URL
82
- if (urlShapeInvalid(rawUrl)) return { invalid: NOTIFY_URL_ENV }
95
+ const optionUrl = trimmed(source, NOTIFY_URL_OPTION)
96
+ const shellUrl = trimmed(source, NOTIFY_URL_ENV)
97
+ const rawUrl = optionUrl || shellUrl || DEFAULT_NTFY_URL
98
+ if (urlShapeInvalid(rawUrl)) return { invalid: optionUrl ? NOTIFY_URL_OPTION : NOTIFY_URL_ENV }
83
99
 
84
100
  return { topic: rawTopic, url: rawUrl }
85
101
  }
@@ -156,10 +172,10 @@ export function sanitizeReason(reason, { topic, url } = {}) {
156
172
  * all: EVERY headline this module sends contains an em dash (`SHIP COMPLETE —
157
173
  * <change>`), HTTP header values are ByteStrings, and Node's `fetch` throws
158
174
  * `Cannot convert argument to a ByteString` rather than transmitting one. So
159
- * a non-ASCII value is sent as an RFC 2047 base64 encoded-word, which ntfy
160
- * documents as the supported encoding for exactly this case (docs.ntfy.sh
161
- * §Publish → "ntfy supports UTF-8 in HTTP headers, but not every library or
162
- * programming language does", checked 2026-09-07).
175
+ * a non-ASCII value is sent as an RFC 2047 base64 encoded-word, which the
176
+ * server's publish documentation names as the supported encoding for exactly
177
+ * this case ("UTF-8 in HTTP headers" is not accepted by every client,
178
+ * checked 2026-09-07).
163
179
  *
164
180
  * A pure-ASCII value is passed through untouched, so the common `interlock
165
181
  * notify --title` case stays readable on the wire.
@@ -175,7 +191,7 @@ export function encodeHeaderValue(value) {
175
191
  }
176
192
 
177
193
  /**
178
- * Post one ntfy message. Never throws: every failure mode — invalid
194
+ * Post one message. Never throws: every failure mode — invalid
179
195
  * configuration, a non-2xx status, a network error, the timeout — resolves
180
196
  * to `{ sent: false, reason }`, sanitized, so the caller can always print
181
197
  * something and never has to catch anything.
@@ -36,6 +36,7 @@ import { dirname, join } from 'node:path'
36
36
 
37
37
  import { inspectChange, normalizeTaskMarkers, taskMatchesId } from './artifacts.mjs'
38
38
  import { LIMITS, LANE_CAPS, SOLO } from './limits.mjs'
39
+ import { laneEffort, laneModel, laneTier } from './waves.mjs'
39
40
 
40
41
  /**
41
42
  * The shape of the plan this reader understands.
@@ -44,15 +45,18 @@ import { LIMITS, LANE_CAPS, SOLO } from './limits.mjs'
44
45
  * and still be shaped for a reader that no longer exists. `/1` was the plan
45
46
  * whose batches held tasks; `/2` is the plan whose batches hold lanes; `/3` is
46
47
  * the plan that carries a mode and whose lanes may be cohesion folds; `/4`
47
- * fuses serial single-lane batches into chain lanes. A stored plan carrying
48
- * another version is not reused however well its inputs match.
48
+ * fuses serial single-lane batches into chain lanes; `/5` routes a multi-task
49
+ * lane's model by the opus floor (`LANE_CAPS.opusMinTier`). A stored plan
50
+ * carrying another version is not reused however well its inputs match.
49
51
  *
50
- * The bump is what forces a `/3` plan to be rebuilt rather than reused. A `/3`
52
+ * The bump is what forces an older plan to be rebuilt rather than reused. A `/3`
51
53
  * plan's inputs can match perfectly and it will still hold one agent per serial
52
54
  * batch — reusing it would silently keep the old shape while the run believed
53
- * it had chain lanes.
55
+ * it had chain lanes. A `/4` plan's lane report names opus for every multi-task
56
+ * lane, while dispatch now derives sonnet for one below the floor — reusing it
57
+ * would leave the report and the spawn disagreeing under `PLAN REUSED`.
54
58
  */
55
- export const PLAN_FORMAT = 'interlock.ship-plan/4'
59
+ export const PLAN_FORMAT = 'interlock.ship-plan/5'
56
60
 
57
61
  /** Where the plan and its fingerprint live — machine-local and gitignored. */
58
62
  export const PLAN_PATH = '.claude/ship/plan.json'
@@ -159,10 +163,31 @@ function capsOf(opts = {}) {
159
163
  redWave,
160
164
  laneCaps: { ...LANE_CAPS.byTier },
161
165
  cohesionMaxTier: LANE_CAPS.cohesionMaxTier,
166
+ // Not a shape cap, but the plan's lane report records each multi-task
167
+ // lane's model, and dispatch re-derives it from this floor. A plan built
168
+ // under another floor would be reused with a report naming the old model.
169
+ opusMinTier: LANE_CAPS.opusMinTier,
162
170
  soloMaxTasks: SOLO.maxTasks
163
171
  }
164
172
  }
165
173
 
174
+ /** A fingerprint's artifact digests as one comparable line; `null` when absent. */
175
+ function inputsLine(inputs) {
176
+ return Array.isArray(inputs) ? inputs.map(i => `${i && i.path} ${i && i.sha256}`).join('\n') : null
177
+ }
178
+
179
+ /** The caps a stored fingerprint and a fresh one disagree on, as `name a → b`. */
180
+ function capsChanged(stored, current) {
181
+ const changed = []
182
+ const line = v => (v && typeof v === 'object' ? laneCapsLine(v) : String(v))
183
+ for (const key of ['maxParallel', 'mode', 'redWave', 'laneCaps', 'cohesionMaxTier', 'opusMinTier', 'soloMaxTasks']) {
184
+ const was = stored[key] ?? null
185
+ const now = current[key] ?? null
186
+ if (line(was) !== line(now)) changed.push(`${key} ${line(was)} → ${line(now)}`)
187
+ }
188
+ return changed
189
+ }
190
+
166
191
  /** `1:8 2:8 3:6 4:4 5:8` — the tier table as one stable, sorted line. */
167
192
  function laneCapsLine(table) {
168
193
  return Object.keys(table)
@@ -229,6 +254,7 @@ export function computeFingerprint(root = '.', change, opts = {}) {
229
254
  `mode ${caps.mode}`,
230
255
  `lane-caps ${laneCapsLine(caps.laneCaps)}`,
231
256
  `cohesion-max-tier ${caps.cohesionMaxTier}`,
257
+ `opus-min-tier ${caps.opusMinTier}`,
232
258
  `solo-max-tasks ${caps.soloMaxTasks}`,
233
259
  // OMITTED ENTIRELY when there is no red wave, for the same reason `edges`
234
260
  // is: a plan built without a TDD shape must hash exactly as it did before
@@ -391,11 +417,18 @@ export function checkPlanReuse(root = '.', change, opts = {}) {
391
417
  )
392
418
  }
393
419
  if (stored.hash !== current.hash) {
394
- return noReuse(
395
- REUSE_MISMATCH,
396
- `the change's artifacts have been edited since the plan was built`,
397
- { ...paths, stored, current }
398
- )
420
+ // A cap that moved is named rather than reported as an edit: retuning
421
+ // `interlock limits` re-plans every stored change, and "the artifacts have
422
+ // been edited" would send the operator looking for an edit nobody made.
423
+ // Both are said when both are true; with neither identified, the edit is
424
+ // the explanation that was always given.
425
+ const caps = capsChanged(stored, current)
426
+ const edited = !caps.length || inputsLine(stored.inputs) !== inputsLine(current.inputs)
427
+ const reasons = [
428
+ ...(caps.length ? [`the caps the plan was built under have changed (${caps.join('; ')})`] : []),
429
+ ...(edited ? [`the change's artifacts have been edited since the plan was built`] : [])
430
+ ]
431
+ return noReuse(REUSE_MISMATCH, reasons.join(', and '), { ...paths, stored, current })
399
432
  }
400
433
 
401
434
  return {
@@ -465,9 +498,44 @@ export function narrowPlan(plan, isComplete) {
465
498
  const laneCount = all.reduce((n, w) => n + w.batches.reduce((m, b) => m + b.length, 0), 0)
466
499
  const taskCount = all.reduce((n, w) => n + w.taskCount, 0)
467
500
 
501
+ // The lane report is re-derived from the lanes that will dispatch, by the same
502
+ // functions dispatch reads. Copied through, it would keep a dropped task's id,
503
+ // and a two-task lane narrowed to one would still be listed under the model of
504
+ // a pair while its spawn asks for the remaining task's own model.
505
+ const report = {}
506
+ if (Array.isArray(plan.lanes)) {
507
+ const byId = new Map()
508
+ for (const wave of all) {
509
+ for (const batch of wave.batches) for (const lane of batch) for (const t of lane) byId.set(t.id, t)
510
+ }
511
+ const lanes = []
512
+ for (const entry of plan.lanes) {
513
+ const tasks = (Array.isArray(entry && entry.ids) ? entry.ids : []).map(id => byId.get(id)).filter(Boolean)
514
+ // A lane of one is not a fold, and the planner does not list it.
515
+ if (tasks.length < 2) continue
516
+ lanes.push({
517
+ ...entry,
518
+ ids: tasks.map(t => t.id),
519
+ tier: laneTier(tasks),
520
+ model: laneModel(tasks),
521
+ effort: laneEffort(tasks)
522
+ })
523
+ }
524
+ report.lanes = lanes
525
+ if (Array.isArray(plan.effort)) {
526
+ report.effort = lanes.map(l => ({
527
+ ids: l.ids,
528
+ tier: l.tier,
529
+ effort: l.effort,
530
+ inherited: l.effort !== null ? null : l.tier >= 1 && l.tier <= 5 ? 'policy' : 'fallback'
531
+ }))
532
+ }
533
+ }
534
+
468
535
  return {
469
536
  plan: {
470
537
  ...plan,
538
+ ...report,
471
539
  waves,
472
540
  testWave,
473
541
  waveCount: waves.length,
@@ -560,7 +628,8 @@ export function formatFingerprint(fp) {
560
628
  )
561
629
  lines.push(
562
630
  ` lane-caps ${laneCapsLine(fp.laneCaps || LANE_CAPS.byTier)}, ` +
563
- `cohesion-max-tier ${fp.cohesionMaxTier}, solo-max-tasks ${fp.soloMaxTasks}`
631
+ `cohesion-max-tier ${fp.cohesionMaxTier}, opus-min-tier ${fp.opusMinTier}, ` +
632
+ `solo-max-tasks ${fp.soloMaxTasks}`
564
633
  )
565
634
  if (fp.edges) lines.push(` ${fp.edges}`)
566
635
  for (const i of fp.inputs || []) {
@@ -130,7 +130,7 @@ export function assembleImplementerPrompt({ change, lane, task, previousHandoffs
130
130
  `- Do not fix unrelated problems you notice; report them instead.\n` +
131
131
  `- Run typecheck and lint on what you changed.\n` +
132
132
  `- Do not commit, and do not edit tasks.md — the orchestrator owns both.\n` +
133
- `- If .claude/graph/graph.json exists, interlock-graph query / consumers before grep.\n` +
133
+ `- Run interlock-graph query / consumers before grep; if it reports no graph, grep.\n` +
134
134
  `- Locate (graph or grep) then Read spans. Do not re-read a file unless it changed.\n` +
135
135
  `- Return the schema only. No narrative.\n` +
136
136
  (tier <= 2