@renzrollon/interlock 1.0.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.
Files changed (125) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +58 -0
  3. package/CHANGELOG.md +380 -0
  4. package/LICENSE +21 -0
  5. package/README.md +358 -0
  6. package/agents/ping.md +8 -0
  7. package/agents/worker.md +8 -0
  8. package/bin/interlock +2655 -0
  9. package/bin/interlock-graph +6 -0
  10. package/bin/interlock-run +508 -0
  11. package/bin/interlock-ship-acp +39 -0
  12. package/hooks/_shared.mjs +162 -0
  13. package/hooks/guard-commit.mjs +77 -0
  14. package/hooks/guard-tasks.mjs +107 -0
  15. package/hooks/guard-tests.mjs +63 -0
  16. package/hooks/preflight.mjs +107 -0
  17. package/lib/artifacts.mjs +316 -0
  18. package/lib/autonomy.mjs +149 -0
  19. package/lib/conformance.mjs +132 -0
  20. package/lib/doctor.mjs +1176 -0
  21. package/lib/drift.mjs +409 -0
  22. package/lib/eval-history.mjs +304 -0
  23. package/lib/evals-calibrate.mjs +317 -0
  24. package/lib/evals-capture.mjs +437 -0
  25. package/lib/evals-promote.mjs +257 -0
  26. package/lib/evals-triage.mjs +216 -0
  27. package/lib/findings.mjs +340 -0
  28. package/lib/graph/cli.mjs +266 -0
  29. package/lib/graph/extract/config-json.mjs +74 -0
  30. package/lib/graph/extract/create-extractor.mjs +35 -0
  31. package/lib/graph/extract/memory.mjs +99 -0
  32. package/lib/graph/extract/openspec.mjs +80 -0
  33. package/lib/graph/extract/python.mjs +137 -0
  34. package/lib/graph/extract/regex-fallback.mjs +187 -0
  35. package/lib/graph/extract/shell.mjs +71 -0
  36. package/lib/graph/extract/typescript.mjs +217 -0
  37. package/lib/graph/extract/walk.mjs +272 -0
  38. package/lib/graph/format.mjs +51 -0
  39. package/lib/graph/graph/build.mjs +374 -0
  40. package/lib/graph/graph/query.mjs +249 -0
  41. package/lib/graph/graph/report.mjs +122 -0
  42. package/lib/graph/graph/update.mjs +47 -0
  43. package/lib/graph/paths.mjs +66 -0
  44. package/lib/graph/retrieve/context.mjs +86 -0
  45. package/lib/graph/retrieve/docs-index.mjs +116 -0
  46. package/lib/graph/retrieve/markdown.mjs +246 -0
  47. package/lib/host/acp.mjs +551 -0
  48. package/lib/host/claude-cli.mjs +285 -0
  49. package/lib/host/codex.mjs +231 -0
  50. package/lib/host/model-map.mjs +129 -0
  51. package/lib/host/qwen.mjs +176 -0
  52. package/lib/host/registry.mjs +213 -0
  53. package/lib/host.mjs +393 -0
  54. package/lib/ledger.mjs +444 -0
  55. package/lib/limits.mjs +525 -0
  56. package/lib/locators.mjs +99 -0
  57. package/lib/merge-lanes.mjs +148 -0
  58. package/lib/metrics.mjs +99 -0
  59. package/lib/notify.mjs +229 -0
  60. package/lib/outcomes.mjs +560 -0
  61. package/lib/plan-fingerprint.mjs +570 -0
  62. package/lib/project-slug.mjs +18 -0
  63. package/lib/prompts/commit.mjs +26 -0
  64. package/lib/prompts/dimensions.mjs +70 -0
  65. package/lib/prompts/handoff.mjs +86 -0
  66. package/lib/prompts/implementer.mjs +150 -0
  67. package/lib/prompts/index.mjs +250 -0
  68. package/lib/prompts/planner.mjs +70 -0
  69. package/lib/prompts/remediate.mjs +137 -0
  70. package/lib/prompts/replan.mjs +23 -0
  71. package/lib/prompts/review.mjs +107 -0
  72. package/lib/prompts/schemas.mjs +206 -0
  73. package/lib/prompts/stage.mjs +48 -0
  74. package/lib/prompts/verify.mjs +88 -0
  75. package/lib/ready.mjs +812 -0
  76. package/lib/receipt.mjs +434 -0
  77. package/lib/remediate.mjs +275 -0
  78. package/lib/report-html.mjs +623 -0
  79. package/lib/report.mjs +1124 -0
  80. package/lib/review-core.mjs +713 -0
  81. package/lib/risk.mjs +370 -0
  82. package/lib/run-log.mjs +1004 -0
  83. package/lib/run-paths.mjs +239 -0
  84. package/lib/run.mjs +2921 -0
  85. package/lib/ship-stage.mjs +223 -0
  86. package/lib/spill.mjs +150 -0
  87. package/lib/surface.mjs +195 -0
  88. package/lib/verify.mjs +1074 -0
  89. package/lib/waves.mjs +2661 -0
  90. package/package.json +40 -0
  91. package/shared/CONTEXT-HYGIENE.md +84 -0
  92. package/shared/DECISION-LEDGER.md +141 -0
  93. package/shared/EXPLORE-BRIEF.md +96 -0
  94. package/shared/INVARIANT-SWEEP.md +57 -0
  95. package/shared/SESSION-SHAPES.md +261 -0
  96. package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
  97. package/shared/TEST-PROFILE.md +204 -0
  98. package/shared/TOOL-ECONOMY.md +155 -0
  99. package/skills/bootstrap/SKILL.md +226 -0
  100. package/skills/commit/SKILL.md +99 -0
  101. package/skills/dispatch/SKILL.md +78 -0
  102. package/skills/docs-digest/SKILL.md +109 -0
  103. package/skills/evals/SKILL.md +160 -0
  104. package/skills/explain-code/SKILL.md +42 -0
  105. package/skills/explain-code/commit-teachin.md +136 -0
  106. package/skills/explain-code/module-walkthrough.md +358 -0
  107. package/skills/explore/SKILL.md +159 -0
  108. package/skills/fix-tests/SKILL.md +120 -0
  109. package/skills/graph/SKILL.md +136 -0
  110. package/skills/manual-test-plan/SKILL.md +121 -0
  111. package/skills/mr/SKILL.md +170 -0
  112. package/skills/report/SKILL.md +121 -0
  113. package/skills/review-artifacts/SKILL.md +127 -0
  114. package/skills/review-code/REVIEW.template.md +49 -0
  115. package/skills/review-code/SKILL.md +156 -0
  116. package/skills/review-code/dimensions/architecture.md +10 -0
  117. package/skills/review-code/dimensions/devops.md +12 -0
  118. package/skills/review-code/dimensions/language.md +12 -0
  119. package/skills/review-code/dimensions/qa.md +12 -0
  120. package/skills/review-code/dimensions/security.md +12 -0
  121. package/skills/review-code/dimensions/technical-lead.md +19 -0
  122. package/skills/ship/SKILL.md +67 -0
  123. package/skills/spec/SKILL.md +264 -0
  124. package/skills/spec/continuity.md +66 -0
  125. package/workflows/ship.js +565 -0
package/lib/waves.mjs ADDED
@@ -0,0 +1,2661 @@
1
+ // Deterministic wave planner.
2
+ //
3
+ // Turns a classified task list into an execution plan: which tasks run in which
4
+ // wave, in which parallel batch, on which model. Classification itself is a
5
+ // judgement call and stays with the model; everything downstream of it is
6
+ // mechanical and lives here.
7
+ //
8
+ // Two rules exist specifically to stop the classifier from harming the run:
9
+ //
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.
14
+ //
15
+ // 2. Fan-out cap. A wave is a set of independent tasks, so the temptation is
16
+ // to spawn all of them at once. The original implementation did exactly
17
+ // that, which meant a classifier that put 30 tasks in group 1 spawned 30
18
+ // parallel agents. Waves are chunked into batches of at most maxParallel.
19
+ //
20
+ // 3. Singleton fold. A classifier that mints a group per sequential slice of
21
+ // one file turns 22 tasks into 15 waves, and each of those waves still
22
+ // buys a record ping and — until the cap — an inter-wave verify. A 1-task
23
+ // wave is ordering, not a checkpoint, so it is folded onto the previous
24
+ // implementation wave as a later batch. Order survives; the checkpoint
25
+ // does not. Every fold is reported.
26
+ //
27
+ // 4. Dependency edges. A group is a barrier and a path collision is a lane,
28
+ // which left the classifier with no way to say "B needs A's output" when A
29
+ // and B edit different files — so it minted a whole new section, dragging
30
+ // every task independent of A along with it. `dependsOn` is that missing
31
+ // signal: a task names the earlier tasks whose output it needs, and the
32
+ // group is split into dependency LAYERS, each of which is a wave. The
33
+ // singleton fold below then reclaims the checkpoint, so a precise edge
34
+ // costs a later batch rather than an extra inter-wave verify. An edge is
35
+ // additive — it can only push a task later than the section model already
36
+ // put it — and a plan with no edges is planned exactly as it was before
37
+ // this existed.
38
+ //
39
+ // 5. Lanes. A batch holds LANES, not tasks: a lane is an ordered task list
40
+ // one agent executes start to finish. Tasks a path collision already
41
+ // forced to run one after another gain nothing from separate agents —
42
+ // isolating a task from the other edits to the file it is about to edit is
43
+ // not isolation — and each separate agent pays its own spawn prefix. So
44
+ // colliding tasks become one lane. Lanes in a batch are path-disjoint and
45
+ // run in parallel; `LANE_CAPS.byTier` bounds how much one interruption can
46
+ // discard, and a uniform override of 1 reproduces one agent per task.
47
+ //
48
+ // 6. Cohesion. Collision alone made every path-disjoint task its own agent,
49
+ // however small: seven tasks each writing one `evals/*/case.yaml` were
50
+ // seven spawns that then had to be told, by a hand-written eighth task,
51
+ // what convention to agree on. So after collision components form, the
52
+ // LOW-TIER ones in a layer pack greedily into shared lanes — next-fit,
53
+ // hardest component first, bounded by that lane's per-tier cap. Tier 4 and
54
+ // 5 work is excluded on purpose: cross-file pattern-following is where a
55
+ // fresh context per task still pays for itself. Every fold is reported.
56
+ //
57
+ // 7. Solo. A small change may be planned as ONE lane — every implementation
58
+ // task in section, layer and id order, then every test task — run by one
59
+ // opus agent inside the same verify-and-commit loop. The decision is made
60
+ // inside a published envelope (`SOLO.maxTasks`): a flag wins, then the
61
+ // classifier's recommendation if the change fits, then waves. The mode,
62
+ // its source and its reason are on the plan, because a run that silently
63
+ // changed its own shape is the failure the plan preview exists to remove.
64
+ //
65
+ // The second half of this file *executes* that plan — as a state machine, not a
66
+ // runner. The orchestration is moving into a dynamic-workflow script, which can
67
+ // import nothing and has no filesystem, shell or agent access of its own; only
68
+ // the agents it spawns do work. So the engine below never spawns an agent,
69
+ // never runs a command and never touches fs. It is handed the run state plus
70
+ // the result of whatever just happened, and answers one question: what next.
71
+ //
72
+ // Every transition returns a NEW state and leaves its input untouched. That is
73
+ // not stylistic. A resumed run is replayed through the same transitions from
74
+ // cached agent results, so an engine that mutated in place would answer
75
+ // differently on the replay than it did on the first pass. The returned state is
76
+ // deep-frozen and JSON-safe so the property stays honest rather than aspirational.
77
+ //
78
+ // The caps are not restated here — they are read from `lib/limits.mjs`, because
79
+ // a cap written down twice is a cap that drifts. What the failure policy in
80
+ // `skills/ship/SKILL.md` describes in prose, this file decides.
81
+ //
82
+ // Pure: no fs, no agent, no I/O, no clock.
83
+ // Exposed to skills as `interlock waves --classified <file>`.
84
+
85
+ import { randomUUID } from 'node:crypto'
86
+
87
+ import { LIMITS, EFFORT, LANE_CAPS, SOLO, clampParallel } from './limits.mjs'
88
+ import { diffIndex, locatorPath } from './locators.mjs'
89
+ import { canonicalizePath, isDocsPath } from './risk.mjs'
90
+
91
+ /** Skip reason when a completed wave claimed only documentation paths. */
92
+ export const SKIP_VERIFY_DOCS = 'docs-only-wave'
93
+ /** Skip reason when LIMITS.interWaveVerifications checkpoints are already spent. */
94
+ export const SKIP_VERIFY_CAP = 'verify-cap-reached'
95
+ /**
96
+ * Skip reason for the leading failing-test wave of a TDD change.
97
+ *
98
+ * The suite is SUPPOSED to be failing at that point, so the ordinary check
99
+ * would spend `LIMITS.interWaveFixAttempts` trying to repair tests that were
100
+ * just written on purpose, and then halt the run. The next wave's check is the
101
+ * real gate: by then the implementation exists, and a still-red suite is a
102
+ * genuine failure that correctly burns those attempts.
103
+ */
104
+ export const SKIP_VERIFY_RED = 'red-wave-expected-failing'
105
+
106
+ export const DEFAULT_MAX_PARALLEL = 8
107
+
108
+ /** A lane whose tasks were joined by a canonical-path collision. */
109
+ export const LANE_COLLISION = 'collision'
110
+ /** A lane packing several path-disjoint low-tier components from one layer. */
111
+ export const LANE_COHESION = 'cohesion'
112
+ /** The single lane of a solo plan: the whole change, in order, on one agent. */
113
+ export const LANE_SOLO = 'solo'
114
+
115
+ /** The two plan shapes. `auto` is not one — it is the absence of an override. */
116
+ export const MODE_WAVES = 'waves'
117
+ export const MODE_SOLO = 'solo'
118
+
119
+ const MODES = new Set([MODE_WAVES, MODE_SOLO])
120
+
121
+ const MODELS = new Set(['haiku', 'sonnet', 'opus'])
122
+
123
+ function fail(message) {
124
+ const err = new Error(message)
125
+ err.userFacing = true
126
+ throw err
127
+ }
128
+
129
+ // Apply the clamp to one task, returning a clamp record when it changed.
130
+ function clampModel(task) {
131
+ const before = task.model
132
+ if (task.tier < 5 || task.model !== 'opus') {
133
+ task.model = task.model === 'haiku' ? 'haiku' : 'sonnet'
134
+ }
135
+ if (task.model === before) return null
136
+ return { id: task.id, from: before, to: task.model, tier: task.tier }
137
+ }
138
+
139
+ /**
140
+ * One task's predicted paths, as canonical identities paired with the spelling
141
+ * its author wrote.
142
+ *
143
+ * THIS IS THE ONLY READER of `task.paths` in this module. The collision key,
144
+ * the changed-file dedup and the docs-only test all go through it, because the
145
+ * defect it closes was never "the collision map used the wrong key" — it was
146
+ * "three readers of one value each did their own thing, and two of them were
147
+ * never looked at". Fixing the reported call site while its siblings stay on
148
+ * the raw form is how the original bug survives its own fix.
149
+ *
150
+ * A path that cannot be placed inside the repository (absolute, or escaping the
151
+ * root) yields `canonical: null`. It is reported, and it never becomes a key:
152
+ * an identity we cannot compute is not an identity that matches nothing.
153
+ *
154
+ * @param {object} task
155
+ * @returns {Array<{authored: string, canonical: string|null}>}
156
+ */
157
+ function predictedPaths(task) {
158
+ const raw = task && Array.isArray(task.paths) ? task.paths : []
159
+ const out = []
160
+ for (const authored of raw) {
161
+ if (typeof authored !== 'string' || !authored.trim()) continue
162
+ out.push({ authored, canonical: canonicalizePath(authored) })
163
+ }
164
+ return out
165
+ }
166
+
167
+ /** Every predicted path in a task list that cannot be used as an identity. */
168
+ function unusablePaths(tasks) {
169
+ const out = []
170
+ for (const t of tasks) {
171
+ for (const p of predictedPaths(t)) {
172
+ if (p.canonical === null) out.push({ id: t.id, path: p.authored })
173
+ }
174
+ }
175
+ return out
176
+ }
177
+
178
+ /**
179
+ * One task's declared dependency edges, deduplicated, in the order written.
180
+ *
181
+ * THE ONLY READER of `task.dependsOn`, for the same reason `predictedPaths` is
182
+ * the only reader of `task.paths`: validation, layering and the replan path all
183
+ * ask the same question, and three readers each doing their own trimming is how
184
+ * one of them ends up matching an id the others do not.
185
+ *
186
+ * Entries are trimmed. A whitespace-only entry is dropped here and rejected by
187
+ * `validate`, which runs first on every path into the planner.
188
+ */
189
+ function dependsOnIds(task) {
190
+ const raw = task && Array.isArray(task.dependsOn) ? task.dependsOn : []
191
+ const out = []
192
+ for (const entry of raw) {
193
+ if (typeof entry !== 'string' || !entry.trim()) continue
194
+ const id = entry.trim()
195
+ if (!out.includes(id)) out.push(id)
196
+ }
197
+ return out
198
+ }
199
+
200
+ function chunk(items, size) {
201
+ const out = []
202
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
203
+ return out
204
+ }
205
+
206
+ /**
207
+ * Hardest first, within a group.
208
+ *
209
+ * Tasks in a group are independent and run in parallel, so this changes nothing
210
+ * until the group is wider than `maxParallel` and gets split into batches. Then
211
+ * it decides which tasks go in batch one — and the tier-5 task is the one whose
212
+ * failure means the design was wrong. Discovering that in the first batch is
213
+ * cheaper than discovering it in the third, after the wave's budget is spent.
214
+ * This is the Boehm/spiral "riskiest first" ordering, applied inside a wave
215
+ * rather than across a release.
216
+ *
217
+ * Tie-break on id so the plan is deterministic: the same classified input must
218
+ * produce the same batches on every run, or nothing downstream is reproducible.
219
+ */
220
+ function byHardestFirst(a, b) {
221
+ if (b.tier !== a.tier) return b.tier - a.tier
222
+ return String(a.id).localeCompare(String(b.id))
223
+ }
224
+
225
+ // Per-task shape only. The dependency check is a SEPARATE call because it needs
226
+ // the resolved red wave, and the red wave can only be resolved once the tasks
227
+ // are known to be well-formed — so the two cannot happen in one pass.
228
+ function validateShape(input) {
229
+ if (!input || typeof input !== 'object') fail('classified input must be a JSON object')
230
+ if (!Array.isArray(input.tasks)) fail('classified input must have a "tasks" array')
231
+
232
+ const seen = new Set()
233
+ input.tasks.forEach((t, i) => {
234
+ const at = `tasks[${i}]`
235
+ if (!t || typeof t !== 'object') fail(`${at} must be an object`)
236
+ if (typeof t.id !== 'string' || !t.id.trim()) fail(`${at}.id must be a non-empty string`)
237
+ if (seen.has(t.id)) fail(`${at}.id "${t.id}" is duplicated`)
238
+ seen.add(t.id)
239
+ if (!Number.isInteger(t.group)) fail(`${at}.group must be an integer (task ${t.id})`)
240
+ if (typeof t.description !== 'string' || !t.description.trim()) {
241
+ fail(`${at}.description must be a non-empty string (task ${t.id})`)
242
+ }
243
+ if (!Number.isInteger(t.tier) || t.tier < 1 || t.tier > 5) {
244
+ fail(`${at}.tier must be an integer 1-5 (task ${t.id})`)
245
+ }
246
+ if (!MODELS.has(t.model)) {
247
+ fail(`${at}.model must be one of haiku|sonnet|opus (task ${t.id})`)
248
+ }
249
+ if (typeof t.isTestTask !== 'boolean') {
250
+ fail(`${at}.isTestTask must be a boolean (task ${t.id})`)
251
+ }
252
+ // `paths` is optional by design. A classifier that cannot predict which
253
+ // files a task will touch must be able to say nothing rather than guess —
254
+ // an invented path would produce a re-group that costs a wave of
255
+ // parallelism for no reason. Shape is checked; content never is.
256
+ if (t.paths !== undefined) {
257
+ if (!Array.isArray(t.paths) || t.paths.some(p => typeof p !== 'string')) {
258
+ fail(`${at}.paths must be an array of strings when present (task ${t.id})`)
259
+ }
260
+ }
261
+ // `dependsOn` is optional the way `paths` is optional — a classifier that
262
+ // sees no dependency says nothing — but unlike `paths` it is a STRUCTURAL
263
+ // claim, so its content is checked too (below). A wrong path costs a
264
+ // needless serialization; a wrong edge is a task that runs before the work
265
+ // it needed, which is the failure this planner exists to prevent.
266
+ if (t.dependsOn !== undefined) {
267
+ if (
268
+ !Array.isArray(t.dependsOn) ||
269
+ t.dependsOn.some(d => typeof d !== 'string' || !d.trim())
270
+ ) {
271
+ fail(`${at}.dependsOn must be an array of non-empty strings when present (task ${t.id})`)
272
+ }
273
+ }
274
+ })
275
+ }
276
+
277
+ /**
278
+ * Check the dependency edge set fail-closed: every reference resolves, no edge
279
+ * points at work that runs later, and the graph is acyclic.
280
+ *
281
+ * Nothing here is repaired. Dropping an unresolved reference or breaking a
282
+ * cycle would produce a plan that looks successful and runs in the wrong order,
283
+ * which is strictly worse than a halt naming the cause — the same reason an
284
+ * out-of-repo `paths` entry is reported rather than normalized into scope.
285
+ *
286
+ * Two rejections beyond "dangling or cyclic" exist because the planner cannot
287
+ * honour those edges at all, and silently ignoring them is the degradation this
288
+ * is here to refuse:
289
+ *
290
+ * - An edge pointing at a LATER numbered section. Sections are a barrier the
291
+ * edge cannot override (a section-predecessor is always earlier), so such
292
+ * an edge asks for an order the section model already forbids.
293
+ * - An implementation task depending on a TEST task. Test tasks are deferred
294
+ * to a single trailing wave, so the dependency could never be satisfied.
295
+ * The ONE exception is a test task in the red wave of a TDD change: that
296
+ * one is not deferred, it runs first, and "make the failing suite pass" is
297
+ * exactly the dependency an author of such a change means.
298
+ * - A RED-WAVE task depending on anything outside the red wave. This
299
+ * rejection exists only because the exception above does: a deferred test
300
+ * task ran last, so any edge it declared was satisfiable and none was
301
+ * checked. A red-wave task runs FIRST, so the same edge now names work
302
+ * that has not happened yet.
303
+ *
304
+ * @param {Array} tasks
305
+ * @param {number|null} redWave the group that runs as the leading failing-test
306
+ * wave, or null. Null is the whole of today's behaviour: `t.group === null`
307
+ * is never true for a validated task, so every branch below collapses to the
308
+ * rule it had before this parameter existed.
309
+ */
310
+ function validateDependencies(tasks, redWave = null) {
311
+ const byId = new Map(tasks.map(t => [t.id, t]))
312
+ const inRedWave = task => redWave !== null && task.group === redWave
313
+
314
+ for (const t of tasks) {
315
+ for (const dep of dependsOnIds(t)) {
316
+ if (!byId.has(dep)) {
317
+ fail(`task ${t.id} depends on "${dep}", which is not a task in this plan`)
318
+ }
319
+ const target = byId.get(dep)
320
+ if (!t.isTestTask && target.isTestTask && !inRedWave(target)) {
321
+ fail(
322
+ `task ${t.id} depends on test task ${dep}, which runs in the trailing test wave — ` +
323
+ `a dependency can only name work that runs earlier`
324
+ )
325
+ }
326
+ // The red wave runs first, so nothing it declares can point outward.
327
+ // Unchecked before this existed and correct then: a deferred test task
328
+ // ran after everything, so any edge it named had already happened.
329
+ if (inRedWave(t) && !inRedWave(target)) {
330
+ fail(
331
+ `task ${t.id} runs in the red wave (section ${redWave}) and depends on ${dep}, which ` +
332
+ `runs after it — a leading failing-test wave cannot depend on the work it pins`
333
+ )
334
+ }
335
+ // No backward-edge check is needed for a red-wave TARGET, and that is a
336
+ // consequence rather than an omission: `resolvePlanRedWave` only honours
337
+ // a claim on the LOWEST group, so a red target is always in the first
338
+ // section and no task can sit earlier than it. If that invariant is ever
339
+ // relaxed, this is the check that has to come back.
340
+ if (!t.isTestTask && !target.isTestTask && target.group > t.group) {
341
+ fail(
342
+ `task ${t.id} (section ${t.group}) depends on ${dep} (section ${target.group}), which ` +
343
+ `runs in a later section — a dependency can only name work that runs earlier`
344
+ )
345
+ }
346
+ }
347
+ }
348
+
349
+ // Depth-first, naming the ids on the cycle rather than reporting that one
350
+ // exists: "there is a cycle" is not something an author can act on.
351
+ const UNVISITED = 0
352
+ const ON_STACK = 1
353
+ const DONE = 2
354
+ const marks = new Map()
355
+ const stack = []
356
+ const walk = id => {
357
+ const mark = marks.get(id) || UNVISITED
358
+ if (mark === DONE) return
359
+ if (mark === ON_STACK) {
360
+ const from = stack.indexOf(id)
361
+ fail(`dependency cycle: ${[...stack.slice(from), id].join(' → ')}`)
362
+ }
363
+ marks.set(id, ON_STACK)
364
+ stack.push(id)
365
+ for (const dep of dependsOnIds(byId.get(id))) walk(dep)
366
+ stack.pop()
367
+ marks.set(id, DONE)
368
+ }
369
+ for (const t of tasks) walk(t.id)
370
+ }
371
+
372
+ /**
373
+ * Split one task set into dependency layers: everything with no dependency
374
+ * inside the set, then everything that depended only on that, and so on.
375
+ *
376
+ * The layer index IS the topological depth over the set's own edges, so layer 0
377
+ * runs first and a task is always in a strictly later layer than everything it
378
+ * depends on. Edges leaving the set are ignored HERE and satisfied elsewhere:
379
+ * a set is either one numbered section (whose predecessors are whole earlier
380
+ * waves) or the trailing test wave (which runs after every implementation
381
+ * wave), so an outbound edge is already ordered by the wave sequence.
382
+ *
383
+ * Order inside a layer is the caller's order, untouched. Re-sorting would be
384
+ * invisible until it changed which tasks share a batch in an edge-free plan,
385
+ * which is exactly the byte-identical guarantee this must not spend.
386
+ *
387
+ * With no edges every task is depth 0, so this returns `[tasks]` — one layer,
388
+ * in the order it was handed, and every caller behaves as it did before.
389
+ */
390
+ function layerByDependencies(tasks) {
391
+ const inSet = new Map(tasks.map(t => [t.id, t]))
392
+ const depth = new Map()
393
+ const onStack = new Set()
394
+
395
+ const depthOf = id => {
396
+ if (depth.has(id)) return depth.get(id)
397
+ // `validate` rejects cycles before any plan is built, but `makeWave` lanes a
398
+ // replan whose tasks never went through it. A cycle there is a halt, not an
399
+ // infinite descent.
400
+ if (onStack.has(id)) fail(`dependency cycle through task ${id}`)
401
+ onStack.add(id)
402
+ let d = 0
403
+ for (const dep of dependsOnIds(inSet.get(id))) {
404
+ if (!inSet.has(dep)) continue
405
+ d = Math.max(d, depthOf(dep) + 1)
406
+ }
407
+ onStack.delete(id)
408
+ depth.set(id, d)
409
+ return d
410
+ }
411
+
412
+ const layers = []
413
+ for (const t of tasks) {
414
+ const d = depthOf(t.id)
415
+ while (layers.length <= d) layers.push([])
416
+ layers[d].push(t)
417
+ }
418
+ return layers
419
+ }
420
+
421
+ /**
422
+ * Ascending task id, natural order, so `1.9` sorts before `1.10`.
423
+ *
424
+ * This is the order tasks run in inside a lane. It is authored order, not tier
425
+ * order: sequential same-file work is ordered work, and a tier is a statement
426
+ * about difficulty, never about what depends on what.
427
+ */
428
+ function byTaskId(a, b) {
429
+ return String(a.id).localeCompare(String(b.id), 'en', { numeric: true })
430
+ }
431
+
432
+ /**
433
+ * Where a planned lane or collision sits, for a report. A null group is the
434
+ * trailing test wave, which has no group number — naming it "wave null" would
435
+ * be a worse report than naming it what it is.
436
+ */
437
+ function laneWhere(group) {
438
+ return group === null || group === undefined ? 'the test wave' : `wave ${group}`
439
+ }
440
+
441
+ /** Every canonical path a lane's tasks claim, deduplicated. */
442
+ function laneCanonicalPaths(lane) {
443
+ const out = new Set()
444
+ for (const t of lane) {
445
+ for (const p of predictedPaths(t)) if (p.canonical !== null) out.add(p.canonical)
446
+ }
447
+ return out
448
+ }
449
+
450
+ /** The tier a lane dispatches on: the highest among its tasks. */
451
+ export function laneTier(lane) {
452
+ return lane.reduce((m, t) => (Number.isInteger(t.tier) && t.tier > m ? t.tier : m), 0)
453
+ }
454
+
455
+ /**
456
+ * The effective per-tier lane cap table for one plan: the published table with a
457
+ * uniform override applied as a CEILING over every tier.
458
+ *
459
+ * A ceiling rather than a replacement, because the override's documented purpose
460
+ * is rollback: an override of 1 must reproduce one agent per task exactly, for
461
+ * cohesion lanes as well as collision lanes. Raising a tier's cap above the
462
+ * published table is deliberately not possible — the table is where a cap is
463
+ * stated, and a caller that could exceed it would be a second statement.
464
+ *
465
+ * @param {number} [override] uniform ceiling; ignored unless a positive integer
466
+ * @returns {Record<number, number>} tier → cap, a fresh object every call
467
+ */
468
+ export function effectiveLaneCaps(override) {
469
+ const ceiling = Number.isInteger(override) && override > 0 ? override : null
470
+ const out = {}
471
+ for (const [tier, cap] of Object.entries(LANE_CAPS.byTier)) {
472
+ out[tier] = ceiling === null ? cap : Math.min(cap, ceiling)
473
+ }
474
+ return out
475
+ }
476
+
477
+ /**
478
+ * The cap that bounds a lane of `tier`, read off an effective table.
479
+ *
480
+ * Tier 0 — a lane whose tasks carry no readable tier — uses the tier-1 entry.
481
+ * That is the same fallback `laneEffort` makes for an unreadable tier, and it
482
+ * errs toward the most permissive cap on the least demanding work.
483
+ */
484
+ function capForTier(caps, tier) {
485
+ const table = caps && typeof caps === 'object' ? caps : LANE_CAPS.byTier
486
+ const cap = table[tier] ?? table[1] ?? LANE_CAPS.byTier[1]
487
+ return Number.isInteger(cap) && cap > 0 ? cap : 1
488
+ }
489
+
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
+ /**
498
+ * The label a lane runs under — the name its agent, its trajectory line and its
499
+ * briefing file all carry.
500
+ *
501
+ * Stable across replays, so a resumed run cache-hits the lane it already ran.
502
+ * This derivation was written three times (the workflow script, the ACP driver,
503
+ * and `bin/interlock`'s own trajectory logger); it is stated here once because a
504
+ * trajectory line and the agent it describes must carry one name, not two.
505
+ */
506
+ export function laneLabel(lane) {
507
+ return lane.length === 1 ? lane[0].id : `${lane[0].id}+${lane.length - 1}`
508
+ }
509
+
510
+ 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'
514
+ }
515
+
516
+ /**
517
+ * The reasoning effort a lane dispatches at — a sibling of `laneModel`, derived
518
+ * from the same hardest-task tier, looked up in the table published by
519
+ * `lib/limits.mjs`. A lane is one agent, and that agent must be capable of the
520
+ * hardest thing in the lane, so the effort is the hardest task's, never the
521
+ * first task's — the one direction of this trade that is not survivable.
522
+ *
523
+ * Returns `null` (inherit the session default — do not force) for tiers 3–4 by
524
+ * 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.
527
+ */
528
+ export function laneEffort(lane) {
529
+ const tier = laneTier(lane)
530
+ return EFFORT.byTier[tier] ?? null
531
+ }
532
+
533
+ /**
534
+ * Group a group's tasks into lanes: connected components over canonical-path
535
+ * collisions, ordered by task id, split at the lane cap.
536
+ *
537
+ * Two tasks join when they claim a common canonical path, directly or through
538
+ * another task — component membership, not pairwise chaining. With tasks
539
+ * A[x,y], B[x] and C[y], pairwise chaining would put B and C in different
540
+ * lanes that both run beside A's writes.
541
+ *
542
+ * The key is the CANONICAL path, never the authored spelling. Keyed on raw
543
+ * text, `src/a.ts` and `./src/a.ts` are two identities for one file, so the
544
+ * two tasks claiming them would run concurrently and one would silently
545
+ * overwrite the other. The spelling the author wrote is still what gets
546
+ * reported — a report naming a path the task author does not recognize is a
547
+ * worse report.
548
+ *
549
+ * A path that cannot be canonicalized joins nothing: an identity we could not
550
+ * compute is not an identity that matches everything, and the plan reports it
551
+ * rather than normalizing it into scope.
552
+ *
553
+ * WHAT THIS DOES NOT DO. `paths` is a model's prediction, so a task that edits
554
+ * a file it never named is still unguarded. This narrows the race; it does not
555
+ * close it.
556
+ */
557
+ function buildLanes(tasks, serialized, group, laneCaps) {
558
+ // Components are built in task-id order so the first claimant of a path is
559
+ // the earliest task, which is both what a lane runs first and what the
560
+ // collision report should name as the holder.
561
+ const ordered = tasks.slice().sort(byTaskId)
562
+ const parent = ordered.map((_, i) => i)
563
+ const find = i => {
564
+ while (parent[i] !== i) {
565
+ parent[i] = parent[parent[i]]
566
+ i = parent[i]
567
+ }
568
+ return i
569
+ }
570
+ const union = (a, b) => {
571
+ const ra = find(a)
572
+ const rb = find(b)
573
+ if (ra === rb) return
574
+ // Lower index wins so a component's root is its earliest task.
575
+ if (ra < rb) parent[rb] = ra
576
+ else parent[ra] = rb
577
+ }
578
+
579
+ const owner = new Map()
580
+ ordered.forEach((t, i) => {
581
+ let reported = false
582
+ for (const p of predictedPaths(t)) {
583
+ if (p.canonical === null) continue
584
+ if (owner.has(p.canonical)) {
585
+ const held = owner.get(p.canonical)
586
+ if (!reported) {
587
+ serialized.push({
588
+ id: t.id,
589
+ group,
590
+ path: p.authored,
591
+ conflictsWith: ordered[held].id
592
+ })
593
+ reported = true
594
+ }
595
+ union(held, i)
596
+ } else {
597
+ owner.set(p.canonical, i)
598
+ }
599
+ }
600
+ })
601
+
602
+ const components = new Map()
603
+ ordered.forEach((t, i) => {
604
+ const root = find(i)
605
+ if (!components.has(root)) components.set(root, [])
606
+ components.get(root).push(t)
607
+ })
608
+
609
+ // Hardest component first, for the same reason tasks used to sort that way:
610
+ // when the group is wider than maxParallel, the component whose failure means
611
+ // the design was wrong should be in the first batch, not the third. Split
612
+ // lanes of one component are emitted in order right after each other, so the
613
+ // packer below can only ever push a later sibling into a later batch.
614
+ //
615
+ // The order is also what makes cohesion's cap safe: eligible components are a
616
+ // suffix of this ranking (eligibility is `tier <= cohesionMaxTier`, and the
617
+ // ranking is by descending tier), so the FIRST component to join an open
618
+ // cohesion lane is its hardest, and the lane's cap is fixed from that moment.
619
+ // A cap can never shrink under a lane that already filled it.
620
+ const ranked = [...components.values()].sort((a, b) => {
621
+ const ta = laneTier(a)
622
+ const tb = laneTier(b)
623
+ if (tb !== ta) return tb - ta
624
+ return byTaskId(a[0], b[0])
625
+ })
626
+
627
+ const lanes = []
628
+ // One open lane, walked in ranked order — next-fit, not first-fit. First-fit
629
+ // packs marginally tighter but lets a later small component slip into an
630
+ // earlier lane whose tier it does not share, which makes "which agent got this
631
+ // task" harder to read off the plan. Next-fit is one open lane, one rule, one
632
+ // line in the preview.
633
+ let open = null
634
+ const close = () => {
635
+ if (!open) return
636
+ // Sorted by id exactly as a collision lane is: two components merged by
637
+ // cohesion may interleave, which is authored order and runs inside one
638
+ // agent, so no concurrency guarantee is spent on it.
639
+ if (open.parts > 1) open.tasks.sort(byTaskId)
640
+ lanes.push(open)
641
+ open = null
642
+ }
643
+
644
+ for (const component of ranked) {
645
+ const tier = laneTier(component)
646
+ const cap = capForTier(laneCaps, tier)
647
+
648
+ // Ineligible (tier above the cohesion ceiling), or too long to pack at all:
649
+ // its own lane, or its own sequential lanes when it exceeds the cap. Closing
650
+ // the open lane keeps next-fit's invariant honest — one open lane, appended
651
+ // to in ranked order — rather than leaving a stale lane behind an emission.
652
+ if (tier > LANE_CAPS.cohesionMaxTier || component.length > cap) {
653
+ close()
654
+ for (const part of chunk(component, cap)) {
655
+ lanes.push({ tasks: part, kind: LANE_COLLISION, tier: laneTier(part), cap })
656
+ }
657
+ continue
658
+ }
659
+
660
+ if (open && open.tasks.length + component.length <= capForTier(laneCaps, Math.max(open.tier, tier))) {
661
+ open.tasks.push(...component)
662
+ open.parts += 1
663
+ continue
664
+ }
665
+ close()
666
+ open = { tasks: component.slice(), kind: LANE_COLLISION, tier, cap, parts: 1 }
667
+ }
668
+ close()
669
+
670
+ // A lane holding more than one COMPONENT was joined by cohesion; a lane
671
+ // holding one component (whole, or a cap-sized slice of it) was joined by a
672
+ // collision. The distinction is not cosmetic — it is what the preview reports,
673
+ // and it cannot be derived after the fact: the two halves of a split star
674
+ // component are path-disjoint from each other, so a lane that re-derived its
675
+ // kind from its own connectivity would call that split a cohesion fold.
676
+ for (const lane of lanes) {
677
+ if (lane.parts > 1) {
678
+ lane.kind = LANE_COHESION
679
+ lane.tier = laneTier(lane.tasks)
680
+ lane.cap = capForTier(laneCaps, lane.tier)
681
+ }
682
+ delete lane.parts
683
+ }
684
+ return lanes
685
+ }
686
+
687
+ /**
688
+ * Pack lanes into batches that never co-schedule two lanes claiming the same
689
+ * path, and never exceed maxParallel lanes.
690
+ *
691
+ * Lanes from different components are path-disjoint by construction, so the
692
+ * only collision the packer can see is between the split halves of a component
693
+ * too long for the cap — which is exactly the pair that must stay sequential.
694
+ *
695
+ * The two deferral kinds are deliberately distinct. A lane pushed out because
696
+ * the batch was full is path-disjoint from everything in it, so it waits for a
697
+ * later batch; it is never folded into another lane's agent, which would
698
+ * serialize work the planner deliberately parallelized.
699
+ */
700
+ function packLanes(entries, maxParallel) {
701
+ const remaining = entries.slice()
702
+ // The lanes are already built, so each one's path set is immutable for the
703
+ // life of this call. Computed once here rather than inside the loop: a lane
704
+ // deferred across k batches would otherwise be re-walked k times.
705
+ const pathSets = new Map(entries.map(e => [e, laneCanonicalPaths(e.tasks)]))
706
+ const batches = []
707
+ while (remaining.length) {
708
+ const batch = []
709
+ const claimed = new Set()
710
+ const deferred = []
711
+ for (const entry of remaining) {
712
+ const paths = pathSets.get(entry)
713
+ let clash = false
714
+ for (const p of paths) {
715
+ if (claimed.has(p)) {
716
+ clash = true
717
+ break
718
+ }
719
+ }
720
+ if (clash || batch.length >= maxParallel) {
721
+ deferred.push(entry)
722
+ continue
723
+ }
724
+ batch.push(entry.tasks)
725
+ for (const p of paths) claimed.add(p)
726
+ }
727
+ if (!batch.length) {
728
+ batch.push(remaining[0].tasks)
729
+ deferred.length = 0
730
+ deferred.push(...remaining.slice(1))
731
+ }
732
+ batches.push(batch)
733
+ remaining.length = 0
734
+ remaining.push(...deferred)
735
+ }
736
+ return batches
737
+ }
738
+
739
+ /**
740
+ * A group's tasks as batches of lanes, plus the lane entries that describe them.
741
+ *
742
+ * A batch is a list of lanes run in parallel; a lane is a list of tasks run in
743
+ * order by one agent. Today's pre-lane behaviour is the case where every lane
744
+ * holds exactly one task, which is what a uniform cap of 1 reproduces exactly.
745
+ *
746
+ * The entries are returned beside the batches rather than attached to them
747
+ * because a lane IS a plain array of tasks — the shape every consumer, the run
748
+ * state and `plan.json` already expect — and a property hung on an array does
749
+ * not survive `JSON.stringify`. Each entry's `tasks` is the very array placed in
750
+ * a batch, so the caller can report a lane's kind without re-deriving it.
751
+ */
752
+ function collisionAwareChunk(tasks, maxParallel, serialized, group, laneCaps) {
753
+ const entries = buildLanes(tasks, serialized, group, laneCaps)
754
+ return { batches: packLanes(entries, maxParallel), entries }
755
+ }
756
+
757
+ /** Every task in a wave, in execution order, regardless of lane nesting. */
758
+ function waveTasks(wave) {
759
+ const batches = wave && Array.isArray(wave.batches) ? wave.batches : []
760
+ return batches.flat(2)
761
+ }
762
+
763
+ /** Every lane in a wave, in execution order. */
764
+ function waveLanes(wave) {
765
+ const batches = wave && Array.isArray(wave.batches) ? wave.batches : []
766
+ return batches.flat(1)
767
+ }
768
+
769
+ /**
770
+ * Fold every 1-task implementation wave onto the wave before it.
771
+ *
772
+ * A wave boundary is a checkpoint: a record ping, and until the cap an
773
+ * inter-wave verification. A wave holding a single task buys that checkpoint to
774
+ * express one thing — that its task runs after the previous wave's. A later
775
+ * *batch* of the previous wave expresses exactly the same ordering and costs
776
+ * nothing, so that is where the task goes.
777
+ *
778
+ * What is deliberately not folded:
779
+ *
780
+ * - A leading singleton. With no previous implementation wave there is
781
+ * nothing to fold onto, and inventing one would reorder the run.
782
+ * - Two waves that each hold two or more tasks. Their boundary is a real
783
+ * checkpoint between two sets of parallel work, not a staircase.
784
+ * - The trailing test wave, in either direction. It is deferred on purpose
785
+ * so a cross-cutting failure is diagnosed once, against finished work.
786
+ *
787
+ * The batches are appended as they were planned, which is what makes D3's
788
+ * split-only `adoptWave` load-bearing: a folded task with a disjoint path has
789
+ * no collision to keep it out of batch 0, so only the batch boundary the plan
790
+ * recorded keeps it from running too early.
791
+ *
792
+ * @param {Array<{group: number, taskCount: number, batches: Array}>} planned
793
+ * @returns {{waves: Array, folded: Array<{id: string, from: number, to: number}>}}
794
+ */
795
+ function foldSingletonWaves(planned) {
796
+ const waves = []
797
+ const folded = []
798
+ for (const wave of planned) {
799
+ const previous = waves[waves.length - 1]
800
+ // Never fold NON-RED work onto the red wave. Folding is "ordering, not a
801
+ // checkpoint", which is true between two implementation waves and false
802
+ // here: the red wave's whole content is a suite that is supposed to be
803
+ // failing, so absorbing the work that makes it pass into the same wave
804
+ // would erase the boundary the shape exists to draw — and hand the merged
805
+ // wave the red wave's skipped verification. A red layer folding onto an
806
+ // earlier red layer is still just ordering, and still folds.
807
+ const ontoRed = previous && previous.red === true && wave.red !== true
808
+ if (wave.taskCount === 1 && previous && !ontoRed) {
809
+ const [task] = waveTasks(wave)
810
+ previous.batches.push(...wave.batches)
811
+ previous.taskCount += wave.taskCount
812
+ folded.push({ id: task.id, from: wave.group, to: previous.group })
813
+ continue
814
+ }
815
+ waves.push(wave)
816
+ }
817
+ return { waves, folded }
818
+ }
819
+
820
+ /**
821
+ * Every claimed path on every task is documentation.
822
+ *
823
+ * Missing paths → not docs-only, and a path that could not be canonicalized →
824
+ * not docs-only either. "We could not place this file in the repo" is unknown,
825
+ * and unknown is never a reason to skip a verification checkpoint.
826
+ */
827
+ export function isDocsOnlyWave(wave) {
828
+ const tasks = waveTasks(wave)
829
+ if (!tasks.length) return false
830
+ for (const t of tasks) {
831
+ const paths = predictedPaths(t)
832
+ if (!paths.length) return false
833
+ if (paths.some(p => p.canonical === null || !isDocsPath(p.canonical))) return false
834
+ }
835
+ return true
836
+ }
837
+
838
+ /**
839
+ * The wave's changed-file set: deduplicated by canonical identity, reported in
840
+ * the spelling each task's author wrote.
841
+ *
842
+ * This feeds `verify plan --changed`. Deduplicating on raw text let one file
843
+ * spelled two ways inflate the changed set a verification is scoped to.
844
+ */
845
+ function uniquePaths(wave) {
846
+ const out = []
847
+ const seen = new Set()
848
+ for (const t of waveTasks(wave)) {
849
+ for (const p of predictedPaths(t)) {
850
+ // Prefixed with "./" because canonicalizePath never returns a leading
851
+ // "./", so an unusable path can never collide with a real canonical
852
+ // identity. (This used to be a literal NUL byte, which made the whole
853
+ // module read as binary to grep.)
854
+ const key = p.canonical === null ? `./unusable:${p.authored}` : p.canonical
855
+ if (seen.has(key)) continue
856
+ seen.add(key)
857
+ out.push(p.authored)
858
+ }
859
+ }
860
+ return out
861
+ }
862
+
863
+ /**
864
+ * Which shape to plan in: flag, then the classifier inside the envelope, then
865
+ * waves. Pure, and the only place the precedence is written down.
866
+ *
867
+ * A flag may exceed the envelope and is warned about; a classifier
868
+ * recommendation may not, and its refusal is warned about too. Both directions
869
+ * are spoken because the operator's question at a plan preview is always "why is
870
+ * it doing it this way", and an unexplained mode is an unanswerable one.
871
+ *
872
+ * An absent, unrecognized or `waves` recommendation is waves with no warning, so
873
+ * a `classified.json` written before this field existed plans exactly as before.
874
+ *
875
+ * @param {unknown} override `opts.mode`
876
+ * @param {unknown} recommended `input.recommendedMode`
877
+ * @param {unknown} reason `input.modeReason`
878
+ * @param {number} taskCount every task the lane would hold
879
+ */
880
+ function decideMode(override, recommended, reason, taskCount) {
881
+ const warnings = []
882
+ // Contradictory input is rejected before planning rather than resolved by
883
+ // order: last-wins would silently pick one of two things the caller asked for.
884
+ if (Array.isArray(override)) {
885
+ const asked = [...new Set(override.map(m => String(m)))]
886
+ fail(
887
+ `contradictory mode overrides (${asked.join(' and ')}) — pass exactly one of ` +
888
+ `${MODE_SOLO}|${MODE_WAVES}`
889
+ )
890
+ }
891
+ if (override !== undefined && override !== null && !MODES.has(override)) {
892
+ fail(`unknown mode override ${JSON.stringify(override)} — expected ${MODE_SOLO}|${MODE_WAVES}`)
893
+ }
894
+
895
+ // Carried verbatim but bounded by the handoff budget, so a runaway reason
896
+ // cannot bloat every plan, every fingerprint and every preview that echoes it.
897
+ const carried =
898
+ typeof reason === 'string' && reason.trim()
899
+ ? reason.trim().slice(0, LIMITS.maxHandoffChars)
900
+ : null
901
+
902
+ if (MODES.has(override)) {
903
+ if (override === MODE_SOLO && taskCount > SOLO.maxTasks) {
904
+ warnings.push(
905
+ `solo forced by flag: ${taskCount} tasks exceeds the published envelope of ` +
906
+ `${SOLO.maxTasks} — the whole change runs on one agent by explicit request`
907
+ )
908
+ }
909
+ return { mode: override, modeSource: 'flag', modeReason: null, warnings }
910
+ }
911
+
912
+ if (recommended === MODE_SOLO) {
913
+ if (taskCount <= SOLO.maxTasks) {
914
+ return { mode: MODE_SOLO, modeSource: 'classifier', modeReason: carried, warnings }
915
+ }
916
+ warnings.push(
917
+ `classifier recommended solo for ${taskCount} tasks; the envelope is ${SOLO.maxTasks}, ` +
918
+ `planned as waves`
919
+ )
920
+ }
921
+ return { mode: MODE_WAVES, modeSource: 'default', modeReason: null, warnings }
922
+ }
923
+
924
+ /**
925
+ * Every task in solo order: implementation by (group, dependency layer, id),
926
+ * then tests by (layer, id).
927
+ *
928
+ * The order is the whole ordering guarantee of a solo plan. There are no batch
929
+ * boundaries left to express "after" with, so a section barrier and a
930
+ * `dependsOn` edge are both honoured by sequence inside the one agent — which is
931
+ * why layer beats id here, and why the edge report is still emitted.
932
+ *
933
+ * @returns {{lane: Array, deferred: Array}}
934
+ */
935
+ function soloOrder(groups, byGroup, testTasks) {
936
+ const lane = []
937
+ const deferred = []
938
+ const orderLayers = (tasks, group) => {
939
+ const layers = layerByDependencies(tasks)
940
+ layers.forEach((layer, depth) => {
941
+ if (depth > 0) {
942
+ const earlier = new Set(layers.slice(0, depth).flat().map(t => t.id))
943
+ for (const t of layer) {
944
+ deferred.push({ id: t.id, group, after: dependsOnIds(t).filter(d => earlier.has(d)) })
945
+ }
946
+ }
947
+ lane.push(...layer.slice().sort(byTaskId))
948
+ })
949
+ }
950
+ for (const group of groups) orderLayers(byGroup.get(group), group)
951
+ if (testTasks.length) orderLayers(testTasks, null)
952
+ return { lane, deferred }
953
+ }
954
+
955
+ /**
956
+ * Adjudicate the caller's red-wave claim against the classified tasks.
957
+ *
958
+ * Two ways a claim is refused, both structural and both checkable here without
959
+ * knowing anything about where the claim came from:
960
+ *
961
+ * - The group holds no test task. Then there is no failing suite to run
962
+ * first, and honouring the claim would only skip that wave's verification.
963
+ * - The group is not the lowest one present. A failing-test section that runs
964
+ * after implementation is not a red wave; treating it as one would move
965
+ * tests earlier than the author put them, in a change they did not write
966
+ * for TDD.
967
+ *
968
+ * A non-integer is not a refusal, it is the absence of a claim — that is how
969
+ * every non-TDD run arrives here.
970
+ *
971
+ * @returns {{group: number|null, refused: string|null}}
972
+ */
973
+ function resolvePlanRedWave(requested, tasks) {
974
+ if (!Number.isInteger(requested)) return { group: null, refused: null }
975
+ const inGroup = tasks.filter(t => t.group === requested)
976
+ if (!inGroup.length) {
977
+ return { group: null, refused: `no task is classified in section ${requested}` }
978
+ }
979
+ if (!inGroup.some(t => t.isTestTask)) {
980
+ return {
981
+ group: null,
982
+ refused: `section ${requested} holds no test task, so there is no failing suite to run first`
983
+ }
984
+ }
985
+ const lowest = Math.min(...tasks.map(t => t.group))
986
+ if (requested !== lowest) {
987
+ return {
988
+ group: null,
989
+ refused:
990
+ `section ${requested} is not the first section (section ${lowest} runs earlier), and a ` +
991
+ `failing-test section that runs after implementation is not a red wave`
992
+ }
993
+ }
994
+ return { group: requested, refused: null }
995
+ }
996
+
997
+ /**
998
+ * @param {{tasks: Array, recommendedMode?: string, modeReason?: string}} input
999
+ * classified tasks, optionally carrying the classifier's mode recommendation
1000
+ * @param {{maxParallel?: number, maxTasksPerAgent?: number, mode?: string}} opts
1001
+ * `maxTasksPerAgent` is a UNIFORM CEILING over every tier's published cap for
1002
+ * this plan only; the caps themselves stay stated once in `LANE_CAPS.byTier`.
1003
+ * It is an override because 1 is the documented rollback lever — it reproduces
1004
+ * one agent per task exactly, cohesion included — and a lever nothing can pull
1005
+ * is not a lever. `mode` forces `solo` or `waves`; absent, the classifier's
1006
+ * recommendation is honoured inside the published envelope.
1007
+ *
1008
+ * `redWave` is the group that runs as a LEADING failing-test wave instead of
1009
+ * being deferred — the execution half of the TDD task shape. It is resolved by
1010
+ * the caller (`resolveRedWave` in `lib/run.mjs`) from the `tasks.md` marker or
1011
+ * an explicit flag, never inferred here: this module is pure and cannot read
1012
+ * the file the shape was written in. Absent — which is every change that is
1013
+ * not authored for TDD — every branch it gates collapses to the behaviour this
1014
+ * planner had before it existed.
1015
+ */
1016
+ export function planWaves(input, opts = {}) {
1017
+ validateShape(input)
1018
+
1019
+ const maxParallel = Number.isInteger(opts.maxParallel) && opts.maxParallel > 0
1020
+ ? opts.maxParallel
1021
+ : DEFAULT_MAX_PARALLEL
1022
+ const laneCaps = effectiveLaneCaps(opts.maxTasksPerAgent)
1023
+
1024
+ // Copy so the caller's objects are never mutated by the clamp.
1025
+ const tasks = input.tasks.map(t => ({ ...t }))
1026
+
1027
+ // Refused rather than honoured when it names nothing runnable: a red wave is
1028
+ // a claim about the task list, and a claim the task list contradicts must not
1029
+ // reorder the run. Refusing is a null here plus a warning below — never a
1030
+ // halt, because the change is still perfectly implementable in the ordinary
1031
+ // shape, and never silence, because a shape the operator asked for and did
1032
+ // not get is exactly the thing that has to be said out loud.
1033
+ const redWave = resolvePlanRedWave(opts.redWave, tasks)
1034
+ validateDependencies(tasks, redWave.group)
1035
+
1036
+ const clamped = []
1037
+ for (const t of tasks) {
1038
+ const record = clampModel(t)
1039
+ if (record) clamped.push(record)
1040
+ }
1041
+
1042
+ const decision = decideMode(opts.mode, input.recommendedMode, input.modeReason, tasks.length)
1043
+
1044
+ // The red wave's test tasks are planned as an ordinary implementation wave —
1045
+ // they keep their group, and therefore their lanes, batches, cohesion folds
1046
+ // and model clamp, all unchanged. Everything else defers as it always has.
1047
+ //
1048
+ // With no red wave `t.group === null` is never true for a validated task, so
1049
+ // this is the same partition it was before, expression for expression.
1050
+ const implTasks = tasks.filter(t => !t.isTestTask || t.group === redWave.group)
1051
+ const testTasks = tasks.filter(t => t.isTestTask && t.group !== redWave.group)
1052
+
1053
+ const byGroup = new Map()
1054
+ for (const t of implTasks) {
1055
+ if (!byGroup.has(t.group)) byGroup.set(t.group, [])
1056
+ byGroup.get(t.group).push(t)
1057
+ }
1058
+
1059
+ // Waves are the distinct classified group numbers, ascending. Groups execute
1060
+ // in order; tasks inside a group that claim the same path become later
1061
+ // batches of that wave, not a new group.
1062
+ const groups = [...byGroup.keys()].filter(g => byGroup.get(g).length).sort((a, b) => a - b)
1063
+
1064
+ const solo = decision.mode === MODE_SOLO
1065
+
1066
+ const serialized = []
1067
+ const deferred = []
1068
+ const laneEntries = []
1069
+ const promoted = []
1070
+ let planned = []
1071
+ let waves = []
1072
+ let folded = []
1073
+ let testWave = null
1074
+ let widest = 0
1075
+
1076
+ if (solo) {
1077
+ // Every task on one agent, so there is nothing to pack: `foldSingletonWaves`,
1078
+ // `packLanes` and the collision report are all answers to "what may run
1079
+ // beside what", and in a lane of one agent that question has no content.
1080
+ // What survives is the ORDER, which is the whole ordering guarantee here.
1081
+ //
1082
+ // Promotion happens after the clamp, not instead of it. The clamp's job is to
1083
+ // stop the CLASSIFIER escalating itself to opus; handing one agent the whole
1084
+ // change is the planner's own decision, so it is recorded as a promotion
1085
+ // rather than smuggled in as a classification.
1086
+ for (const t of tasks) {
1087
+ if (t.model === 'opus') continue
1088
+ promoted.push({ id: t.id, from: t.model, to: 'opus', tier: t.tier })
1089
+ t.model = 'opus'
1090
+ }
1091
+
1092
+ const ordered = soloOrder(groups, byGroup, testTasks)
1093
+ deferred.push(...ordered.deferred)
1094
+ if (ordered.lane.length) {
1095
+ const group = groups.length ? groups[0] : Math.min(...tasks.map(t => t.group))
1096
+ const entry = {
1097
+ tasks: ordered.lane,
1098
+ kind: LANE_SOLO,
1099
+ tier: laneTier(ordered.lane),
1100
+ cap: null
1101
+ }
1102
+ laneEntries.push({ group, entry })
1103
+ waves = [{ group, taskCount: ordered.lane.length, batches: [[ordered.lane]] }]
1104
+ widest = ordered.lane.length
1105
+ }
1106
+ } else {
1107
+ // A group is split into dependency layers, each of which becomes a wave of
1108
+ // its own. With no edges a group is exactly one layer, so this loop emits the
1109
+ // same one-wave-per-group plan it always did; with edges, the dependent task
1110
+ // lands in a later layer, and the singleton fold below usually turns that
1111
+ // layer back into a later BATCH of the same wave rather than a new checkpoint.
1112
+ planned = []
1113
+ for (const group of groups) {
1114
+ const layers = layerByDependencies(byGroup.get(group))
1115
+ layers.forEach((layer, depth) => {
1116
+ if (depth > 0) {
1117
+ const earlier = new Set(layers.slice(0, depth).flat().map(t => t.id))
1118
+ for (const t of layer) {
1119
+ deferred.push({
1120
+ id: t.id,
1121
+ group,
1122
+ after: dependsOnIds(t).filter(d => earlier.has(d))
1123
+ })
1124
+ }
1125
+ }
1126
+ const layerTasks = layer.slice().sort(byHardestFirst)
1127
+ const { batches, entries } = collisionAwareChunk(
1128
+ layerTasks,
1129
+ maxParallel,
1130
+ serialized,
1131
+ group,
1132
+ laneCaps
1133
+ )
1134
+ for (const entry of entries) laneEntries.push({ group, entry })
1135
+ // `red` is spread in only when this IS the red wave, so a plan for an
1136
+ // ordinary change carries no new key and stays byte-comparable with the
1137
+ // plans every existing reader and fixture was written against.
1138
+ planned.push({
1139
+ group,
1140
+ taskCount: layerTasks.length,
1141
+ batches,
1142
+ ...(group === redWave.group ? { red: true } : {})
1143
+ })
1144
+ })
1145
+ }
1146
+
1147
+ // The width warning describes what the classifier handed us — how wide a
1148
+ // single group was before anything was folded onto it. Measured after the
1149
+ // fold it would report a wave whose batches already fit the cap.
1150
+ widest = planned.reduce((m, w) => Math.max(m, w.taskCount), 0)
1151
+
1152
+ const folding = foldSingletonWaves(planned)
1153
+ waves = folding.waves
1154
+ folded = folding.folded
1155
+
1156
+ // Test tasks are deferred to a single trailing wave so that a cross-cutting
1157
+ // test failure is diagnosed once, against the finished implementation, rather
1158
+ // than repeatedly against half-built state.
1159
+ //
1160
+ // The wave is laned exactly as an implementation layer is. It used to be
1161
+ // chunked one task per lane on the reasoning that deferral had already bought
1162
+ // the parallelism a lane would spend — but sixteen tier-2 test tasks were
1163
+ // sixteen spawns for that, and two test tasks claiming one path were run side
1164
+ // by side, which is the collision the packer exists to prevent. The deferral
1165
+ // is about WHEN tests run, not about how many agents run them.
1166
+ //
1167
+ // Layered for the same reason a group is: two test tasks joined by an edge
1168
+ // would otherwise be chunked side by side into one concurrent batch. Layers
1169
+ // become later batches here rather than waves, because the test wave is one
1170
+ // wave by construction.
1171
+ if (testTasks.length) {
1172
+ const batches = []
1173
+ for (const layer of layerByDependencies(testTasks)) {
1174
+ const packed = collisionAwareChunk(
1175
+ layer.slice().sort(byHardestFirst),
1176
+ maxParallel,
1177
+ serialized,
1178
+ null,
1179
+ laneCaps
1180
+ )
1181
+ for (const entry of packed.entries) laneEntries.push({ group: null, entry })
1182
+ batches.push(...packed.batches)
1183
+ }
1184
+ testWave = { taskCount: testTasks.length, batches }
1185
+ }
1186
+ }
1187
+
1188
+ const implCount = implTasks.length
1189
+
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.
1193
+ //
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.
1199
+ 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
+ })
1211
+ }
1212
+ const laneCount = waves.reduce((n, w) => n + waveLanes(w).length, 0)
1213
+
1214
+ // One effort entry per listed lane, mirroring the clamp report. An inherited
1215
+ // effort (null) is either "by policy" (a tier 3–4 lane the table leaves to the
1216
+ // session default) or "by fallback" (a lane whose tier could not be read, tier
1217
+ // 0) — reported apart so an inherited effort is never mistaken for no routing.
1218
+ const effort = lanes.map(l => ({
1219
+ ids: l.ids,
1220
+ tier: l.tier,
1221
+ effort: l.effort,
1222
+ inherited: l.effort !== null ? null : l.tier >= 1 && l.tier <= 5 ? 'policy' : 'fallback'
1223
+ }))
1224
+
1225
+ const warnings = []
1226
+ warnings.push(...decision.warnings)
1227
+ // Both directions are spoken. A refused claim is the operator not getting the
1228
+ // shape they asked for; an honoured one changes what the run verifies and
1229
+ // when, which nobody should have to infer from a missing checkpoint.
1230
+ if (redWave.refused) {
1231
+ warnings.push(`red wave refused: ${redWave.refused} — planned in the ordinary shape instead`)
1232
+ }
1233
+ if (redWave.group !== null) {
1234
+ warnings.push(
1235
+ `section ${redWave.group} is the red wave: its failing tests run FIRST rather than ` +
1236
+ `deferring to the trailing test wave, and its inter-wave check is skipped because the ` +
1237
+ `suite is expected to fail there — so this run cannot tell a genuinely failing suite ` +
1238
+ `from one that already passed. The next wave's check is the gate.`
1239
+ )
1240
+ }
1241
+ if (widest > maxParallel && !solo) {
1242
+ warnings.push(
1243
+ `widest wave has ${widest} tasks; splitting into batches of ${maxParallel}`
1244
+ )
1245
+ }
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) {
1250
+ warnings.push(
1251
+ `effectively serial: ${waves.length} waves for ${laneCount} implementation lane(s)`
1252
+ )
1253
+ }
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) {
1259
+ warnings.push(
1260
+ `solo lane: tasks ${lane.ids.join(', ')} run in that order on one opus agent — ` +
1261
+ `every section barrier and dependency edge is honoured by position in the lane`
1262
+ )
1263
+ } else if (lane.kind === LANE_COHESION) {
1264
+ 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})`
1268
+ )
1269
+ } else {
1270
+ 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`
1273
+ )
1274
+ }
1275
+ }
1276
+ for (const f of folded) {
1277
+ warnings.push(
1278
+ `task ${f.id} was a 1-task wave in group ${f.from}; folded into wave ${f.to} as a later ` +
1279
+ `batch — ordering is kept, the checkpoint is not`
1280
+ )
1281
+ }
1282
+ if (!implTasks.length) warnings.push('no implementation tasks — only test tasks were classified')
1283
+
1284
+ // Reported at the boundary, never silently dropped: a predicted path that
1285
+ // cannot be placed in the repo is not compared against anything, so the task
1286
+ // claiming it is unguarded against a collision on that file and the plan has
1287
+ // to say so.
1288
+ const rejectedPaths = unusablePaths(tasks)
1289
+ for (const r of rejectedPaths) {
1290
+ warnings.push(
1291
+ `task ${r.id} predicted "${r.path}", which is absolute or escapes the repository root — ` +
1292
+ `it is not usable as a collision key and was not rewritten into scope`
1293
+ )
1294
+ }
1295
+
1296
+ for (const m of serialized) {
1297
+ warnings.push(
1298
+ `task ${m.id} serialized in ${laneWhere(m.group)}: it claims ${m.path}, already claimed by ` +
1299
+ `${m.conflictsWith} — they run in one lane, in task-id order`
1300
+ )
1301
+ }
1302
+
1303
+ // Named rather than left to be inferred from a batch boundary: an edge is the
1304
+ // one ordering signal in this planner an author wrote by hand, so an author
1305
+ // reading the plan has to be able to see it took effect. In solo mode the
1306
+ // batch boundary is gone and the edge is honoured by position in the lane, so
1307
+ // it matters even more that the plan says the edge was seen.
1308
+ for (const d of deferred) {
1309
+ warnings.push(
1310
+ solo
1311
+ ? `task ${d.id} declares dependsOn ${d.after.join(', ')}; it runs after them inside the ` +
1312
+ `solo lane rather than beside them`
1313
+ : `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`
1315
+ )
1316
+ }
1317
+
1318
+ return {
1319
+ maxParallel,
1320
+ mode: decision.mode,
1321
+ modeSource: decision.modeSource,
1322
+ modeReason: decision.modeReason,
1323
+ totalTasks: tasks.length,
1324
+ // The resolved group, not the requested one: a refused claim reads as no
1325
+ // red wave here, and the refusal is in `warnings` where a reader looks for
1326
+ // what the planner decided against.
1327
+ redWave: redWave.group,
1328
+ implCount,
1329
+ testCount: testTasks.length,
1330
+ waveCount: waves.length,
1331
+ laneCount,
1332
+ laneCaps,
1333
+ waves,
1334
+ testWave,
1335
+ clamped,
1336
+ promoted,
1337
+ serialized,
1338
+ deferred,
1339
+ lanes,
1340
+ effort,
1341
+ folded,
1342
+ rejectedPaths,
1343
+ warnings
1344
+ }
1345
+ }
1346
+
1347
+ /**
1348
+ * A wave's tasks, one line each, with a lane header for anything folded.
1349
+ *
1350
+ * A single-task lane prints exactly the line it printed before lanes existed:
1351
+ * naming a "lane" for every task would bury the folds this is here to show.
1352
+ */
1353
+ function formatBatches(wave) {
1354
+ const lines = []
1355
+ for (const batch of wave.batches) {
1356
+ for (const lane of batch) {
1357
+ if (lane.length > 1) {
1358
+ lines.push(
1359
+ ` lane [${laneModel(lane)}/T${laneTier(lane)}] ${lane.length} tasks, one agent, in order:`
1360
+ )
1361
+ }
1362
+ for (const t of lane) lines.push(` - [${t.model}/T${t.tier}] ${t.id} ${t.description}`)
1363
+ }
1364
+ }
1365
+ return lines
1366
+ }
1367
+
1368
+ /** Human-readable execution plan, for the skill to echo before it fans out. */
1369
+ export function formatPlan(plan) {
1370
+ const lines = []
1371
+ // The mode opens the preview, before the shape it produced: an operator
1372
+ // looking at one lane for twelve tasks has to be told it was a decision, whose
1373
+ // decision it was, and why — at the top, not inferred from the absence of
1374
+ // batches further down.
1375
+ const source = plan.modeSource || 'default'
1376
+ lines.push(
1377
+ `mode: ${plan.mode || MODE_WAVES} (${source}${plan.modeReason ? `: ${plan.modeReason}` : ''})`
1378
+ )
1379
+ lines.push(
1380
+ `${plan.totalTasks} tasks → ${plan.waveCount} wave(s), ` +
1381
+ `${plan.implCount} impl + ${plan.testCount} test, max ${plan.maxParallel} parallel`
1382
+ )
1383
+ for (const wave of plan.waves) {
1384
+ const batchNote = wave.batches.length > 1 ? ` in ${wave.batches.length} batches` : ''
1385
+ // Named on the wave's own line rather than only in the warnings, because
1386
+ // this is the one wave whose failing check is not a problem — an operator
1387
+ // watching the run has to be able to see which one that is.
1388
+ const redNote = wave.red === true ? ' — RED: failing tests, check skipped' : ''
1389
+ lines.push(` Wave ${wave.group}: ${wave.taskCount} task(s)${batchNote}${redNote}`)
1390
+ for (const line of formatBatches(wave)) lines.push(line)
1391
+ }
1392
+ if (plan.testWave) {
1393
+ lines.push(` Test wave: ${plan.testWave.taskCount} task(s)`)
1394
+ for (const line of formatBatches(plan.testWave)) lines.push(line)
1395
+ }
1396
+ for (const c of plan.clamped) {
1397
+ lines.push(` clamped ${c.id}: ${c.from} → ${c.to} (tier ${c.tier})`)
1398
+ }
1399
+ // Beside the clamps, and phrased the same way, because they are the same kind
1400
+ // of fact read in opposite directions: the clamp is what the classifier was
1401
+ // not allowed to ask for, the promotion is what the planner decided anyway.
1402
+ for (const p of plan.promoted || []) {
1403
+ lines.push(` promoted ${p.id}: ${p.from} → ${p.to} (tier ${p.tier}, solo lane)`)
1404
+ }
1405
+ for (const m of plan.serialized || []) {
1406
+ lines.push(
1407
+ ` serialized ${m.id}: same lane in ${laneWhere(m.group)} ` +
1408
+ `(${m.path} held by ${m.conflictsWith})`
1409
+ )
1410
+ }
1411
+ for (const d of plan.deferred || []) {
1412
+ lines.push(
1413
+ ` ordered ${d.id}: after ${d.after.join(', ')} in ${laneWhere(d.group)} (dependsOn)`
1414
+ )
1415
+ }
1416
+ // The over-split is still visible even though it no longer costs a
1417
+ // checkpoint — a plan that silently absorbed it would stop the authoring
1418
+ // problem from ever being fixed.
1419
+ for (const f of plan.folded || []) {
1420
+ lines.push(` folded ${f.id}: 1-task wave ${f.from} → later batch of wave ${f.to}`)
1421
+ }
1422
+ // The fold that removed the spawns, named rather than left to be inferred
1423
+ // from an agent count that is smaller than the task count. Cohesion prints
1424
+ // under its own keyword and names the cap that bounded it, so an operator can
1425
+ // see both that the fold happened and what would have stopped it.
1426
+ for (const lane of plan.lanes || []) {
1427
+ if (lane.kind === LANE_SOLO) {
1428
+ lines.push(
1429
+ ` solo ${lane.ids.join(' → ')}: ${lane.ids.length} tasks, the whole change on one ` +
1430
+ `${lane.model}/T${lane.tier} agent`
1431
+ )
1432
+ } else if (lane.kind === LANE_COHESION) {
1433
+ lines.push(
1434
+ ` cohesion ${lane.ids.join(' → ')}: ${lane.ids.length} path-disjoint tasks in ` +
1435
+ `${laneWhere(lane.group)} on one ${lane.model}/T${lane.tier} agent (cap ${lane.cap})`
1436
+ )
1437
+ } else {
1438
+ lines.push(
1439
+ ` lane ${lane.ids.join(' → ')}: ${lane.ids.length} tasks in ${laneWhere(lane.group)} on ` +
1440
+ `one ${lane.model}/T${lane.tier} agent`
1441
+ )
1442
+ }
1443
+ }
1444
+ // The effort each listed lane routes at, named the way clamps are — an
1445
+ // inherited effort says which kind, so "tier 3 → inherited (policy)" reads
1446
+ // differently from "tier unreadable → inherited (fallback)".
1447
+ for (const e of plan.effort || []) {
1448
+ const chosen =
1449
+ e.effort === null
1450
+ ? `inherited (${e.inherited === 'fallback' ? 'tier unreadable' : 'by policy'})`
1451
+ : e.effort
1452
+ lines.push(` effort ${e.ids.join(' → ')}: ${chosen} (tier ${e.tier})`)
1453
+ }
1454
+ const cost = projectedWaveLoopAgents(plan)
1455
+ lines.push(
1456
+ `projected agents: ${cost.total} ` +
1457
+ `(${cost.implementers} implementers + ${cost.recordPings} record + ${cost.verifyPings} verify)`
1458
+ )
1459
+ for (const w of plan.warnings) lines.push(` warning: ${w}`)
1460
+ return lines.join('\n') + '\n'
1461
+ }
1462
+
1463
+ /** Wave-loop agent bill: implementers + one record ping per wave + capped verifies. */
1464
+ export function projectedWaveLoopAgents(plan) {
1465
+ const implWaves = Array.isArray(plan.waves) ? plan.waves : []
1466
+ const testWaves = plan.testWave ? [plan.testWave] : []
1467
+ const allWaves = [...implWaves, ...testWaves]
1468
+ // One lane is one agent, however many tasks it carries. Billing this by task
1469
+ // is what made the fold invisible: the whole point of a lane is that four
1470
+ // tasks in it cost one spawn prefix, not four.
1471
+ const implementers = allWaves.reduce((n, w) => n + waveLanes(w).length, 0)
1472
+ const recordPings = allWaves.length
1473
+ let verifyEligible = 0
1474
+ for (let i = 0; i < allWaves.length - 1; i++) {
1475
+ if (!isDocsOnlyWave(allWaves[i])) verifyEligible += 1
1476
+ }
1477
+ const verifyPings = Math.min(LIMITS.interWaveVerifications, verifyEligible)
1478
+ return { implementers, recordPings, verifyPings, total: implementers + recordPings + verifyPings }
1479
+ }
1480
+
1481
+ // ---------------------------------------------------------------------------
1482
+ // Execution state machine
1483
+ // ---------------------------------------------------------------------------
1484
+ //
1485
+ // The walk is: for each wave in order, run its batches one after another (the
1486
+ // tasks inside a batch are what the caller fans out in parallel); when a wave
1487
+ // ends and another wave follows, verify before starting it. Verification only
1488
+ // ever runs *between* waves, because the one question it answers — "do these
1489
+ // errors block the next wave?" — is meaningless when there is no next wave. The
1490
+ // full suite after the trailing test wave is a separate step the caller owns.
1491
+
1492
+ /** Why a run stopped. Callers branch on these, so they are constants. */
1493
+ export const HALT_TASK_FAILURES = 'task-failures'
1494
+ export const HALT_INTER_WAVE_VERIFY = 'inter-wave-verify'
1495
+
1496
+ // ---------------------------------------------------------------------------
1497
+ // Wave handoff
1498
+ // ---------------------------------------------------------------------------
1499
+ //
1500
+ // What one wave tells the next. Before this existed the next wave inferred it
1501
+ // from `git log` plus a mutable state file, which is a reconstruction, not a
1502
+ // report — and a reconstruction cannot be validated, bounded or refused.
1503
+ //
1504
+ // So it is a schema with a character cap, and it fails closed. A packet that is
1505
+ // missing, malformed or over budget fails its task; it is never truncated and
1506
+ // never passed through as prose for the next wave to interpret. Truncation is
1507
+ // the exact silent degradation this replaces.
1508
+
1509
+ /** Version tag on every stored packet, so a later shape change is detectable. */
1510
+ export const HANDOFF_SCHEMA = 'interlock.wave-handoff/1'
1511
+
1512
+ const HANDOFF_STATUSES = new Set(['ok', 'blocked', 'partial'])
1513
+
1514
+ /** Evidence is a locator, not a file body: `path`, `path:12`, `path:12-40`. */
1515
+ const EVIDENCE_LOCATOR = /^[^\s:]+(:\d+(-\d+)?)?$/
1516
+ const MAX_EVIDENCE = 8
1517
+
1518
+ function handoffError(reason) {
1519
+ return { ok: false, error: reason }
1520
+ }
1521
+
1522
+ /**
1523
+ * Check one task's handoff packet. Pure — no state, no clock, no I/O.
1524
+ *
1525
+ * @param {string} taskId the id the packet must be reporting on
1526
+ * @param {unknown} packet
1527
+ * @returns {{ok: true, handoff: object} | {ok: false, error: string}}
1528
+ */
1529
+ export function validateHandoff(taskId, packet) {
1530
+ if (!packet || typeof packet !== 'object' || Array.isArray(packet)) {
1531
+ return handoffError('handoff must be an object')
1532
+ }
1533
+
1534
+ // `schema` is defaulted rather than demanded. A model that drops a constant
1535
+ // string it was shown verbatim has not told us anything about its work, and
1536
+ // failing the whole task over it would throw away a valid report. A *wrong*
1537
+ // version is different — that is a packet written against another contract.
1538
+ const schema = packet.schema === undefined ? HANDOFF_SCHEMA : packet.schema
1539
+ if (schema !== HANDOFF_SCHEMA) {
1540
+ return handoffError(`unknown handoff schema "${schema}" (expected ${HANDOFF_SCHEMA})`)
1541
+ }
1542
+
1543
+ if (typeof packet.taskId !== 'string' || packet.taskId !== taskId) {
1544
+ return handoffError(`taskId must be "${taskId}", got ${JSON.stringify(packet.taskId)}`)
1545
+ }
1546
+
1547
+ if (!HANDOFF_STATUSES.has(packet.status)) {
1548
+ return handoffError(
1549
+ `status must be one of ${[...HANDOFF_STATUSES].join('|')}, got ${JSON.stringify(packet.status)}`
1550
+ )
1551
+ }
1552
+
1553
+ if (typeof packet.summary !== 'string' || !packet.summary.trim()) {
1554
+ return handoffError('summary must be a non-empty string')
1555
+ }
1556
+ if (typeof packet.next !== 'string' || !packet.next.trim()) {
1557
+ return handoffError('next must be a non-empty string')
1558
+ }
1559
+
1560
+ const blocker = packet.blocker === undefined ? null : packet.blocker
1561
+ if (packet.status === 'ok') {
1562
+ if (blocker !== null) return handoffError('status "ok" must have a null blocker')
1563
+ } else if (typeof blocker !== 'string' || !blocker.trim()) {
1564
+ return handoffError(`status "${packet.status}" must have a non-empty blocker`)
1565
+ }
1566
+
1567
+ const evidence = packet.evidence === undefined ? [] : packet.evidence
1568
+ if (!Array.isArray(evidence)) return handoffError('evidence must be an array of locators')
1569
+ if (evidence.length > MAX_EVIDENCE) {
1570
+ return handoffError(`evidence has ${evidence.length} entries; at most ${MAX_EVIDENCE} are allowed`)
1571
+ }
1572
+ for (const entry of evidence) {
1573
+ if (typeof entry !== 'string' || !EVIDENCE_LOCATOR.test(entry)) {
1574
+ return handoffError(
1575
+ `evidence entry ${JSON.stringify(entry)} is not a locator (path, path:line or path:start-end)`
1576
+ )
1577
+ }
1578
+ }
1579
+
1580
+ const counted =
1581
+ packet.summary.length + packet.next.length + (blocker || '').length + evidence.join('\n').length
1582
+ if (counted > LIMITS.maxHandoffChars) {
1583
+ return handoffError(
1584
+ `handoff is ${counted} characters; the cap is ${LIMITS.maxHandoffChars} ` +
1585
+ `(shorten it — it is never truncated for you)`
1586
+ )
1587
+ }
1588
+
1589
+ // Rebuilt rather than spread, so unknown keys are dropped instead of stored.
1590
+ return {
1591
+ ok: true,
1592
+ handoff: {
1593
+ schema: HANDOFF_SCHEMA,
1594
+ taskId,
1595
+ status: packet.status,
1596
+ summary: packet.summary,
1597
+ evidence: evidence.slice(),
1598
+ next: packet.next,
1599
+ blocker: packet.status === 'ok' ? null : blocker
1600
+ }
1601
+ }
1602
+ }
1603
+
1604
+ // ---------------------------------------------------------------------------
1605
+ // Handoff evidence audit
1606
+ // ---------------------------------------------------------------------------
1607
+ //
1608
+ // `validateHandoff` checks that an evidence entry has locator SHAPE. Shape
1609
+ // alone is satisfied by inventing `lib/nowhere.mjs:1`, which is exactly the
1610
+ // hole `lib/review-core.mjs` closed for review findings by adding a second,
1611
+ // independent condition: the cited path must be one that actually changed.
1612
+ // This is that condition, applied to the second surface, with the same
1613
+ // deliberate ceilings:
1614
+ //
1615
+ // - Path membership only. The cited LINE is never required to exist — a
1616
+ // locator may name a line the same wave later moved or deleted, and
1617
+ // rejecting it would be a false rejection of a true claim.
1618
+ // - No semantic judgement. Whether the cited span supports the summary needs
1619
+ // a model, and a model there recreates the unverified-claim problem one
1620
+ // layer down.
1621
+ //
1622
+ // AND IT NEVER GATES. The verdict is recorded and read; it does not decide
1623
+ // whether a task succeeded, whether a wave proceeds, or whether the run halts.
1624
+ // An unconfirmed packet is still handed to the next wave, because a report with
1625
+ // unverifiable evidence is still more information than no report — and because
1626
+ // membership here is run-scoped, not task-scoped (see `source` below), so a
1627
+ // halt built on it would stop good runs.
1628
+
1629
+ /** Every locator names a path in the changed-path set. */
1630
+ export const AUDIT_CONFIRMED = 'confirmed'
1631
+ /** At least one locator names a path the set does not contain. */
1632
+ export const AUDIT_UNCONFIRMED = 'unconfirmed'
1633
+ /** The check could not run. NOT a synonym for failure — see below. */
1634
+ export const AUDIT_NOT_AUDITED = 'not-audited'
1635
+
1636
+ /** Does one discrete locator name a path in `index`? Shape, then membership. */
1637
+ function locatorMatches(entry, index) {
1638
+ if (typeof entry !== 'string' || !EVIDENCE_LOCATOR.test(entry)) return false
1639
+ const path = locatorPath(entry)
1640
+ return path !== null && index.has(path)
1641
+ }
1642
+
1643
+ /**
1644
+ * Audit one handoff packet's evidence against the paths that changed.
1645
+ *
1646
+ * Pure by construction: the path set is an ARGUMENT, never fetched. `lib/` has
1647
+ * no business running git, and the moment that matters is the caller's — at
1648
+ * `record-batch` the working tree still matches what the implementer just
1649
+ * claimed, and audited any later a `confirmed` verdict means nothing.
1650
+ *
1651
+ * `source` is load-bearing, not decoration. Auditing self-reported evidence
1652
+ * against a self-reported path set is a CONSISTENCY check — it catches an agent
1653
+ * that contradicts itself, not one that fabricates coherently. Recording the
1654
+ * two identically would present the weaker check as the stronger one, which is
1655
+ * the defect class this exists to remove. So the fallback is kept and labelled,
1656
+ * and an unlabelled path set is treated as `reported`: the weaker claim is the
1657
+ * safe default, because the failure mode of the other default is a lie.
1658
+ *
1659
+ * `reportedMatch` is the narrower cross-check kept separately. The observed set
1660
+ * is `git status` at record time, which mid-run is the accumulated diff of the
1661
+ * RUN, not of this task — tasks in a wave run concurrently against one working
1662
+ * tree, so no git question can attribute a path to a lane. Read together the
1663
+ * two fields say: the run demonstrably changed this path, and the task claimed
1664
+ * it. Collapsing them into one boolean would sell the aggregate as per-task
1665
+ * attribution.
1666
+ *
1667
+ * @param {unknown} packet a validated handoff packet
1668
+ * @param {unknown} changedPaths the path set to test against
1669
+ * @param {{source?: 'observed'|'reported', reportedPaths?: string[]}} [opts]
1670
+ * @returns {{verdict: string, source: string|null, reason: string,
1671
+ * unmatched: string[], reportedMatch: boolean|null}}
1672
+ */
1673
+ export function auditHandoffEvidence(packet, changedPaths, opts = {}) {
1674
+ const source = opts.source === 'observed' ? 'observed' : 'reported'
1675
+ const evidence =
1676
+ packet && typeof packet === 'object' && Array.isArray(packet.evidence) ? packet.evidence : []
1677
+
1678
+ const index = diffIndex(changedPaths)
1679
+ const reported = diffIndex(opts.reportedPaths)
1680
+ // `null` means "the task reported no paths", which is not the same fact as
1681
+ // "the task reported paths and none of them match".
1682
+ const reportedMatch =
1683
+ reported && evidence.length ? evidence.every(e => locatorMatches(e, reported)) : null
1684
+
1685
+ const verdict = (verdict, reason, unmatched = []) => ({
1686
+ verdict,
1687
+ // Named only when a path set was actually used. A `not-audited` verdict
1688
+ // that still named a source would read as a check that ran.
1689
+ source: verdict === AUDIT_NOT_AUDITED ? null : source,
1690
+ reason,
1691
+ unmatched,
1692
+ reportedMatch
1693
+ })
1694
+
1695
+ // Evidence is optional in the packet schema, so a packet without it is not
1696
+ // malformed — there is simply nothing to audit. Reporting that as
1697
+ // `unconfirmed` would blame a packet for a claim it never made.
1698
+ if (!evidence.length) {
1699
+ return verdict(AUDIT_NOT_AUDITED, 'the packet cites no evidence locators')
1700
+ }
1701
+ if (!index) {
1702
+ return verdict(
1703
+ AUDIT_NOT_AUDITED,
1704
+ 'no changed-path set was available from either the observed source or the task\'s own report'
1705
+ )
1706
+ }
1707
+
1708
+ const unmatched = evidence.filter(e => !locatorMatches(e, index))
1709
+ if (unmatched.length) {
1710
+ return verdict(
1711
+ AUDIT_UNCONFIRMED,
1712
+ `${unmatched.length} of ${evidence.length} locator(s) name a path absent from the ` +
1713
+ `${source} changed-path set: ${unmatched.map(e => JSON.stringify(e)).join(', ')}`,
1714
+ unmatched
1715
+ )
1716
+ }
1717
+ return verdict(
1718
+ AUDIT_CONFIRMED,
1719
+ `all ${evidence.length} locator(s) name a path in the ${source} changed-path set`
1720
+ )
1721
+ }
1722
+
1723
+ function deepFreeze(value) {
1724
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
1725
+ Object.freeze(value)
1726
+ for (const inner of Object.values(value)) deepFreeze(inner)
1727
+ }
1728
+ return value
1729
+ }
1730
+
1731
+ // Clone through JSON rather than by hand: it deep-copies, it strips anything a
1732
+ // state could not survive being persisted and rehydrated as, and it guarantees
1733
+ // no object the caller still holds ends up inside — or frozen by — a new state.
1734
+ function cloneState(state) {
1735
+ return JSON.parse(JSON.stringify(state))
1736
+ }
1737
+
1738
+ function finalize(state) {
1739
+ return deepFreeze(cloneState(state))
1740
+ }
1741
+
1742
+ function requireState(state) {
1743
+ if (!state || typeof state !== 'object' || !Array.isArray(state.waves) || !state.cursor) {
1744
+ fail('run state must be the object returned by createRunState')
1745
+ }
1746
+ return state
1747
+ }
1748
+
1749
+ /**
1750
+ * The shape a run was planned in. Absent means waves: every state written before
1751
+ * modes existed describes a waves run, and defaulting the other way would brief
1752
+ * a whole fleet of ordinary lanes as if each held a whole change.
1753
+ */
1754
+ function runMode(state) {
1755
+ return state && state.mode === MODE_SOLO ? MODE_SOLO : MODE_WAVES
1756
+ }
1757
+
1758
+ function mutable(state) {
1759
+ requireState(state)
1760
+ if (state.halt) {
1761
+ fail(`run has halted (${state.halt.reason}); no further results can be recorded`)
1762
+ }
1763
+ const next = cloneState(state)
1764
+ // `record-batch` reports the batch it just recorded beside the state it
1765
+ // returns, so a caller that piped that stdout into its state file carries one
1766
+ // batch's outcomes on it. They describe work that is now history: a run
1767
+ // resumed from such a file must not present them as this batch's verdict.
1768
+ delete next.recorded
1769
+ return next
1770
+ }
1771
+
1772
+ /** "wave 2" / "the test wave" — used in every reason string, so it lives once. */
1773
+ function label(wave) {
1774
+ if (!wave) return 'the end of the run'
1775
+ return wave.kind === 'test' ? 'the test wave' : `wave ${wave.group}`
1776
+ }
1777
+
1778
+ function makeWave(kind, group, tasks, maxParallel, laneCaps, opts = {}) {
1779
+ const serialized = []
1780
+ // A state written before the table existed carries no `laneCaps`. It reads as
1781
+ // the published table rather than as an error, the same fallback the
1782
+ // outcome-audit fields already make: a run resumed across an upgrade must
1783
+ // still be able to replan.
1784
+ const caps = laneCaps && typeof laneCaps === 'object' ? laneCaps : LANE_CAPS.byTier
1785
+ 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
+ )
1809
+ }
1810
+ }
1811
+
1812
+ // Accept a wave straight out of planWaves. The plan's batch boundaries are the
1813
+ // answer, not a suggestion: a batch wider than this run's ceiling is SPLIT, and
1814
+ // nothing is ever merged forward into an earlier batch.
1815
+ //
1816
+ // This used to flatten the wave and re-run the collision packer, which was
1817
+ // harmless while every later batch existed because of a path collision — the
1818
+ // packer would rebuild exactly the same boundary. It stopped being harmless
1819
+ // once the singleton fold started appending later batches whose task has a
1820
+ // *disjoint* path: re-packing would find no collision, see room under
1821
+ // maxParallel, and pull that task into batch 0 — running it in parallel with
1822
+ // the work it was ordered after. A higher runtime cap is not permission to
1823
+ // undo an ordering the plan recorded.
1824
+ function adoptWave(wave, kind, index, maxParallel, warnings) {
1825
+ if (!wave || typeof wave !== 'object') fail(`plan wave ${index} must be an object`)
1826
+ if (!Array.isArray(wave.batches)) fail(`plan wave ${index} must have a "batches" array`)
1827
+ wave.batches.forEach((batch, i) => {
1828
+ if (!Array.isArray(batch)) fail(`plan wave ${index} batch ${i} must be an array of lanes`)
1829
+ batch.forEach((lane, j) => {
1830
+ if (!Array.isArray(lane)) {
1831
+ fail(`plan wave ${index} batch ${i} lane ${j} must be an array of tasks`)
1832
+ }
1833
+ })
1834
+ })
1835
+ const group = kind === 'test' ? null : wave.group
1836
+ if (kind !== 'test' && !Number.isInteger(group)) {
1837
+ fail(`plan wave ${index} must have an integer "group"`)
1838
+ }
1839
+
1840
+ // Width is measured in lanes, because a lane is what runs concurrently. A
1841
+ // batch of three lanes is three agents whatever the lanes hold.
1842
+ const batches = []
1843
+ let split = false
1844
+ for (const batch of wave.batches) {
1845
+ const lanes = batch.filter(lane => lane.length)
1846
+ if (!lanes.length) continue
1847
+ if (lanes.length > maxParallel) {
1848
+ split = true
1849
+ batches.push(...chunk(lanes, maxParallel))
1850
+ } else {
1851
+ batches.push(lanes.map(lane => lane.slice()))
1852
+ }
1853
+ }
1854
+ if (split) {
1855
+ warnings.push(
1856
+ `${label({ kind, group })}: batches wider than ${maxParallel} were re-split to fit the cap`
1857
+ )
1858
+ }
1859
+
1860
+ return {
1861
+ kind,
1862
+ group,
1863
+ // Carried from the plan rather than re-derived: whether a wave is the red
1864
+ // one is a fact about how it was PLANNED, and the run state is the only
1865
+ // thing the engine sees afterwards. Spread in only when true, so a state
1866
+ // for an ordinary change is unchanged — and a state written before red
1867
+ // waves existed reads as `undefined`, which every check below treats as
1868
+ // not-red rather than as an error.
1869
+ ...(wave.red === true ? { red: true } : {}),
1870
+ taskCount: batches.reduce((n, b) => n + b.reduce((m, lane) => m + lane.length, 0), 0),
1871
+ batches
1872
+ }
1873
+ }
1874
+
1875
+ function validateTasks(tasks, where) {
1876
+ if (!Array.isArray(tasks)) fail(`${where} must be an array of tasks`)
1877
+ tasks.forEach((t, i) => {
1878
+ if (!t || typeof t !== 'object') fail(`${where}[${i}] must be an object`)
1879
+ if (typeof t.id !== 'string' || !t.id.trim()) {
1880
+ fail(`${where}[${i}].id must be a non-empty string`)
1881
+ }
1882
+ })
1883
+ return tasks
1884
+ }
1885
+
1886
+ /**
1887
+ * Start a run from a plan.
1888
+ *
1889
+ * @param {ReturnType<typeof planWaves>} plan
1890
+ * @param {{maxParallel?: number, change?: string}} [opts] `maxParallel` is a
1891
+ * runtime override, clamped by `clampParallel`, because over-asking is a
1892
+ * preference, not a mistake. `change` names the change this run ships and is
1893
+ * carried on the state for the life of the run.
1894
+ * @returns {object} a frozen, JSON-safe run state
1895
+ */
1896
+ export function createRunState(plan, opts = {}) {
1897
+ if (!plan || typeof plan !== 'object') fail('createRunState needs the plan object from planWaves')
1898
+ if (!Array.isArray(plan.waves)) fail('plan must have a "waves" array — pass the output of planWaves')
1899
+ if (plan.testWave != null && typeof plan.testWave !== 'object') {
1900
+ fail('plan.testWave must be an object or null')
1901
+ }
1902
+
1903
+ const requested = Number.isInteger(opts.maxParallel) ? opts.maxParallel : plan.maxParallel
1904
+ const { value: maxParallel, clamped, reason } = clampParallel(requested)
1905
+
1906
+ const warnings = []
1907
+ if (clamped) warnings.push(`max parallel reduced to ${maxParallel}: ${reason}`)
1908
+
1909
+ const waves = plan.waves.map((w, i) => adoptWave(w, 'impl', i, maxParallel, warnings))
1910
+ if (plan.testWave) {
1911
+ waves.push(adoptWave(plan.testWave, 'test', waves.length, maxParallel, warnings))
1912
+ }
1913
+
1914
+ return finalize({
1915
+ // Stable id for this run, stored on the frozen state so every later
1916
+ // wave-state call (and --write-state into .claude/ship/state.json) can
1917
+ // find it without a new flag.
1918
+ runId: randomUUID(),
1919
+ // The change this run ships, travelling the same route as `runId` for the
1920
+ // same reason: a per-invocation flag that every later wave-state call has to
1921
+ // remember is a flag that gets dropped, and it was — every recorded event in
1922
+ // this repo read `unnamed`. Named once, here. Absent stays absent (the JSON
1923
+ // clone below drops an undefined key), because a run with no name must still
1924
+ // run; `appendRunLogEvent` bounds and defaults it at the write site, which is
1925
+ // where the log's field limits already live.
1926
+ change: typeof opts.change === 'string' && opts.change.trim() ? opts.change.trim() : undefined,
1927
+ maxParallel,
1928
+ // The shape this run was planned in, so `wave-state next` can tell the host
1929
+ // how to brief the implementer without the host re-deciding it. A state
1930
+ // written before modes existed is a waves run — the only thing it could have
1931
+ // been.
1932
+ mode: plan.mode === MODE_SOLO ? MODE_SOLO : MODE_WAVES,
1933
+ // Carried so a replan lanes its revision the way the plan was laned — the
1934
+ // EFFECTIVE table, so a uniform override the plan was built under survives
1935
+ // into the revision rather than silently lapsing back to the published caps.
1936
+ laneCaps:
1937
+ plan.laneCaps && typeof plan.laneCaps === 'object'
1938
+ ? { ...plan.laneCaps }
1939
+ : { ...LANE_CAPS.byTier },
1940
+ waves: waves.filter(w => w.batches.length > 0),
1941
+ // Where the walk is. `phase` is 'batch' while the wave at waveIndex still
1942
+ // has batches to run, 'verify' once it is finished and another wave follows.
1943
+ cursor: { waveIndex: 0, batchIndex: 0, phase: 'batch' },
1944
+ // Consecutive failed checks for the wave at the cursor: 0 means the check
1945
+ // has not run yet, N means N fix attempts have been consumed.
1946
+ verifyFailures: 0,
1947
+ lastVerifyErrors: [],
1948
+ replansUsed: 0,
1949
+ replanPending: false,
1950
+ // Groups with at least one recorded batch result. Replan is measured
1951
+ // against this, not against the cursor: a wave the cursor has reached but
1952
+ // nothing has been recorded for has not executed.
1953
+ executedGroups: [],
1954
+ // Validated per-task packets, keyed by task id. `nextStep` hands the
1955
+ // previous wave's back to the caller so a fresh implementer is given a
1956
+ // report rather than left to reconstruct one from git.
1957
+ handoffs: {},
1958
+ // What each task REPORTED changing, keyed by task id. Requested from every
1959
+ // implementer and, until this existed, thrown away — while being the only
1960
+ // cross-check available against the packet's own evidence. A report, never
1961
+ // presented as evidence that those paths changed.
1962
+ reportedPaths: {},
1963
+ // One `auditHandoffEvidence` verdict per stored packet. Read by a human at a
1964
+ // halt and by later scoring; read by no control-flow decision anywhere.
1965
+ evidenceAudits: {},
1966
+ completed: [],
1967
+ failures: [],
1968
+ unresolved: [],
1969
+ skippedVerifications: [],
1970
+ verificationsUsed: 0,
1971
+ warnings,
1972
+ halt: null
1973
+ })
1974
+ }
1975
+
1976
+ /**
1977
+ * What should happen next. Pure — call it as often as you like.
1978
+ *
1979
+ * @returns {{action: 'run-batch'|'test-wave'|'verify'|'replan'|'done'|'halt'}}
1980
+ */
1981
+ export function nextStep(state) {
1982
+ requireState(state)
1983
+
1984
+ // On every payload, not only the one that dispatches an implementer. The host
1985
+ // reads a step and nothing else, so a field present on some steps and absent
1986
+ // on others is a field the host has to defend against — and a solo run whose
1987
+ // step forgot to say so briefs its one agent as an ordinary lane.
1988
+ const mode = runMode(state)
1989
+
1990
+ if (state.halt) {
1991
+ return {
1992
+ action: 'halt',
1993
+ mode,
1994
+ kind: state.halt.kind,
1995
+ reason: state.halt.reason,
1996
+ failures: state.failures,
1997
+ unresolved: state.unresolved
1998
+ }
1999
+ }
2000
+
2001
+ const { waveIndex, batchIndex, phase } = state.cursor
2002
+ const wave = state.waves[waveIndex]
2003
+ if (!wave) return { action: 'done', mode, summary: summarize(state) }
2004
+
2005
+ const following = state.waves[waveIndex + 1] || null
2006
+
2007
+ if (phase === 'verify') {
2008
+ const used = state.verifyFailures
2009
+ return {
2010
+ action: 'verify',
2011
+ wave: wave.group,
2012
+ waveIndex,
2013
+ waveKind: wave.kind,
2014
+ // The ONE step whose `mode` is not the run's plan shape: this field named
2015
+ // the verify attempt (initial or fix) before plan modes existed, and both
2016
+ // hosts branch on it. The run mode is deliberately not echoed here rather
2017
+ // than shadowed by it — a solo plan is one wave, so it never reaches an
2018
+ // inter-wave verify, and nothing downstream of this step briefs an
2019
+ // implementer. `runMode` carries it for a reader that wants both.
2020
+ mode: used === 0 ? 'initial' : 'fix',
2021
+ runMode: mode,
2022
+ fixAttempt: used,
2023
+ fixAttemptsRemaining: Math.max(0, LIMITS.interWaveFixAttempts - used),
2024
+ errors: state.lastVerifyErrors,
2025
+ nextWave: following ? following.group : null,
2026
+ nextWaveKind: following ? following.kind : null,
2027
+ changed: uniquePaths(wave)
2028
+ }
2029
+ }
2030
+
2031
+ // Replan is offered only at a wave boundary — revising a group mid-wave would
2032
+ // mean revising work already in flight.
2033
+ if (batchIndex === 0 && state.replanPending) {
2034
+ const revisable = revisableGroups(state)
2035
+ if (state.replansUsed < LIMITS.replansPerRun && revisable.length) {
2036
+ return {
2037
+ action: 'replan',
2038
+ mode,
2039
+ revisableGroups: revisable,
2040
+ replansUsed: state.replansUsed,
2041
+ replansRemaining: LIMITS.replansPerRun - state.replansUsed
2042
+ }
2043
+ }
2044
+ }
2045
+
2046
+ return {
2047
+ action: wave.kind === 'test' ? 'test-wave' : 'run-batch',
2048
+ mode,
2049
+ wave: wave.group,
2050
+ waveIndex,
2051
+ waveKind: wave.kind,
2052
+ batchIndex,
2053
+ batchCount: wave.batches.length,
2054
+ tasks: wave.batches[batchIndex],
2055
+ remainingBatches: wave.batches.slice(batchIndex),
2056
+ previousHandoffs: previousWaveHandoffs(state, waveIndex),
2057
+ // The wave's changed-file set, canonically deduplicated. A caller that
2058
+ // fuses the inter-wave verify plan into the record-batch ping (ship.js
2059
+ // does) needs it here, and recomputing it from raw `task.paths` is how
2060
+ // duplicate spellings inflate the set a verification is scoped to.
2061
+ changed: uniquePaths(wave),
2062
+ maxParallel: state.maxParallel
2063
+ }
2064
+ }
2065
+
2066
+ /**
2067
+ * The packets recorded by the wave immediately before this one.
2068
+ *
2069
+ * Immediately before, and nothing else. Accumulating every earlier wave would
2070
+ * grow without bound and bury the one report that is actually load-bearing.
2071
+ * Earlier batches of the *current* wave are excluded too: `remainingBatches`
2072
+ * hands the caller a whole wave from one step, so every batch in it must see
2073
+ * the same input — a batch that saw its predecessor's handoff would be running
2074
+ * under a different contract than the batch beside it.
2075
+ */
2076
+ function previousWaveHandoffs(state, waveIndex) {
2077
+ const previous = state.waves[waveIndex - 1]
2078
+ if (!previous) return []
2079
+ const stored = state.handoffs && typeof state.handoffs === 'object' ? state.handoffs : {}
2080
+ const audits =
2081
+ state.evidenceAudits && typeof state.evidenceAudits === 'object' ? state.evidenceAudits : {}
2082
+ const out = []
2083
+ for (const t of waveTasks(previous)) {
2084
+ const packet = stored[t.id]
2085
+ if (!packet) continue
2086
+ // The verdict travels with the packet it is about, so a reader of the step
2087
+ // is never left inferring which audit belongs to which report. It does NOT
2088
+ // change what the next implementer is told: the prompt renders taskId,
2089
+ // status, summary, evidence, next and blocker, and an unconfirmed packet
2090
+ // renders byte-identically to a confirmed one. Annotating the prompt would
2091
+ // tell an implementer to distrust its predecessor on an aggregate signal
2092
+ // (design.md — Decision 6).
2093
+ const audit = audits[t.id]
2094
+ out.push(audit ? { ...packet, audit } : packet)
2095
+ }
2096
+ return out
2097
+ }
2098
+
2099
+ // Groups a replan may still touch: implementation waves at or after the first
2100
+ // index the cursor has not committed to, minus anything already executed.
2101
+ function firstMutableIndex(state) {
2102
+ const { waveIndex, batchIndex, phase } = state.cursor
2103
+ if (phase === 'verify' || batchIndex > 0) return waveIndex + 1
2104
+ return waveIndex
2105
+ }
2106
+
2107
+ function revisableGroups(state) {
2108
+ const from = firstMutableIndex(state)
2109
+ // Deduplicated: a group split into dependency layers occupies more than one
2110
+ // wave, and offering the same group number twice would read as two things a
2111
+ // replan could revise separately when a revision replaces the whole group.
2112
+ return [
2113
+ ...new Set(
2114
+ state.waves
2115
+ .filter((w, i) => i >= from && w.kind === 'impl' && !state.executedGroups.includes(w.group))
2116
+ .map(w => w.group)
2117
+ )
2118
+ ]
2119
+ }
2120
+
2121
+ function normalizeTaskResults(result) {
2122
+ if (!result || typeof result !== 'object') fail('batch result must be an object')
2123
+ if (!Array.isArray(result.tasks)) {
2124
+ fail('batch result must have a "tasks" array of { id, ok }')
2125
+ }
2126
+ return result.tasks.map((t, i) => {
2127
+ const at = `batch result tasks[${i}]`
2128
+ if (!t || typeof t !== 'object') fail(`${at} must be an object`)
2129
+ if (typeof t.id !== 'string' || !t.id.trim()) fail(`${at}.id must be a non-empty string`)
2130
+ if (typeof t.ok !== 'boolean') fail(`${at}.ok must be a boolean (task ${t.id})`)
2131
+ const error = typeof t.error === 'string' ? t.error : null
2132
+ // What the agent says it touched, kept as a REPORT. It is the cross-check
2133
+ // against the packet's own evidence, and it cannot be recovered later once
2134
+ // the working tree moves on — which is why it is retained here rather than
2135
+ // dropped as it was. Never substituted for by the evidence locators: a task
2136
+ // that reported no paths reported no paths.
2137
+ const filesChanged = Array.isArray(t.filesChanged)
2138
+ ? t.filesChanged.filter(p => typeof p === 'string' && p.trim()).map(p => p.trim())
2139
+ : []
2140
+ const checked = validateHandoff(t.id, t.handoff)
2141
+
2142
+ // A task that already reported failure does not owe a packet. ship.js turns
2143
+ // a null agent result into ok:false with no handoff, and rewriting that as
2144
+ // an invalid-handoff error would replace the reason it actually failed with
2145
+ // a complaint about the report it was never in a position to write. A valid
2146
+ // packet on a failed task is still kept — a blocked task's blocker is the
2147
+ // most useful thing the next wave could be handed.
2148
+ if (!t.ok) {
2149
+ return {
2150
+ id: t.id,
2151
+ ok: false,
2152
+ error,
2153
+ filesChanged,
2154
+ handoff: checked.ok ? checked.handoff : null
2155
+ }
2156
+ }
2157
+
2158
+ // Claiming success is what obliges a report. Fail closed.
2159
+ if (!checked.ok) {
2160
+ return {
2161
+ id: t.id,
2162
+ ok: false,
2163
+ error: `invalid handoff: ${checked.error}`,
2164
+ filesChanged,
2165
+ handoff: null
2166
+ }
2167
+ }
2168
+ return { id: t.id, ok: true, error, filesChanged, handoff: checked.handoff }
2169
+ })
2170
+ }
2171
+
2172
+ function advanceWave(state) {
2173
+ state.cursor = { waveIndex: state.cursor.waveIndex + 1, batchIndex: 0, phase: 'batch' }
2174
+ state.verifyFailures = 0
2175
+ state.lastVerifyErrors = []
2176
+ return state
2177
+ }
2178
+
2179
+ /**
2180
+ * Record the outcome of the batch `nextStep` last asked for.
2181
+ *
2182
+ * @param {object} state
2183
+ * @param {{tasks: Array<{id: string, ok: boolean, error?: string,
2184
+ * filesChanged?: string[]}>, replanSuggested?: boolean}} result
2185
+ * @param {{changedPaths?: string[]}} [opts] the OBSERVED changed-path set, read
2186
+ * from version control by the caller at this moment — the only point at which
2187
+ * the working tree still matches what the implementer just claimed. It is a
2188
+ * separate argument rather than a field on `result` on purpose: `result` is
2189
+ * written by the agent being audited, and an audit whose own input the subject
2190
+ * supplies is not an audit. Absent, each task is audited against its own
2191
+ * reported paths and the verdict says so.
2192
+ * @returns {object} a new state; `state` is left untouched
2193
+ */
2194
+ export function recordBatchResult(state, result, opts = {}) {
2195
+ const next = mutable(state)
2196
+ const wave = next.waves[next.cursor.waveIndex]
2197
+ if (!wave || next.cursor.phase !== 'batch') {
2198
+ fail('recordBatchResult called while the run is not waiting on a batch — ask nextStep first')
2199
+ }
2200
+
2201
+ const tasks = normalizeTaskResults(result)
2202
+ // An in-flight state.json written before handoffs existed has no such key.
2203
+ if (!next.handoffs || typeof next.handoffs !== 'object') next.handoffs = {}
2204
+ // Same guard, same reason: a state written before these fields existed is
2205
+ // initialized, never treated as malformed. (design.md — Migration Plan.)
2206
+ if (!next.reportedPaths || typeof next.reportedPaths !== 'object') next.reportedPaths = {}
2207
+ if (!next.evidenceAudits || typeof next.evidenceAudits !== 'object') next.evidenceAudits = {}
2208
+
2209
+ // Resolved once per batch: git's answer is about the working tree, not about
2210
+ // one task, so asking it per task would be the same answer at a higher cost
2211
+ // and would imply an attribution it cannot make.
2212
+ const observed = Array.isArray(opts.changedPaths)
2213
+ ? opts.changedPaths.filter(p => typeof p === 'string' && p.trim())
2214
+ : []
2215
+
2216
+ for (const t of tasks) {
2217
+ // Stored for every task that was actually reported on, including a failed
2218
+ // one: what a failed task touched is exactly what the next reader needs.
2219
+ next.reportedPaths[t.id] = t.filesChanged
2220
+ if (t.handoff) {
2221
+ next.handoffs[t.id] = t.handoff
2222
+ // Recorded, never gated on. Nothing below this line reads it.
2223
+ next.evidenceAudits[t.id] = auditHandoffEvidence(
2224
+ t.handoff,
2225
+ observed.length ? observed : t.filesChanged,
2226
+ { source: observed.length ? 'observed' : 'reported', reportedPaths: t.filesChanged }
2227
+ )
2228
+ }
2229
+ if (t.ok) next.completed.push(t.id)
2230
+ else {
2231
+ next.failures.push({
2232
+ id: t.id,
2233
+ wave: wave.group,
2234
+ waveKind: wave.kind,
2235
+ error: t.error
2236
+ })
2237
+ }
2238
+ }
2239
+
2240
+ if (wave.kind === 'impl' && !next.executedGroups.includes(wave.group)) {
2241
+ next.executedGroups.push(wave.group)
2242
+ }
2243
+ if (result.replanSuggested === true) next.replanPending = true
2244
+
2245
+ next.cursor.batchIndex += 1
2246
+ if (next.cursor.batchIndex >= wave.batches.length) {
2247
+ if (next.waves[next.cursor.waveIndex + 1]) {
2248
+ const used = Number.isInteger(next.verificationsUsed) ? next.verificationsUsed : 0
2249
+ if (isDocsOnlyWave(wave)) {
2250
+ next.skippedVerifications.push({
2251
+ wave: wave.group,
2252
+ waveKind: wave.kind,
2253
+ reason: SKIP_VERIFY_DOCS
2254
+ })
2255
+ advanceWave(next)
2256
+ } else if (wave.red === true) {
2257
+ // Ahead of the cap check on purpose: the red wave must not consume one
2258
+ // of the run's inter-wave verification slots. Checking it would mean
2259
+ // spending `LIMITS.interWaveFixAttempts` on a suite that is failing
2260
+ // because it was asked to, and then halting the run over it.
2261
+ next.skippedVerifications.push({
2262
+ wave: wave.group,
2263
+ waveKind: wave.kind,
2264
+ reason: SKIP_VERIFY_RED
2265
+ })
2266
+ advanceWave(next)
2267
+ } else if (used >= LIMITS.interWaveVerifications) {
2268
+ next.skippedVerifications.push({
2269
+ wave: wave.group,
2270
+ waveKind: wave.kind,
2271
+ reason: SKIP_VERIFY_CAP
2272
+ })
2273
+ advanceWave(next)
2274
+ } else {
2275
+ next.cursor.phase = 'verify'
2276
+ next.verificationsUsed = used + 1
2277
+ next.verifyFailures = 0
2278
+ next.lastVerifyErrors = []
2279
+ }
2280
+ } else {
2281
+ advanceWave(next)
2282
+ }
2283
+ }
2284
+
2285
+ // Checked last, so the failures that tripped the cap are already in the state
2286
+ // the halt is reported from. The trailing test wave counts like any other.
2287
+ if (next.failures.length > LIMITS.taskFailureHalt) {
2288
+ next.halt = {
2289
+ kind: HALT_TASK_FAILURES,
2290
+ reason:
2291
+ `${next.failures.length} task failures accumulated across waves; ` +
2292
+ `more than ${LIMITS.taskFailureHalt} halts the run`
2293
+ }
2294
+ }
2295
+
2296
+ return finalize(next)
2297
+ }
2298
+
2299
+ // ---------------------------------------------------------------------------
2300
+ // What a batch recorded
2301
+ // ---------------------------------------------------------------------------
2302
+ //
2303
+ // `recordBatchResult` already adjudicates every task — an invalid or oversized
2304
+ // packet fails it, a lane result missing an outcome fails the whole lane — and
2305
+ // then tells its caller nothing but the next action. So a caller learned what
2306
+ // to do next and never learned what had just been decided, and both ship hosts
2307
+ // ticked checkboxes and counted waves from the agent's own claim instead. That
2308
+ // is how five tasks the machine failed got ticked beside a halt naming them.
2309
+ //
2310
+ // The delta is DERIVED from two states rather than stored on one: `recorded`
2311
+ // describes one batch, and a durable field holding it would be read as current
2312
+ // by a run resumed a day later.
2313
+
2314
+ /** A task the batch recorded as done. */
2315
+ export const OUTCOME_OK = 'ok'
2316
+ /** A task the batch adjudicated as failed, with the reason it was failed for. */
2317
+ export const OUTCOME_FAILED = 'failed'
2318
+ /**
2319
+ * A task in the batch that no result accounted for. Distinct from failed on
2320
+ * purpose: it is ticked nowhere and counted in no failure budget.
2321
+ */
2322
+ export const OUTCOME_NOT_ATTEMPTED = 'not-attempted'
2323
+
2324
+ /** An adjudication reason travels through an agent, so it is a string, not a log. */
2325
+ const MAX_OUTCOME_REASON = 300
2326
+
2327
+ function shortReason(reason) {
2328
+ const text = typeof reason === 'string' ? reason.trim() : ''
2329
+ if (!text) return undefined
2330
+ return text.length > MAX_OUTCOME_REASON ? `${text.slice(0, MAX_OUTCOME_REASON - 1)}…` : text
2331
+ }
2332
+
2333
+ /** The task ids the batch at `state`'s cursor was supposed to account for. */
2334
+ function plannedBatchIds(state) {
2335
+ const wave =
2336
+ state && Array.isArray(state.waves) && state.cursor
2337
+ ? state.waves[state.cursor.waveIndex]
2338
+ : null
2339
+ const batch = wave && Array.isArray(wave.batches) ? wave.batches[state.cursor.batchIndex] : null
2340
+ if (!Array.isArray(batch)) return []
2341
+ return batch
2342
+ .flat(1)
2343
+ .map(t => (t && typeof t.id === 'string' ? t.id : null))
2344
+ .filter(Boolean)
2345
+ }
2346
+
2347
+ /**
2348
+ * The per-task outcomes one `recordBatchResult` call recorded.
2349
+ *
2350
+ * Pure, and derived: `recordBatchResult` appends to `completed` and `failures`,
2351
+ * so this batch's verdicts are the tails of both, and a planned task in neither
2352
+ * was never attempted. Nothing here reads a field the recorder stored, and
2353
+ * nothing here is written back onto a state.
2354
+ *
2355
+ * @param {object} before the state passed to `recordBatchResult`
2356
+ * @param {object} after the state it returned
2357
+ * @returns {Array<{id: string, outcome: string, reason?: string}>} this batch's
2358
+ * outcomes only — never the run's accumulated history. Ordered by the plan,
2359
+ * with anything recorded that the plan did not name appended after it.
2360
+ */
2361
+ export function batchOutcomes(before, after) {
2362
+ const prevCompleted = before && Array.isArray(before.completed) ? before.completed.length : 0
2363
+ const prevFailures = before && Array.isArray(before.failures) ? before.failures.length : 0
2364
+ const completed = after && Array.isArray(after.completed) ? after.completed.slice(prevCompleted) : []
2365
+ const failures = after && Array.isArray(after.failures) ? after.failures.slice(prevFailures) : []
2366
+
2367
+ const outcomes = new Map()
2368
+ for (const id of completed) {
2369
+ if (typeof id === 'string') outcomes.set(id, { id, outcome: OUTCOME_OK })
2370
+ }
2371
+ for (const f of failures) {
2372
+ if (!f || typeof f.id !== 'string') continue
2373
+ const reason = shortReason(f.error)
2374
+ outcomes.set(f.id, reason ? { id: f.id, outcome: OUTCOME_FAILED, reason } : { id: f.id, outcome: OUTCOME_FAILED })
2375
+ }
2376
+
2377
+ const ordered = []
2378
+ for (const id of plannedBatchIds(before)) {
2379
+ if (ordered.some(o => o.id === id)) continue
2380
+ ordered.push(
2381
+ outcomes.get(id) || {
2382
+ id,
2383
+ outcome: OUTCOME_NOT_ATTEMPTED,
2384
+ reason: 'the batch result accounted for no outcome for this task'
2385
+ }
2386
+ )
2387
+ }
2388
+ for (const [id, outcome] of outcomes) {
2389
+ if (!ordered.some(o => o.id === id)) ordered.push(outcome)
2390
+ }
2391
+ return ordered
2392
+ }
2393
+
2394
+ function normalizeErrors(errors) {
2395
+ if (!Array.isArray(errors)) return []
2396
+ return errors.filter(e => typeof e === 'string' && e.trim())
2397
+ }
2398
+
2399
+ /**
2400
+ * Record the outcome of an inter-wave check.
2401
+ *
2402
+ * A failure buys up to `LIMITS.interWaveFixAttempts` targeted fix attempts for
2403
+ * that wave. When those are spent the errors are logged and the run halts if
2404
+ * the caller says they block the next wave, or continues with a warning if not.
2405
+ * Whether they block is an input, never an inference: only the caller can see
2406
+ * what the next wave is about to touch.
2407
+ *
2408
+ * @param {object} state
2409
+ * @param {{ok?: boolean, errors?: string[], blocksNextWave?: boolean,
2410
+ * skipped?: boolean, reason?: string, replanSuggested?: boolean}} result
2411
+ * @returns {object} a new state; `state` is left untouched
2412
+ */
2413
+ export function recordVerifyResult(state, result) {
2414
+ const next = mutable(state)
2415
+ if (next.cursor.phase !== 'verify') {
2416
+ fail('recordVerifyResult called while the run is not waiting on a check — ask nextStep first')
2417
+ }
2418
+ if (!result || typeof result !== 'object') fail('verify result must be an object')
2419
+
2420
+ const wave = next.waves[next.cursor.waveIndex]
2421
+ const following = next.waves[next.cursor.waveIndex + 1] || null
2422
+ if (result.replanSuggested === true) next.replanPending = true
2423
+
2424
+ // A skip is a legitimate outcome — no detectable commands, pre-existing
2425
+ // failures, over budget — but it is never silent: the reason is the banner.
2426
+ if (result.skipped === true) {
2427
+ const reason = typeof result.reason === 'string' ? result.reason.trim() : ''
2428
+ if (!reason) fail('a skipped verify result must carry a non-empty "reason"')
2429
+ next.skippedVerifications.push({ wave: wave.group, waveKind: wave.kind, reason })
2430
+ return finalize(advanceWave(next))
2431
+ }
2432
+
2433
+ if (typeof result.ok !== 'boolean') {
2434
+ fail('verify result must have a boolean "ok" (or skipped: true with a reason)')
2435
+ }
2436
+ if (result.ok) return finalize(advanceWave(next))
2437
+
2438
+ const errors = normalizeErrors(result.errors)
2439
+ next.verifyFailures += 1
2440
+ next.lastVerifyErrors = errors
2441
+
2442
+ // Still inside the fix budget: the next step is another targeted fix and a
2443
+ // re-run of the failing check.
2444
+ if (next.verifyFailures <= LIMITS.interWaveFixAttempts) return finalize(next)
2445
+
2446
+ const blocks = result.blocksNextWave === true
2447
+ next.unresolved.push({
2448
+ wave: wave.group,
2449
+ waveKind: wave.kind,
2450
+ attempts: LIMITS.interWaveFixAttempts,
2451
+ errors,
2452
+ blockedNextWave: blocks
2453
+ })
2454
+
2455
+ if (blocks) {
2456
+ next.halt = {
2457
+ kind: HALT_INTER_WAVE_VERIFY,
2458
+ reason:
2459
+ `inter-wave checks after ${label(wave)} still failing after ` +
2460
+ `${LIMITS.interWaveFixAttempts} fix attempts, and the caller reported the ` +
2461
+ `errors block ${label(following)}`
2462
+ }
2463
+ return finalize(next)
2464
+ }
2465
+
2466
+ next.warnings.push(
2467
+ `${label(wave)}: ${errors.length} unresolved error(s) after ` +
2468
+ `${LIMITS.interWaveFixAttempts} fix attempts; continuing — reported as not blocking ` +
2469
+ `${label(following)}`
2470
+ )
2471
+ return finalize(advanceWave(next))
2472
+ }
2473
+
2474
+ /**
2475
+ * Revise groups that have not executed yet.
2476
+ *
2477
+ * Revising a group that has already run is a user-facing error rather than a
2478
+ * quietly-ignored request: the caller believes it changed the plan, and a run
2479
+ * that says otherwise only in a log has already misled it.
2480
+ *
2481
+ * @param {object} state
2482
+ * @param {Array<{group: number, tasks: Array}>} revisedGroups
2483
+ * an empty `tasks` array drops that group's wave
2484
+ * @returns {object} a new state; `state` is left untouched
2485
+ */
2486
+ export function applyReplan(state, revisedGroups) {
2487
+ const next = mutable(state)
2488
+ if (!Array.isArray(revisedGroups) || !revisedGroups.length) {
2489
+ fail('applyReplan needs a non-empty array of { group, tasks } revisions')
2490
+ }
2491
+ if (next.replansUsed >= LIMITS.replansPerRun) {
2492
+ fail(
2493
+ `replan cap reached: ${LIMITS.replansPerRun} replans per run, ` +
2494
+ `${next.replansUsed} already used`
2495
+ )
2496
+ }
2497
+
2498
+ // Validate every revision before applying any of them, so a rejected replan
2499
+ // leaves the run exactly where it was.
2500
+ for (const revision of revisedGroups) {
2501
+ if (!revision || typeof revision !== 'object') fail('each revision must be an object')
2502
+ if (!Number.isInteger(revision.group)) {
2503
+ fail('each revision must name an integer "group"; the trailing test wave cannot be replanned')
2504
+ }
2505
+ if (next.executedGroups.includes(revision.group)) {
2506
+ fail(`cannot replan group ${revision.group}: it has already executed`)
2507
+ }
2508
+ validateTasks(revision.tasks, `revision for group ${revision.group}: "tasks"`)
2509
+ }
2510
+
2511
+ for (const revision of revisedGroups) {
2512
+ const from = firstMutableIndex(next)
2513
+ const at = next.waves.findIndex(w => w.kind === 'impl' && w.group === revision.group)
2514
+
2515
+ if (at >= 0) {
2516
+ if (at < from) fail(`cannot replan group ${revision.group}: it has already executed`)
2517
+ // A group occupies more than one wave once dependency layers split it, and
2518
+ // those waves are contiguous. Every one of them is replaced by the single
2519
+ // revised wave: leaving a later layer in place would run the tasks the
2520
+ // revision superseded after the tasks that replaced them.
2521
+ const spans = next.waves.filter(w => w.kind === 'impl' && w.group === revision.group).length
2522
+ // Read off the wave being replaced, not recomputed: the red wave is a
2523
+ // decision the PLAN made, and a replan revises a group's tasks, never
2524
+ // which group leads with the failing suite.
2525
+ const wasRed = next.waves[at] && next.waves[at].red === true
2526
+ if (!revision.tasks.length) next.waves.splice(at, spans)
2527
+ 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
+ )
2535
+ }
2536
+ continue
2537
+ }
2538
+
2539
+ if (!revision.tasks.length) continue // dropping a group that is not there
2540
+ if (from >= next.waves.length && from > 0) {
2541
+ fail(`cannot replan group ${revision.group}: the run has no unexecuted groups left`)
2542
+ }
2543
+ // A group the plan never had is inserted in ascending order, but never
2544
+ // before the cursor and never after the trailing test wave.
2545
+ let insertAt = next.waves.length
2546
+ for (let i = from; i < next.waves.length; i++) {
2547
+ const w = next.waves[i]
2548
+ if (w.kind === 'test' || w.group > revision.group) {
2549
+ insertAt = i
2550
+ break
2551
+ }
2552
+ }
2553
+ next.waves.splice(insertAt, 0, makeWave('impl', revision.group, revision.tasks, next.maxParallel, next.laneCaps))
2554
+ }
2555
+
2556
+ next.replansUsed += 1
2557
+ next.replanPending = false
2558
+ return finalize(next)
2559
+ }
2560
+
2561
+ function summarize(state) {
2562
+ return {
2563
+ waves: state.waves.length,
2564
+ tasksCompleted: state.completed.length,
2565
+ taskFailures: state.failures.length,
2566
+ replansUsed: state.replansUsed,
2567
+ unresolved: state.unresolved,
2568
+ skippedVerifications: state.skippedVerifications,
2569
+ verificationsUsed: state.verificationsUsed || 0,
2570
+ warnings: state.warnings
2571
+ }
2572
+ }
2573
+
2574
+ /** Tasks across a batch of lanes — one agent per lane, N tasks inside them. */
2575
+ function countTasks(batch) {
2576
+ return (Array.isArray(batch) ? batch : []).reduce(
2577
+ (n, lane) => n + (Array.isArray(lane) ? lane.length : 0),
2578
+ 0
2579
+ )
2580
+ }
2581
+
2582
+ function describeStep(step) {
2583
+ switch (step.action) {
2584
+ case 'run-batch':
2585
+ return `run wave ${step.wave} batch ${step.batchIndex + 1}/${step.batchCount} ` +
2586
+ `(${step.tasks.length} lane(s), ${countTasks(step.tasks)} task(s), ` +
2587
+ `max ${step.maxParallel} parallel)`
2588
+ case 'test-wave':
2589
+ return `run the test wave, batch ${step.batchIndex + 1}/${step.batchCount} ` +
2590
+ `(${step.tasks.length} lane(s), ${countTasks(step.tasks)} task(s))`
2591
+ case 'verify':
2592
+ return step.mode === 'initial'
2593
+ ? `verify after ${step.waveKind === 'test' ? 'the test wave' : `wave ${step.wave}`}`
2594
+ : `fix and re-verify (attempt ${step.fixAttempt} of ${LIMITS.interWaveFixAttempts})`
2595
+ case 'replan':
2596
+ return `replan available for group(s) ${step.revisableGroups.join(', ')} ` +
2597
+ `(${step.replansRemaining} left)`
2598
+ case 'halt':
2599
+ return `HALTED — ${step.reason}`
2600
+ default:
2601
+ return 'done'
2602
+ }
2603
+ }
2604
+
2605
+ /** Human-readable run state, for the skill to echo between steps. */
2606
+ export function formatRunState(state) {
2607
+ requireState(state)
2608
+ const step = nextStep(state)
2609
+ const lines = []
2610
+ lines.push(
2611
+ `run: ${state.completed.length} task(s) done, ${state.failures.length} failed ` +
2612
+ `(halts above ${LIMITS.taskFailureHalt}), ` +
2613
+ `${state.replansUsed}/${LIMITS.replansPerRun} replans used`
2614
+ )
2615
+ lines.push(` next: ${describeStep(step)}`)
2616
+ for (const f of state.failures) {
2617
+ lines.push(
2618
+ ` failed ${f.id} in ${label({ kind: f.waveKind, group: f.wave })}` +
2619
+ (f.error ? `: ${f.error}` : '')
2620
+ )
2621
+ }
2622
+ for (const u of state.unresolved) {
2623
+ lines.push(
2624
+ ` unresolved after ${label({ kind: u.waveKind, group: u.wave })}: ` +
2625
+ `${u.errors.length} error(s) after ${u.attempts} fix attempts` +
2626
+ (u.blockedNextWave ? ' (blocked the next wave)' : ' (did not block the next wave)')
2627
+ )
2628
+ }
2629
+ for (const s of state.skippedVerifications) {
2630
+ lines.push(` VERIFICATION SKIPPED: reason=${s.reason}`)
2631
+ }
2632
+ // The audit is recorded, so it has to be readable — a verdict nobody ever sees
2633
+ // is a verdict that may as well not exist. Printed as a tally plus a line per
2634
+ // packet that did NOT confirm: a run's confirmed packets are the boring case
2635
+ // and listing all of them would bury the two that are not.
2636
+ //
2637
+ // "recorded only" is in the banner on purpose. Anyone reading a halt beside a
2638
+ // column of `unconfirmed` will wonder whether the audit caused it; the line
2639
+ // itself answers that, at the one moment the question gets asked.
2640
+ const audits = Object.entries(
2641
+ state.evidenceAudits && typeof state.evidenceAudits === 'object' ? state.evidenceAudits : {}
2642
+ ).filter(([, a]) => a && typeof a === 'object')
2643
+ if (audits.length) {
2644
+ const tally = { [AUDIT_CONFIRMED]: 0, [AUDIT_UNCONFIRMED]: 0, [AUDIT_NOT_AUDITED]: 0 }
2645
+ for (const [, a] of audits) if (a.verdict in tally) tally[a.verdict] += 1
2646
+ lines.push(
2647
+ ` evidence audit: ${tally[AUDIT_CONFIRMED]} confirmed, ` +
2648
+ `${tally[AUDIT_UNCONFIRMED]} unconfirmed, ${tally[AUDIT_NOT_AUDITED]} not audited ` +
2649
+ `(recorded only — never gates the run)`
2650
+ )
2651
+ for (const [id, a] of audits) {
2652
+ if (a.verdict === AUDIT_CONFIRMED) continue
2653
+ lines.push(
2654
+ ` ${a.verdict} ${id}${a.source ? ` [${a.source}]` : ''}: ${a.reason}`
2655
+ )
2656
+ }
2657
+ }
2658
+ for (const w of state.warnings) lines.push(` warning: ${w}`)
2659
+ if (state.halt) lines.push(`HALTED: ${state.halt.reason}`)
2660
+ return lines.join('\n') + '\n'
2661
+ }