brainclaw 1.17.0 → 1.19.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 (97) 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 +223 -43
  7. package/dist/commands/inbox.js +10 -4
  8. package/dist/commands/install-hooks.js +184 -27
  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-claims.js +57 -0
  15. package/dist/commands/mcp-write-coordination.js +216 -57
  16. package/dist/commands/mcp-write-entities.js +11 -0
  17. package/dist/commands/mcp.js +29 -2
  18. package/dist/commands/session-end.js +15 -0
  19. package/dist/commands/session-start.js +19 -0
  20. package/dist/core/agentrun-reconciler.js +171 -7
  21. package/dist/core/agentruns.js +6 -1
  22. package/dist/core/claim-conformity.js +193 -0
  23. package/dist/core/claim-scope.js +155 -0
  24. package/dist/core/claims.js +127 -2
  25. package/dist/core/code-map/aggregate.js +473 -0
  26. package/dist/core/code-map/backend.js +36 -10
  27. package/dist/core/code-map/freshness.js +36 -1
  28. package/dist/core/code-map/lang/c/imports.scm +12 -0
  29. package/dist/core/code-map/lang/c/index.js +150 -0
  30. package/dist/core/code-map/lang/c/tags.scm +68 -0
  31. package/dist/core/code-map/lang/cpp/imports.scm +14 -0
  32. package/dist/core/code-map/lang/cpp/index.js +149 -0
  33. package/dist/core/code-map/lang/cpp/tags.scm +87 -0
  34. package/dist/core/code-map/lang/csharp/imports.scm +20 -0
  35. package/dist/core/code-map/lang/csharp/index.js +224 -0
  36. package/dist/core/code-map/lang/csharp/tags.scm +63 -0
  37. package/dist/core/code-map/lang/go/imports.scm +13 -0
  38. package/dist/core/code-map/lang/go/index.js +139 -0
  39. package/dist/core/code-map/lang/go/tags.scm +36 -0
  40. package/dist/core/code-map/lang/providers.js +12 -1
  41. package/dist/core/code-map/lang/ruby/imports.scm +24 -0
  42. package/dist/core/code-map/lang/ruby/index.js +198 -0
  43. package/dist/core/code-map/lang/ruby/tags.scm +49 -0
  44. package/dist/core/code-map/lang/rust/imports.scm +44 -0
  45. package/dist/core/code-map/lang/rust/index.js +136 -0
  46. package/dist/core/code-map/lang/rust/tags.scm +47 -0
  47. package/dist/core/code-map/query.js +229 -80
  48. package/dist/core/code-map/types.js +18 -0
  49. package/dist/core/code-map/work-section.js +8 -7
  50. package/dist/core/codev-responses.js +16 -0
  51. package/dist/core/dispatcher.js +176 -22
  52. package/dist/core/execution-adapters.js +29 -3
  53. package/dist/core/facade-schema.js +32 -0
  54. package/dist/core/guidance-telemetry.js +197 -0
  55. package/dist/core/ideation-loop-close.js +152 -0
  56. package/dist/core/instruction-templates.js +11 -3
  57. package/dist/core/loops/artifact-resolver.js +197 -0
  58. package/dist/core/loops/attempt-reservation.js +576 -0
  59. package/dist/core/loops/commit-intent.js +494 -0
  60. package/dist/core/loops/facade-schema.js +48 -0
  61. package/dist/core/loops/impl-bind.js +144 -0
  62. package/dist/core/loops/index.js +1 -1
  63. package/dist/core/loops/iteration-engine.js +29 -0
  64. package/dist/core/loops/lock.js +14 -0
  65. package/dist/core/loops/project-resolution.js +157 -0
  66. package/dist/core/loops/reconcile-turn.js +369 -0
  67. package/dist/core/loops/result-reducers.js +88 -0
  68. package/dist/core/loops/store.js +46 -7
  69. package/dist/core/loops/types.js +139 -11
  70. package/dist/core/loops/verbs.js +49 -4
  71. package/dist/core/loops/verify-command.js +209 -0
  72. package/dist/core/messaging.js +58 -5
  73. package/dist/core/next-actions.js +157 -0
  74. package/dist/core/review-loop-close.js +27 -6
  75. package/dist/core/review-loop-turn-dispatch.js +290 -28
  76. package/dist/core/runtime-signals.js +68 -0
  77. package/dist/core/schema.js +64 -0
  78. package/dist/core/surface-freshness.js +150 -0
  79. package/dist/core/warnings.js +98 -0
  80. package/dist/core/worktree.js +24 -0
  81. package/dist/facts.js +9 -9
  82. package/dist/facts.json +8 -8
  83. package/dist/wasm/tree-sitter-c.wasm +0 -0
  84. package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
  85. package/dist/wasm/tree-sitter-cpp.wasm +0 -0
  86. package/dist/wasm/tree-sitter-go.wasm +0 -0
  87. package/dist/wasm/tree-sitter-ruby.wasm +0 -0
  88. package/dist/wasm/tree-sitter-rust.wasm +0 -0
  89. package/docs/cli.md +1 -1
  90. package/docs/code-map.md +22 -6
  91. package/docs/concepts/loop-engine.md +24 -0
  92. package/docs/concepts/observer-protocol.md +22 -0
  93. package/docs/concepts/plans-and-claims.md +57 -0
  94. package/docs/integrations/claude-code.md +53 -0
  95. package/docs/integrations/mcp.md +45 -0
  96. package/docs/mcp-schema-changelog.md +118 -2
  97. 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,82 @@ 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
+ import { reconcileClaimConformity } from '../core/claim-conformity.js';
32
+ import { toWarningDetail } from '../core/warnings.js';
33
+ /**
34
+ * pln#630 PR3a — finalize a TURN-OWNED review lane via the exactly-once `reconcileTurn`
35
+ * instead of the legacy `closeReviewLoopFromLaneResult`. Returns `undefined` for a legacy
36
+ * (non-reserved) lane so the caller runs the unchanged legacy path — this is the
37
+ * exactly-one-finalizer discriminator: a lane is turn-owned iff a reservation OWNS its
38
+ * assignment_id (only the turn-owned dispatch writes a reservation file).
39
+ *
40
+ * Evidence sourcing (the load-bearing subtlety): a real reviewer's LANE-RESULT.json is
41
+ * KEYLESS — the review brief never asks the worker to echo turn_id/run_id/nonce — so
42
+ * read-strict `reconcileTurn` (which matches lane.{turn_id,run_id,nonce} against the
43
+ * attempt) would REJECT it. We source the keys authoritatively: turn_id + run_id are
44
+ * deterministic from the reservation, and the NONCE — the non-derivable proof that THIS
45
+ * launch generation actually ran — comes from the coordinator's completion SENTINEL
46
+ * (written mechanically by the ack-wrapper with the launch-grant token). A caller/test
47
+ * that already supplies keyed lanes is honored (lane.* wins); a stale generation's
48
+ * sentinel carries the old token → still rejected, preserving the anti-stale guarantee.
49
+ */
50
+ /**
51
+ * The turn-owned FINALIZATION discriminator (pln#630, review Finding 1). A lane finalizes via
52
+ * the exactly-once reconcileTurn ONLY if a committed reservation OWNS it AND turn-keyed evidence
53
+ * (the nonce) is available — from the lane or the coordinator's completion SENTINEL. Without the
54
+ * nonce, reconcileTurn's read-strict gate can NEVER converge: this is reachable in production
55
+ * when a turn-owned dispatch WON the fence but did not ack-wrap-spawn (inbox_only / IDE-only
56
+ * reviewer, command_ready_manual, capacity cap, BRAINCLAW_NO_SPAWN, worktree-creation failure) —
57
+ * it minted a reservation but no sentinel will ever be written. Returning undefined there routes
58
+ * the lane to the LEGACY presence-based closer so the loop still converges instead of stalling
59
+ * forever. This is SAFE: the exactly-once SPAWN guarantee is enforced at DISPATCH by the launch
60
+ * fence (already run), so using legacy FINALIZATION for a sentinel-less lane reintroduces no
61
+ * double-spawn; and a sentinel that lands after a legacy close makes a later reconcile a
62
+ * terminal-loop idempotent no-op.
63
+ */
64
+ function turnOwnedLaneEvidence(lane, cwd) {
65
+ const reservation = findReservationByAssignmentId(lane.assignment_id, cwd);
66
+ if (!reservation)
67
+ return undefined; // legacy lane (no reservation)
68
+ const nonce = lane.nonce ?? readCompletionSignals(cwd, reservation.child_ids.assignment_id).completed?.nonce;
69
+ if (!nonce)
70
+ return undefined; // reservation but NO turn-keyed evidence → legacy finalization
71
+ return { reservation, nonce };
72
+ }
73
+ function reconcileTurnOwnedReviewLane(lane, cwd) {
74
+ const ev = turnOwnedLaneEvidence(lane, cwd);
75
+ if (!ev)
76
+ return undefined; // legacy lane OR no turn-keyed evidence — caller runs the legacy path
77
+ const { reservation, nonce } = ev;
78
+ const enrichedLane = {
79
+ ...lane,
80
+ turn_id: lane.turn_id ?? reservation.turn_id,
81
+ run_id: lane.run_id ?? reservation.child_ids.run_id,
82
+ nonce,
83
+ };
84
+ const result = reconcileTurn({ turn_id: reservation.turn_id, lane: enrichedLane, cwd });
85
+ return { reservation, result };
86
+ }
87
+ /**
88
+ * Map a `reconcileTurn` result onto the `ReviewLoopCloseResult` shape harvest records for
89
+ * observability (entry.review_loop / CLI). No keep_claim / next_turn: the request_changes
90
+ * turn-owned re-dispatch (fix cycle) is pln#630 PR3b — deferred and non-corrupting (the
91
+ * loop stays open awaiting its next turn, identical to the legacy asymmetric path).
92
+ */
93
+ function reconcileToReviewLoopResult(reservation, rr, lane) {
94
+ return {
95
+ loop_id: reservation.loop_id,
96
+ verdict: lane.review_verdict === 'request_changes' ? 'request_changes' : 'approve',
97
+ action: rr.auto_closed ? 'closed' : rr.reconciled ? 'advanced' : 'noop',
98
+ reason: rr.reason,
99
+ loop_status: rr.loop_status,
100
+ };
101
+ }
27
102
  /**
28
103
  * Auto-detect all worktree directories under the brainclaw-managed base dir.
29
104
  * Returns subdirectories that exist on disk (may or may not have an inbox).
@@ -256,7 +331,7 @@ function laneHarvestedMarkerPath(cwd, assignmentId) {
256
331
  export function harvestLaneResults(options = {}) {
257
332
  const cwd = options.cwd ?? process.cwd();
258
333
  const agent = options.agent ?? 'coordinator';
259
- const result = { harvested: [], skipped: [], errors: [] };
334
+ const result = { harvested: [], skipped: [], errors: [], warnings: [] };
260
335
  const worktreePaths = resolveLaneScanPaths(options, cwd);
261
336
  for (const worktreePath of worktreePaths) {
262
337
  const file = getLaneResultPath(worktreePath);
@@ -273,6 +348,7 @@ export function harvestLaneResults(options = {}) {
273
348
  // Assignment filter (when harvesting a specific lane).
274
349
  if (options.assignmentId && lane.assignment_id !== options.assignmentId)
275
350
  continue;
351
+ let ideationLoop = undefined;
276
352
  // pln#628 Focus 4B (Codex review of #87 BLOCKING 1) — a review lane must
277
353
  // close/advance its loop on the plain report-only harvest path too, not only
278
354
  // on `--integrate`. closeReviewLoopFromLaneResult is convergent + idempotent
@@ -284,8 +360,38 @@ export function harvestLaneResults(options = {}) {
284
360
  // (no re-dispatch, no claim retention). `harvest --integrate` owns the cycle.
285
361
  try {
286
362
  const laneAssignment = loadAssignment(lane.assignment_id, cwd);
287
- if (laneAssignment)
288
- closeReviewLoopFromLaneResult(laneAssignment, lane, agent, cwd, { cycleOnRequestChanges: false });
363
+ if (laneAssignment) {
364
+ // pln#630 PR3a — a TURN-OWNED review lane (reservation + turn-keyed evidence) is finalized
365
+ // ONLY by reconcileTurn on the `--integrate` path (which owns claim/worktree teardown). The
366
+ // report path must NOT pre-empt it with a legacy close (which would terminalize the loop and
367
+ // strand the turn-owned run). So skip the legacy review-close for such a lane. Kill-switch
368
+ // (=0), a legacy lane (no reservation), OR a reservation WITHOUT evidence (review Finding 1:
369
+ // an inbox_only/non-ack-wrapped dispatch that never wrote a sentinel) → false → the lane
370
+ // takes the unchanged legacy close so it still converges. Ideation stays legacy (review-only).
371
+ const laneIsTurnOwned = turnOwnedReviewEnabled() && !!turnOwnedLaneEvidence(lane, cwd);
372
+ if (!laneIsTurnOwned) {
373
+ closeReviewLoopFromLaneResult(laneAssignment, lane, agent, cwd, { cycleOnRequestChanges: false });
374
+ }
375
+ // pln#521 P2-bis — the ideation analog: a critic lane records its critique +
376
+ // advances the ideation loop. Returns undefined for non-ideate scopes (no-op here).
377
+ ideationLoop = closeIdeationLoopFromLaneResult(laneAssignment, lane, agent, cwd);
378
+ // pln#636 C2 (review F3) — the universal net's most important trigger.
379
+ // A file-fallback worker declares its own footprint in `files_changed`,
380
+ // which is BOTH cheaper and more reliable than a git diff here: by
381
+ // harvest time the lane's worktree may already have been reaped, so
382
+ // trusting the worker's declaration is the only thing that still works.
383
+ if (laneAssignment.claim_id && lane.files_changed?.length) {
384
+ const laneClaim = loadClaim(laneAssignment.claim_id, cwd);
385
+ if (laneClaim) {
386
+ const conformity = reconcileClaimConformity(laneClaim, cwd, {
387
+ touchedPaths: lane.files_changed,
388
+ });
389
+ if (conformity.warning) {
390
+ result.warnings.push(toWarningDetail(conformity.warning));
391
+ }
392
+ }
393
+ }
394
+ }
289
395
  }
290
396
  catch { /* never block harvest on loop-close */ }
291
397
  const marker = laneHarvestedMarkerPath(cwd, lane.assignment_id);
@@ -305,6 +411,9 @@ export function harvestLaneResults(options = {}) {
305
411
  assignment_id: lane.assignment_id,
306
412
  status: lane.status,
307
413
  artifacts: lane.artifacts ?? [],
414
+ body: lane.body ?? null,
415
+ artifact_type: lane.artifact_type ?? null,
416
+ ideation_loop: ideationLoop ?? null,
308
417
  files_changed: lane.files_changed ?? [],
309
418
  source_worktree: worktreePath,
310
419
  },
@@ -469,48 +578,116 @@ export function integrateLaneResults(options = {}) {
469
578
  // next_turn) unless the iteration cap is hit. This is the --integrate
470
579
  // path, so it MAY cycle (it can re-dispatch AND retain the claim). No-op
471
580
  // 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
- }
581
+ // pln#521 P2-bis — the ideation analog (record critique + advance the ideation
582
+ // loop). Turn-owned is REVIEW-ONLY (pln#630), so ideation always uses the legacy
583
+ // path. It is independent of the review-loop close (a lane's scope is review-loop
584
+ // XOR ideate-loop), so it runs once here regardless of the branch below.
585
+ const ideationClose = closeIdeationLoopFromLaneResult(assignment, lane, actor, cwd);
586
+ if (ideationClose) {
587
+ reasons.push(`ideate-loop ${ideationClose.loop_id}: ${ideationClose.action} — ${ideationClose.reason}`);
588
+ entry.ideation_loop = ideationClose;
479
589
  }
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)');
590
+ // pln#630 PR3a — a TURN-OWNED review lane finalizes via the exactly-once
591
+ // reconcileTurn, which REPLACES the legacy closer + teardown gate for this lane
592
+ // (exactly-one finalizer per lane). Kill-switch (=0), a legacy (non-reserved) lane, OR a
593
+ // reservation with NO turn-keyed evidence (review Finding 1) `turnOwned` is undefined
594
+ // and the unchanged legacy `else` block runs so the loop still converges.
595
+ const turnOwned = turnOwnedReviewEnabled()
596
+ ? reconcileTurnOwnedReviewLane(lane, cwd)
597
+ : undefined;
598
+ if (turnOwned) {
599
+ const { reservation, result: rr } = turnOwned;
600
+ entry.review_loop = reconcileToReviewLoopResult(reservation, rr, lane);
601
+ reasons.push(`turn-owned reconcile ${reservation.loop_id}: ${entry.review_loop.action} ${rr.reason}${rr.conflict ? ' [CONFLICT — held]' : ''}`);
602
+ // pln#630 PR3b a symmetric request_changes bumped the round + retained the claim
603
+ // and handed back the next fix-cycle turn. Push it exactly like the legacy path so
604
+ // the existing async re-dispatch loop spawns round N+1 into the reused worktree. The
605
+ // fresh iteration means deriveTurnId mints a new turn_id → the launch fence admits
606
+ // exactly one spawn (a stale re-emit of the same turn_id is denied).
607
+ if (rr.next_turn) {
608
+ result.next_turns.push({ loop_id: reservation.loop_id, ...rr.next_turn });
609
+ }
610
+ // Claim/run/assignment settling is OWNED by reconcileTurn, so we do NOT run the
611
+ // legacy teardown gatejust reflect the resulting claim state. Settlement
612
+ // semantics (reconcile-turn.ts, review #1): an ACCEPTED lane — approve OR
613
+ // request_changes, both settle the slot 'done' — completes the assignment AND
614
+ // releases the claim; only a REJECTED/superseded lane (evidence mismatch/absent,
615
+ // §13 R4 conflict) leaves the claim intact for a retry. No next_turns push: the
616
+ // request_changes fix-cycle re-dispatch — AND re-establishing the claim/worktree
617
+ // that the release above implies — is pln#630 PR3b (deferred; the loop stays open
618
+ // awaiting its next turn, so nothing is corrupted, only not-yet-autonomous).
619
+ try {
620
+ entry.claim_released = loadClaim(assignment.claim_id, cwd)?.status === 'released';
621
+ }
622
+ catch {
623
+ entry.claim_released = false;
624
+ }
625
+ // A turn-owned lane that reached here HAS turn-keyed evidence (turnOwnedLaneEvidence
626
+ // gated on a present nonce — the missing-sentinel/non-spawn case already fell back to
627
+ // legacy, review Finding 1). So a non-convergence here means the evidence MISMATCHED
628
+ // the live attempt (stale/superseded generation) — which must NOT fall back to legacy
629
+ // (that would converge on evidence for a DIFFERENT generation). Emit an observable
630
+ // event so an operator/doctor sees the (correctly) withheld convergence. (An R4
631
+ // conflict already journals its own event, so it is excluded here.)
632
+ if (!rr.reconciled && !rr.conflict && lane.review_verdict) {
633
+ try {
634
+ createRuntimeEvent({
635
+ agent: actor,
636
+ event_type: 'run_blocked',
637
+ 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`,
638
+ tags: ['harvest', 'reconcile', 'turn-owned', 'unconverged'],
639
+ assignment_id: lane.assignment_id,
640
+ run_id: reservation.child_ids.run_id,
641
+ status_reason: 'turn_owned_evidence_unaccepted',
642
+ }, cwd);
643
+ }
644
+ catch { /* observability best-effort */ }
645
+ }
502
646
  }
503
647
  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}` : ''}`);
648
+ const loopClose = closeReviewLoopFromLaneResult(assignment, lane, actor, cwd);
649
+ if (loopClose) {
650
+ entry.review_loop = loopClose;
651
+ reasons.push(`review-loop ${loopClose.loop_id}: ${loopClose.action} ${loopClose.reason}`);
652
+ if (loopClose.next_turn) {
653
+ result.next_turns.push({ loop_id: loopClose.loop_id, ...loopClose.next_turn });
654
+ }
655
+ }
656
+ // PR2 claim-teardown gate. Skip the release when either:
657
+ // (a) keep_claim the symmetric fix cycle reuses the claim/worktree for
658
+ // the re-dispatched turn (commits accumulate on one branch); or
659
+ // (b) Codex review P0 — an idempotent re-harvest of an OLD lane whose
660
+ // loop is still OPEN returns a `noop` (the reviewer slot is now bound
661
+ // to a NEWER assignment under an active cycle). Releasing here would
662
+ // tear down the reused claim/worktree out from under the live turn
663
+ // and strand the fix cycle. The loop machinery owns the lifecycle
664
+ // while it is open; only a terminal close (approve/blocked, action
665
+ // 'closed') or an asymmetric hand-off ('advanced' without keep_claim)
666
+ // releases here. A `noop` on a TERMINAL loop still releases (safe —
667
+ // the closing pass already released, so this is a no-op).
668
+ const loopStillOpen = loopClose?.loop_status !== undefined &&
669
+ !['completed', 'cancelled', 'blocked'].includes(loopClose.loop_status);
670
+ const keepClaimAlive = loopClose?.keep_claim === true || (loopClose?.action === 'noop' && loopStillOpen);
671
+ if (keepClaimAlive) {
672
+ // The next_turn spawn (async) is awaited by runHarvestLane. The
673
+ // assignment for THIS turn is still completed above.
674
+ entry.claim_released = false;
675
+ reasons.push(loopClose?.keep_claim
676
+ ? 'claim kept alive for review fix-cycle re-dispatch (PR2)'
677
+ : 'claim left intact — idempotent re-harvest on an active review loop (no strand)');
678
+ }
679
+ else {
680
+ // trp#928 — use the cascade helper (was releaseClaimWithCascade — same
681
+ // logic for the last-claim rule but the cascade wrapper LOGS per-claim,
682
+ // so a silent ownership failure is observable in the runtime event log
683
+ // rather than only in this in-memory `reasons` string).
684
+ const cascade = releaseClaimsCascade([assignment.claim_id], { cwd, planStatus: 'done' });
685
+ logCascadeReleaseResult({ actor, trigger: 'harvest_integrate', assignment_id: lane.assignment_id, claim_id: assignment.claim_id, cascade, cwd });
686
+ const claimEntry = cascade.entries[0];
687
+ entry.claim_released = claimEntry?.released === true;
688
+ if (claimEntry && !claimEntry.released) {
689
+ reasons.push(`claim release ${claimEntry.reason}${claimEntry.error ? `: ${claimEntry.error}` : ''}`);
690
+ }
514
691
  }
515
692
  }
516
693
  }
@@ -550,6 +727,9 @@ export function integrateLaneResults(options = {}) {
550
727
  files_changed: entry.files_changed,
551
728
  assignment_completed: entry.assignment_completed,
552
729
  claim_released: entry.claim_released,
730
+ body: lane.body ?? null,
731
+ artifact_type: lane.artifact_type ?? null,
732
+ ideation_loop: entry.ideation_loop ?? null,
553
733
  },
554
734
  }, cwd);
555
735
  }
@@ -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) {