@renzrollon/interlock 1.0.2 → 1.1.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.
package/lib/waves.mjs CHANGED
@@ -8,9 +8,12 @@
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. Sonnet handles tiers 1-4; opus survives only on a
12
- // true tier-5 task. The clamp is applied after classification so the model
13
- // cannot escalate itself, and every clamp is reported.
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
15
+ // (rule 8) without rewriting any task's recorded model. Every clamp is
16
+ // reported.
14
17
  //
15
18
  // 2. Fan-out cap. A wave is a set of independent tasks, so the temptation is
16
19
  // to spawn all of them at once. The original implementation did exactly
@@ -44,6 +47,8 @@
44
47
  // colliding tasks become one lane. Lanes in a batch are path-disjoint and
45
48
  // run in parallel; `LANE_CAPS.byTier` bounds how much one interruption can
46
49
  // discard, and a uniform override of 1 reproduces one agent per task.
50
+ // Inside a layer a lane is a collision or a cohesion fold. A chain (rule 8)
51
+ // is formed later, from batches, and does not change how those two are built.
47
52
  //
48
53
  // 6. Cohesion. Collision alone made every path-disjoint task its own agent,
49
54
  // however small: seven tasks each writing one `evals/*/case.yaml` were
@@ -53,6 +58,7 @@
53
58
  // hardest component first, bounded by that lane's per-tier cap. Tier 4 and
54
59
  // 5 work is excluded on purpose: cross-file pattern-following is where a
55
60
  // fresh context per task still pays for itself. Every fold is reported.
61
+ // Cohesion stays inside one dependency layer of one section.
56
62
  //
57
63
  // 7. Solo. A small change may be planned as ONE lane — every implementation
58
64
  // task in section, layer and id order, then every test task — run by one
@@ -61,6 +67,16 @@
61
67
  // classifier's recommendation if the change fits, then waves. The mode,
62
68
  // its source and its reason are on the plan, because a run that silently
63
69
  // changed its own shape is the failure the plan preview exists to remove.
70
+ // A solo plan is not chain-fused. One agent for the whole change is already
71
+ // what solo is.
72
+ //
73
+ // 8. Chain fusion. After the singleton fold, and inside the trailing test
74
+ // wave, every run of consecutive batches that each hold exactly one lane
75
+ // becomes one chain lane, packed next-fit under the same per-tier cap. A
76
+ // batch of two or more lanes is left alone, so work that was going to run
77
+ // concurrently still does. Fusion never crosses a wave. A chain runs in
78
+ // the batch order it was fused from, never re-sorted by task id, and it
79
+ // dispatches on opus because it holds more than one task.
64
80
  //
65
81
  // The second half of this file *executes* that plan — as a state machine, not a
66
82
  // runner. The orchestration is moving into a dynamic-workflow script, which can
@@ -111,6 +127,8 @@ export const LANE_COLLISION = 'collision'
111
127
  export const LANE_COHESION = 'cohesion'
112
128
  /** The single lane of a solo plan: the whole change, in order, on one agent. */
113
129
  export const LANE_SOLO = 'solo'
130
+ /** A lane fused from consecutive single-lane batches of one wave. */
131
+ export const LANE_CHAIN = 'chain'
114
132
 
115
133
  /** The two plan shapes. `auto` is not one — it is the absence of an override. */
116
134
  export const MODE_WAVES = 'waves'
@@ -487,13 +505,6 @@ function capForTier(caps, tier) {
487
505
  return Number.isInteger(cap) && cap > 0 ? cap : 1
488
506
  }
489
507
 
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
508
  /**
498
509
  * The label a lane runs under — the name its agent, its trajectory line and its
499
510
  * briefing file all carry.
@@ -507,10 +518,18 @@ export function laneLabel(lane) {
507
518
  return lane.length === 1 ? lane[0].id : `${lane[0].id}+${lane.length - 1}`
508
519
  }
509
520
 
521
+ /**
522
+ * The model a lane dispatches on, derived from the lane's shape.
523
+ *
524
+ * A lane of two or more tasks is the work agent the planner chose not to split,
525
+ * so it dispatches on opus. A lane of one task dispatches on that task's clamped
526
+ * model — haiku or sonnet, or opus for a tier-5 task or a solo promotion. Nothing
527
+ * here rewrites `task.model`: a narrowed lane of one returns to the task's model.
528
+ */
510
529
  export function laneModel(lane) {
511
- const tier = laneTier(lane)
512
- const hardest = lane.find(t => t.tier === tier)
513
- return (hardest && hardest.model) || (lane[0] && lane[0].model) || 'sonnet'
530
+ if (lane.length >= 2) return 'opus'
531
+ const only = lane[0]
532
+ return (only && only.model) || 'sonnet'
514
533
  }
515
534
 
516
535
  /**
@@ -522,8 +541,9 @@ export function laneModel(lane) {
522
541
  *
523
542
  * Returns `null` (inherit the session default — do not force) for tiers 3–4 by
524
543
  * policy and for an untiered lane (`laneTier` → 0) by fallback; the plan's
525
- * effort report distinguishes the two. Effort is orthogonal to the model clamp:
526
- * both are derived from tier so a future model tweak never silently moves effort.
544
+ * effort report distinguishes the two. Effort is derived from tier alone. The
545
+ * model a lane dispatches on follows the lane's shape, so an opus lane of
546
+ * tier-1 or tier-2 tasks still runs at `low`.
527
547
  */
528
548
  export function laneEffort(lane) {
529
549
  const tier = laneTier(lane)
@@ -817,6 +837,67 @@ function foldSingletonWaves(planned) {
817
837
  return { waves, folded }
818
838
  }
819
839
 
840
+ /**
841
+ * Fuse consecutive single-lane batches of one wave into chain lanes.
842
+ *
843
+ * A batch of one lane buys no parallelism, so next-fit appends it to the open
844
+ * chain while the chain stays within the cap for the hardest tier across the
845
+ * chain and the lane being appended. A batch of two or more lanes closes the
846
+ * open chain and is emitted untouched. A chain drawn from a single batch keeps
847
+ * that batch's array identity; a chain drawn from two or more is one new lane,
848
+ * in batch order, never re-sorted by task id.
849
+ *
850
+ * @param {Array<Array<Array>>} batches
851
+ * @param {Record<number, number>} laneCaps
852
+ * @returns {{batches: Array, chains: Array}}
853
+ */
854
+ function fuseSerialBatches(batches, laneCaps) {
855
+ const out = []
856
+ const chains = []
857
+ let open = null
858
+
859
+ const emitOpen = () => {
860
+ if (!open) return
861
+ if (open.sources === 1) {
862
+ out.push(open.batch)
863
+ } else {
864
+ const lane = open.tasks
865
+ out.push([lane])
866
+ chains.push({
867
+ tasks: lane,
868
+ ids: lane.map(t => t.id),
869
+ fusedBatches: open.sources,
870
+ tier: open.tier,
871
+ cap: capForTier(laneCaps, open.tier)
872
+ })
873
+ }
874
+ open = null
875
+ }
876
+
877
+ for (const batch of batches) {
878
+ if (!Array.isArray(batch) || batch.length !== 1) {
879
+ emitOpen()
880
+ out.push(batch)
881
+ continue
882
+ }
883
+ const lane = batch[0]
884
+ const tier = laneTier(lane)
885
+ if (open) {
886
+ const hardest = Math.max(open.tier, tier)
887
+ if (open.tasks.length + lane.length <= capForTier(laneCaps, hardest)) {
888
+ open.tasks.push(...lane)
889
+ open.tier = hardest
890
+ open.sources += 1
891
+ continue
892
+ }
893
+ emitOpen()
894
+ }
895
+ open = { tasks: lane.slice(), tier, sources: 1, batch }
896
+ }
897
+ emitOpen()
898
+ return { batches: out, chains }
899
+ }
900
+
820
901
  /**
821
902
  * Every claimed path on every task is documentation.
822
903
  *
@@ -1067,11 +1148,15 @@ export function planWaves(input, opts = {}) {
1067
1148
  const deferred = []
1068
1149
  const laneEntries = []
1069
1150
  const promoted = []
1151
+ const chains = []
1070
1152
  let planned = []
1071
1153
  let waves = []
1072
1154
  let folded = []
1073
1155
  let testWave = null
1074
1156
  let widest = 0
1157
+ // Post-fold, pre-fusion. Solo never fuses, and its lane count is 1, so the
1158
+ // serial warning's threshold is the same either way.
1159
+ let preFusionLaneCount = 0
1075
1160
 
1076
1161
  if (solo) {
1077
1162
  // Every task on one agent, so there is nothing to pack: `foldSingletonWaves`,
@@ -1183,31 +1268,78 @@ export function planWaves(input, opts = {}) {
1183
1268
  }
1184
1269
  testWave = { taskCount: testTasks.length, batches }
1185
1270
  }
1271
+
1272
+ // Measured before fusion. A chain the planner is about to fuse is not a
1273
+ // shape the operator still has to fix, so the serial warning keeps the
1274
+ // post-fold count it had before this step existed.
1275
+ preFusionLaneCount = waves.reduce((n, w) => n + waveLanes(w).length, 0)
1276
+ const fusedWaves = []
1277
+ for (const wave of waves) {
1278
+ const fused = fuseSerialBatches(wave.batches, laneCaps)
1279
+ for (const chain of fused.chains) chains.push({ group: wave.group, ...chain })
1280
+ fusedWaves.push({ ...wave, batches: fused.batches })
1281
+ }
1282
+ waves = fusedWaves
1283
+ if (testWave) {
1284
+ const fused = fuseSerialBatches(testWave.batches, laneCaps)
1285
+ for (const chain of fused.chains) chains.push({ group: null, ...chain })
1286
+ testWave = { ...testWave, batches: fused.batches }
1287
+ }
1186
1288
  }
1187
1289
 
1188
1290
  const implCount = implTasks.length
1189
1291
 
1190
- // Every lane holding more than one task, so a fold is visible rather than
1191
- // inferred from a smaller agent bill. A lane of one is not a fold and is not
1192
- // listed — the plan would otherwise report a "fold" for every task it has.
1292
+ // Every dispatched lane holding more than one task, so a fold is visible
1293
+ // rather than inferred from a smaller agent bill. A lane of one is not a fold
1294
+ // and is not listed.
1193
1295
  //
1194
- // Read off the entries the packer produced rather than re-walked from the
1195
- // waves, because `kind` is a fact about how the lane was BUILT and cannot be
1196
- // recovered from the lane's contents afterwards. The test wave's lanes are
1197
- // listed too (with a null group): a cohesion fold there is exactly as much a
1198
- // fold as one in an implementation wave.
1296
+ // Solo reads the build-time entries: there is one lane and its kind is a fact
1297
+ // about how it was built. Waves mode reads the lanes that will dispatch, after
1298
+ // fusion. A chain's kind cannot be recovered from its tasks, so it is taken
1299
+ // from the fusion record; a lane fusion left untouched keeps the array the
1300
+ // packer built, and its kind comes from that entry. The test wave's lanes are
1301
+ // listed too (with a null group).
1302
+ const listedLane = (group, tasks, kind, cap, extra) => ({
1303
+ group,
1304
+ kind,
1305
+ ids: tasks.map(t => t.id),
1306
+ tier: laneTier(tasks),
1307
+ cap,
1308
+ model: laneModel(tasks),
1309
+ effort: laneEffort(tasks),
1310
+ ...extra
1311
+ })
1199
1312
  const lanes = []
1200
- for (const { group, entry } of laneEntries) {
1201
- if (entry.tasks.length < 2) continue
1202
- lanes.push({
1203
- group,
1204
- kind: entry.kind,
1205
- ids: entry.tasks.map(t => t.id),
1206
- tier: laneTier(entry.tasks),
1207
- cap: entry.cap,
1208
- model: laneModel(entry.tasks),
1209
- effort: laneEffort(entry.tasks)
1210
- })
1313
+ if (solo) {
1314
+ for (const { group, entry } of laneEntries) {
1315
+ if (entry.tasks.length < 2) continue
1316
+ lanes.push(listedLane(group, entry.tasks, entry.kind, entry.cap))
1317
+ }
1318
+ } else {
1319
+ const chainByTasks = new Map(chains.map(c => [c.tasks, c]))
1320
+ const entryByTasks = new Map(laneEntries.map(({ entry }) => [entry.tasks, entry]))
1321
+ const consider = (group, wave) => {
1322
+ if (!wave) return
1323
+ for (const lane of waveLanes(wave)) {
1324
+ if (lane.length < 2) continue
1325
+ const chain = chainByTasks.get(lane)
1326
+ if (chain) {
1327
+ lanes.push(listedLane(group, lane, LANE_CHAIN, chain.cap, { fusedBatches: chain.fusedBatches }))
1328
+ continue
1329
+ }
1330
+ const entry = entryByTasks.get(lane)
1331
+ lanes.push(
1332
+ listedLane(
1333
+ group,
1334
+ lane,
1335
+ entry ? entry.kind : LANE_COLLISION,
1336
+ entry ? entry.cap : capForTier(laneCaps, laneTier(lane))
1337
+ )
1338
+ )
1339
+ }
1340
+ }
1341
+ for (const wave of waves) consider(wave.group, wave)
1342
+ if (testWave) consider(null, testWave)
1211
1343
  }
1212
1344
  const laneCount = waves.reduce((n, w) => n + waveLanes(w).length, 0)
1213
1345
 
@@ -1243,36 +1375,47 @@ export function planWaves(input, opts = {}) {
1243
1375
  `widest wave has ${widest} tasks; splitting into batches of ${maxParallel}`
1244
1376
  )
1245
1377
  }
1246
- // Measured on the collapsed list and in lanes, because a lane is one agent:
1247
- // a staircase the fold already removed, or a chain the lanes already folded,
1248
- // is not a shape the operator still has to fix.
1249
- if (laneCount >= 2 && waves.length > laneCount * 0.5) {
1378
+ // Measured on the post-fold, pre-fusion lane count. A chain the planner has
1379
+ // already fused is not a shape the operator still has to fix, and neither is
1380
+ // a staircase the singleton fold already removed.
1381
+ if (preFusionLaneCount >= 2 && waves.length > preFusionLaneCount * 0.5) {
1250
1382
  warnings.push(
1251
- `effectively serial: ${waves.length} waves for ${laneCount} implementation lane(s)`
1383
+ `effectively serial: ${waves.length} waves for ${preFusionLaneCount} implementation lane(s)`
1252
1384
  )
1253
1385
  }
1254
- // One warning per fold, phrased by KIND. A cohesion fold is not a collision:
1255
- // an operator reading "share a file" about seven path-disjoint tasks would go
1256
- // looking for a file they do not share.
1257
- for (const lane of lanes) {
1258
- if (lane.kind === LANE_SOLO) {
1386
+ // Build-time warnings, phrased by KIND, from the entries the packer produced.
1387
+ // A cohesion fold that later became part of a chain is still named here. A
1388
+ // cohesion fold is not a collision: an operator reading "share a file" about
1389
+ // seven path-disjoint tasks would go looking for a file they do not share.
1390
+ for (const { group, entry } of laneEntries) {
1391
+ if (entry.tasks.length < 2) continue
1392
+ const ids = entry.tasks.map(t => t.id)
1393
+ const tier = laneTier(entry.tasks)
1394
+ if (entry.kind === LANE_SOLO) {
1259
1395
  warnings.push(
1260
- `solo lane: tasks ${lane.ids.join(', ')} run in that order on one opus agent — ` +
1396
+ `solo lane: tasks ${ids.join(', ')} run in that order on one opus agent — ` +
1261
1397
  `every section barrier and dependency edge is honoured by position in the lane`
1262
1398
  )
1263
- } else if (lane.kind === LANE_COHESION) {
1399
+ } else if (entry.kind === LANE_COHESION) {
1264
1400
  warnings.push(
1265
- `tasks ${lane.ids.join(', ')} are path-disjoint tier-${lane.tier} work in ` +
1266
- `${laneWhere(lane.group)}; packed into one cohesion lane run by a single agent in that ` +
1267
- `order (tier-${lane.tier} lane cap ${lane.cap})`
1401
+ `tasks ${ids.join(', ')} are path-disjoint tier-${tier} work in ` +
1402
+ `${laneWhere(group)}; packed into one cohesion lane run by a single agent in that ` +
1403
+ `order (tier-${tier} lane cap ${entry.cap})`
1268
1404
  )
1269
1405
  } else {
1270
1406
  warnings.push(
1271
- `tasks ${lane.ids.join(', ')} share a file in ${laneWhere(lane.group)}; folded into one ` +
1272
- `lane run by a single tier-${lane.tier} agent in that order`
1407
+ `tasks ${ids.join(', ')} share a file in ${laneWhere(group)}; folded into one ` +
1408
+ `lane run by a single tier-${tier} agent in that order`
1273
1409
  )
1274
1410
  }
1275
1411
  }
1412
+ for (const chain of chains) {
1413
+ warnings.push(
1414
+ `tasks ${chain.ids.join(', ')} were ${chain.fusedBatches} consecutive single-lane batches in ` +
1415
+ `${laneWhere(chain.group)}; fused into one chain lane run by one opus agent in that order ` +
1416
+ `(tier-${chain.tier} lane cap ${chain.cap})`
1417
+ )
1418
+ }
1276
1419
  for (const f of folded) {
1277
1420
  warnings.push(
1278
1421
  `task ${f.id} was a 1-task wave in group ${f.from}; folded into wave ${f.to} as a later ` +
@@ -1311,7 +1454,7 @@ export function planWaves(input, opts = {}) {
1311
1454
  ? `task ${d.id} declares dependsOn ${d.after.join(', ')}; it runs after them inside the ` +
1312
1455
  `solo lane rather than beside them`
1313
1456
  : `task ${d.id} declares dependsOn ${d.after.join(', ')}; it runs after them in ` +
1314
- `${laneWhere(d.group)}, in a later batch or wave rather than beside them`
1457
+ `${laneWhere(d.group)}, in a later batch, wave or lane position rather than beside them`
1315
1458
  )
1316
1459
  }
1317
1460
 
@@ -1434,6 +1577,12 @@ export function formatPlan(plan) {
1434
1577
  ` cohesion ${lane.ids.join(' → ')}: ${lane.ids.length} path-disjoint tasks in ` +
1435
1578
  `${laneWhere(lane.group)} on one ${lane.model}/T${lane.tier} agent (cap ${lane.cap})`
1436
1579
  )
1580
+ } else if (lane.kind === LANE_CHAIN) {
1581
+ lines.push(
1582
+ ` chain ${lane.ids.join(' → ')}: ${lane.ids.length} tasks in ` +
1583
+ `${laneWhere(lane.group)} on one ${lane.model}/T${lane.tier} agent ` +
1584
+ `(${lane.fusedBatches} batches, cap ${lane.cap})`
1585
+ )
1437
1586
  } else {
1438
1587
  lines.push(
1439
1588
  ` lane ${lane.ids.join(' → ')}: ${lane.ids.length} tasks in ${laneWhere(lane.group)} on ` +
@@ -1782,30 +1931,38 @@ function makeWave(kind, group, tasks, maxParallel, laneCaps, opts = {}) {
1782
1931
  // outcome-audit fields already make: a run resumed across an upgrade must
1783
1932
  // still be able to replan.
1784
1933
  const caps = laneCaps && typeof laneCaps === 'object' ? laneCaps : LANE_CAPS.byTier
1934
+ // Cloned, then clamped, so a replanned task obeys the same model clamp the
1935
+ // planner applied at plan time. The caller's objects are left untouched.
1936
+ const clamped = []
1937
+ const cloned = tasks.map(t => {
1938
+ const copy = { ...t }
1939
+ const record = clampModel(copy)
1940
+ if (record) clamped.push(record)
1941
+ return copy
1942
+ })
1943
+ // Layered by `dependsOn` the way initial planning is, so a revision that
1944
+ // carries edges keeps their order. The layers become later BATCHES here
1945
+ // rather than separate waves, because a replan revises one group into one
1946
+ // wave — and those single-lane batches then fuse under the caps the run was
1947
+ // planned with, the same way planWaves fuses after the fold.
1948
+ const built = layerByDependencies(cloned).flatMap(
1949
+ layer => collisionAwareChunk(layer, maxParallel, serialized, group, caps).batches
1950
+ )
1951
+ const fused = fuseSerialBatches(built, caps)
1785
1952
  return {
1786
- kind,
1787
- group,
1788
- // Preserved across a replan. The marker is what stops the red wave's
1789
- // failing suite being treated as a defect, so a revision that rebuilt the
1790
- // group without it would restore exactly the halt this wave exists to
1791
- // avoid — and would do it only on replanned runs, which is the hardest
1792
- // version of that bug to find.
1793
- ...(opts.red === true ? { red: true } : {}),
1794
- taskCount: tasks.length,
1795
- // A replanned group is laned under the SAME caps the rest of the run was
1796
- // planned with, read off the state. Falling back to the current published
1797
- // table would let a replan mid-run produce lanes of a different length than
1798
- // the plan the run started from — including under a uniform override, which
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
- )
1953
+ clamped,
1954
+ wave: {
1955
+ kind,
1956
+ group,
1957
+ // Preserved across a replan. The marker is what stops the red wave's
1958
+ // failing suite being treated as a defect, so a revision that rebuilt the
1959
+ // group without it would restore exactly the halt this wave exists to
1960
+ // avoid — and would do it only on replanned runs, which is the hardest
1961
+ // version of that bug to find.
1962
+ ...(opts.red === true ? { red: true } : {}),
1963
+ taskCount: cloned.length,
1964
+ batches: fused.batches
1965
+ }
1809
1966
  }
1810
1967
  }
1811
1968
 
@@ -2525,13 +2682,15 @@ export function applyReplan(state, revisedGroups) {
2525
2682
  const wasRed = next.waves[at] && next.waves[at].red === true
2526
2683
  if (!revision.tasks.length) next.waves.splice(at, spans)
2527
2684
  else {
2528
- next.waves.splice(
2529
- at,
2530
- spans,
2531
- makeWave('impl', revision.group, revision.tasks, next.maxParallel, next.laneCaps, {
2532
- red: wasRed
2533
- })
2534
- )
2685
+ const made = makeWave('impl', revision.group, revision.tasks, next.maxParallel, next.laneCaps, {
2686
+ red: wasRed
2687
+ })
2688
+ next.waves.splice(at, spans, made.wave)
2689
+ for (const c of made.clamped) {
2690
+ next.warnings.push(
2691
+ `task ${c.id} clamped ${c.from} → ${c.to} (tier ${c.tier}) on replan of group ${revision.group}`
2692
+ )
2693
+ }
2535
2694
  }
2536
2695
  continue
2537
2696
  }
@@ -2550,7 +2709,13 @@ export function applyReplan(state, revisedGroups) {
2550
2709
  break
2551
2710
  }
2552
2711
  }
2553
- next.waves.splice(insertAt, 0, makeWave('impl', revision.group, revision.tasks, next.maxParallel, next.laneCaps))
2712
+ const made = makeWave('impl', revision.group, revision.tasks, next.maxParallel, next.laneCaps)
2713
+ next.waves.splice(insertAt, 0, made.wave)
2714
+ for (const c of made.clamped) {
2715
+ next.warnings.push(
2716
+ `task ${c.id} clamped ${c.from} → ${c.to} (tier ${c.tier}) on replan of group ${revision.group}`
2717
+ )
2718
+ }
2554
2719
  }
2555
2720
 
2556
2721
  next.replansUsed += 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@renzrollon/interlock",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
4
4
  "description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec",
5
5
  "type": "module",
6
6
  "engines": {
@@ -110,10 +110,17 @@ one you need. When Rule 0 applies, graph query/consumers counts as “locate.”
110
110
 
111
111
  At session start, current-task context comes from a few cheap, authoritative
112
112
  sources — `git log --oneline`, `git status --short`, `openspec status --json`,
113
- an open-task grep over `openspec/changes/*/tasks.md`, and the latest handoff.
113
+ an open-task grep over `openspec/changes/*/tasks.md`, and the latest explore
114
+ brief (`.claude/handoff/explore-*.md`).
114
115
  Front-load these in one pass. Read them,
115
116
  then act. Do not re-traverse the tree to reconstruct what they already tell you.
116
117
 
118
+ The glob is load-bearing, not tidiness. `.claude/handoff/` also holds
119
+ `ship-*.md` halt resume cards, which are records of runs that already stopped —
120
+ nothing reads them, and finding one is not permission to start anything. Reading
121
+ "the latest handoff" unqualified after a halt would hand you one as if it were
122
+ session context.
123
+
117
124
  **Explore brief as Rule-3 state:** when `/interlock:spec` runs after explore
118
125
  (especially after `/clear` or prompt-cache expiry), read the latest matching
119
126
  `.claude/handoff/explore-*.md` first (see `EXPLORE-BRIEF.md`).