brainclaw 1.17.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 (80) hide show
  1. package/README.md +5 -5
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/commands/code-map.js +4 -1
  4. package/dist/commands/codev.js +61 -30
  5. package/dist/commands/doctor.js +14 -1
  6. package/dist/commands/harvest.js +196 -42
  7. package/dist/commands/inbox.js +10 -4
  8. package/dist/commands/loop.js +2 -2
  9. package/dist/commands/loops-handlers.js +82 -1
  10. package/dist/commands/mcp-catalog.js +12 -4
  11. package/dist/commands/mcp-read-handlers.js +90 -7
  12. package/dist/commands/mcp-schemas.generated.js +3 -0
  13. package/dist/commands/mcp-write-coordination.js +159 -40
  14. package/dist/commands/mcp.js +11 -2
  15. package/dist/core/agentrun-reconciler.js +171 -7
  16. package/dist/core/agentruns.js +6 -1
  17. package/dist/core/code-map/aggregate.js +473 -0
  18. package/dist/core/code-map/backend.js +36 -10
  19. package/dist/core/code-map/freshness.js +36 -1
  20. package/dist/core/code-map/lang/c/imports.scm +12 -0
  21. package/dist/core/code-map/lang/c/index.js +150 -0
  22. package/dist/core/code-map/lang/c/tags.scm +68 -0
  23. package/dist/core/code-map/lang/cpp/imports.scm +14 -0
  24. package/dist/core/code-map/lang/cpp/index.js +149 -0
  25. package/dist/core/code-map/lang/cpp/tags.scm +87 -0
  26. package/dist/core/code-map/lang/csharp/imports.scm +20 -0
  27. package/dist/core/code-map/lang/csharp/index.js +224 -0
  28. package/dist/core/code-map/lang/csharp/tags.scm +63 -0
  29. package/dist/core/code-map/lang/go/imports.scm +13 -0
  30. package/dist/core/code-map/lang/go/index.js +139 -0
  31. package/dist/core/code-map/lang/go/tags.scm +36 -0
  32. package/dist/core/code-map/lang/providers.js +12 -1
  33. package/dist/core/code-map/lang/ruby/imports.scm +24 -0
  34. package/dist/core/code-map/lang/ruby/index.js +198 -0
  35. package/dist/core/code-map/lang/ruby/tags.scm +49 -0
  36. package/dist/core/code-map/lang/rust/imports.scm +44 -0
  37. package/dist/core/code-map/lang/rust/index.js +136 -0
  38. package/dist/core/code-map/lang/rust/tags.scm +47 -0
  39. package/dist/core/code-map/query.js +229 -80
  40. package/dist/core/code-map/types.js +18 -0
  41. package/dist/core/code-map/work-section.js +8 -7
  42. package/dist/core/codev-responses.js +16 -0
  43. package/dist/core/dispatcher.js +176 -22
  44. package/dist/core/execution-adapters.js +29 -3
  45. package/dist/core/ideation-loop-close.js +124 -0
  46. package/dist/core/loops/artifact-resolver.js +197 -0
  47. package/dist/core/loops/attempt-reservation.js +576 -0
  48. package/dist/core/loops/commit-intent.js +494 -0
  49. package/dist/core/loops/facade-schema.js +48 -0
  50. package/dist/core/loops/impl-bind.js +144 -0
  51. package/dist/core/loops/index.js +1 -1
  52. package/dist/core/loops/iteration-engine.js +29 -0
  53. package/dist/core/loops/lock.js +14 -0
  54. package/dist/core/loops/project-resolution.js +157 -0
  55. package/dist/core/loops/reconcile-turn.js +369 -0
  56. package/dist/core/loops/result-reducers.js +88 -0
  57. package/dist/core/loops/store.js +46 -7
  58. package/dist/core/loops/types.js +139 -11
  59. package/dist/core/loops/verbs.js +9 -3
  60. package/dist/core/loops/verify-command.js +209 -0
  61. package/dist/core/messaging.js +58 -5
  62. package/dist/core/review-loop-close.js +5 -2
  63. package/dist/core/review-loop-turn-dispatch.js +290 -28
  64. package/dist/core/runtime-signals.js +68 -0
  65. package/dist/core/schema.js +24 -0
  66. package/dist/core/worktree.js +24 -0
  67. package/dist/facts.js +9 -9
  68. package/dist/facts.json +8 -8
  69. package/dist/wasm/tree-sitter-c.wasm +0 -0
  70. package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
  71. package/dist/wasm/tree-sitter-cpp.wasm +0 -0
  72. package/dist/wasm/tree-sitter-go.wasm +0 -0
  73. package/dist/wasm/tree-sitter-ruby.wasm +0 -0
  74. package/dist/wasm/tree-sitter-rust.wasm +0 -0
  75. package/docs/cli.md +1 -1
  76. package/docs/code-map.md +22 -6
  77. package/docs/concepts/loop-engine.md +24 -0
  78. package/docs/concepts/observer-protocol.md +22 -0
  79. package/docs/mcp-schema-changelog.md +43 -1
  80. 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
@@ -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
 
Binary file
@@ -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,7 +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 { dispatchReviewLoopTurn } from '../core/review-loop-turn-dispatch.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
+ }
27
100
  /**
28
101
  * Auto-detect all worktree directories under the brainclaw-managed base dir.
29
102
  * Returns subdirectories that exist on disk (may or may not have an inbox).
@@ -284,8 +357,22 @@ export function harvestLaneResults(options = {}) {
284
357
  // (no re-dispatch, no claim retention). `harvest --integrate` owns the cycle.
285
358
  try {
286
359
  const laneAssignment = loadAssignment(lane.assignment_id, cwd);
287
- if (laneAssignment)
288
- closeReviewLoopFromLaneResult(laneAssignment, lane, agent, cwd, { cycleOnRequestChanges: false });
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
+ }
289
376
  }
290
377
  catch { /* never block harvest on loop-close */ }
291
378
  const marker = laneHarvestedMarkerPath(cwd, lane.assignment_id);
@@ -469,48 +556,115 @@ export function integrateLaneResults(options = {}) {
469
556
  // next_turn) unless the iteration cap is hit. This is the --integrate
470
557
  // path, so it MAY cycle (it can re-dispatch AND retain the claim). No-op
471
558
  // for non-review lanes / lanes without a verdict; never throws.
472
- const loopClose = closeReviewLoopFromLaneResult(assignment, lane, actor, cwd);
473
- if (loopClose) {
474
- entry.review_loop = loopClose;
475
- reasons.push(`review-loop ${loopClose.loop_id}: ${loopClose.action} ${loopClose.reason}`);
476
- if (loopClose.next_turn) {
477
- result.next_turns.push({ loop_id: loopClose.loop_id, ...loopClose.next_turn });
478
- }
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}`);
479
566
  }
480
- // PR2 claim-teardown gate. Skip the release when either:
481
- // (a) keep_claim the symmetric fix cycle reuses the claim/worktree for
482
- // the re-dispatched turn (commits accumulate on one branch); or
483
- // (b) Codex review P0 an idempotent re-harvest of an OLD lane whose
484
- // loop is still OPEN returns a `noop` (the reviewer slot is now bound
485
- // to a NEWER assignment under an active cycle). Releasing here would
486
- // tear down the reused claim/worktree out from under the live turn
487
- // and strand the fix cycle. The loop machinery owns the lifecycle
488
- // while it is open; only a terminal close (approve/blocked, action
489
- // 'closed') or an asymmetric hand-off ('advanced' without keep_claim)
490
- // releases here. A `noop` on a TERMINAL loop still releases (safe —
491
- // the closing pass already released, so this is a no-op).
492
- const loopStillOpen = loopClose?.loop_status !== undefined &&
493
- !['completed', 'cancelled', 'blocked'].includes(loopClose.loop_status);
494
- const keepClaimAlive = loopClose?.keep_claim === true || (loopClose?.action === 'noop' && loopStillOpen);
495
- if (keepClaimAlive) {
496
- // The next_turn spawn (async) is awaited by runHarvestLane. The
497
- // assignment for THIS turn is still completed above.
498
- entry.claim_released = false;
499
- reasons.push(loopClose?.keep_claim
500
- ? 'claim kept alive for review fix-cycle re-dispatch (PR2)'
501
- : 'claim left intactidempotent re-harvest on an active review loop (no strand)');
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 gatejust 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
+ }
502
623
  }
503
624
  else {
504
- // trp#928 use the cascade helper (was releaseClaimWithCascade same
505
- // logic for the last-claim rule but the cascade wrapper LOGS per-claim,
506
- // so a silent ownership failure is observable in the runtime event log
507
- // rather than only in this in-memory `reasons` string).
508
- const cascade = releaseClaimsCascade([assignment.claim_id], { cwd, planStatus: 'done' });
509
- logCascadeReleaseResult({ actor, trigger: 'harvest_integrate', assignment_id: lane.assignment_id, claim_id: assignment.claim_id, cascade, cwd });
510
- const claimEntry = cascade.entries[0];
511
- entry.claim_released = claimEntry?.released === true;
512
- if (claimEntry && !claimEntry.released) {
513
- reasons.push(`claim release ${claimEntry.reason}${claimEntry.error ? `: ${claimEntry.error}` : ''}`);
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
+ }
514
668
  }
515
669
  }
516
670
  }
@@ -21,12 +21,15 @@ export function runInboxList(options) {
21
21
  }
22
22
  const effectiveCwd = cwd ?? process.cwd();
23
23
  const agent = options.agent ?? resolveCurrentAgentName(effectiveCwd) ?? 'unknown';
24
- const status = options.all ? undefined : options.status ?? 'pending';
24
+ // --all widens past the core actionable default (pln#627 Phase A); without
25
+ // includeAll, a bare undefined status now hides acknowledged + archived.
26
+ const includeAll = options.all === true;
27
+ const status = includeAll ? undefined : options.status ?? 'pending';
25
28
  const msgType = options.type;
26
29
  const threadId = options.thread;
27
30
  let messages;
28
31
  if (options.localOnly) {
29
- const result = readInbox({ agent, status, type: msgType, thread_id: threadId, markAsRead: false }, effectiveCwd);
32
+ const result = readInbox({ agent, status, includeAll, type: msgType, thread_id: threadId, markAsRead: false }, effectiveCwd);
30
33
  messages = result.messages;
31
34
  }
32
35
  else {
@@ -36,7 +39,7 @@ export function runInboxList(options) {
36
39
  for (const store of chain) {
37
40
  try {
38
41
  // Fetch all messages from this store (no pagination limit)
39
- const storeResult = readInbox({ agent, status, type: msgType, thread_id: threadId, markAsRead: false, limit: 1_000_000 }, store.cwd);
42
+ const storeResult = readInbox({ agent, status, includeAll, type: msgType, thread_id: threadId, markAsRead: false, limit: 1_000_000 }, store.cwd);
40
43
  for (const msg of storeResult.messages) {
41
44
  if (!seenIds.has(msg.id)) {
42
45
  seenIds.add(msg.id);
@@ -48,7 +51,7 @@ export function runInboxList(options) {
48
51
  }
49
52
  // Fallback when no chain found
50
53
  if (messages.length === 0 && chain.length === 0) {
51
- const result = readInbox({ agent, status, type: msgType, thread_id: threadId, markAsRead: false }, effectiveCwd);
54
+ const result = readInbox({ agent, status, includeAll, type: msgType, thread_id: threadId, markAsRead: false }, effectiveCwd);
52
55
  messages = result.messages;
53
56
  }
54
57
  }
@@ -140,6 +143,9 @@ export function runInboxSend(to, text, options) {
140
143
  }
141
144
  else {
142
145
  console.log(`✔ Message sent: [${result.shortLabel}] ${result.type} → ${result.to}`);
146
+ if (result.warning) {
147
+ console.warn(`⚠ ${result.warning}`);
148
+ }
143
149
  }
144
150
  }
145
151
  catch (err) {
@@ -122,14 +122,14 @@ function buildRequest(subcommand, loopId, opts) {
122
122
  };
123
123
  }
124
124
  }
125
- export function runLoopCommand(subcommand, args, options = {}, cwd) {
125
+ export async function runLoopCommand(subcommand, args, options = {}, cwd) {
126
126
  if (!memoryExists(cwd)) {
127
127
  console.error('Error: .brainclaw/ not found. Run `brainclaw init` first.');
128
128
  process.exit(1);
129
129
  }
130
130
  const loopId = requireLoopId(args, options);
131
131
  const request = buildRequest(subcommand, loopId, options);
132
- const handled = handleBclawLoop({ args: request, cwd });
132
+ const handled = await handleBclawLoop({ args: request, cwd });
133
133
  if (handled.response.status !== 'ok') {
134
134
  const message = handled.response.error ?? handled.summary;
135
135
  fail(`bclaw_loop.${String(request.intent)} rejected the call: ${message}`, 2, options);