brainclaw 1.16.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +22 -8
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli/register-review.js +2 -2
  4. package/dist/commands/code-map.js +4 -1
  5. package/dist/commands/codev.js +61 -30
  6. package/dist/commands/doctor.js +14 -1
  7. package/dist/commands/harvest.js +241 -25
  8. package/dist/commands/inbox.js +10 -4
  9. package/dist/commands/loop.js +2 -2
  10. package/dist/commands/loops-handlers.js +82 -1
  11. package/dist/commands/mcp-catalog.js +12 -4
  12. package/dist/commands/mcp-read-handlers.js +90 -7
  13. package/dist/commands/mcp-schemas.generated.js +3 -0
  14. package/dist/commands/mcp-write-coordination.js +159 -40
  15. package/dist/commands/mcp.js +11 -2
  16. package/dist/core/agent-capability.js +7 -2
  17. package/dist/core/agent-files.js +53 -2
  18. package/dist/core/agent-integrations.js +1 -0
  19. package/dist/core/agentrun-reconciler.js +171 -7
  20. package/dist/core/agentruns.js +6 -1
  21. package/dist/core/code-map/aggregate.js +473 -0
  22. package/dist/core/code-map/backend.js +36 -10
  23. package/dist/core/code-map/freshness.js +36 -1
  24. package/dist/core/code-map/lang/c/imports.scm +12 -0
  25. package/dist/core/code-map/lang/c/index.js +150 -0
  26. package/dist/core/code-map/lang/c/tags.scm +68 -0
  27. package/dist/core/code-map/lang/cpp/imports.scm +14 -0
  28. package/dist/core/code-map/lang/cpp/index.js +149 -0
  29. package/dist/core/code-map/lang/cpp/tags.scm +87 -0
  30. package/dist/core/code-map/lang/csharp/imports.scm +20 -0
  31. package/dist/core/code-map/lang/csharp/index.js +224 -0
  32. package/dist/core/code-map/lang/csharp/tags.scm +63 -0
  33. package/dist/core/code-map/lang/go/imports.scm +13 -0
  34. package/dist/core/code-map/lang/go/index.js +139 -0
  35. package/dist/core/code-map/lang/go/tags.scm +36 -0
  36. package/dist/core/code-map/lang/providers.js +12 -1
  37. package/dist/core/code-map/lang/ruby/imports.scm +24 -0
  38. package/dist/core/code-map/lang/ruby/index.js +198 -0
  39. package/dist/core/code-map/lang/ruby/tags.scm +49 -0
  40. package/dist/core/code-map/lang/rust/imports.scm +44 -0
  41. package/dist/core/code-map/lang/rust/index.js +136 -0
  42. package/dist/core/code-map/lang/rust/tags.scm +47 -0
  43. package/dist/core/code-map/query.js +229 -80
  44. package/dist/core/code-map/types.js +18 -0
  45. package/dist/core/code-map/work-section.js +8 -7
  46. package/dist/core/codev-responses.js +16 -0
  47. package/dist/core/dispatcher.js +209 -29
  48. package/dist/core/execution-adapters.js +29 -3
  49. package/dist/core/ideation-loop-close.js +124 -0
  50. package/dist/core/loops/artifact-resolver.js +197 -0
  51. package/dist/core/loops/attempt-reservation.js +576 -0
  52. package/dist/core/loops/commit-intent.js +494 -0
  53. package/dist/core/loops/facade-schema.js +48 -0
  54. package/dist/core/loops/impl-bind.js +144 -0
  55. package/dist/core/loops/index.js +1 -1
  56. package/dist/core/loops/iteration-engine.js +29 -0
  57. package/dist/core/loops/lock.js +14 -0
  58. package/dist/core/loops/project-resolution.js +157 -0
  59. package/dist/core/loops/reconcile-turn.js +369 -0
  60. package/dist/core/loops/result-reducers.js +88 -0
  61. package/dist/core/loops/store.js +46 -7
  62. package/dist/core/loops/types.js +139 -11
  63. package/dist/core/loops/verbs.js +9 -3
  64. package/dist/core/loops/verify-command.js +209 -0
  65. package/dist/core/messaging.js +58 -5
  66. package/dist/core/review-loop-close.js +106 -34
  67. package/dist/core/review-loop-turn-dispatch.js +445 -0
  68. package/dist/core/runtime-signals.js +68 -0
  69. package/dist/core/schema.js +34 -0
  70. package/dist/core/worktree.js +240 -22
  71. package/dist/facts.js +10 -10
  72. package/dist/facts.json +9 -9
  73. package/dist/wasm/tree-sitter-c.wasm +0 -0
  74. package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
  75. package/dist/wasm/tree-sitter-cpp.wasm +0 -0
  76. package/dist/wasm/tree-sitter-go.wasm +0 -0
  77. package/dist/wasm/tree-sitter-ruby.wasm +0 -0
  78. package/dist/wasm/tree-sitter-rust.wasm +0 -0
  79. package/docs/cli.md +1 -1
  80. package/docs/code-map.md +22 -6
  81. package/docs/concepts/loop-engine.md +28 -2
  82. package/docs/concepts/observer-protocol.md +22 -0
  83. package/docs/integrations/codex.md +19 -3
  84. package/docs/mcp-schema-changelog.md +43 -1
  85. package/package.json +1 -1
@@ -20,12 +20,14 @@ import { appendAuditEntry } from '../core/audit.js';
20
20
  import { nowISO } from '../core/ids.js';
21
21
  import { validateMcpField } from '../core/input-validation.js';
22
22
  import { generateCandidateIdWithLabel, saveCandidate } from '../core/candidates.js';
23
+ import { validateLoopProjectResolution } from '../core/loops/project-resolution.js';
23
24
  import { ackMessage, getThread, hasActiveAssignment, sendMessage } from '../core/messaging.js';
24
25
  import { dispatch, dispatchReview, generateDispatchBrief } from '../core/dispatcher.js';
25
26
  import { CoordinateRequestSchema } from '../core/facade-schema.js';
26
27
  import { buildInvokeCommand, getCapabilityProfile, getSpawnableAgents, resolveModel, validateAgentForDispatch, } from '../core/agent-capability.js';
27
28
  import { attemptExecution } from '../core/execution.js';
28
29
  import { createAgentRun, transitionAgentRun } from '../core/agentruns.js';
30
+ import { prepareTurnOwnedReviewDispatch, turnOwnedReviewEnabled } from '../core/review-loop-turn-dispatch.js';
29
31
  import { createAssignment, generateAssignmentId, patchAssignmentMessageId, transitionAssignment, } from '../core/assignments.js';
30
32
  import { createToolErrorResponse, toolResponse, } from './mcp-contract.js';
31
33
  import { handleMcpReadToolCall } from './mcp-read-handlers.js';
@@ -228,11 +230,13 @@ export function handleBclawSendMessage(args, ctx) {
228
230
  }, cwd);
229
231
  appendAuditEntry({ actor: resolved.identity.agent_name, actor_id: resolved.identity.agent_id, action: 'create', item_id: result.id, item_type: 'message', scope: to }, cwd);
230
232
  const threadInfo = threadId ? ` thread:${threadId}` : '';
233
+ const warningLine = result.warning ? `\n⚠ ${result.warning}` : '';
231
234
  return {
232
235
  response: toolResponse({
233
- content: [{ type: 'text', text: `✔ Message sent: [${result.shortLabel}] ${msgType} → ${to}${threadInfo}` }],
236
+ content: [{ type: 'text', text: `✔ Message sent: [${result.shortLabel}] ${msgType} → ${to}${threadInfo}${warningLine}` }],
234
237
  message_id: result.id,
235
238
  thread_id: threadId,
239
+ ...(result.warning ? { warning: result.warning } : {}),
236
240
  }),
237
241
  };
238
242
  }
@@ -270,7 +274,7 @@ export function handleBclawAckMessage(args, ctx) {
270
274
  }
271
275
  }
272
276
  export async function handleBclawCoordinate(args, ctx) {
273
- const { cwd, connectionSessionId, currentModel } = ctx;
277
+ const { cwd, connectionSessionId, currentModel, effectiveScope } = ctx;
274
278
  const startMs = Date.now();
275
279
  const parseResult = CoordinateRequestSchema.safeParse(args);
276
280
  if (!parseResult.success) {
@@ -356,6 +360,35 @@ export async function handleBclawCoordinate(args, ctx) {
356
360
  warnings.push(`cross-project dispatch (project='${req.project}') — auto-spawn disabled; the target agent picks up the brief async via its own bclaw_work.`);
357
361
  }
358
362
  const effectiveAutoExecute = isCrossProject ? false : req.autoExecute;
363
+ // pln#521 P1 — project resolution gate. A review loop written into the wrong
364
+ // store is worse than one that never opened: candidate, claim, assignment and
365
+ // loop all persist where nobody is watching, and the reviewer spawns against
366
+ // the wrong repo (DGX misroute). So when this store can host several projects
367
+ // and NONE was selected, refuse here — before the pre-flight spawn and before
368
+ // the first write — instead of silently defaulting to cwd. Explicit `project`,
369
+ // a session switch, or an active-project pointer all count as a choice; ref /
370
+ // scope / path never do (B3 rejected, art_e29e88878209). Scoped to the failing
371
+ // path (review + open_loop): every other intent keeps its routing untouched.
372
+ let projectResolution;
373
+ if (req.intent === 'review' && req.open_loop === true) {
374
+ // Resolve from `cwd`, not `dispatchCwd`: an explicit project name is
375
+ // resolvable from the SOURCE store (its links / store chain), which is
376
+ // exactly what produced dispatchCwd above. Re-resolving from the target
377
+ // could fail for a link whose name differs from the target's project_name.
378
+ const resolution = validateLoopProjectResolution({
379
+ cwd,
380
+ projectArg: req.project,
381
+ activeSource: effectiveScope?.active_source,
382
+ });
383
+ if (!resolution.ok) {
384
+ return {
385
+ response: createToolErrorResponse(resolution.code, resolution.message, {
386
+ candidates: resolution.candidates,
387
+ }),
388
+ };
389
+ }
390
+ projectResolution = resolution;
391
+ }
359
392
  // can_30c295b4 / trp#371 Tier 2 — scope-aware dirty-working-tree guard.
360
393
  // Intents that spawn a worktree worker from HEAD can review/edit stale code,
361
394
  // so they are guarded; consult/summarize (no worktree) are not. pln#626
@@ -403,7 +436,7 @@ export async function handleBclawCoordinate(args, ctx) {
403
436
  /** Run E2E execution phase on prepared delivery entries. Returns overall execution status. */
404
437
  const runCoordinateExecution = async (prepared, opts) => {
405
438
  let overall = 'inbox_only';
406
- for (const { entry, invoke, worktreePath } of prepared) {
439
+ for (const { entry, invoke, worktreePath, turnEcho } of prepared) {
407
440
  const execResult = await attemptExecution(invoke, {
408
441
  agent: entry.agent,
409
442
  autoExecute: opts.autoExecute,
@@ -414,6 +447,8 @@ export async function handleBclawCoordinate(args, ctx) {
414
447
  dispatcherAgentId: opts.senderAgentId,
415
448
  cwd: opts.cwd,
416
449
  requireWorktree: true, // pln#531: never spawn a worker in the integration repo
450
+ turnEcho, // pln#630 — turn-owned reviewer (initial dispatch): the ack-wrapper writes the
451
+ // turn-keyed completion sentinel. undefined for every non-turn-owned entry.
417
452
  });
418
453
  entry.execution_status = execResult.execution_status;
419
454
  // pln#626 Phase 1 — carry the machine-readable reason (+ failure_kind) to
@@ -436,7 +471,23 @@ export async function handleBclawCoordinate(args, ctx) {
436
471
  // emit three identical context-free warnings (pln#626 Phase 1 R5).
437
472
  if (execResult.error)
438
473
  opts.warnings.push(`${entry.agent}: ${execResult.error}`);
439
- if (entry.assignment_id && entry.claim_id) {
474
+ if (turnEcho) {
475
+ // pln#630 risk #1 — a turn-owned reviewer's run was ALREADY created (`created`) by
476
+ // prepareTurnOwnedReviewDispatch. Do NOT mint a second run here (double-mint). Transition
477
+ // the deterministic run → running on a real spawn (mirrors dispatchReviewLoopTurn); leave
478
+ // it `created` otherwise so the no-sentinel legacy fallback (turnOwnedLaneEvidence) + the
479
+ // pre-run lease reconciler govern it. Non-turn-owned entries take the unchanged else-branch.
480
+ if (execResult.execution_status === 'delivered_and_started') {
481
+ try {
482
+ transitionAgentRun(turnEcho.run_id, 'running', {
483
+ actor: opts.senderAgent, actor_id: opts.senderAgentId, pid: execResult.pid,
484
+ status_reason: 'turn-owned reviewer spawned by coordinator',
485
+ }, opts.cwd);
486
+ }
487
+ catch { /* best-effort — the reconciler converges if this races */ }
488
+ }
489
+ }
490
+ else if (entry.assignment_id && entry.claim_id) {
440
491
  if (execResult.failure_kind === 'spawn_no_handshake') {
441
492
  try {
442
493
  const run = createAgentRun({
@@ -969,38 +1020,82 @@ export async function handleBclawCoordinate(args, ctx) {
969
1020
  id: claimResult.claimId,
970
1021
  });
971
1022
  let reviewAssignmentId;
972
- try {
973
- const preId = generateAssignmentId(dispatchCwd);
974
- const assignment = createAssignment({
975
- id: preId.id,
976
- short_label: preId.short_label,
977
- claim_id: claimResult.claimId,
1023
+ let reviewTurnEcho;
1024
+ // pln#630 turn-own the INITIAL reviewer dispatch (same default + kill-switch as the
1025
+ // fix cycle). Skipped for cross-project reviews (no local worktree/sentinel → they never
1026
+ // spawn here). WON: prepare minted the DETERMINISTIC assignment + run + turn()-bound the
1027
+ // slot, so we reuse prep.assignmentId for the brief/message/linkage below and carry the
1028
+ // turnEcho so the ack-wrapper writes the turn-keyed sentinel. DENIED: the exactly-once
1029
+ // fence says this dispatch is NOT the spawner — do NOT spawn, do NOT fall back to legacy
1030
+ // (that is the double-spawn hole), and do NOT release the (possibly shared) claim; leave
1031
+ // the slot for reconcile/self-heal. LEGACY: the unchanged inline mint runs.
1032
+ let usedTurnOwned = false;
1033
+ if (turnOwnedReviewEnabled() && !req.project) {
1034
+ const prep = prepareTurnOwnedReviewDispatch({
1035
+ loopId: loop.id,
1036
+ slotId: slot.slot_id,
978
1037
  agent: slot.agent ?? '',
979
- dispatcher_agent: senderAgent,
980
- dispatcher_session_id: connectionSessionId,
981
- scope: reviewScope,
1038
+ agentId: slot.agent_id,
1039
+ phase: 'findings',
1040
+ task: req.task,
982
1041
  description: reviewDescription,
983
- tags: ['coordinate', 'review', 'loop'],
984
- }, dispatchCwd);
985
- reviewAssignmentId = assignment.id;
986
- out.artifacts.push({ type: 'assignment', id: assignment.id });
1042
+ scope: reviewScope,
1043
+ claimId: claimResult.claimId,
1044
+ worktreePath: claimResult.worktreePath,
1045
+ dispatcherAgent: senderAgent,
1046
+ dispatcherAgentId: senderAgentId,
1047
+ sessionId: connectionSessionId,
1048
+ isReviewer: true,
1049
+ cwd: dispatchCwd,
1050
+ });
1051
+ if (prep.kind === 'won') {
1052
+ reviewAssignmentId = prep.assignmentId; // deterministic — harvest correlates on it
1053
+ reviewTurnEcho = { turn_id: prep.turnId, run_id: prep.runId, nonce: prep.nonce };
1054
+ out.artifacts.push({ type: 'assignment', id: prep.assignmentId });
1055
+ usedTurnOwned = true; // prepare already created the assignment + run + bound the slot
1056
+ }
1057
+ else if (prep.kind === 'denied') {
1058
+ out.partial = true;
1059
+ out.warnings.push(`open_loop: turn-owned reviewer dispatch denied for slot ${slot.slot_id} (${prep.reason}); not spawning — slot left for reconcile/self-heal`);
1060
+ continue; // MUST NOT spawn AND MUST NOT legacy-fallback beside a live reservation
1061
+ }
1062
+ // prep.kind === 'legacy' (pre-identity failure) → fall through to the inline mint.
987
1063
  }
988
- catch (asgErr) {
989
- out.warnings.push(`Review assignment creation failed for slot ${slot.slot_id}: ${asgErr instanceof Error ? asgErr.message : String(asgErr)}`);
1064
+ if (!usedTurnOwned) {
1065
+ try {
1066
+ const preId = generateAssignmentId(dispatchCwd);
1067
+ const assignment = createAssignment({
1068
+ id: preId.id,
1069
+ short_label: preId.short_label,
1070
+ claim_id: claimResult.claimId,
1071
+ agent: slot.agent ?? '',
1072
+ dispatcher_agent: senderAgent,
1073
+ dispatcher_session_id: connectionSessionId,
1074
+ scope: reviewScope,
1075
+ description: reviewDescription,
1076
+ tags: ['coordinate', 'review', 'loop'],
1077
+ }, dispatchCwd);
1078
+ reviewAssignmentId = assignment.id;
1079
+ out.artifacts.push({ type: 'assignment', id: assignment.id });
1080
+ }
1081
+ catch (asgErr) {
1082
+ out.warnings.push(`Review assignment creation failed for slot ${slot.slot_id}: ${asgErr instanceof Error ? asgErr.message : String(asgErr)}`);
1083
+ }
1084
+ // pln#628 Focus 4B (BLOCKING 2) — assign the slot NOW that the
1085
+ // claim/assignment exist, binding their ids onto the slot so the
1086
+ // harvest close resolves this exact reviewer by assignment_id. Runs
1087
+ // even if assignment creation failed (undefined id → the harvest
1088
+ // falls back to the legacy agent match for this one slot). (For the
1089
+ // turn-owned WON path, prepare already turn()-bound the slot.)
1090
+ turn({
1091
+ id: loop.id,
1092
+ slot_id: slot.slot_id,
1093
+ actor: creatorActor,
1094
+ input: req.task,
1095
+ assignment_id: reviewAssignmentId,
1096
+ claim_id: claimResult.claimId,
1097
+ }, dispatchCwd);
990
1098
  }
991
- // pln#628 Focus 4B (BLOCKING 2) — assign the slot NOW that the
992
- // claim/assignment exist, binding their ids onto the slot so the
993
- // harvest close resolves this exact reviewer by assignment_id. Runs
994
- // even if assignment creation failed (undefined id → the harvest
995
- // falls back to the legacy agent match for this one slot).
996
- turn({
997
- id: loop.id,
998
- slot_id: slot.slot_id,
999
- actor: creatorActor,
1000
- input: req.task,
1001
- assignment_id: reviewAssignmentId,
1002
- claim_id: claimResult.claimId,
1003
- }, dispatchCwd);
1004
1099
  const reviewBrief = buildCoordinateBrief(slot.agent ?? '', reviewDescription + reviewVerdictBriefSuffix, {
1005
1100
  claimId: claimResult.claimId,
1006
1101
  scope: reviewScope,
@@ -1045,6 +1140,7 @@ export async function handleBclawCoordinate(args, ctx) {
1045
1140
  entry: queued.entry,
1046
1141
  invoke: queued.invoke,
1047
1142
  worktreePath: claimResult.worktreePath,
1143
+ turnEcho: reviewTurnEcho,
1048
1144
  });
1049
1145
  }
1050
1146
  catch (dispatchErr) {
@@ -1105,6 +1201,16 @@ export async function handleBclawCoordinate(args, ctx) {
1105
1201
  result = {
1106
1202
  candidate_id: output.candidateId,
1107
1203
  selected_targets: resolvedAgents,
1204
+ // pln#521 P1 (B4) — echo the routing decision so an operator can see WHERE
1205
+ // the loop landed without reverse-engineering cwd and store state. Present
1206
+ // for open_loop reviews (the gated path); the reasoning behind the decision
1207
+ // ships as `_resolution_trace` on dispatch_status, not here.
1208
+ ...(projectResolution
1209
+ ? {
1210
+ project_cwd: projectResolution.project_cwd,
1211
+ ...(projectResolution.project_name ? { project_name: projectResolution.project_name } : {}),
1212
+ }
1213
+ : {}),
1108
1214
  // pln#626 Phase 1 (review rework) — expose the reviewer delivery entries
1109
1215
  // so review is as honest as assign/reroute: each entry's execution_reason
1110
1216
  // (set by runCoordinateExecution) feeds the top-level derivation, so a
@@ -1550,12 +1656,6 @@ export async function handleBclawCoordinate(args, ctx) {
1550
1656
  slotRole: slot.role,
1551
1657
  memoryProvider: provider,
1552
1658
  });
1553
- turn({
1554
- id: loopId,
1555
- slot_id: slot.slot_id,
1556
- actor: creatorActor,
1557
- input: briefResult.text,
1558
- }, dispatchCwd);
1559
1659
  // pln#626 Phase 2 (Option B) — spawn the critic as a worktree-isolated
1560
1660
  // worker, mirroring the intent=assign / review chain. Each critic gets
1561
1661
  // its OWN claim + worktree (scope is unique per slot) so parallel
@@ -1605,6 +1705,23 @@ export async function handleBclawCoordinate(args, ctx) {
1605
1705
  catch (asgErr) {
1606
1706
  warnings.push(`ideate assignment creation failed for slot ${slot.slot_id}: ${asgErr instanceof Error ? asgErr.message : String(asgErr)}`);
1607
1707
  }
1708
+ // pln#629 — bind the slot to its claim/assignment NOW that both
1709
+ // exist (mirrors the review path, pln#628 BLOCKING 2). The turn()
1710
+ // used to fire BEFORE the assignment was created, leaving
1711
+ // slot.assignment_id undefined: bclaw_loop get's reconcile then
1712
+ // skipped the critic slot (loops-handlers.ts `if (!assignmentId)
1713
+ // continue`) and dispatch_status(lop_) resolved no assignment, so
1714
+ // ideate loops could never be reconciled (trp_dfe0b941 /
1715
+ // trp_2187b340 / trp_1de94516). Runs even if assignment creation
1716
+ // failed (undefined id → legacy agent-match fallback, as review).
1717
+ turn({
1718
+ id: loopId,
1719
+ slot_id: slot.slot_id,
1720
+ actor: creatorActor,
1721
+ input: briefResult.text,
1722
+ assignment_id: criticAssignmentId,
1723
+ claim_id: claimResult.claimId,
1724
+ }, dispatchCwd);
1608
1725
  // pln#626 Phase 2 — the critique-only contract must reach the
1609
1726
  // DELIVERED brief, not just the claim record: buildCoordinateBrief
1610
1727
  // wraps this in a worker envelope, so prepend the constraint + the
@@ -1806,7 +1923,9 @@ export async function handleBclawLoop(args, ctx) {
1806
1923
  }
1807
1924
  // pln#562 step 4 — dispatching a turn hands work to another agent; gate
1808
1925
  // it at the same trust bar as the other dispatch surfaces.
1809
- if (args?.intent === 'turn' && args?.dispatch === true) {
1926
+ // pln#632 `bind` also SPAWNS real workers (it dispatches the loop's linked
1927
+ // sequence), so it is gated at the same 'trusted' bar as turn-dispatch / coordinate.
1928
+ if ((args?.intent === 'turn' && args?.dispatch === true) || args?.intent === 'bind') {
1810
1929
  const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'trusted', cwd, connectionSessionId);
1811
1930
  if (resolved.error) {
1812
1931
  return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
@@ -1814,7 +1933,7 @@ export async function handleBclawLoop(args, ctx) {
1814
1933
  }
1815
1934
  const { handleBclawLoop: runLoopIntent } = await import('./loops-handlers.js');
1816
1935
  const targetCwd = resolveProjectCwd(args?.project, cwd);
1817
- const result = runLoopIntent({ args: args, cwd: targetCwd });
1936
+ const result = await runLoopIntent({ args: args, cwd: targetCwd, sessionId: connectionSessionId });
1818
1937
  return {
1819
1938
  response: toolResponse({
1820
1939
  content: [{ type: 'text', text: result.summary }],
@@ -27,7 +27,7 @@ import { switchProject } from './switch.js';
27
27
  import { assessBootstrapNeed, resolveEmptyMemoryRecommendation } from '../core/setup-flow.js';
28
28
  import { WorkRequestSchema } from '../core/facade-schema.js';
29
29
  import { codeMapWorkSection, codeMapRefreshNextActions } from '../core/code-map/work-section.js';
30
- import { sweepDeadPidRunningAgentRunsAtRead } from '../core/agentrun-reconciler.js';
30
+ import { sweepDeadPidRunningAgentRunsAtRead, sweepTurnOwnedPreRunLeaseAtRead } from '../core/agentrun-reconciler.js';
31
31
  import { bumpActiveAssignmentHeartbeat } from '../core/assignments.js';
32
32
  import { handleBclawAckMessage, handleBclawCoordinate, handleBclawDispatch, handleBclawLoop, handleBclawSendMessage, } from './mcp-write-coordination.js';
33
33
  import { ensureTrust, resolveMutationIdentity, explicitSessionIdFromEnv, projectInfoForCwd, scopeMetadataForTarget, } from './mcp-write-support.js';
@@ -1556,7 +1556,10 @@ async function _executeMcpToolCallInner(payload) {
1556
1556
  };
1557
1557
  }
1558
1558
  if (name === 'bclaw_coordinate') {
1559
- return await handleBclawCoordinate(args, { cwd, connectionSessionId, currentModel });
1559
+ // pln#521 P1 scopeInfo carries WHICH selector produced `cwd`, which the
1560
+ // review open_loop project gate needs to distinguish a chosen project
1561
+ // from the bare cwd fallback.
1562
+ return await handleBclawCoordinate(args, { cwd, connectionSessionId, currentModel, effectiveScope: scopeInfo });
1560
1563
  }
1561
1564
  if (name === 'bclaw_loop') {
1562
1565
  return await handleBclawLoop(args, { cwd, connectionSessionId });
@@ -1871,6 +1874,12 @@ export async function executeMcpToolCall(payload) {
1871
1874
  sweepDeadPidRunningAgentRunsAtRead(cwd);
1872
1875
  }
1873
1876
  catch { /* best-effort */ }
1877
+ // pln#630 PR2c-lease: converge turn-owned runs still `created`/`launching`
1878
+ // on their dispatch/launch lease (inert until live turn-owned dispatch).
1879
+ try {
1880
+ sweepTurnOwnedPreRunLeaseAtRead(cwd);
1881
+ }
1882
+ catch { /* best-effort */ }
1874
1883
  }
1875
1884
  // ── Delegate to inner handler ───────────────────────────────────────────────
1876
1885
  const outcome = await _executeMcpToolCallInner({
@@ -159,10 +159,15 @@ const PROFILES = {
159
159
  // blocker.
160
160
  codex: {
161
161
  name: 'codex', category: 'code-agent', workflowModel: 'task-based',
162
- hasMcp: true, hasHooks: false, hasAutoApprove: false, hasSkills: true, hasRules: true,
162
+ // hooks: Codex gained a native lifecycle hook surface (SessionStart /
163
+ // UserPromptSubmit / Stop / PreToolUse / … via .codex/hooks.json or [hooks]
164
+ // in config.toml; developers.openai.com/codex/hooks, verified 2026-07 —
165
+ // trp_fe75dafc). brainclaw writes .codex/hooks.json (ensureCodexHooks),
166
+ // giving Codex the same session-lifecycle wiring as Claude Code.
167
+ hasMcp: true, hasHooks: true, hasAutoApprove: false, hasSkills: true, hasRules: true,
163
168
  instructionFile: 'AGENTS.md', sharedInstructionFile: true, mcpConfigScope: 'machine', templateTier: 'A',
164
169
  role_capabilities: ['execute', 'review'],
165
- runtime: { mcp_direct: true, hooks: false, canBeSpawnedCli: true, canSpawnOtherCli: false, inbox: true },
170
+ runtime: { mcp_direct: true, hooks: true, canBeSpawnedCli: true, canSpawnOtherCli: false, inbox: true },
166
171
  max_concurrent_tasks: 5,
167
172
  // pln#475: prefer stdin_pipe to avoid Windows cmd.exe arg-parsing breaking
168
173
  // long prompts. codex.cmd resolves through cmd shell, where embedded
@@ -314,6 +314,7 @@ const ANTIGRAVITY_MCP_RELATIVE_PATH = '.gemini/antigravity/mcp_config.json';
314
314
  const ANTIGRAVITY_HOOKS_RELATIVE_PATH = '.gemini/antigravity/hooks.json';
315
315
  const CURSOR_HOOKS_RELATIVE_PATH = '.cursor/hooks.json';
316
316
  const COPILOT_HOOKS_RELATIVE_PATH = '.github/copilot/hooks.json';
317
+ const CODEX_HOOKS_RELATIVE_PATH = '.codex/hooks.json';
317
318
  const OPENCLAW_MCP_RELATIVE_PATH = '.openclaw/mcp.json';
318
319
  const VSCODE_EXTENSIONS_RELATIVE_PATH = '.vscode/extensions.json';
319
320
  const UNIVERSAL_SKILL_RELATIVE_PATH = '.agents/skills/brainclaw/SKILL.md';
@@ -1686,7 +1687,10 @@ export function ensureCodexMcpConfig(homeDir, env = process.env) {
1686
1687
  '\n[mcp_servers.brainclaw]',
1687
1688
  `command = "${normalizedCommand}"`,
1688
1689
  `args = [${normalizedArgs.map(a => `"${a}"`).join(', ')}]`,
1689
- 'startup_timeout_ms = 20000',
1690
+ // Codex renamed this field to `_sec` (developers.openai.com/codex/extend/mcp;
1691
+ // the docs note "uses _sec, not _ms"). The old `startup_timeout_ms` is an
1692
+ // unrecognized key → Codex silently falls back to its default startup timeout.
1693
+ 'startup_timeout_sec = 20',
1690
1694
  '',
1691
1695
  '[mcp_servers.brainclaw.env]',
1692
1696
  'BRAINCLAW_AGENT = "codex"',
@@ -2115,6 +2119,53 @@ export function ensureAntigravityHooks(homeDir) {
2115
2119
  relativePath: ANTIGRAVITY_HOOKS_RELATIVE_PATH,
2116
2120
  };
2117
2121
  }
2122
+ /**
2123
+ * Writes `.codex/hooks.json` — Codex CLI's native lifecycle hooks config
2124
+ * (project scope). Codex gained a full hook surface (developers.openai.com/codex/hooks,
2125
+ * verified 2026-07 — trp_fe75dafc); this wires brainclaw's session lifecycle to it,
2126
+ * mirroring the Claude Code / Antigravity hook writers.
2127
+ *
2128
+ * Events (PascalCase, per the Codex schema): `SessionStart` loads shared context,
2129
+ * `UserPromptSubmit` surfaces the context diff, `Stop` runs session-end cleanup.
2130
+ * File shape: `{ "hooks": { "<Event>": [ { "matcher": "", "hooks": [ { "type": "command", "command": "…" } ] } ] } }`
2131
+ * — the top-level `hooks` wrapper + per-entry `hooks` array (`matcher: ""` = match all).
2132
+ *
2133
+ * brainclaw OWNS these three event arrays (overwrite, not merge) — the same
2134
+ * contract as the Cursor / Antigravity `hooks.json` writers. This is
2135
+ * unconditionally idempotent regardless of how the CLI path resolves, with no
2136
+ * cross-upgrade pile-up. It deliberately does NOT use command-recognition to
2137
+ * preserve user entries within these events: recognizing brainclaw's own hook
2138
+ * path-independently requires matching bare CLI subcommands, which over-matches
2139
+ * legitimate user hooks that merely pass `session-start`/etc. as an argument
2140
+ * (Codex review of #94, round 2). Other events the user defines are untouched
2141
+ * (only these three keys are set).
2142
+ */
2143
+ export function ensureCodexHooks(cwd) {
2144
+ const filePath = path.join(cwd, CODEX_HOOKS_RELATIVE_PATH);
2145
+ const existing = readJsonObject(filePath);
2146
+ if (existing === undefined) {
2147
+ return skippedAutoConfigResult('rule', 'Codex session hooks', filePath, CODEX_HOOKS_RELATIVE_PATH);
2148
+ }
2149
+ const hooks = isJsonObject(existing.hooks) ? { ...existing.hooks } : {};
2150
+ const sessionStartCmd = buildHookCommand(['session-start', '--include-context']);
2151
+ const contextDiffCmd = buildHookCommand(['context-diff']);
2152
+ const sessionEndCmd = buildHookCommand(['session-end', '--auto-release', '--reflect', '--reflect-handoff', '--dispatch-review']);
2153
+ hooks.SessionStart = [buildCommandHookEntry(sessionStartCmd)];
2154
+ hooks.UserPromptSubmit = [buildCommandHookEntry(contextDiffCmd)];
2155
+ hooks.Stop = [buildCommandHookEntry(sessionEndCmd)];
2156
+ const { created, updated } = writeJsonFileIfChanged(filePath, {
2157
+ ...existing,
2158
+ hooks,
2159
+ });
2160
+ return {
2161
+ kind: 'rule',
2162
+ label: 'Codex session hooks',
2163
+ created,
2164
+ updated,
2165
+ filePath,
2166
+ relativePath: CODEX_HOOKS_RELATIVE_PATH,
2167
+ };
2168
+ }
2118
2169
  /**
2119
2170
  * Writes `.github/copilot/hooks.json` — GitHub Copilot's native hooks config.
2120
2171
  * Events: sessionStart, userPromptSubmitted, sessionEnd (camelCase).
@@ -2284,7 +2335,7 @@ export const AGENT_WIRING_REGISTRY = {
2284
2335
  writeProtocolSkills,
2285
2336
  ],
2286
2337
  userWriters: [(ctx) => ensureCodexMcpConfig(ctx.homeDir, ctx.env)],
2287
- hookWriters: [],
2338
+ hookWriters: [(ctx) => ensureCodexHooks(ctx.cwd)],
2288
2339
  },
2289
2340
  continue: {
2290
2341
  workspaceWriters: [(ctx) => ensureContinueMcpConfig(ctx.cwd)],
@@ -61,6 +61,7 @@ const DEFAULT_SURFACES = {
61
61
  'codex': [
62
62
  { kind: 'instructions', location: 'workspace', path: 'AGENTS.md' },
63
63
  { kind: 'mcp', location: 'machine', path: '.codex/config.toml' },
64
+ { kind: 'hook', location: 'workspace', path: '.codex/hooks.json' },
64
65
  { kind: 'skill', location: 'workspace', path: '.agents/skills/brainclaw/SKILL.md' },
65
66
  ],
66
67
  'opencode': [