brainclaw 1.16.0 → 1.18.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 (85) hide show
  1. package/README.md +22 -8
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli/register-review.js +2 -2
  4. package/dist/commands/code-map.js +4 -1
  5. package/dist/commands/codev.js +61 -30
  6. package/dist/commands/doctor.js +14 -1
  7. package/dist/commands/harvest.js +241 -25
  8. package/dist/commands/inbox.js +10 -4
  9. package/dist/commands/loop.js +2 -2
  10. package/dist/commands/loops-handlers.js +82 -1
  11. package/dist/commands/mcp-catalog.js +12 -4
  12. package/dist/commands/mcp-read-handlers.js +90 -7
  13. package/dist/commands/mcp-schemas.generated.js +3 -0
  14. package/dist/commands/mcp-write-coordination.js +159 -40
  15. package/dist/commands/mcp.js +11 -2
  16. package/dist/core/agent-capability.js +7 -2
  17. package/dist/core/agent-files.js +53 -2
  18. package/dist/core/agent-integrations.js +1 -0
  19. package/dist/core/agentrun-reconciler.js +171 -7
  20. package/dist/core/agentruns.js +6 -1
  21. package/dist/core/code-map/aggregate.js +473 -0
  22. package/dist/core/code-map/backend.js +36 -10
  23. package/dist/core/code-map/freshness.js +36 -1
  24. package/dist/core/code-map/lang/c/imports.scm +12 -0
  25. package/dist/core/code-map/lang/c/index.js +150 -0
  26. package/dist/core/code-map/lang/c/tags.scm +68 -0
  27. package/dist/core/code-map/lang/cpp/imports.scm +14 -0
  28. package/dist/core/code-map/lang/cpp/index.js +149 -0
  29. package/dist/core/code-map/lang/cpp/tags.scm +87 -0
  30. package/dist/core/code-map/lang/csharp/imports.scm +20 -0
  31. package/dist/core/code-map/lang/csharp/index.js +224 -0
  32. package/dist/core/code-map/lang/csharp/tags.scm +63 -0
  33. package/dist/core/code-map/lang/go/imports.scm +13 -0
  34. package/dist/core/code-map/lang/go/index.js +139 -0
  35. package/dist/core/code-map/lang/go/tags.scm +36 -0
  36. package/dist/core/code-map/lang/providers.js +12 -1
  37. package/dist/core/code-map/lang/ruby/imports.scm +24 -0
  38. package/dist/core/code-map/lang/ruby/index.js +198 -0
  39. package/dist/core/code-map/lang/ruby/tags.scm +49 -0
  40. package/dist/core/code-map/lang/rust/imports.scm +44 -0
  41. package/dist/core/code-map/lang/rust/index.js +136 -0
  42. package/dist/core/code-map/lang/rust/tags.scm +47 -0
  43. package/dist/core/code-map/query.js +229 -80
  44. package/dist/core/code-map/types.js +18 -0
  45. package/dist/core/code-map/work-section.js +8 -7
  46. package/dist/core/codev-responses.js +16 -0
  47. package/dist/core/dispatcher.js +209 -29
  48. package/dist/core/execution-adapters.js +29 -3
  49. package/dist/core/ideation-loop-close.js +124 -0
  50. package/dist/core/loops/artifact-resolver.js +197 -0
  51. package/dist/core/loops/attempt-reservation.js +576 -0
  52. package/dist/core/loops/commit-intent.js +494 -0
  53. package/dist/core/loops/facade-schema.js +48 -0
  54. package/dist/core/loops/impl-bind.js +144 -0
  55. package/dist/core/loops/index.js +1 -1
  56. package/dist/core/loops/iteration-engine.js +29 -0
  57. package/dist/core/loops/lock.js +14 -0
  58. package/dist/core/loops/project-resolution.js +157 -0
  59. package/dist/core/loops/reconcile-turn.js +369 -0
  60. package/dist/core/loops/result-reducers.js +88 -0
  61. package/dist/core/loops/store.js +46 -7
  62. package/dist/core/loops/types.js +139 -11
  63. package/dist/core/loops/verbs.js +9 -3
  64. package/dist/core/loops/verify-command.js +209 -0
  65. package/dist/core/messaging.js +58 -5
  66. package/dist/core/review-loop-close.js +106 -34
  67. package/dist/core/review-loop-turn-dispatch.js +445 -0
  68. package/dist/core/runtime-signals.js +68 -0
  69. package/dist/core/schema.js +34 -0
  70. package/dist/core/worktree.js +240 -22
  71. package/dist/facts.js +10 -10
  72. package/dist/facts.json +9 -9
  73. package/dist/wasm/tree-sitter-c.wasm +0 -0
  74. package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
  75. package/dist/wasm/tree-sitter-cpp.wasm +0 -0
  76. package/dist/wasm/tree-sitter-go.wasm +0 -0
  77. package/dist/wasm/tree-sitter-ruby.wasm +0 -0
  78. package/dist/wasm/tree-sitter-rust.wasm +0 -0
  79. package/docs/cli.md +1 -1
  80. package/docs/code-map.md +22 -6
  81. package/docs/concepts/loop-engine.md +28 -2
  82. package/docs/concepts/observer-protocol.md +22 -0
  83. package/docs/integrations/codex.md +19 -3
  84. package/docs/mcp-schema-changelog.md +43 -1
  85. package/package.json +1 -1
package/README.md CHANGED
@@ -44,7 +44,7 @@ It sits alongside your coding agents and gives them a shared state layer they ca
44
44
  | **Project memory** | constraints, decisions, traps, handoffs, and layered instructions agents can resume from |
45
45
  | **Coordination state** | shared plans, file claims (dispatched work isolated in Git Worktrees), runtime notes, and board views for active work |
46
46
  | **Agent-ready context** | compact, prompt-sized context built from real workspace state instead of stale instructions |
47
- | **Code Map** | a Tree-sitter symbol + import index (JS/TS, Python, PHP, Java) so agents ask "where is X / what should I read first" before editing, with related decisions/traps attached — `bclaw_code_find` / `bclaw_code_brief`, see [code map](docs/code-map.md) |
47
+ | **Code Map** | a Tree-sitter symbol + import index (11 languages — JS/TS, Python, PHP, Java, Go, Rust, C#, Ruby, C, C++) so agents ask "where is X / what should I read first" before editing, with related decisions/traps attached — `bclaw_code_find` / `bclaw_code_brief`, see [code map](docs/code-map.md) |
48
48
  | **Native agent files** | auto-writes `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `.cursor/rules/`, `.windsurfrules`, and similar local guidance |
49
49
  | **Multi-turn loops** | review and ideation loops with structured phases, iteration semantics, and per-phase memory filters — see[loop engine](docs/concepts/loop-engine.md) and [ideation loop](docs/concepts/ideation-loop.md) |
50
50
  | **Machine AI surface discovery** | detects local coding agents plus desktop AI work surfaces such as ChatGPT Desktop and Gemini CLI |
@@ -55,7 +55,7 @@ It sits alongside your coding agents and gives them a shared state layer they ca
55
55
 
56
56
  ## Code Map
57
57
 
58
- When an agent (or you) is about to edit unfamiliar code, the first question is *"where is this, and what should I read first?"* Code Map answers it without grepping: a per-project [Tree-sitter](https://tree-sitter.github.io/) index of the symbols each file defines (functions, classes, types, React components/hooks), what it imports/exports, and how files relate — across **JS / TS / TSX, Python, PHP, and Java**.
58
+ When an agent (or you) is about to edit unfamiliar code, the first question is *"where is this, and what should I read first?"* Code Map answers it without grepping: a per-project [Tree-sitter](https://tree-sitter.github.io/) index of the symbols each file defines (functions, classes, types, React components/hooks), what it imports/exports, and how files relate — across **11 languages: JS / TS / TSX, Python, PHP, Java, Go, Rust, C#, Ruby, C, and C++**.
59
59
 
60
60
  ```bash
61
61
  brainclaw code-map find useAuth # locate a symbol / component / hook by name
@@ -103,7 +103,7 @@ brainclaw is designed to sit alongside the coding agents teams are already using
103
103
  | Logo | Agent | Tier | What brainclaw configures |
104
104
  | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ---- | ----------------------------------------------------------------------- |
105
105
  | [![Claude Code](https://img.shields.io/badge/Claude_Code-111111?logo=anthropic&logoColor=white)](https://github.com/anthropics/claude-code) | **[Claude Code](https://github.com/anthropics/claude-code)** | A | MCP + CLAUDE.md + hooks + auto-approve + permissions + /brainclaw skill |
106
- | [![Codex](https://img.shields.io/badge/Codex-111111?logo=openai&logoColor=white)](https://openai.com/codex/) | **[Codex](https://openai.com/codex/)** | A | MCP + AGENTS.md + skills |
106
+ | [![Codex](https://img.shields.io/badge/Codex-111111?logo=openai&logoColor=white)](https://openai.com/codex/) | **[Codex](https://openai.com/codex/)** | A | MCP + AGENTS.md + hooks + skills |
107
107
  | [![Cursor](https://img.shields.io/badge/Cursor-1F2430?logo=cursor&logoColor=white)](https://cursor.com/en-US) | **[Cursor](https://cursor.com/en-US)** | A | MCP (machine) + .cursor/rules/ + hooks + skills |
108
108
  | [![Windsurf](https://img.shields.io/badge/Windsurf-0B1220?logo=codeium&logoColor=white)](https://windsurf.com/) | **[Windsurf](https://windsurf.com/)** | A | MCP (machine) + .windsurfrules + .windsurf/rules/ |
109
109
  | [![Cline](https://img.shields.io/badge/Cline-0F766E?logoColor=white)](https://github.com/cline/cline) | **[Cline](https://github.com/cline/cline)** | A | MCP + auto-approve + .clinerules/ |
@@ -256,8 +256,8 @@ bclaw_coordinate({
256
256
  });
257
257
 
258
258
  // 4. The reviewer's verdict is harvested from its LANE-RESULT.json and the loop
259
- // auto-closes on approve no manual advance needed for the approve path.
260
- // (bclaw_loop remains available to drive the request_changes fix cycle by hand.)
259
+ // auto-closes on approve; request_changes re-dispatches the fix cycle
260
+ // autonomously. (bclaw_loop remains available to inspect or drive by hand.)
261
261
  bclaw_loop({ intent: "get", loop_id: "lop_abc" }); // inspect status any time
262
262
  ```
263
263
 
@@ -270,7 +270,7 @@ Brainclaw's Loop Engine moves beyond manual ping-pong by formalizing multi-turn
270
270
 
271
271
  Each loop maintains a structured lifecycle, explicit phases, iteration bounds, and per-phase memory filters, executed seamlessly via `bclaw_loop`.
272
272
 
273
- **Autonomous convergence (pln#628 Focus 4B):** a dispatched reviewer doesn't need to be driven by hand. It writes its verdict (`review_verdict: approve | request_changes`) into its `LANE-RESULT.json`; when the coordinator harvests the lane, brainclaw records the verdict on the loop and **auto-closes it on approve** — the review loop reaches `reviewer_green` with no human ping-pong. `request_changes` advances to the author phase (the automated fix→re-review cycle is a planned follow-up).
273
+ **Autonomous convergence (pln#628 Focus 4B + pln#630):** a dispatched reviewer doesn't need to be driven by hand. It writes its verdict (`review_verdict: approve | request_changes`) into its `LANE-RESULT.json`; when the coordinator harvests the lane, brainclaw records the verdict on the loop and **auto-closes it on approve** — the review loop reaches `reviewer_green` with no human ping-pong. On `request_changes`, brainclaw **runs the fix→re-review cycle autonomously**: it bumps the round, retains the worktree, and re-dispatches through an exactly-once turn-attempt state machine (immutable attempt records behind an atomic launch fence, on by default; kill-switch `BRAINCLAW_TURN_OWNED_REVIEW=0`) so a turn is never double-spawned, with a bounded round cap that lands on `blocked` instead of looping forever.
274
274
 
275
275
  ## Enterprise Ready: Mono-repo & Micro-services
276
276
 
@@ -319,7 +319,7 @@ Still sharp:
319
319
 
320
320
  1. **Same-checkout concurrent edits** — running two agents in the *same* working tree (no per-claim worktree) is still the wrong answer. Use the dispatch path (auto-worktree per claim) instead of raw concurrent CLI sessions.
321
321
  2. **Cross-machine sync** — federation across machines is on the roadmap, not in v1.x. Today brainclaw's store is local and one-machine-per-project.
322
- 3. **Next.js / Turbopack dev server in a worktree** — the provisioned `node_modules` symlink points outside the worktree root, which `next dev` (Turbopack) rejects (build/tsc/vitest are fine). brainclaw warns; `npm install` in the worktree or smoke-test on the merged branch. A Turbopack-compatible dependency mode is a planned follow-up.
322
+ 3. **Next.js / Turbopack dev server in a worktree** — in the default `link` mode the provisioned `node_modules` symlink points outside the worktree root, which `next dev` (Turbopack) rejects (build/tsc/vitest are fine); brainclaw warns. Fix: opt into a Turbopack-compatible **per-worktree dependency mode** — set `worktree.deps_mode: install` (or `copy`) in config, or `BRAINCLAW_WORKTREE_DEPS_MODE=install`, so the worktree gets a real in-root `node_modules` (see [Multi-stack worktree](#multi-stack-worktree)).
323
323
  3. **Spawn-and-forget assumptions** — spawned workers don't always commit their work cleanly. The brief-ack file confirms the spawn started; in the worst case the coordinator harvests open changes.
324
324
  4. **Live state for hook-less agents** — supported hook-less file surfaces such as Cline, Windsurf, Continue, Antigravity/Gemini CLI, and Mistral Vibe can get live context via `.live.md` companions regenerated on session-end and handoff, not via real-time push.
325
325
 
@@ -349,12 +349,26 @@ Maven, Gradle, and Cargo are intentionally excluded — their dependency caches
349
349
 
350
350
  Build outputs like `dist` are **not** symlinked — they must be per-worktree to avoid EBUSY errors when other processes hold handles on the output directory.
351
351
 
352
- > **Next.js / Turbopack caveat.** The `node_modules` link is a symlink to the main worktree, i.e. it points **outside** the agent worktree's root. `tsc`, `vitest`, and production `build` follow it fine, but `next dev` (Turbopack) panics on a `node_modules` link outside the worktree root. brainclaw detects Next.js projects and surfaces a `symlink_warnings` note at worktree creation. Workaround for dev-server work: run `npm install` inside the worktree (optionally with `BRAINCLAW_NO_LINK_DEPS=1`), or smoke-test on the merged branch. A Turbopack-compatible per-worktree dependency mode is a planned follow-up.
352
+ ### Dependency provisioning mode (`deps_mode`)
353
+
354
+ How a worktree gets its JS `node_modules` is controlled by `deps_mode` (default `link`):
355
+
356
+ | Mode | How `node_modules` is provisioned | Turbopack / `next dev` | Cost |
357
+ | --------- | ------------------------------------------------------------------------------- | ---------------------- | ---- |
358
+ | `link` | junction/symlink to the main tree (out-of-root) | ❌ rejected | instant, zero disk |
359
+ | `install` | runs the detected package manager's install at the worktree root (real in-root) | ✅ works | slower, may hit network/cache |
360
+ | `copy` | recursively copies `node_modules` from the main tree (real in-root) | ✅ works | disk-heavy, offline |
361
+ | `none` | provisions nothing (central validation / manual install) | n/a | instant |
362
+
363
+ > **Next.js / Turbopack caveat.** In the default `link` mode the `node_modules` link points **outside** the worktree root. `tsc`, `vitest`, and production `build` follow it fine, but `next dev` (Turbopack) panics on it. brainclaw detects Next.js projects and surfaces a `symlink_warnings` note at worktree creation. For dev-server work, switch to `deps_mode: install` (or `copy`) — the worktree then gets a real in-root `node_modules` that Turbopack accepts. The package manager is auto-detected from the lockfile (`pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, `bun.lock*` → bun, else npm) or the `packageManager` field.
364
+
365
+ Precedence: `BRAINCLAW_WORKTREE_DEPS_MODE` (env) > `BRAINCLAW_NO_LINK_DEPS=1` (→ `none`, backward compat) > `worktree.deps_mode` (config) > `link`. Install timeout: `BRAINCLAW_WORKTREE_INSTALL_TIMEOUT_MS` (default 10 min).
353
366
 
354
367
  Override detection in `.brainclaw/config.yaml`:
355
368
 
356
369
  ```yaml
357
370
  worktree:
371
+ deps_mode: install # link (default) | install | copy | none
358
372
  shared_paths: [".cache"] # additive to auto-detected
359
373
  exclude_shared: ["node_modules"] # opt-out a detected entry
360
374
  ```
Binary file
@@ -103,9 +103,9 @@ export function registerReviewCommands(program) {
103
103
  .option('--dry-run', 'Preview without writing events/markers')
104
104
  .option('--worktree <path>', 'Explicit worktree path to scan (repeatable)', collect, [])
105
105
  .option('--json', 'Output as JSON')
106
- .action((assignmentId, options) => {
106
+ .action(async (assignmentId, options) => {
107
107
  const globalOpts = program.opts();
108
- runHarvestLane(assignmentId, { ...options, cwd: globalOpts.cwd });
108
+ await runHarvestLane(assignmentId, { ...options, cwd: globalOpts.cwd });
109
109
  });
110
110
  // --- prune-candidates ---
111
111
  program
@@ -15,7 +15,10 @@ function badgeLine(badge) {
15
15
  const detail = detailKeys.length
16
16
  ? ` (${detailKeys.map((k) => `${k}=${JSON.stringify(badge.details[k])}`).join(', ')})`
17
17
  : '';
18
- return `Freshness: ${badge.status}${detail}`;
18
+ // pln#601 — lead with the coarse rollup (uniform across all read surfaces), then
19
+ // the precise status + details. `coarse` may be absent on legacy/hand-built badges.
20
+ const coarse = badge.coarse ? `${badge.coarse} · ` : '';
21
+ return `Freshness: ${coarse}${badge.status}${detail}`;
19
22
  }
20
23
  export async function runCodeMap(subcommand, args, options = {}) {
21
24
  const normalized = (subcommand ?? '').trim().toLowerCase();
@@ -20,6 +20,7 @@ import path from 'node:path';
20
20
  import os from 'node:os';
21
21
  import { memoryExists, readProjectVision } from '../core/io.js';
22
22
  import { sendMessage, getThread, getThreadCount } from '../core/messaging.js';
23
+ import { writePhaseArtifact } from '../core/codev-responses.js';
23
24
  import { resolveCurrentAgentName } from '../core/agent-registry.js';
24
25
  import { CODEV_PERSONAS, listPersonas } from '../core/codev-personas.js';
25
26
  import { buildContext } from '../core/context.js';
@@ -36,6 +37,36 @@ function toSlug(topic) {
36
37
  function sanitizeForPath(slug) {
37
38
  return slug.replace(/[<>:"/\\|?*]/g, '_');
38
39
  }
40
+ /**
41
+ * Head of each phase body kept inline in the thread message (pln#627 Phase C).
42
+ * Comfortably covers the largest thread readback (`getThread(truncateText: 3000)`
43
+ * used to build the next phase's brief), so rerouting the full body to the
44
+ * artifact store leaves CoDev's iterative context byte-identical while the
45
+ * inbox message stays a few KB instead of hundreds.
46
+ */
47
+ export const CODEV_INBOX_HEAD_CHARS = 4000;
48
+ /**
49
+ * Persist a phase body to the artifact store and post a bounded head + pointer
50
+ * to the thread instead of the full text (pln#627 Phase C). Returns the
51
+ * sendMessage result. The full body is always readable at the returned relPath.
52
+ */
53
+ export function postPhase(opts) {
54
+ const { agent, threadId, label, text, tags, cwd } = opts;
55
+ const artifact = writePhaseArtifact(threadId, label, text, cwd);
56
+ const body = text.length > CODEV_INBOX_HEAD_CHARS
57
+ ? `${text.slice(0, CODEV_INBOX_HEAD_CHARS)}\n\n[…full phase body (${artifact.charCount} chars) at ${artifact.relPath}]`
58
+ : text;
59
+ return sendMessage({
60
+ from: agent,
61
+ to: agent,
62
+ type: 'rfc',
63
+ text: body,
64
+ ref: artifact.relPath,
65
+ thread_id: threadId,
66
+ payload: { artifact_path: artifact.relPath, phase: label, char_count: artifact.charCount },
67
+ tags,
68
+ }, cwd);
69
+ }
39
70
  const STOP_WORDS = new Set([
40
71
  'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
41
72
  'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
@@ -106,14 +137,14 @@ export function runCodev(topic, options = {}) {
106
137
  .map(t => `[${t.severity}] ${t.text}`);
107
138
  // ── Phase 1: Exposition ───────────────────────────────────
108
139
  const expositionText = buildExposition(topic, vision, relatedPlans, constraints, traps);
109
- const opening = sendMessage({
110
- from: agent,
111
- to: agent,
112
- type: 'rfc',
140
+ const opening = postPhase({
141
+ agent,
142
+ threadId,
143
+ label: 'exposition',
113
144
  text: expositionText,
114
- thread_id: threadId,
115
145
  tags: ['codev', 'phase:exposition'],
116
- }, cwd);
146
+ cwd,
147
+ });
117
148
  // ── Resolve spawn agents ──────────────────────────────────
118
149
  let spawnAgents = [];
119
150
  if (options.spawn) {
@@ -238,14 +269,14 @@ export function runCodev(topic, options = {}) {
238
269
  // Each spawned agent gets the exposition + their persona brief — that's enough context.
239
270
  const threadHistory = options.spawn ? [] : getThread(threadId, cwd, { truncateText: 2000 });
240
271
  const brief = buildConsultantBrief(persona, expositionText, 'clarification', threadHistory);
241
- sendMessage({
242
- from: agent,
243
- to: agent,
244
- type: 'rfc',
272
+ postPhase({
273
+ agent,
274
+ threadId,
275
+ label: `clarification_${persona.name}`,
245
276
  text: brief,
246
- thread_id: threadId,
247
277
  tags: ['codev', 'phase:clarification', `persona:${persona.name}`],
248
- }, cwd);
278
+ cwd,
279
+ });
249
280
  if (options.spawn && spawnAgents.length > 0) {
250
281
  const targetAgent = spawnAgents[i % spawnAgents.length];
251
282
  console.log(` → ${persona.name} (clarification) → ${targetAgent.name}`);
@@ -267,28 +298,28 @@ export function runCodev(topic, options = {}) {
267
298
  const clarificationMessages = getThread(threadId, cwd, { truncateText: 3000 })
268
299
  .filter(m => m.tags?.includes('phase:clarification'));
269
300
  const contractText = buildContract(topic, clarificationMessages, decisions, constraints, traps);
270
- sendMessage({
271
- from: agent,
272
- to: agent,
273
- type: 'rfc',
301
+ postPhase({
302
+ agent,
303
+ threadId,
304
+ label: 'contract',
274
305
  text: contractText,
275
- thread_id: threadId,
276
306
  tags: ['codev', 'phase:contract'],
277
- }, cwd);
307
+ cwd,
308
+ });
278
309
  // ── Phase 4: Consultation briefs ──────────────────────────
279
310
  for (let i = 0; i < personas.length; i++) {
280
311
  const persona = personas[i];
281
312
  // For spawned agents, include only the contract (not full history) to avoid message explosion
282
313
  const threadHistory = options.spawn ? [] : getThread(threadId, cwd, { truncateText: 2000 });
283
314
  const brief = buildConsultantBrief(persona, expositionText, 'consultation', threadHistory);
284
- sendMessage({
285
- from: agent,
286
- to: agent,
287
- type: 'rfc',
315
+ postPhase({
316
+ agent,
317
+ threadId,
318
+ label: `consultation_${persona.name}`,
288
319
  text: brief,
289
- thread_id: threadId,
290
320
  tags: ['codev', 'phase:consultation', `persona:${persona.name}`],
291
- }, cwd);
321
+ cwd,
322
+ });
292
323
  if (options.spawn && spawnAgents.length > 0) {
293
324
  const targetAgent = spawnAgents[i % spawnAgents.length];
294
325
  console.log(` → ${persona.name} (consultation) → ${targetAgent.name}`);
@@ -310,14 +341,14 @@ export function runCodev(topic, options = {}) {
310
341
  const consultationMessages = getThread(threadId, cwd, { truncateText: 3000 })
311
342
  .filter(m => m.tags?.includes('phase:consultation'));
312
343
  const synthesisText = buildSynthesis(topic, consultationMessages);
313
- sendMessage({
314
- from: agent,
315
- to: agent,
316
- type: 'rfc',
344
+ postPhase({
345
+ agent,
346
+ threadId,
347
+ label: 'synthesis',
317
348
  text: synthesisText,
318
- thread_id: threadId,
319
349
  tags: ['codev', 'phase:synthesis'],
320
- }, cwd);
350
+ cwd,
351
+ });
321
352
  // ── Output ────────────────────────────────────────────────
322
353
  if (options.json) {
323
354
  console.log(JSON.stringify({
@@ -497,6 +497,7 @@ export function runDispatchHealthCheck(options = {}) {
497
497
  const inferred_completed = [];
498
498
  const health_check_unverified = [];
499
499
  const inferred_failed = [];
500
+ const inferred_cancelled = [];
500
501
  let no_op_open = 0;
501
502
  for (const result of results) {
502
503
  const run = loadAgentRun(result.run_id, options.cwd);
@@ -525,6 +526,9 @@ export function runDispatchHealthCheck(options = {}) {
525
526
  case 'inferred_failed':
526
527
  inferred_failed.push(summary);
527
528
  break;
529
+ case 'inferred_cancelled':
530
+ inferred_cancelled.push(summary);
531
+ break;
528
532
  case 'no_op':
529
533
  no_op_open += 1;
530
534
  break;
@@ -536,6 +540,7 @@ export function runDispatchHealthCheck(options = {}) {
536
540
  inferred_completed,
537
541
  health_check_unverified,
538
542
  inferred_failed,
543
+ inferred_cancelled,
539
544
  no_op_open,
540
545
  exit_code: inferred_failed.length > 0 ? 1 : 0,
541
546
  };
@@ -565,7 +570,15 @@ function renderDispatchHealthHumanReport(report) {
565
570
  }
566
571
  lines.push('');
567
572
  }
568
- if (report.inferred_failed.length === 0 && report.inferred_completed.length === 0 && report.health_check_unverified.length === 0) {
573
+ if (report.inferred_cancelled.length > 0) {
574
+ lines.push(`⊘ ${report.inferred_cancelled.length} turn-owned run(s) cancelled on lease expiry (reserved, never launched):`);
575
+ for (const r of report.inferred_cancelled) {
576
+ lines.push(` - ${r.run_id} ${r.agent} (${r.scope}) — ${r.reason}`);
577
+ }
578
+ lines.push('');
579
+ }
580
+ if (report.inferred_failed.length === 0 && report.inferred_completed.length === 0
581
+ && report.health_check_unverified.length === 0 && report.inferred_cancelled.length === 0) {
569
582
  lines.push(`✔ No dispatch issues detected (${report.no_op_open} open run(s) within grace window or already healthy).`);
570
583
  }
571
584
  else {
@@ -23,6 +23,80 @@ import { loadClaim, releaseClaimsCascade, logCascadeReleaseResult } from '../cor
23
23
  import { getCapabilityProfile, dispatchCanCommit } from '../core/agent-capability.js';
24
24
  import { commitWorktreeOnBehalf, worktreesBaseDir, resolveGitToplevel } from '../core/worktree.js';
25
25
  import { closeReviewLoopFromLaneResult } from '../core/review-loop-close.js';
26
+ import { closeIdeationLoopFromLaneResult } from '../core/ideation-loop-close.js';
27
+ import { dispatchReviewLoopTurn, turnOwnedReviewEnabled } from '../core/review-loop-turn-dispatch.js';
28
+ import { reconcileTurn } from '../core/loops/reconcile-turn.js';
29
+ import { findReservationByAssignmentId } from '../core/loops/attempt-reservation.js';
30
+ import { readCompletionSignals } from '../core/runtime-signals.js';
31
+ /**
32
+ * pln#630 PR3a — finalize a TURN-OWNED review lane via the exactly-once `reconcileTurn`
33
+ * instead of the legacy `closeReviewLoopFromLaneResult`. Returns `undefined` for a legacy
34
+ * (non-reserved) lane so the caller runs the unchanged legacy path — this is the
35
+ * exactly-one-finalizer discriminator: a lane is turn-owned iff a reservation OWNS its
36
+ * assignment_id (only the turn-owned dispatch writes a reservation file).
37
+ *
38
+ * Evidence sourcing (the load-bearing subtlety): a real reviewer's LANE-RESULT.json is
39
+ * KEYLESS — the review brief never asks the worker to echo turn_id/run_id/nonce — so
40
+ * read-strict `reconcileTurn` (which matches lane.{turn_id,run_id,nonce} against the
41
+ * attempt) would REJECT it. We source the keys authoritatively: turn_id + run_id are
42
+ * deterministic from the reservation, and the NONCE — the non-derivable proof that THIS
43
+ * launch generation actually ran — comes from the coordinator's completion SENTINEL
44
+ * (written mechanically by the ack-wrapper with the launch-grant token). A caller/test
45
+ * that already supplies keyed lanes is honored (lane.* wins); a stale generation's
46
+ * sentinel carries the old token → still rejected, preserving the anti-stale guarantee.
47
+ */
48
+ /**
49
+ * The turn-owned FINALIZATION discriminator (pln#630, review Finding 1). A lane finalizes via
50
+ * the exactly-once reconcileTurn ONLY if a committed reservation OWNS it AND turn-keyed evidence
51
+ * (the nonce) is available — from the lane or the coordinator's completion SENTINEL. Without the
52
+ * nonce, reconcileTurn's read-strict gate can NEVER converge: this is reachable in production
53
+ * when a turn-owned dispatch WON the fence but did not ack-wrap-spawn (inbox_only / IDE-only
54
+ * reviewer, command_ready_manual, capacity cap, BRAINCLAW_NO_SPAWN, worktree-creation failure) —
55
+ * it minted a reservation but no sentinel will ever be written. Returning undefined there routes
56
+ * the lane to the LEGACY presence-based closer so the loop still converges instead of stalling
57
+ * forever. This is SAFE: the exactly-once SPAWN guarantee is enforced at DISPATCH by the launch
58
+ * fence (already run), so using legacy FINALIZATION for a sentinel-less lane reintroduces no
59
+ * double-spawn; and a sentinel that lands after a legacy close makes a later reconcile a
60
+ * terminal-loop idempotent no-op.
61
+ */
62
+ function turnOwnedLaneEvidence(lane, cwd) {
63
+ const reservation = findReservationByAssignmentId(lane.assignment_id, cwd);
64
+ if (!reservation)
65
+ return undefined; // legacy lane (no reservation)
66
+ const nonce = lane.nonce ?? readCompletionSignals(cwd, reservation.child_ids.assignment_id).completed?.nonce;
67
+ if (!nonce)
68
+ return undefined; // reservation but NO turn-keyed evidence → legacy finalization
69
+ return { reservation, nonce };
70
+ }
71
+ function reconcileTurnOwnedReviewLane(lane, cwd) {
72
+ const ev = turnOwnedLaneEvidence(lane, cwd);
73
+ if (!ev)
74
+ return undefined; // legacy lane OR no turn-keyed evidence — caller runs the legacy path
75
+ const { reservation, nonce } = ev;
76
+ const enrichedLane = {
77
+ ...lane,
78
+ turn_id: lane.turn_id ?? reservation.turn_id,
79
+ run_id: lane.run_id ?? reservation.child_ids.run_id,
80
+ nonce,
81
+ };
82
+ const result = reconcileTurn({ turn_id: reservation.turn_id, lane: enrichedLane, cwd });
83
+ return { reservation, result };
84
+ }
85
+ /**
86
+ * Map a `reconcileTurn` result onto the `ReviewLoopCloseResult` shape harvest records for
87
+ * observability (entry.review_loop / CLI). No keep_claim / next_turn: the request_changes
88
+ * turn-owned re-dispatch (fix cycle) is pln#630 PR3b — deferred and non-corrupting (the
89
+ * loop stays open awaiting its next turn, identical to the legacy asymmetric path).
90
+ */
91
+ function reconcileToReviewLoopResult(reservation, rr, lane) {
92
+ return {
93
+ loop_id: reservation.loop_id,
94
+ verdict: lane.review_verdict === 'request_changes' ? 'request_changes' : 'approve',
95
+ action: rr.auto_closed ? 'closed' : rr.reconciled ? 'advanced' : 'noop',
96
+ reason: rr.reason,
97
+ loop_status: rr.loop_status,
98
+ };
99
+ }
26
100
  /**
27
101
  * Auto-detect all worktree directories under the brainclaw-managed base dir.
28
102
  * Returns subdirectories that exist on disk (may or may not have an inbox).
@@ -278,10 +352,27 @@ export function harvestLaneResults(options = {}) {
278
352
  // (a terminal loop is a no-op; a stuck approve is resumed), so firing it here
279
353
  // AND in integrateLaneResults is safe — and it runs BEFORE the harvested
280
354
  // marker short-circuits below, so a re-harvest still resumes a stuck loop.
355
+ // PR2: cycleOnRequestChanges=false — the report path only closes on approve;
356
+ // it must NOT advance a request_changes cycle it cannot follow through on
357
+ // (no re-dispatch, no claim retention). `harvest --integrate` owns the cycle.
281
358
  try {
282
359
  const laneAssignment = loadAssignment(lane.assignment_id, cwd);
283
- if (laneAssignment)
284
- closeReviewLoopFromLaneResult(laneAssignment, lane, agent, cwd);
360
+ if (laneAssignment) {
361
+ // pln#630 PR3a — a TURN-OWNED review lane (reservation + turn-keyed evidence) is finalized
362
+ // ONLY by reconcileTurn on the `--integrate` path (which owns claim/worktree teardown). The
363
+ // report path must NOT pre-empt it with a legacy close (which would terminalize the loop and
364
+ // strand the turn-owned run). So skip the legacy review-close for such a lane. Kill-switch
365
+ // (=0), a legacy lane (no reservation), OR a reservation WITHOUT evidence (review Finding 1:
366
+ // an inbox_only/non-ack-wrapped dispatch that never wrote a sentinel) → false → the lane
367
+ // takes the unchanged legacy close so it still converges. Ideation stays legacy (review-only).
368
+ const laneIsTurnOwned = turnOwnedReviewEnabled() && !!turnOwnedLaneEvidence(lane, cwd);
369
+ if (!laneIsTurnOwned) {
370
+ closeReviewLoopFromLaneResult(laneAssignment, lane, agent, cwd, { cycleOnRequestChanges: false });
371
+ }
372
+ // pln#521 P2-bis — the ideation analog: a critic lane records its critique +
373
+ // advances the ideation loop. Returns undefined for non-ideate scopes (no-op here).
374
+ closeIdeationLoopFromLaneResult(laneAssignment, lane, agent, cwd);
375
+ }
285
376
  }
286
377
  catch { /* never block harvest on loop-close */ }
287
378
  const marker = laneHarvestedMarkerPath(cwd, lane.assignment_id);
@@ -391,7 +482,7 @@ function forceCompleteAssignment(assignmentId, artifacts, statusReason, actor, c
391
482
  export function integrateLaneResults(options = {}) {
392
483
  const cwd = options.cwd ?? process.cwd();
393
484
  const actor = options.agent ?? 'coordinator';
394
- const result = { integrated: [], skipped: [], errors: [] };
485
+ const result = { integrated: [], skipped: [], errors: [], next_turns: [] };
395
486
  const worktreePaths = resolveLaneScanPaths(options, cwd);
396
487
  for (const worktreePath of worktreePaths) {
397
488
  const file = getLaneResultPath(worktreePath);
@@ -459,26 +550,122 @@ export function integrateLaneResults(options = {}) {
459
550
  ...entry.files_changed.slice(0, 50).map((f) => ({ type: 'file', ref: f })),
460
551
  ];
461
552
  entry.assignment_completed = forceCompleteAssignment(lane.assignment_id, artifacts, `pln#534 on-behalf integration: ${lane.summary.slice(0, 120)}`, actor, cwd);
462
- // trp#928use the cascade helper (was releaseClaimWithCascade same
463
- // logic for the last-claim rule but the cascade wrapper LOGS per-claim,
464
- // so a silent ownership failure is observable in the runtime event log
465
- // rather than only in this in-memory `reasons` string).
466
- const cascade = releaseClaimsCascade([assignment.claim_id], { cwd, planStatus: 'done' });
467
- logCascadeReleaseResult({ actor, trigger: 'harvest_integrate', assignment_id: lane.assignment_id, claim_id: assignment.claim_id, cascade, cwd });
468
- const claimEntry = cascade.entries[0];
469
- entry.claim_released = claimEntry?.released === true;
470
- if (claimEntry && !claimEntry.released) {
471
- reasons.push(`claim release ${claimEntry.reason}${claimEntry.error ? `: ${claimEntry.error}` : ''}`);
553
+ // pln#628 Focus 4B map this lane onto its review loop BEFORE deciding
554
+ // teardown: PR1 records the verdict + advances (auto-close on approve);
555
+ // PR2 continues the fix cycle on request_changes (bump round, emit a
556
+ // next_turn) unless the iteration cap is hit. This is the --integrate
557
+ // path, so it MAY cycle (it can re-dispatch AND retain the claim). No-op
558
+ // for non-review lanes / lanes without a verdict; never throws.
559
+ // pln#521 P2-bis — the ideation analog (record critique + advance the ideation
560
+ // loop). Turn-owned is REVIEW-ONLY (pln#630), so ideation always uses the legacy
561
+ // path. It is independent of the review-loop close (a lane's scope is review-loop
562
+ // XOR ideate-loop), so it runs once here regardless of the branch below.
563
+ const ideationClose = closeIdeationLoopFromLaneResult(assignment, lane, actor, cwd);
564
+ if (ideationClose) {
565
+ reasons.push(`ideate-loop ${ideationClose.loop_id}: ${ideationClose.action} — ${ideationClose.reason}`);
566
+ }
567
+ // pln#630 PR3a — a TURN-OWNED review lane finalizes via the exactly-once
568
+ // reconcileTurn, which REPLACES the legacy closer + teardown gate for this lane
569
+ // (exactly-one finalizer per lane). Kill-switch (=0), a legacy (non-reserved) lane, OR a
570
+ // reservation with NO turn-keyed evidence (review Finding 1) → `turnOwned` is undefined
571
+ // and the unchanged legacy `else` block runs so the loop still converges.
572
+ const turnOwned = turnOwnedReviewEnabled()
573
+ ? reconcileTurnOwnedReviewLane(lane, cwd)
574
+ : undefined;
575
+ if (turnOwned) {
576
+ const { reservation, result: rr } = turnOwned;
577
+ entry.review_loop = reconcileToReviewLoopResult(reservation, rr, lane);
578
+ reasons.push(`turn-owned reconcile ${reservation.loop_id}: ${entry.review_loop.action} — ${rr.reason}${rr.conflict ? ' [CONFLICT — held]' : ''}`);
579
+ // pln#630 PR3b — a symmetric request_changes bumped the round + retained the claim
580
+ // and handed back the next fix-cycle turn. Push it exactly like the legacy path so
581
+ // the existing async re-dispatch loop spawns round N+1 into the reused worktree. The
582
+ // fresh iteration means deriveTurnId mints a new turn_id → the launch fence admits
583
+ // exactly one spawn (a stale re-emit of the same turn_id is denied).
584
+ if (rr.next_turn) {
585
+ result.next_turns.push({ loop_id: reservation.loop_id, ...rr.next_turn });
586
+ }
587
+ // Claim/run/assignment settling is OWNED by reconcileTurn, so we do NOT run the
588
+ // legacy teardown gate — just reflect the resulting claim state. Settlement
589
+ // semantics (reconcile-turn.ts, review #1): an ACCEPTED lane — approve OR
590
+ // request_changes, both settle the slot 'done' — completes the assignment AND
591
+ // releases the claim; only a REJECTED/superseded lane (evidence mismatch/absent,
592
+ // §13 R4 conflict) leaves the claim intact for a retry. No next_turns push: the
593
+ // request_changes fix-cycle re-dispatch — AND re-establishing the claim/worktree
594
+ // that the release above implies — is pln#630 PR3b (deferred; the loop stays open
595
+ // awaiting its next turn, so nothing is corrupted, only not-yet-autonomous).
596
+ try {
597
+ entry.claim_released = loadClaim(assignment.claim_id, cwd)?.status === 'released';
598
+ }
599
+ catch {
600
+ entry.claim_released = false;
601
+ }
602
+ // A turn-owned lane that reached here HAS turn-keyed evidence (turnOwnedLaneEvidence
603
+ // gated on a present nonce — the missing-sentinel/non-spawn case already fell back to
604
+ // legacy, review Finding 1). So a non-convergence here means the evidence MISMATCHED
605
+ // the live attempt (stale/superseded generation) — which must NOT fall back to legacy
606
+ // (that would converge on evidence for a DIFFERENT generation). Emit an observable
607
+ // event so an operator/doctor sees the (correctly) withheld convergence. (An R4
608
+ // conflict already journals its own event, so it is excluded here.)
609
+ if (!rr.reconciled && !rr.conflict && lane.review_verdict) {
610
+ try {
611
+ createRuntimeEvent({
612
+ agent: actor,
613
+ event_type: 'run_blocked',
614
+ text: `harvest: turn-owned review lane ${lane.assignment_id} carried verdict '${lane.review_verdict}' but reconcile did not converge (${rr.reason}); loop ${reservation.loop_id} left OPEN — needs the completion sentinel or a manual turn`,
615
+ tags: ['harvest', 'reconcile', 'turn-owned', 'unconverged'],
616
+ assignment_id: lane.assignment_id,
617
+ run_id: reservation.child_ids.run_id,
618
+ status_reason: 'turn_owned_evidence_unaccepted',
619
+ }, cwd);
620
+ }
621
+ catch { /* observability best-effort */ }
622
+ }
472
623
  }
473
- // pln#628 Focus 4B — if this lane is a review-loop turn carrying a
474
- // verdict, map it onto the loop: record the verdict artifact + advance,
475
- // which auto-closes the loop on reviewer_green (approve) without a human
476
- // driving complete_turn/advance by hand. No-op for non-review lanes or
477
- // lanes without a verdict; never throws (harvest is not blocked on it).
478
- const loopClose = closeReviewLoopFromLaneResult(assignment, lane, actor, cwd);
479
- if (loopClose) {
480
- entry.review_loop = loopClose;
481
- reasons.push(`review-loop ${loopClose.loop_id}: ${loopClose.action} — ${loopClose.reason}`);
624
+ else {
625
+ const loopClose = closeReviewLoopFromLaneResult(assignment, lane, actor, cwd);
626
+ if (loopClose) {
627
+ entry.review_loop = loopClose;
628
+ reasons.push(`review-loop ${loopClose.loop_id}: ${loopClose.action} ${loopClose.reason}`);
629
+ if (loopClose.next_turn) {
630
+ result.next_turns.push({ loop_id: loopClose.loop_id, ...loopClose.next_turn });
631
+ }
632
+ }
633
+ // PR2 claim-teardown gate. Skip the release when either:
634
+ // (a) keep_claim — the symmetric fix cycle reuses the claim/worktree for
635
+ // the re-dispatched turn (commits accumulate on one branch); or
636
+ // (b) Codex review P0 — an idempotent re-harvest of an OLD lane whose
637
+ // loop is still OPEN returns a `noop` (the reviewer slot is now bound
638
+ // to a NEWER assignment under an active cycle). Releasing here would
639
+ // tear down the reused claim/worktree out from under the live turn
640
+ // and strand the fix cycle. The loop machinery owns the lifecycle
641
+ // while it is open; only a terminal close (approve/blocked, action
642
+ // 'closed') or an asymmetric hand-off ('advanced' without keep_claim)
643
+ // releases here. A `noop` on a TERMINAL loop still releases (safe —
644
+ // the closing pass already released, so this is a no-op).
645
+ const loopStillOpen = loopClose?.loop_status !== undefined &&
646
+ !['completed', 'cancelled', 'blocked'].includes(loopClose.loop_status);
647
+ const keepClaimAlive = loopClose?.keep_claim === true || (loopClose?.action === 'noop' && loopStillOpen);
648
+ if (keepClaimAlive) {
649
+ // The next_turn spawn (async) is awaited by runHarvestLane. The
650
+ // assignment for THIS turn is still completed above.
651
+ entry.claim_released = false;
652
+ reasons.push(loopClose?.keep_claim
653
+ ? 'claim kept alive for review fix-cycle re-dispatch (PR2)'
654
+ : 'claim left intact — idempotent re-harvest on an active review loop (no strand)');
655
+ }
656
+ else {
657
+ // trp#928 — use the cascade helper (was releaseClaimWithCascade — same
658
+ // logic for the last-claim rule but the cascade wrapper LOGS per-claim,
659
+ // so a silent ownership failure is observable in the runtime event log
660
+ // rather than only in this in-memory `reasons` string).
661
+ const cascade = releaseClaimsCascade([assignment.claim_id], { cwd, planStatus: 'done' });
662
+ logCascadeReleaseResult({ actor, trigger: 'harvest_integrate', assignment_id: lane.assignment_id, claim_id: assignment.claim_id, cascade, cwd });
663
+ const claimEntry = cascade.entries[0];
664
+ entry.claim_released = claimEntry?.released === true;
665
+ if (claimEntry && !claimEntry.released) {
666
+ reasons.push(`claim release ${claimEntry.reason}${claimEntry.error ? `: ${claimEntry.error}` : ''}`);
667
+ }
668
+ }
482
669
  }
483
670
  }
484
671
  else {
@@ -697,7 +884,7 @@ export function harvestOrphaned(options) {
697
884
  }
698
885
  return report;
699
886
  }
700
- export function runHarvestLane(assignmentId, options = {}) {
887
+ export async function runHarvestLane(assignmentId, options = {}) {
701
888
  const cwd = options.cwd ?? process.cwd();
702
889
  if (!memoryExists(cwd)) {
703
890
  console.error('Error: .brainclaw/ not found. Run `brainclaw init` first.');
@@ -759,8 +946,30 @@ export function runHarvestLane(assignmentId, options = {}) {
759
946
  dryRun: options.dryRun,
760
947
  cwd,
761
948
  });
949
+ // pln#628 Focus 4B PR2 — spawn the review fix-cycle turns the sync integrate
950
+ // pass emitted. Re-dispatches the SAME reviewer into the SAME (kept) worktree
951
+ // to apply the requested changes + re-review. Dry-run only reports them.
952
+ const dispatchedTurns = [];
953
+ if (!options.dryRun) {
954
+ for (const nt of integ.next_turns) {
955
+ const dispatched = await dispatchReviewLoopTurn({
956
+ loopId: nt.loop_id,
957
+ slot: { slot_id: nt.slot_id, role: nt.role, agent: nt.agent, agent_id: nt.agent_id },
958
+ phase: nt.phase,
959
+ task: nt.task,
960
+ dispatcherAgent: options.agent ?? 'coordinator',
961
+ cwd,
962
+ // NO worktreeBaseRef: reuse the kept worktree so the fixes accumulate;
963
+ // pinning a ref would reset the branch and wipe prior-round commits.
964
+ });
965
+ dispatchedTurns.push({
966
+ loop_id: nt.loop_id, agent: nt.agent, iteration: nt.iteration,
967
+ execution_status: dispatched.execution_status, error: dispatched.error,
968
+ });
969
+ }
970
+ }
762
971
  if (options.json) {
763
- console.log(JSON.stringify(integ, null, 2));
972
+ console.log(JSON.stringify({ ...integ, dispatched_turns: dispatchedTurns }, null, 2));
764
973
  return;
765
974
  }
766
975
  const dry = options.dryRun ? ' (dry-run)' : '';
@@ -787,7 +996,14 @@ export function runHarvestLane(assignmentId, options = {}) {
787
996
  }
788
997
  for (const err of integ.errors)
789
998
  console.error(` ✗ ${err}`);
790
- console.log(`\n✔ Lane integrate complete${dry}: ${integ.integrated.length} integrated, ${integ.errors.length} error(s).`);
999
+ for (const dt of dispatchedTurns) {
1000
+ const status = dt.error ? `error: ${dt.error}` : (dt.execution_status ?? 'unknown');
1001
+ console.log(` ↻ Fix-cycle re-dispatch [${dt.loop_id}] round ${dt.iteration} → ${dt.agent} (${status})`);
1002
+ }
1003
+ if (options.dryRun && integ.next_turns.length > 0) {
1004
+ console.log(` (dry-run) ${integ.next_turns.length} fix-cycle turn(s) would be re-dispatched.`);
1005
+ }
1006
+ console.log(`\n✔ Lane integrate complete${dry}: ${integ.integrated.length} integrated, ${dispatchedTurns.length} re-dispatched, ${integ.errors.length} error(s).`);
791
1007
  return;
792
1008
  }
793
1009
  const result = harvestLaneResults({