@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/waves.mjs
CHANGED
|
@@ -8,9 +8,13 @@
|
|
|
8
8
|
// Two rules exist specifically to stop the classifier from harming the run:
|
|
9
9
|
//
|
|
10
10
|
// 1. Model clamp. Classifiers reliably over-assign `opus` to anything that
|
|
11
|
-
// touches several files.
|
|
12
|
-
//
|
|
13
|
-
// cannot escalate itself
|
|
11
|
+
// touches several files. The clamp keeps a task's recorded model at haiku
|
|
12
|
+
// or sonnet below tier 5, and opus only on a true tier-5 task, so the
|
|
13
|
+
// model cannot escalate itself. That clamped model is what a single-task
|
|
14
|
+
// lane dispatches on. A lane of two or more tasks dispatches on opus only
|
|
15
|
+
// when its hardest tier clears `LANE_CAPS.opusMinTier` (or every task
|
|
16
|
+
// already carries opus, as in solo); otherwise it runs on sonnet. Every
|
|
17
|
+
// clamp is reported.
|
|
14
18
|
//
|
|
15
19
|
// 2. Fan-out cap. A wave is a set of independent tasks, so the temptation is
|
|
16
20
|
// to spawn all of them at once. The original implementation did exactly
|
|
@@ -44,6 +48,8 @@
|
|
|
44
48
|
// colliding tasks become one lane. Lanes in a batch are path-disjoint and
|
|
45
49
|
// run in parallel; `LANE_CAPS.byTier` bounds how much one interruption can
|
|
46
50
|
// discard, and a uniform override of 1 reproduces one agent per task.
|
|
51
|
+
// Inside a layer a lane is a collision or a cohesion fold. A chain (rule 8)
|
|
52
|
+
// is formed later, from batches, and does not change how those two are built.
|
|
47
53
|
//
|
|
48
54
|
// 6. Cohesion. Collision alone made every path-disjoint task its own agent,
|
|
49
55
|
// however small: seven tasks each writing one `evals/*/case.yaml` were
|
|
@@ -53,6 +59,7 @@
|
|
|
53
59
|
// hardest component first, bounded by that lane's per-tier cap. Tier 4 and
|
|
54
60
|
// 5 work is excluded on purpose: cross-file pattern-following is where a
|
|
55
61
|
// fresh context per task still pays for itself. Every fold is reported.
|
|
62
|
+
// Cohesion stays inside one dependency layer of one section.
|
|
56
63
|
//
|
|
57
64
|
// 7. Solo. A small change may be planned as ONE lane — every implementation
|
|
58
65
|
// task in section, layer and id order, then every test task — run by one
|
|
@@ -61,6 +68,17 @@
|
|
|
61
68
|
// classifier's recommendation if the change fits, then waves. The mode,
|
|
62
69
|
// its source and its reason are on the plan, because a run that silently
|
|
63
70
|
// changed its own shape is the failure the plan preview exists to remove.
|
|
71
|
+
// A solo plan is not chain-fused. One agent for the whole change is already
|
|
72
|
+
// what solo is.
|
|
73
|
+
//
|
|
74
|
+
// 8. Chain fusion. After the singleton fold, and inside the trailing test
|
|
75
|
+
// wave, every run of consecutive batches that each hold exactly one lane
|
|
76
|
+
// becomes one chain lane, packed next-fit under the same per-tier cap. A
|
|
77
|
+
// batch of two or more lanes is left alone, so work that was going to run
|
|
78
|
+
// concurrently still does. Fusion never crosses a wave. A chain runs in
|
|
79
|
+
// the batch order it was fused from, never re-sorted by task id. Its model
|
|
80
|
+
// follows `laneModel` — opus only when the hardest tier clears the
|
|
81
|
+
// published floor, otherwise sonnet.
|
|
64
82
|
//
|
|
65
83
|
// The second half of this file *executes* that plan — as a state machine, not a
|
|
66
84
|
// runner. The orchestration is moving into a dynamic-workflow script, which can
|
|
@@ -111,6 +129,8 @@ export const LANE_COLLISION = 'collision'
|
|
|
111
129
|
export const LANE_COHESION = 'cohesion'
|
|
112
130
|
/** The single lane of a solo plan: the whole change, in order, on one agent. */
|
|
113
131
|
export const LANE_SOLO = 'solo'
|
|
132
|
+
/** A lane fused from consecutive single-lane batches of one wave. */
|
|
133
|
+
export const LANE_CHAIN = 'chain'
|
|
114
134
|
|
|
115
135
|
/** The two plan shapes. `auto` is not one — it is the absence of an override. */
|
|
116
136
|
export const MODE_WAVES = 'waves'
|
|
@@ -487,13 +507,6 @@ function capForTier(caps, tier) {
|
|
|
487
507
|
return Number.isInteger(cap) && cap > 0 ? cap : 1
|
|
488
508
|
}
|
|
489
509
|
|
|
490
|
-
/**
|
|
491
|
-
* The model a lane dispatches on — the one assigned to its hardest task.
|
|
492
|
-
*
|
|
493
|
-
* A lane is one agent, and that agent has to be capable of everything in it.
|
|
494
|
-
* Taking the first task's model would run a tier-5 task on whatever tier 1 was
|
|
495
|
-
* given, which is the one direction of this trade that is not survivable.
|
|
496
|
-
*/
|
|
497
510
|
/**
|
|
498
511
|
* The label a lane runs under — the name its agent, its trajectory line and its
|
|
499
512
|
* briefing file all carry.
|
|
@@ -507,10 +520,53 @@ export function laneLabel(lane) {
|
|
|
507
520
|
return lane.length === 1 ? lane[0].id : `${lane[0].id}+${lane.length - 1}`
|
|
508
521
|
}
|
|
509
522
|
|
|
523
|
+
/** Separates a spawn's label from the words its title adds. */
|
|
524
|
+
export const TITLE_SEPARATOR = ' · '
|
|
525
|
+
|
|
526
|
+
/** The longest title a lane displays under, ellipsis included. */
|
|
527
|
+
const TITLE_MAX = 48
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* The name a lane is SHOWN under — its label, then the first words of its first
|
|
531
|
+
* task's description: `1.1+5 · Add the relaunch guard to…`.
|
|
532
|
+
*
|
|
533
|
+
* Display only. Everything that joins on a lane — its briefing file, worktree,
|
|
534
|
+
* trajectory line, record-batch fold — keys on `laneLabel`, because a key built
|
|
535
|
+
* from prose would change whenever a task was reworded. It is still a pure
|
|
536
|
+
* function of the lane, so a replay displays, and cache-hits, the same agent.
|
|
537
|
+
*/
|
|
538
|
+
export function laneTitle(lane) {
|
|
539
|
+
const label = laneLabel(lane)
|
|
540
|
+
const first = lane[0] && typeof lane[0].description === 'string' ? lane[0].description : ''
|
|
541
|
+
const words = first.replace(/[`*_]/g, '').replace(/\s+/g, ' ').trim().split(' ').slice(0, 6).join(' ')
|
|
542
|
+
const gist = words.replace(/[\s.,;:—-]+$/, '')
|
|
543
|
+
if (!gist) return label
|
|
544
|
+
const full = `${label}${TITLE_SEPARATOR}${gist}`
|
|
545
|
+
return full.length <= TITLE_MAX ? full : `${full.slice(0, TITLE_MAX - 1).trimEnd()}…`
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* The model a lane dispatches on, derived from the lane's shape and hardest tier.
|
|
550
|
+
*
|
|
551
|
+
* A lane of one task dispatches on that task's clamped model — haiku or sonnet,
|
|
552
|
+
* or opus for a tier-5 task or a solo promotion. A lane of two or more tasks is
|
|
553
|
+
* the work agent the planner chose not to split: it dispatches on opus when its
|
|
554
|
+
* hardest tier is at or above `LANE_CAPS.opusMinTier`, or when every task already
|
|
555
|
+
* carries opus (solo promotion of the whole change). Otherwise it runs on
|
|
556
|
+
* sonnet — routine multi-task work no longer pays the flagship model by default.
|
|
557
|
+
* Nothing here rewrites `task.model`: a narrowed lane of one returns to the
|
|
558
|
+
* task's model.
|
|
559
|
+
*/
|
|
510
560
|
export function laneModel(lane) {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
561
|
+
if (!Array.isArray(lane) || lane.length === 0) return 'sonnet'
|
|
562
|
+
if (lane.length === 1) {
|
|
563
|
+
const only = lane[0]
|
|
564
|
+
return (only && only.model) || 'sonnet'
|
|
565
|
+
}
|
|
566
|
+
// Solo promotions rewrite every task to opus after the clamp; an all-opus
|
|
567
|
+
// multi-task lane is that decision (or a pure tier-5 pack), not the floor.
|
|
568
|
+
if (lane.every(t => t && t.model === 'opus')) return 'opus'
|
|
569
|
+
return laneTier(lane) >= LANE_CAPS.opusMinTier ? 'opus' : 'sonnet'
|
|
514
570
|
}
|
|
515
571
|
|
|
516
572
|
/**
|
|
@@ -522,8 +578,9 @@ export function laneModel(lane) {
|
|
|
522
578
|
*
|
|
523
579
|
* Returns `null` (inherit the session default — do not force) for tiers 3–4 by
|
|
524
580
|
* policy and for an untiered lane (`laneTier` → 0) by fallback; the plan's
|
|
525
|
-
* effort report distinguishes the two. Effort is
|
|
526
|
-
*
|
|
581
|
+
* effort report distinguishes the two. Effort is derived from tier alone. The
|
|
582
|
+
* model a lane dispatches on follows shape and the opus floor, so a multi-task
|
|
583
|
+
* lane of tier-1 or tier-2 tasks runs on sonnet at `low`.
|
|
527
584
|
*/
|
|
528
585
|
export function laneEffort(lane) {
|
|
529
586
|
const tier = laneTier(lane)
|
|
@@ -817,6 +874,67 @@ function foldSingletonWaves(planned) {
|
|
|
817
874
|
return { waves, folded }
|
|
818
875
|
}
|
|
819
876
|
|
|
877
|
+
/**
|
|
878
|
+
* Fuse consecutive single-lane batches of one wave into chain lanes.
|
|
879
|
+
*
|
|
880
|
+
* A batch of one lane buys no parallelism, so next-fit appends it to the open
|
|
881
|
+
* chain while the chain stays within the cap for the hardest tier across the
|
|
882
|
+
* chain and the lane being appended. A batch of two or more lanes closes the
|
|
883
|
+
* open chain and is emitted untouched. A chain drawn from a single batch keeps
|
|
884
|
+
* that batch's array identity; a chain drawn from two or more is one new lane,
|
|
885
|
+
* in batch order, never re-sorted by task id.
|
|
886
|
+
*
|
|
887
|
+
* @param {Array<Array<Array>>} batches
|
|
888
|
+
* @param {Record<number, number>} laneCaps
|
|
889
|
+
* @returns {{batches: Array, chains: Array}}
|
|
890
|
+
*/
|
|
891
|
+
function fuseSerialBatches(batches, laneCaps) {
|
|
892
|
+
const out = []
|
|
893
|
+
const chains = []
|
|
894
|
+
let open = null
|
|
895
|
+
|
|
896
|
+
const emitOpen = () => {
|
|
897
|
+
if (!open) return
|
|
898
|
+
if (open.sources === 1) {
|
|
899
|
+
out.push(open.batch)
|
|
900
|
+
} else {
|
|
901
|
+
const lane = open.tasks
|
|
902
|
+
out.push([lane])
|
|
903
|
+
chains.push({
|
|
904
|
+
tasks: lane,
|
|
905
|
+
ids: lane.map(t => t.id),
|
|
906
|
+
fusedBatches: open.sources,
|
|
907
|
+
tier: open.tier,
|
|
908
|
+
cap: capForTier(laneCaps, open.tier)
|
|
909
|
+
})
|
|
910
|
+
}
|
|
911
|
+
open = null
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
for (const batch of batches) {
|
|
915
|
+
if (!Array.isArray(batch) || batch.length !== 1) {
|
|
916
|
+
emitOpen()
|
|
917
|
+
out.push(batch)
|
|
918
|
+
continue
|
|
919
|
+
}
|
|
920
|
+
const lane = batch[0]
|
|
921
|
+
const tier = laneTier(lane)
|
|
922
|
+
if (open) {
|
|
923
|
+
const hardest = Math.max(open.tier, tier)
|
|
924
|
+
if (open.tasks.length + lane.length <= capForTier(laneCaps, hardest)) {
|
|
925
|
+
open.tasks.push(...lane)
|
|
926
|
+
open.tier = hardest
|
|
927
|
+
open.sources += 1
|
|
928
|
+
continue
|
|
929
|
+
}
|
|
930
|
+
emitOpen()
|
|
931
|
+
}
|
|
932
|
+
open = { tasks: lane.slice(), tier, sources: 1, batch }
|
|
933
|
+
}
|
|
934
|
+
emitOpen()
|
|
935
|
+
return { batches: out, chains }
|
|
936
|
+
}
|
|
937
|
+
|
|
820
938
|
/**
|
|
821
939
|
* Every claimed path on every task is documentation.
|
|
822
940
|
*
|
|
@@ -1067,11 +1185,15 @@ export function planWaves(input, opts = {}) {
|
|
|
1067
1185
|
const deferred = []
|
|
1068
1186
|
const laneEntries = []
|
|
1069
1187
|
const promoted = []
|
|
1188
|
+
const chains = []
|
|
1070
1189
|
let planned = []
|
|
1071
1190
|
let waves = []
|
|
1072
1191
|
let folded = []
|
|
1073
1192
|
let testWave = null
|
|
1074
1193
|
let widest = 0
|
|
1194
|
+
// Post-fold, pre-fusion. Solo never fuses, and its lane count is 1, so the
|
|
1195
|
+
// serial warning's threshold is the same either way.
|
|
1196
|
+
let preFusionLaneCount = 0
|
|
1075
1197
|
|
|
1076
1198
|
if (solo) {
|
|
1077
1199
|
// Every task on one agent, so there is nothing to pack: `foldSingletonWaves`,
|
|
@@ -1183,31 +1305,78 @@ export function planWaves(input, opts = {}) {
|
|
|
1183
1305
|
}
|
|
1184
1306
|
testWave = { taskCount: testTasks.length, batches }
|
|
1185
1307
|
}
|
|
1308
|
+
|
|
1309
|
+
// Measured before fusion. A chain the planner is about to fuse is not a
|
|
1310
|
+
// shape the operator still has to fix, so the serial warning keeps the
|
|
1311
|
+
// post-fold count it had before this step existed.
|
|
1312
|
+
preFusionLaneCount = waves.reduce((n, w) => n + waveLanes(w).length, 0)
|
|
1313
|
+
const fusedWaves = []
|
|
1314
|
+
for (const wave of waves) {
|
|
1315
|
+
const fused = fuseSerialBatches(wave.batches, laneCaps)
|
|
1316
|
+
for (const chain of fused.chains) chains.push({ group: wave.group, ...chain })
|
|
1317
|
+
fusedWaves.push({ ...wave, batches: fused.batches })
|
|
1318
|
+
}
|
|
1319
|
+
waves = fusedWaves
|
|
1320
|
+
if (testWave) {
|
|
1321
|
+
const fused = fuseSerialBatches(testWave.batches, laneCaps)
|
|
1322
|
+
for (const chain of fused.chains) chains.push({ group: null, ...chain })
|
|
1323
|
+
testWave = { ...testWave, batches: fused.batches }
|
|
1324
|
+
}
|
|
1186
1325
|
}
|
|
1187
1326
|
|
|
1188
1327
|
const implCount = implTasks.length
|
|
1189
1328
|
|
|
1190
|
-
// Every lane holding more than one task, so a fold is visible
|
|
1191
|
-
// inferred from a smaller agent bill. A lane of one is not a fold
|
|
1192
|
-
//
|
|
1329
|
+
// Every dispatched lane holding more than one task, so a fold is visible
|
|
1330
|
+
// rather than inferred from a smaller agent bill. A lane of one is not a fold
|
|
1331
|
+
// and is not listed.
|
|
1193
1332
|
//
|
|
1194
|
-
//
|
|
1195
|
-
//
|
|
1196
|
-
//
|
|
1197
|
-
//
|
|
1198
|
-
//
|
|
1333
|
+
// Solo reads the build-time entries: there is one lane and its kind is a fact
|
|
1334
|
+
// about how it was built. Waves mode reads the lanes that will dispatch, after
|
|
1335
|
+
// fusion. A chain's kind cannot be recovered from its tasks, so it is taken
|
|
1336
|
+
// from the fusion record; a lane fusion left untouched keeps the array the
|
|
1337
|
+
// packer built, and its kind comes from that entry. The test wave's lanes are
|
|
1338
|
+
// listed too (with a null group).
|
|
1339
|
+
const listedLane = (group, tasks, kind, cap, extra) => ({
|
|
1340
|
+
group,
|
|
1341
|
+
kind,
|
|
1342
|
+
ids: tasks.map(t => t.id),
|
|
1343
|
+
tier: laneTier(tasks),
|
|
1344
|
+
cap,
|
|
1345
|
+
model: laneModel(tasks),
|
|
1346
|
+
effort: laneEffort(tasks),
|
|
1347
|
+
...extra
|
|
1348
|
+
})
|
|
1199
1349
|
const lanes = []
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
group,
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1350
|
+
if (solo) {
|
|
1351
|
+
for (const { group, entry } of laneEntries) {
|
|
1352
|
+
if (entry.tasks.length < 2) continue
|
|
1353
|
+
lanes.push(listedLane(group, entry.tasks, entry.kind, entry.cap))
|
|
1354
|
+
}
|
|
1355
|
+
} else {
|
|
1356
|
+
const chainByTasks = new Map(chains.map(c => [c.tasks, c]))
|
|
1357
|
+
const entryByTasks = new Map(laneEntries.map(({ entry }) => [entry.tasks, entry]))
|
|
1358
|
+
const consider = (group, wave) => {
|
|
1359
|
+
if (!wave) return
|
|
1360
|
+
for (const lane of waveLanes(wave)) {
|
|
1361
|
+
if (lane.length < 2) continue
|
|
1362
|
+
const chain = chainByTasks.get(lane)
|
|
1363
|
+
if (chain) {
|
|
1364
|
+
lanes.push(listedLane(group, lane, LANE_CHAIN, chain.cap, { fusedBatches: chain.fusedBatches }))
|
|
1365
|
+
continue
|
|
1366
|
+
}
|
|
1367
|
+
const entry = entryByTasks.get(lane)
|
|
1368
|
+
lanes.push(
|
|
1369
|
+
listedLane(
|
|
1370
|
+
group,
|
|
1371
|
+
lane,
|
|
1372
|
+
entry ? entry.kind : LANE_COLLISION,
|
|
1373
|
+
entry ? entry.cap : capForTier(laneCaps, laneTier(lane))
|
|
1374
|
+
)
|
|
1375
|
+
)
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
for (const wave of waves) consider(wave.group, wave)
|
|
1379
|
+
if (testWave) consider(null, testWave)
|
|
1211
1380
|
}
|
|
1212
1381
|
const laneCount = waves.reduce((n, w) => n + waveLanes(w).length, 0)
|
|
1213
1382
|
|
|
@@ -1243,36 +1412,47 @@ export function planWaves(input, opts = {}) {
|
|
|
1243
1412
|
`widest wave has ${widest} tasks; splitting into batches of ${maxParallel}`
|
|
1244
1413
|
)
|
|
1245
1414
|
}
|
|
1246
|
-
// Measured on the
|
|
1247
|
-
// a
|
|
1248
|
-
//
|
|
1249
|
-
if (
|
|
1415
|
+
// Measured on the post-fold, pre-fusion lane count. A chain the planner has
|
|
1416
|
+
// already fused is not a shape the operator still has to fix, and neither is
|
|
1417
|
+
// a staircase the singleton fold already removed.
|
|
1418
|
+
if (preFusionLaneCount >= 2 && waves.length > preFusionLaneCount * 0.5) {
|
|
1250
1419
|
warnings.push(
|
|
1251
|
-
`effectively serial: ${waves.length} waves for ${
|
|
1420
|
+
`effectively serial: ${waves.length} waves for ${preFusionLaneCount} implementation lane(s)`
|
|
1252
1421
|
)
|
|
1253
1422
|
}
|
|
1254
|
-
//
|
|
1255
|
-
//
|
|
1256
|
-
//
|
|
1257
|
-
for
|
|
1258
|
-
|
|
1423
|
+
// Build-time warnings, phrased by KIND, from the entries the packer produced.
|
|
1424
|
+
// A cohesion fold that later became part of a chain is still named here. A
|
|
1425
|
+
// cohesion fold is not a collision: an operator reading "share a file" about
|
|
1426
|
+
// seven path-disjoint tasks would go looking for a file they do not share.
|
|
1427
|
+
for (const { group, entry } of laneEntries) {
|
|
1428
|
+
if (entry.tasks.length < 2) continue
|
|
1429
|
+
const ids = entry.tasks.map(t => t.id)
|
|
1430
|
+
const tier = laneTier(entry.tasks)
|
|
1431
|
+
if (entry.kind === LANE_SOLO) {
|
|
1259
1432
|
warnings.push(
|
|
1260
|
-
`solo lane: tasks ${
|
|
1433
|
+
`solo lane: tasks ${ids.join(', ')} run in that order on one opus agent — ` +
|
|
1261
1434
|
`every section barrier and dependency edge is honoured by position in the lane`
|
|
1262
1435
|
)
|
|
1263
|
-
} else if (
|
|
1436
|
+
} else if (entry.kind === LANE_COHESION) {
|
|
1264
1437
|
warnings.push(
|
|
1265
|
-
`tasks ${
|
|
1266
|
-
`${laneWhere(
|
|
1267
|
-
`order (tier-${
|
|
1438
|
+
`tasks ${ids.join(', ')} are path-disjoint tier-${tier} work in ` +
|
|
1439
|
+
`${laneWhere(group)}; packed into one cohesion lane run by a single agent in that ` +
|
|
1440
|
+
`order (tier-${tier} lane cap ${entry.cap})`
|
|
1268
1441
|
)
|
|
1269
1442
|
} else {
|
|
1270
1443
|
warnings.push(
|
|
1271
|
-
`tasks ${
|
|
1272
|
-
`lane run by a single tier-${
|
|
1444
|
+
`tasks ${ids.join(', ')} share a file in ${laneWhere(group)}; folded into one ` +
|
|
1445
|
+
`lane run by a single tier-${tier} agent in that order`
|
|
1273
1446
|
)
|
|
1274
1447
|
}
|
|
1275
1448
|
}
|
|
1449
|
+
for (const chain of chains) {
|
|
1450
|
+
warnings.push(
|
|
1451
|
+
`tasks ${chain.ids.join(', ')} were ${chain.fusedBatches} consecutive single-lane batches in ` +
|
|
1452
|
+
`${laneWhere(chain.group)}; fused into one chain lane run by one ${laneModel(chain.tasks)} agent in that order ` +
|
|
1453
|
+
`(tier-${chain.tier} lane cap ${chain.cap})`
|
|
1454
|
+
)
|
|
1455
|
+
}
|
|
1276
1456
|
for (const f of folded) {
|
|
1277
1457
|
warnings.push(
|
|
1278
1458
|
`task ${f.id} was a 1-task wave in group ${f.from}; folded into wave ${f.to} as a later ` +
|
|
@@ -1311,7 +1491,7 @@ export function planWaves(input, opts = {}) {
|
|
|
1311
1491
|
? `task ${d.id} declares dependsOn ${d.after.join(', ')}; it runs after them inside the ` +
|
|
1312
1492
|
`solo lane rather than beside them`
|
|
1313
1493
|
: `task ${d.id} declares dependsOn ${d.after.join(', ')}; it runs after them in ` +
|
|
1314
|
-
`${laneWhere(d.group)}, in a later batch or
|
|
1494
|
+
`${laneWhere(d.group)}, in a later batch, wave or lane position rather than beside them`
|
|
1315
1495
|
)
|
|
1316
1496
|
}
|
|
1317
1497
|
|
|
@@ -1434,6 +1614,12 @@ export function formatPlan(plan) {
|
|
|
1434
1614
|
` cohesion ${lane.ids.join(' → ')}: ${lane.ids.length} path-disjoint tasks in ` +
|
|
1435
1615
|
`${laneWhere(lane.group)} on one ${lane.model}/T${lane.tier} agent (cap ${lane.cap})`
|
|
1436
1616
|
)
|
|
1617
|
+
} else if (lane.kind === LANE_CHAIN) {
|
|
1618
|
+
lines.push(
|
|
1619
|
+
` chain ${lane.ids.join(' → ')}: ${lane.ids.length} tasks in ` +
|
|
1620
|
+
`${laneWhere(lane.group)} on one ${lane.model}/T${lane.tier} agent ` +
|
|
1621
|
+
`(${lane.fusedBatches} batches, cap ${lane.cap})`
|
|
1622
|
+
)
|
|
1437
1623
|
} else {
|
|
1438
1624
|
lines.push(
|
|
1439
1625
|
` lane ${lane.ids.join(' → ')}: ${lane.ids.length} tasks in ${laneWhere(lane.group)} on ` +
|
|
@@ -1782,30 +1968,38 @@ function makeWave(kind, group, tasks, maxParallel, laneCaps, opts = {}) {
|
|
|
1782
1968
|
// outcome-audit fields already make: a run resumed across an upgrade must
|
|
1783
1969
|
// still be able to replan.
|
|
1784
1970
|
const caps = laneCaps && typeof laneCaps === 'object' ? laneCaps : LANE_CAPS.byTier
|
|
1971
|
+
// Cloned, then clamped, so a replanned task obeys the same model clamp the
|
|
1972
|
+
// planner applied at plan time. The caller's objects are left untouched.
|
|
1973
|
+
const clamped = []
|
|
1974
|
+
const cloned = tasks.map(t => {
|
|
1975
|
+
const copy = { ...t }
|
|
1976
|
+
const record = clampModel(copy)
|
|
1977
|
+
if (record) clamped.push(record)
|
|
1978
|
+
return copy
|
|
1979
|
+
})
|
|
1980
|
+
// Layered by `dependsOn` the way initial planning is, so a revision that
|
|
1981
|
+
// carries edges keeps their order. The layers become later BATCHES here
|
|
1982
|
+
// rather than separate waves, because a replan revises one group into one
|
|
1983
|
+
// wave — and those single-lane batches then fuse under the caps the run was
|
|
1984
|
+
// planned with, the same way planWaves fuses after the fold.
|
|
1985
|
+
const built = layerByDependencies(cloned).flatMap(
|
|
1986
|
+
layer => collisionAwareChunk(layer, maxParallel, serialized, group, caps).batches
|
|
1987
|
+
)
|
|
1988
|
+
const fused = fuseSerialBatches(built, caps)
|
|
1785
1989
|
return {
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
// is already folded into the table the state carries.
|
|
1800
|
-
//
|
|
1801
|
-
// And it is layered by `dependsOn` the way initial planning is, so a
|
|
1802
|
-
// revision that carries edges keeps their order. The layers become later
|
|
1803
|
-
// BATCHES here rather than separate waves, because a replan revises one
|
|
1804
|
-
// group into one wave — a later batch expresses the same ordering, which is
|
|
1805
|
-
// what the singleton fold would have collapsed the extra wave into anyway.
|
|
1806
|
-
batches: layerByDependencies(tasks).flatMap(
|
|
1807
|
-
layer => collisionAwareChunk(layer, maxParallel, serialized, group, caps).batches
|
|
1808
|
-
)
|
|
1990
|
+
clamped,
|
|
1991
|
+
wave: {
|
|
1992
|
+
kind,
|
|
1993
|
+
group,
|
|
1994
|
+
// Preserved across a replan. The marker is what stops the red wave's
|
|
1995
|
+
// failing suite being treated as a defect, so a revision that rebuilt the
|
|
1996
|
+
// group without it would restore exactly the halt this wave exists to
|
|
1997
|
+
// avoid — and would do it only on replanned runs, which is the hardest
|
|
1998
|
+
// version of that bug to find.
|
|
1999
|
+
...(opts.red === true ? { red: true } : {}),
|
|
2000
|
+
taskCount: cloned.length,
|
|
2001
|
+
batches: fused.batches
|
|
2002
|
+
}
|
|
1809
2003
|
}
|
|
1810
2004
|
}
|
|
1811
2005
|
|
|
@@ -2525,13 +2719,15 @@ export function applyReplan(state, revisedGroups) {
|
|
|
2525
2719
|
const wasRed = next.waves[at] && next.waves[at].red === true
|
|
2526
2720
|
if (!revision.tasks.length) next.waves.splice(at, spans)
|
|
2527
2721
|
else {
|
|
2528
|
-
next.
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2722
|
+
const made = makeWave('impl', revision.group, revision.tasks, next.maxParallel, next.laneCaps, {
|
|
2723
|
+
red: wasRed
|
|
2724
|
+
})
|
|
2725
|
+
next.waves.splice(at, spans, made.wave)
|
|
2726
|
+
for (const c of made.clamped) {
|
|
2727
|
+
next.warnings.push(
|
|
2728
|
+
`task ${c.id} clamped ${c.from} → ${c.to} (tier ${c.tier}) on replan of group ${revision.group}`
|
|
2729
|
+
)
|
|
2730
|
+
}
|
|
2535
2731
|
}
|
|
2536
2732
|
continue
|
|
2537
2733
|
}
|
|
@@ -2550,7 +2746,13 @@ export function applyReplan(state, revisedGroups) {
|
|
|
2550
2746
|
break
|
|
2551
2747
|
}
|
|
2552
2748
|
}
|
|
2553
|
-
|
|
2749
|
+
const made = makeWave('impl', revision.group, revision.tasks, next.maxParallel, next.laneCaps)
|
|
2750
|
+
next.waves.splice(insertAt, 0, made.wave)
|
|
2751
|
+
for (const c of made.clamped) {
|
|
2752
|
+
next.warnings.push(
|
|
2753
|
+
`task ${c.id} clamped ${c.from} → ${c.to} (tier ${c.tier}) on replan of group ${revision.group}`
|
|
2754
|
+
)
|
|
2755
|
+
}
|
|
2554
2756
|
}
|
|
2555
2757
|
|
|
2556
2758
|
next.replansUsed += 1
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@renzrollon/interlock",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"workflows",
|
|
19
19
|
"skills",
|
|
20
20
|
"hooks",
|
|
21
|
+
"types",
|
|
21
22
|
"agents",
|
|
22
23
|
"shared",
|
|
23
24
|
".claude-plugin",
|
package/shared/TEST-PROFILE.md
CHANGED
|
@@ -5,8 +5,9 @@ Durable, machine-readable record of how a project's tests run. Owned by
|
|
|
5
5
|
re-sniff `package.json` on every run.
|
|
6
6
|
|
|
7
7
|
**Path:** `.claude/testing/profile.json`
|
|
8
|
-
**Human mirror:** idempotent `## Testing` block in
|
|
9
|
-
|
|
8
|
+
**Human mirror:** idempotent `## Testing` block in the project's instruction
|
|
9
|
+
file — `CLAUDE.md`, `.claude/CLAUDE.md`, or `AGENTS.md` on Cursor-only
|
|
10
|
+
projects — between HTML comment markers (see below).
|
|
10
11
|
|
|
11
12
|
Learned gotchas discovered mid-fix go to
|
|
12
13
|
`.claude/memory/failure-modes/<slug>.md` (max 3 per session) — **not** into
|
|
@@ -153,9 +154,13 @@ reuse as-is.
|
|
|
153
154
|
|
|
154
155
|
## Human mirror (`## Testing` block)
|
|
155
156
|
|
|
156
|
-
Write (or replace) content **between** these markers inside
|
|
157
|
-
|
|
158
|
-
is
|
|
157
|
+
Write (or replace) content **between** these markers inside the first of
|
|
158
|
+
these that exists: `CLAUDE.md` at the root, then `.claude/CLAUDE.md`, then
|
|
159
|
+
`AGENTS.md` (if that is the project's agent doc). Create a root `CLAUDE.md`
|
|
160
|
+
only when none of the three exists. A repository that is also a Claude Code
|
|
161
|
+
plugin keeps its instructions in `.claude/CLAUDE.md`, because
|
|
162
|
+
`claude plugin validate --strict` rejects a `CLAUDE.md` at the plugin root; a
|
|
163
|
+
mirror that recreated one there would turn that repository's CI red.
|
|
159
164
|
|
|
160
165
|
```markdown
|
|
161
166
|
## Testing
|
|
@@ -34,7 +34,7 @@ Resolve `typecheck.command` and `lint.command` on the same pass, the way `covera
|
|
|
34
34
|
|
|
35
35
|
This is the only skill permitted to interview the user about tests. Every later run is zero-question.
|
|
36
36
|
|
|
37
|
-
Persist the profile and mirror it into the `## Testing` block
|
|
37
|
+
Persist the profile and mirror it into the `## Testing` block of the first instruction file that exists — `CLAUDE.md`, then `.claude/CLAUDE.md`, then `AGENTS.md` — between the managed markers, idempotently.
|
|
38
38
|
|
|
39
39
|
---
|
|
40
40
|
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Interlock's type contract: the session state its hooks module keeps
|
|
2
|
+
// (guard-ship-relaunch-in-process design D2). `.claude-plugin/plugin.json`
|
|
3
|
+
// names this file as `types`; `claude plugin validate` holds every `$.state`
|
|
4
|
+
// key `hooks/mod.mjs` reads or writes to the keys declared here.
|
|
5
|
+
//
|
|
6
|
+
// One key, the launch guard's record, in the shape `lib/launch-rule.mjs`
|
|
7
|
+
// reads and the settings form's file ledger holds: times are ISO strings.
|
|
8
|
+
|
|
9
|
+
/** One ship launch the runtime accepted, as its result named it. */
|
|
10
|
+
export type Launch = {
|
|
11
|
+
at: string
|
|
12
|
+
runId: string | null
|
|
13
|
+
workflowName: string | null
|
|
14
|
+
scriptPath: string | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The session's launches and the time of its last human prompt. */
|
|
18
|
+
export type LaunchRecord = {
|
|
19
|
+
schema: 'interlock.launch-ledger/1'
|
|
20
|
+
launches: Launch[]
|
|
21
|
+
lastHumanPromptAt: string | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
declare module 'claude-code' {
|
|
25
|
+
interface PluginState {
|
|
26
|
+
interlock: { ledger: LaunchRecord }
|
|
27
|
+
}
|
|
28
|
+
}
|