@renzrollon/interlock 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +94 -1
- package/CHANGELOG.md +135 -1
- package/bin/interlock +409 -49
- 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 +149 -41
- package/lib/meter-quiet.mjs +45 -0
- 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 +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/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-
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
id
|
|
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:
|
|
296
|
-
'claude-sonnet-5': { input:
|
|
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
|
-
* `
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
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
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
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
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
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
|
-
|
|
322
|
-
|
|
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`
|
|
341
|
-
* inter-wave
|
|
342
|
-
*
|
|
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.
|
|
364
|
-
*
|
|
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
|
-
/**
|
|
442
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/lib/plan-fingerprint.mjs
CHANGED
|
@@ -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
|
|
48
|
-
*
|
|
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
|
|
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/
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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},
|
|
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
|
-
`-
|
|
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
|
package/lib/prompts/verify.mjs
CHANGED
|
@@ -29,6 +29,27 @@ function retryPreamble(fixAttempt, fixAttemptsRemaining, errors) {
|
|
|
29
29
|
)
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// Shell-quoted, because a planned command is one argument to the wrapper and
|
|
33
|
+
// routinely holds spaces, `&&` or a pipe. The same rule `workflows/ship.js`
|
|
34
|
+
// quotes a relay's argv with.
|
|
35
|
+
const quote = a => (/[^\w@%+=:,./-]/.test(String(a)) ? `'${String(a).replace(/'/g, `'\\''`)}'` : String(a))
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One planned step as the line the agent runs: the command, verbatim, inside
|
|
39
|
+
* `interlock verify exec`, which passes its output and exit code through and
|
|
40
|
+
* records how long it took. The inter-wave budget is charged from that record —
|
|
41
|
+
* the CLI's own clock around the command — rather than from the relay and agent
|
|
42
|
+
* time between the step's dispatch and its judgement.
|
|
43
|
+
*
|
|
44
|
+
* The line is the shell command and nothing else. The briefing says to run each
|
|
45
|
+
* line exactly as written, so a `kind:` label in front of it would be run too —
|
|
46
|
+
* and fail. The kind is already on the line, as `--kind`.
|
|
47
|
+
*/
|
|
48
|
+
function execLine(s) {
|
|
49
|
+
const cwd = typeof s.cwd === 'string' && s.cwd.trim() && s.cwd.trim() !== '.' ? ` --cwd ${quote(s.cwd.trim())}` : ''
|
|
50
|
+
return ` interlock verify exec --kind ${quote(s.kind)}${cwd} --command ${quote(s.command)}`
|
|
51
|
+
}
|
|
52
|
+
|
|
32
53
|
/**
|
|
33
54
|
* @param {object} input
|
|
34
55
|
* @param {string} input.change
|
|
@@ -51,7 +72,7 @@ export function assembleVerifyPrompt({
|
|
|
51
72
|
fixAttemptsRemaining,
|
|
52
73
|
errors
|
|
53
74
|
}) {
|
|
54
|
-
const planned = (Array.isArray(steps) ? steps : []).map(
|
|
75
|
+
const planned = (Array.isArray(steps) ? steps : []).map(execLine).join('\n')
|
|
55
76
|
// A run id the CLI already knows is stated; otherwise the agent is told where
|
|
56
77
|
// to read it, rather than being left to invent one and spill into nowhere.
|
|
57
78
|
const spillRunId = runId
|
|
@@ -66,6 +87,8 @@ export function assembleVerifyPrompt({
|
|
|
66
87
|
`Run these verification steps for change "${change}" and report what happened.\n\n` +
|
|
67
88
|
retry +
|
|
68
89
|
planned +
|
|
90
|
+
`\n\nRun each line above exactly as written, as one shell command: \`interlock verify exec\` runs the command unchanged — its ` +
|
|
91
|
+
`output and exit code are the command's own — and records how long the check took.` +
|
|
69
92
|
// The repair clause is the one sentence a retry must not inherit: "do not
|
|
70
93
|
// repair on a first failure" is exactly wrong on the second attempt, which
|
|
71
94
|
// exists to repair.
|