brainclaw 1.15.0 → 1.17.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 (59) hide show
  1. package/README.md +25 -4
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli/register-capture.js +209 -0
  4. package/dist/cli/register-code-map.js +19 -0
  5. package/dist/cli/register-coordination.js +472 -0
  6. package/dist/cli/register-federation.js +258 -0
  7. package/dist/cli/register-lifecycle.js +436 -0
  8. package/dist/cli/register-memory-context.js +502 -0
  9. package/dist/cli/register-planning.js +167 -0
  10. package/dist/cli/register-review.js +149 -0
  11. package/dist/cli/shared.js +5 -0
  12. package/dist/cli.js +212 -2183
  13. package/dist/commands/dispatch-watch.js +25 -2
  14. package/dist/commands/harvest.js +107 -20
  15. package/dist/commands/mcp-catalog.js +1438 -0
  16. package/dist/commands/mcp-contract.js +33 -0
  17. package/dist/commands/mcp-presentation.js +27 -0
  18. package/dist/commands/mcp-read-handlers.js +72 -36
  19. package/dist/commands/mcp-write-admin.js +328 -0
  20. package/dist/commands/mcp-write-claims.js +864 -0
  21. package/dist/commands/mcp-write-coordination.js +1825 -0
  22. package/dist/commands/mcp-write-entities.js +620 -0
  23. package/dist/commands/mcp-write-memory.js +451 -0
  24. package/dist/commands/mcp-write-sequences.js +116 -0
  25. package/dist/commands/mcp-write-support.js +367 -0
  26. package/dist/commands/mcp.js +261 -5584
  27. package/dist/commands/update-handoff.js +28 -42
  28. package/dist/core/agent-capability.js +38 -16
  29. package/dist/core/agent-files.js +54 -3
  30. package/dist/core/agent-integrations.js +1 -0
  31. package/dist/core/coordination.js +5 -2
  32. package/dist/core/cross-project.js +35 -1
  33. package/dist/core/dispatcher.js +67 -27
  34. package/dist/core/entity-operations.js +335 -12
  35. package/dist/core/entity-registry.js +72 -9
  36. package/dist/core/execution.js +28 -4
  37. package/dist/core/facade-schema.js +18 -4
  38. package/dist/core/handoff-review.js +35 -0
  39. package/dist/core/protocol-tool-policy.js +113 -0
  40. package/dist/core/review-loop-close.js +184 -0
  41. package/dist/core/review-loop-turn-dispatch.js +183 -0
  42. package/dist/core/schema.js +24 -2
  43. package/dist/core/security-detectors.js +35 -6
  44. package/dist/core/security.js +32 -12
  45. package/dist/core/worktree.js +274 -12
  46. package/dist/facts.js +13 -11
  47. package/dist/facts.json +12 -10
  48. package/docs/PROTOCOL.md +7 -3
  49. package/docs/concepts/coordinator-runbook.md +3 -0
  50. package/docs/concepts/dispatch-lifecycle.md +4 -4
  51. package/docs/concepts/loop-engine.md +6 -2
  52. package/docs/concepts/troubleshooting.md +1 -1
  53. package/docs/integrations/codex.md +22 -6
  54. package/docs/integrations/overview.md +1 -1
  55. package/docs/mcp-schema-changelog.md +137 -2
  56. package/docs/playbooks/orchestration.md +1 -1
  57. package/docs/product/entity-model-audit.md +3 -2
  58. package/docs/security.md +22 -1
  59. package/package.json +3 -1
@@ -42,6 +42,24 @@ export function evaluateWatchTick(input) {
42
42
  return 'running';
43
43
  }
44
44
  const AGENT_CHILD_NAMES = ['claude', 'codex', 'copilot', 'node'];
45
+ /**
46
+ * Parse `ps -A -o ppid=,comm=` output and return the lowercased command names
47
+ * whose parent pid equals `ppid`. Pure + exported so the ppid-filter logic is
48
+ * unit-tested independently of the platform the test runs on (trp_3b096bf4:
49
+ * BSD `ps` on macOS rejects the GNU `--ppid` flag, so we filter here instead).
50
+ */
51
+ export function parseChildCommsByPpid(psOutput, ppid) {
52
+ const target = Math.floor(ppid);
53
+ return psOutput
54
+ .split('\n')
55
+ .map((line) => {
56
+ const m = /^\s*(\d+)\s+(.*)$/.exec(line);
57
+ if (!m || Number(m[1]) !== target)
58
+ return undefined;
59
+ return m[2].trim().toLowerCase();
60
+ })
61
+ .filter((n) => !!n);
62
+ }
45
63
  /**
46
64
  * Does a real agent process live under the wrapper pid?
47
65
  * Returns undefined when the observation itself fails (never treated as death).
@@ -59,10 +77,15 @@ export function probeAgentChildAlive(wrapperPid) {
59
77
  const names = out.split(/\r?\n/).map((l) => l.trim().toLowerCase()).filter(Boolean);
60
78
  return names.some((n) => AGENT_CHILD_NAMES.some((a) => n.startsWith(a)));
61
79
  }
62
- const out = execFileSync('ps', ['-o', 'comm=', '--ppid', String(wrapperPid)], {
80
+ // BSD `ps` (macOS) does NOT support the GNU `--ppid` flag — it errored on
81
+ // every poll on macOS and broke child-pid discovery there (trp_3b096bf4).
82
+ // List every process with its parent pid + command (portable across Linux
83
+ // and macOS — same `-A -o …=` form already used in ai-surface-inventory.ts)
84
+ // and filter by ppid in parseChildCommsByPpid.
85
+ const out = execFileSync('ps', ['-A', '-o', 'ppid=,comm='], {
63
86
  encoding: 'utf-8', timeout: 15000,
64
87
  });
65
- const names = out.split('\n').map((l) => l.trim().toLowerCase()).filter(Boolean);
88
+ const names = parseChildCommsByPpid(out, wrapperPid);
66
89
  return names.some((n) => AGENT_CHILD_NAMES.some((a) => n.includes(a)));
67
90
  }
68
91
  catch {
@@ -22,6 +22,8 @@ import { loadAssignment, transitionAssignment } from '../core/assignments.js';
22
22
  import { loadClaim, releaseClaimsCascade, logCascadeReleaseResult } from '../core/claims.js';
23
23
  import { getCapabilityProfile, dispatchCanCommit } from '../core/agent-capability.js';
24
24
  import { commitWorktreeOnBehalf, worktreesBaseDir, resolveGitToplevel } from '../core/worktree.js';
25
+ import { closeReviewLoopFromLaneResult } from '../core/review-loop-close.js';
26
+ import { dispatchReviewLoopTurn } from '../core/review-loop-turn-dispatch.js';
25
27
  /**
26
28
  * Auto-detect all worktree directories under the brainclaw-managed base dir.
27
29
  * Returns subdirectories that exist on disk (may or may not have an inbox).
@@ -112,10 +114,11 @@ function collectWorktreeCandidateFiles(worktreePath) {
112
114
  /**
113
115
  * Harvest candidates from worktree inboxes into the main project store.
114
116
  *
115
- * This is the coordinator-side fix for gap 5 of E2E test n°1: agents
116
- * running under `--sandbox workspace-write` cannot reach the main store
117
- * via MCP. They write candidates directly to their worktree inbox; the
118
- * coordinator calls `harvestCandidates` to sync them.
117
+ * This is the coordinator-side fix for gap 5 of E2E test n°1: a worker that
118
+ * cannot write to the main store — a genuinely MCP-less agent, or (post
119
+ * dec#133) a sandboxed codex whose `.git` is read-only so it cannot commit —
120
+ * leaves candidates in its worktree inbox; the coordinator calls
121
+ * `harvestCandidates` to sync them.
119
122
  *
120
123
  * @returns HarvestResult with counts of harvested, skipped, and errors.
121
124
  */
@@ -231,8 +234,9 @@ export function runHarvestCandidates(options = {}) {
231
234
  //
232
235
  // A dispatched worker writes a single `LANE-RESULT.json` at its worktree root
233
236
  // as its final step. This is the standard, brief-boilerplate-free channel for a
234
- // worker (especially a sandboxed one that cannot reach MCP) to report its
235
- // outcome. The coordinator ingests it with `brainclaw harvest <assignment_id>`.
237
+ // worker (a genuinely MCP-less agent, or a sandboxed codex that cannot git
238
+ // commit) to report its outcome. The coordinator ingests it with
239
+ // `brainclaw harvest <assignment_id>`.
236
240
  // ─────────────────────────────────────────────────────────────────────────────
237
241
  /** Conventional path of a worker's lane-result file at the worktree root. */
238
242
  export function getLaneResultPath(worktreePath) {
@@ -269,6 +273,21 @@ export function harvestLaneResults(options = {}) {
269
273
  // Assignment filter (when harvesting a specific lane).
270
274
  if (options.assignmentId && lane.assignment_id !== options.assignmentId)
271
275
  continue;
276
+ // pln#628 Focus 4B (Codex review of #87 BLOCKING 1) — a review lane must
277
+ // close/advance its loop on the plain report-only harvest path too, not only
278
+ // on `--integrate`. closeReviewLoopFromLaneResult is convergent + idempotent
279
+ // (a terminal loop is a no-op; a stuck approve is resumed), so firing it here
280
+ // AND in integrateLaneResults is safe — and it runs BEFORE the harvested
281
+ // marker short-circuits below, so a re-harvest still resumes a stuck loop.
282
+ // PR2: cycleOnRequestChanges=false — the report path only closes on approve;
283
+ // it must NOT advance a request_changes cycle it cannot follow through on
284
+ // (no re-dispatch, no claim retention). `harvest --integrate` owns the cycle.
285
+ try {
286
+ const laneAssignment = loadAssignment(lane.assignment_id, cwd);
287
+ if (laneAssignment)
288
+ closeReviewLoopFromLaneResult(laneAssignment, lane, agent, cwd, { cycleOnRequestChanges: false });
289
+ }
290
+ catch { /* never block harvest on loop-close */ }
272
291
  const marker = laneHarvestedMarkerPath(cwd, lane.assignment_id);
273
292
  if (fs.existsSync(marker)) {
274
293
  result.skipped.push(lane.assignment_id);
@@ -376,7 +395,7 @@ function forceCompleteAssignment(assignmentId, artifacts, statusReason, actor, c
376
395
  export function integrateLaneResults(options = {}) {
377
396
  const cwd = options.cwd ?? process.cwd();
378
397
  const actor = options.agent ?? 'coordinator';
379
- const result = { integrated: [], skipped: [], errors: [] };
398
+ const result = { integrated: [], skipped: [], errors: [], next_turns: [] };
380
399
  const worktreePaths = resolveLaneScanPaths(options, cwd);
381
400
  for (const worktreePath of worktreePaths) {
382
401
  const file = getLaneResultPath(worktreePath);
@@ -444,16 +463,55 @@ export function integrateLaneResults(options = {}) {
444
463
  ...entry.files_changed.slice(0, 50).map((f) => ({ type: 'file', ref: f })),
445
464
  ];
446
465
  entry.assignment_completed = forceCompleteAssignment(lane.assignment_id, artifacts, `pln#534 on-behalf integration: ${lane.summary.slice(0, 120)}`, actor, cwd);
447
- // trp#928 — use the cascade helper (was releaseClaimWithCascade — same
448
- // logic for the last-claim rule but the cascade wrapper LOGS per-claim,
449
- // so a silent ownership failure is observable in the runtime event log
450
- // rather than only in this in-memory `reasons` string).
451
- const cascade = releaseClaimsCascade([assignment.claim_id], { cwd, planStatus: 'done' });
452
- logCascadeReleaseResult({ actor, trigger: 'harvest_integrate', assignment_id: lane.assignment_id, claim_id: assignment.claim_id, cascade, cwd });
453
- const claimEntry = cascade.entries[0];
454
- entry.claim_released = claimEntry?.released === true;
455
- if (claimEntry && !claimEntry.released) {
456
- reasons.push(`claim release ${claimEntry.reason}${claimEntry.error ? `: ${claimEntry.error}` : ''}`);
466
+ // pln#628 Focus 4B — map this lane onto its review loop BEFORE deciding
467
+ // teardown: PR1 records the verdict + advances (auto-close on approve);
468
+ // PR2 continues the fix cycle on request_changes (bump round, emit a
469
+ // next_turn) unless the iteration cap is hit. This is the --integrate
470
+ // path, so it MAY cycle (it can re-dispatch AND retain the claim). No-op
471
+ // 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
+ }
479
+ }
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 intact — idempotent re-harvest on an active review loop (no strand)');
502
+ }
503
+ 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}` : ''}`);
514
+ }
457
515
  }
458
516
  }
459
517
  else {
@@ -672,7 +730,7 @@ export function harvestOrphaned(options) {
672
730
  }
673
731
  return report;
674
732
  }
675
- export function runHarvestLane(assignmentId, options = {}) {
733
+ export async function runHarvestLane(assignmentId, options = {}) {
676
734
  const cwd = options.cwd ?? process.cwd();
677
735
  if (!memoryExists(cwd)) {
678
736
  console.error('Error: .brainclaw/ not found. Run `brainclaw init` first.');
@@ -734,8 +792,30 @@ export function runHarvestLane(assignmentId, options = {}) {
734
792
  dryRun: options.dryRun,
735
793
  cwd,
736
794
  });
795
+ // pln#628 Focus 4B PR2 — spawn the review fix-cycle turns the sync integrate
796
+ // pass emitted. Re-dispatches the SAME reviewer into the SAME (kept) worktree
797
+ // to apply the requested changes + re-review. Dry-run only reports them.
798
+ const dispatchedTurns = [];
799
+ if (!options.dryRun) {
800
+ for (const nt of integ.next_turns) {
801
+ const dispatched = await dispatchReviewLoopTurn({
802
+ loopId: nt.loop_id,
803
+ slot: { slot_id: nt.slot_id, role: nt.role, agent: nt.agent, agent_id: nt.agent_id },
804
+ phase: nt.phase,
805
+ task: nt.task,
806
+ dispatcherAgent: options.agent ?? 'coordinator',
807
+ cwd,
808
+ // NO worktreeBaseRef: reuse the kept worktree so the fixes accumulate;
809
+ // pinning a ref would reset the branch and wipe prior-round commits.
810
+ });
811
+ dispatchedTurns.push({
812
+ loop_id: nt.loop_id, agent: nt.agent, iteration: nt.iteration,
813
+ execution_status: dispatched.execution_status, error: dispatched.error,
814
+ });
815
+ }
816
+ }
737
817
  if (options.json) {
738
- console.log(JSON.stringify(integ, null, 2));
818
+ console.log(JSON.stringify({ ...integ, dispatched_turns: dispatchedTurns }, null, 2));
739
819
  return;
740
820
  }
741
821
  const dry = options.dryRun ? ' (dry-run)' : '';
@@ -762,7 +842,14 @@ export function runHarvestLane(assignmentId, options = {}) {
762
842
  }
763
843
  for (const err of integ.errors)
764
844
  console.error(` ✗ ${err}`);
765
- console.log(`\n✔ Lane integrate complete${dry}: ${integ.integrated.length} integrated, ${integ.errors.length} error(s).`);
845
+ for (const dt of dispatchedTurns) {
846
+ const status = dt.error ? `error: ${dt.error}` : (dt.execution_status ?? 'unknown');
847
+ console.log(` ↻ Fix-cycle re-dispatch [${dt.loop_id}] round ${dt.iteration} → ${dt.agent} (${status})`);
848
+ }
849
+ if (options.dryRun && integ.next_turns.length > 0) {
850
+ console.log(` (dry-run) ${integ.next_turns.length} fix-cycle turn(s) would be re-dispatched.`);
851
+ }
852
+ console.log(`\n✔ Lane integrate complete${dry}: ${integ.integrated.length} integrated, ${dispatchedTurns.length} re-dispatched, ${integ.errors.length} error(s).`);
766
853
  return;
767
854
  }
768
855
  const result = harvestLaneResults({