brainclaw 1.16.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.
package/README.md CHANGED
@@ -103,7 +103,7 @@ brainclaw is designed to sit alongside the coding agents teams are already using
103
103
  | Logo | Agent | Tier | What brainclaw configures |
104
104
  | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ---- | ----------------------------------------------------------------------- |
105
105
  | [![Claude Code](https://img.shields.io/badge/Claude_Code-111111?logo=anthropic&logoColor=white)](https://github.com/anthropics/claude-code) | **[Claude Code](https://github.com/anthropics/claude-code)** | A | MCP + CLAUDE.md + hooks + auto-approve + permissions + /brainclaw skill |
106
- | [![Codex](https://img.shields.io/badge/Codex-111111?logo=openai&logoColor=white)](https://openai.com/codex/) | **[Codex](https://openai.com/codex/)** | A | MCP + AGENTS.md + skills |
106
+ | [![Codex](https://img.shields.io/badge/Codex-111111?logo=openai&logoColor=white)](https://openai.com/codex/) | **[Codex](https://openai.com/codex/)** | A | MCP + AGENTS.md + hooks + skills |
107
107
  | [![Cursor](https://img.shields.io/badge/Cursor-1F2430?logo=cursor&logoColor=white)](https://cursor.com/en-US) | **[Cursor](https://cursor.com/en-US)** | A | MCP (machine) + .cursor/rules/ + hooks + skills |
108
108
  | [![Windsurf](https://img.shields.io/badge/Windsurf-0B1220?logo=codeium&logoColor=white)](https://windsurf.com/) | **[Windsurf](https://windsurf.com/)** | A | MCP (machine) + .windsurfrules + .windsurf/rules/ |
109
109
  | [![Cline](https://img.shields.io/badge/Cline-0F766E?logoColor=white)](https://github.com/cline/cline) | **[Cline](https://github.com/cline/cline)** | A | MCP + auto-approve + .clinerules/ |
@@ -319,7 +319,7 @@ Still sharp:
319
319
 
320
320
  1. **Same-checkout concurrent edits** — running two agents in the *same* working tree (no per-claim worktree) is still the wrong answer. Use the dispatch path (auto-worktree per claim) instead of raw concurrent CLI sessions.
321
321
  2. **Cross-machine sync** — federation across machines is on the roadmap, not in v1.x. Today brainclaw's store is local and one-machine-per-project.
322
- 3. **Next.js / Turbopack dev server in a worktree** — the provisioned `node_modules` symlink points outside the worktree root, which `next dev` (Turbopack) rejects (build/tsc/vitest are fine). brainclaw warns; `npm install` in the worktree or smoke-test on the merged branch. A Turbopack-compatible dependency mode is a planned follow-up.
322
+ 3. **Next.js / Turbopack dev server in a worktree** — in the default `link` mode the provisioned `node_modules` symlink points outside the worktree root, which `next dev` (Turbopack) rejects (build/tsc/vitest are fine); brainclaw warns. Fix: opt into a Turbopack-compatible **per-worktree dependency mode** — set `worktree.deps_mode: install` (or `copy`) in config, or `BRAINCLAW_WORKTREE_DEPS_MODE=install`, so the worktree gets a real in-root `node_modules` (see [Multi-stack worktree](#multi-stack-worktree)).
323
323
  3. **Spawn-and-forget assumptions** — spawned workers don't always commit their work cleanly. The brief-ack file confirms the spawn started; in the worst case the coordinator harvests open changes.
324
324
  4. **Live state for hook-less agents** — supported hook-less file surfaces such as Cline, Windsurf, Continue, Antigravity/Gemini CLI, and Mistral Vibe can get live context via `.live.md` companions regenerated on session-end and handoff, not via real-time push.
325
325
 
@@ -349,12 +349,26 @@ Maven, Gradle, and Cargo are intentionally excluded — their dependency caches
349
349
 
350
350
  Build outputs like `dist` are **not** symlinked — they must be per-worktree to avoid EBUSY errors when other processes hold handles on the output directory.
351
351
 
352
- > **Next.js / Turbopack caveat.** The `node_modules` link is a symlink to the main worktree, i.e. it points **outside** the agent worktree's root. `tsc`, `vitest`, and production `build` follow it fine, but `next dev` (Turbopack) panics on a `node_modules` link outside the worktree root. brainclaw detects Next.js projects and surfaces a `symlink_warnings` note at worktree creation. Workaround for dev-server work: run `npm install` inside the worktree (optionally with `BRAINCLAW_NO_LINK_DEPS=1`), or smoke-test on the merged branch. A Turbopack-compatible per-worktree dependency mode is a planned follow-up.
352
+ ### Dependency provisioning mode (`deps_mode`)
353
+
354
+ How a worktree gets its JS `node_modules` is controlled by `deps_mode` (default `link`):
355
+
356
+ | Mode | How `node_modules` is provisioned | Turbopack / `next dev` | Cost |
357
+ | --------- | ------------------------------------------------------------------------------- | ---------------------- | ---- |
358
+ | `link` | junction/symlink to the main tree (out-of-root) | ❌ rejected | instant, zero disk |
359
+ | `install` | runs the detected package manager's install at the worktree root (real in-root) | ✅ works | slower, may hit network/cache |
360
+ | `copy` | recursively copies `node_modules` from the main tree (real in-root) | ✅ works | disk-heavy, offline |
361
+ | `none` | provisions nothing (central validation / manual install) | n/a | instant |
362
+
363
+ > **Next.js / Turbopack caveat.** In the default `link` mode the `node_modules` link points **outside** the worktree root. `tsc`, `vitest`, and production `build` follow it fine, but `next dev` (Turbopack) panics on it. brainclaw detects Next.js projects and surfaces a `symlink_warnings` note at worktree creation. For dev-server work, switch to `deps_mode: install` (or `copy`) — the worktree then gets a real in-root `node_modules` that Turbopack accepts. The package manager is auto-detected from the lockfile (`pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, `bun.lock*` → bun, else npm) or the `packageManager` field.
364
+
365
+ Precedence: `BRAINCLAW_WORKTREE_DEPS_MODE` (env) > `BRAINCLAW_NO_LINK_DEPS=1` (→ `none`, backward compat) > `worktree.deps_mode` (config) > `link`. Install timeout: `BRAINCLAW_WORKTREE_INSTALL_TIMEOUT_MS` (default 10 min).
353
366
 
354
367
  Override detection in `.brainclaw/config.yaml`:
355
368
 
356
369
  ```yaml
357
370
  worktree:
371
+ deps_mode: install # link (default) | install | copy | none
358
372
  shared_paths: [".cache"] # additive to auto-detected
359
373
  exclude_shared: ["node_modules"] # opt-out a detected entry
360
374
  ```
Binary file
@@ -103,9 +103,9 @@ export function registerReviewCommands(program) {
103
103
  .option('--dry-run', 'Preview without writing events/markers')
104
104
  .option('--worktree <path>', 'Explicit worktree path to scan (repeatable)', collect, [])
105
105
  .option('--json', 'Output as JSON')
106
- .action((assignmentId, options) => {
106
+ .action(async (assignmentId, options) => {
107
107
  const globalOpts = program.opts();
108
- runHarvestLane(assignmentId, { ...options, cwd: globalOpts.cwd });
108
+ await runHarvestLane(assignmentId, { ...options, cwd: globalOpts.cwd });
109
109
  });
110
110
  // --- prune-candidates ---
111
111
  program
@@ -23,6 +23,7 @@ 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
27
  /**
27
28
  * Auto-detect all worktree directories under the brainclaw-managed base dir.
28
29
  * Returns subdirectories that exist on disk (may or may not have an inbox).
@@ -278,10 +279,13 @@ export function harvestLaneResults(options = {}) {
278
279
  // (a terminal loop is a no-op; a stuck approve is resumed), so firing it here
279
280
  // AND in integrateLaneResults is safe — and it runs BEFORE the harvested
280
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.
281
285
  try {
282
286
  const laneAssignment = loadAssignment(lane.assignment_id, cwd);
283
287
  if (laneAssignment)
284
- closeReviewLoopFromLaneResult(laneAssignment, lane, agent, cwd);
288
+ closeReviewLoopFromLaneResult(laneAssignment, lane, agent, cwd, { cycleOnRequestChanges: false });
285
289
  }
286
290
  catch { /* never block harvest on loop-close */ }
287
291
  const marker = laneHarvestedMarkerPath(cwd, lane.assignment_id);
@@ -391,7 +395,7 @@ function forceCompleteAssignment(assignmentId, artifacts, statusReason, actor, c
391
395
  export function integrateLaneResults(options = {}) {
392
396
  const cwd = options.cwd ?? process.cwd();
393
397
  const actor = options.agent ?? 'coordinator';
394
- const result = { integrated: [], skipped: [], errors: [] };
398
+ const result = { integrated: [], skipped: [], errors: [], next_turns: [] };
395
399
  const worktreePaths = resolveLaneScanPaths(options, cwd);
396
400
  for (const worktreePath of worktreePaths) {
397
401
  const file = getLaneResultPath(worktreePath);
@@ -459,26 +463,55 @@ export function integrateLaneResults(options = {}) {
459
463
  ...entry.files_changed.slice(0, 50).map((f) => ({ type: 'file', ref: f })),
460
464
  ];
461
465
  entry.assignment_completed = forceCompleteAssignment(lane.assignment_id, artifacts, `pln#534 on-behalf integration: ${lane.summary.slice(0, 120)}`, actor, cwd);
462
- // trp#928use the cascade helper (was releaseClaimWithCascade same
463
- // logic for the last-claim rule but the cascade wrapper LOGS per-claim,
464
- // so a silent ownership failure is observable in the runtime event log
465
- // rather than only in this in-memory `reasons` string).
466
- const cascade = releaseClaimsCascade([assignment.claim_id], { cwd, planStatus: 'done' });
467
- logCascadeReleaseResult({ actor, trigger: 'harvest_integrate', assignment_id: lane.assignment_id, claim_id: assignment.claim_id, cascade, cwd });
468
- const claimEntry = cascade.entries[0];
469
- entry.claim_released = claimEntry?.released === true;
470
- if (claimEntry && !claimEntry.released) {
471
- reasons.push(`claim release ${claimEntry.reason}${claimEntry.error ? `: ${claimEntry.error}` : ''}`);
472
- }
473
- // pln#628 Focus 4B — if this lane is a review-loop turn carrying a
474
- // verdict, map it onto the loop: record the verdict artifact + advance,
475
- // which auto-closes the loop on reviewer_green (approve) without a human
476
- // driving complete_turn/advance by hand. No-op for non-review lanes or
477
- // lanes without a verdict; never throws (harvest is not blocked on it).
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.
478
472
  const loopClose = closeReviewLoopFromLaneResult(assignment, lane, actor, cwd);
479
473
  if (loopClose) {
480
474
  entry.review_loop = loopClose;
481
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
+ }
482
515
  }
483
516
  }
484
517
  else {
@@ -697,7 +730,7 @@ export function harvestOrphaned(options) {
697
730
  }
698
731
  return report;
699
732
  }
700
- export function runHarvestLane(assignmentId, options = {}) {
733
+ export async function runHarvestLane(assignmentId, options = {}) {
701
734
  const cwd = options.cwd ?? process.cwd();
702
735
  if (!memoryExists(cwd)) {
703
736
  console.error('Error: .brainclaw/ not found. Run `brainclaw init` first.');
@@ -759,8 +792,30 @@ export function runHarvestLane(assignmentId, options = {}) {
759
792
  dryRun: options.dryRun,
760
793
  cwd,
761
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
+ }
762
817
  if (options.json) {
763
- console.log(JSON.stringify(integ, null, 2));
818
+ console.log(JSON.stringify({ ...integ, dispatched_turns: dispatchedTurns }, null, 2));
764
819
  return;
765
820
  }
766
821
  const dry = options.dryRun ? ' (dry-run)' : '';
@@ -787,7 +842,14 @@ export function runHarvestLane(assignmentId, options = {}) {
787
842
  }
788
843
  for (const err of integ.errors)
789
844
  console.error(` ✗ ${err}`);
790
- 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).`);
791
853
  return;
792
854
  }
793
855
  const result = harvestLaneResults({
@@ -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': [
@@ -279,13 +279,39 @@ export function buildProtocolSection(options) {
279
279
  }
280
280
  if (options?.worktreePath) {
281
281
  parts.push(`Worktree: ${options.worktreePath}`);
282
- // pln#523: tell the worker how dependencies are provisioned so it does not
283
- // stall trying to install them. node_modules (and per-package node_modules in
284
- // monorepos) are junction-linked from the main repo — run builds/typecheck
285
- // directly. If they are missing, do NOT `npm install` in the worktree: check
286
- // `.brainclaw-worktree.json` `symlink_warnings` (a link may have failed,
287
- // e.g. cross-volume) and validate the build centrally with the coordinator.
288
- parts.push('Dependencies: node_modules is linked from the main repo (incl. monorepo per-package). Build/typecheck directly; if deps are missing, do NOT npm install here — see .brainclaw-worktree.json symlink_warnings and validate centrally.');
282
+ // pln#523 / trp_37b05a15: tell the worker how dependencies are provisioned so
283
+ // it does not stall trying to (re)install them. The authoritative record is
284
+ // the worktree's `.brainclaw-worktree.json` `deps_mode` (absent `link`).
285
+ // - link (default): node_modules (incl. monorepo per-package) is
286
+ // junction-linked from the main repo build/typecheck directly; do NOT
287
+ // `npm install`. An out-of-root symlink, so `next dev`/Turbopack rejects
288
+ // it (build/tsc/vitest are fine).
289
+ // - install/copy: node_modules is a REAL in-root directory — everything,
290
+ // including a dev server, works directly; no reinstall needed.
291
+ // - none: no deps provisioned — run the project's install first.
292
+ let depsMode = 'link';
293
+ let depsProvisioned;
294
+ try {
295
+ const sidecar = JSON.parse(fs.readFileSync(path.join(options.worktreePath, '.brainclaw-worktree.json'), 'utf-8'));
296
+ if (sidecar.deps_mode)
297
+ depsMode = sidecar.deps_mode;
298
+ depsProvisioned = sidecar.deps_provisioned;
299
+ }
300
+ catch { /* sidecar absent/unreadable — assume the default `link` */ }
301
+ if ((depsMode === 'install' || depsMode === 'copy') && depsProvisioned === false) {
302
+ // Codex review P1: provisioning was ATTEMPTED but FAILED (best-effort, non-fatal).
303
+ // Do not claim node_modules is usable — tell the worker to install it.
304
+ parts.push(`Dependencies: in-root provisioning was attempted (deps_mode=${depsMode}) but FAILED — node_modules may be missing or incomplete. Run the project's install (npm/pnpm/yarn/bun) in the worktree before building; see .brainclaw-worktree.json symlink_warnings for the failure.`);
305
+ }
306
+ else if (depsMode === 'install' || depsMode === 'copy') {
307
+ parts.push(`Dependencies: node_modules is a real in-root directory (deps_mode=${depsMode}) — build, typecheck, and dev server all work directly; do NOT reinstall. If anything is missing, see .brainclaw-worktree.json symlink_warnings.`);
308
+ }
309
+ else if (depsMode === 'none') {
310
+ parts.push('Dependencies: none were provisioned (deps_mode=none) — run the project\'s install (npm/pnpm/yarn/bun) in the worktree before building.');
311
+ }
312
+ else {
313
+ parts.push('Dependencies: node_modules is linked from the main repo (incl. monorepo per-package). Build/typecheck directly; if deps are missing, do NOT npm install here — see .brainclaw-worktree.json symlink_warnings and validate centrally. (Out-of-root symlink: next dev/Turbopack needs deps_mode=install.)');
314
+ }
289
315
  }
290
316
  parts.push('');
291
317
  // Assignment lifecycle protocol (Agent SDK)
@@ -4,6 +4,15 @@ import { withLoopLock } from './loops/lock.js';
4
4
  /** review-loop:lop_xxx → the loop id (mirrors assignment-reconciler.ts). */
5
5
  const REVIEW_LOOP_SCOPE_RE = /^review-loop:(lop_[0-9a-z]+)/;
6
6
  const LOOP_TERMINAL = new Set(['completed', 'cancelled', 'blocked']);
7
+ /** Build the fix+re-review brief for a request_changes cycle turn (symmetric). */
8
+ function buildFixCycleTask(summary, iteration) {
9
+ return (`The reviewer requested changes (fix cycle round ${iteration}). `
10
+ + 'Apply the requested changes DIRECTLY in this worktree (it is the same '
11
+ + 'checkout, kept across turns so your commits accumulate), then RE-REVIEW '
12
+ + 'the result. Set review_verdict="approve" once the change is correct and '
13
+ + 'complete, or "request_changes" to take another pass.'
14
+ + (summary ? `\n\nRequested changes: ${summary}` : ''));
15
+ }
7
16
  /** Mirrors verbs.ts:isVerdictAccepted — reviewer_green fires only on a `verdict`
8
17
  * artifact whose body starts with "accepted". */
9
18
  function isAcceptedVerdict(artifact) {
@@ -38,22 +47,13 @@ function resolveReviewerSlot(loop, assignment) {
38
47
  const byAgent = assignment.agent ? active.find((s) => s.agent === assignment.agent) : undefined;
39
48
  return byAgent ?? active[0];
40
49
  }
41
- /**
42
- * Map a harvested review lane onto its loop and close/advance it.
43
- *
44
- * Fires ONLY when the assignment scope is a review-loop (`review-loop:lop_…`)
45
- * AND the lane carries a `review_verdict` — otherwise returns undefined and the
46
- * caller (harvest) proceeds unchanged. Idempotent, convergent, and defensive:
47
- * a terminal loop is a no-op, a partial prior pass is resumed, and any
48
- * loop-verb / lock error is swallowed into a `noop` result so a loop-close
49
- * failure never breaks harvest (mirrors convergeSlotAssignmentsForClosedLoop).
50
- */
51
- export function closeReviewLoopFromLaneResult(assignment, lane, actor, cwd) {
50
+ export function closeReviewLoopFromLaneResult(assignment, lane, actor, cwd, options) {
52
51
  const scopeMatch = assignment.scope?.match(REVIEW_LOOP_SCOPE_RE);
53
52
  if (!scopeMatch)
54
53
  return undefined;
55
54
  if (!lane.review_verdict)
56
55
  return undefined;
56
+ const cycleOnRequestChanges = options?.cycleOnRequestChanges ?? true;
57
57
  const loopId = scopeMatch[1];
58
58
  const verdict = lane.review_verdict;
59
59
  const noop = (reason, loop_status) => ({
@@ -75,35 +75,104 @@ export function closeReviewLoopFromLaneResult(assignment, lane, actor, cwd) {
75
75
  return noop(`loop already ${loop.status}`, loop.status);
76
76
  const slot = resolveReviewerSlot(loop, assignment);
77
77
  const acceptedVerdictExists = loop.artifacts.some(isAcceptedVerdict);
78
- if (slot) {
79
- // Active reviewer slot record the verdict on it. isVerdictAccepted
80
- // fires reviewer_green ONLY on an "accepted…" body, so approve MUST
81
- // start with "accepted" and request_changes must NOT.
82
- const summary = (lane.review_summary ?? '').trim();
83
- const body = verdict === 'approve'
84
- ? `accepted${summary ? `: ${summary}` : ''}`
85
- : `changes-requested${summary ? `: ${summary}` : ''}`;
86
- complete_turn({ id: loopId, slot_id: slot.slot_id, actor, artifact: { phase: loop.current_phase, type: 'verdict', body } }, cwd);
78
+ const summary = (lane.review_summary ?? '').trim();
79
+ // ── approveclose on reviewer_green ───────────────────────────────
80
+ if (verdict === 'approve') {
81
+ if (slot) {
82
+ // isVerdictAccepted fires reviewer_green ONLY on an "accepted…" body.
83
+ complete_turn({
84
+ id: loopId, slot_id: slot.slot_id, actor,
85
+ artifact: { phase: loop.current_phase, type: 'verdict', body: `accepted${summary ? `: ${summary}` : ''}` },
86
+ }, cwd);
87
+ }
88
+ else if (!acceptedVerdictExists) {
89
+ // No slot to complete and no accepted verdict recorded → a prior pass
90
+ // already processed this (idempotent no-op).
91
+ return noop('already processed (no active reviewer slot; no accepted verdict to resume)', loop.status);
92
+ }
93
+ // Advance: closes on reviewer_green. Convergent — safe whether we just
94
+ // recorded the verdict or are resuming an interrupted approve.
95
+ const advanced = advance({ id: loopId, actor }, cwd);
96
+ return {
97
+ loop_id: loopId,
98
+ verdict,
99
+ action: advanced.auto_closed ? 'closed' : 'advanced',
100
+ reason: advanced.auto_closed
101
+ ? `reviewer_green → loop ${advanced.loop.status}`
102
+ : `accepted verdict recorded → advanced to "${advanced.loop.current_phase}"`,
103
+ loop_status: advanced.loop.status,
104
+ };
105
+ }
106
+ // ── request_changes → autonomous fix cycle (PR2) ────────────────────
107
+ if (!slot) {
108
+ // The cycle already advanced + re-dispatched on the first pass (the
109
+ // re-dispatched slot is now bound to a NEWER assignment, so
110
+ // resolveReviewerSlot returned undefined here → idempotent no-op).
111
+ return noop('already processed (no active reviewer slot to cycle)', loop.status);
112
+ }
113
+ if (!cycleOnRequestChanges) {
114
+ // Report-only path: never advance a cycle it can't follow through on
115
+ // (no re-dispatch, no claim retention). Defer to `harvest --integrate`.
116
+ return noop('request_changes deferred to --integrate (report path does not cycle)', loop.status);
117
+ }
118
+ // Codex review P1 — the autonomous fix cycle is SYMMETRIC-only in v1: it
119
+ // asks the SAME reviewer slot to modify AND re-review in the reused
120
+ // worktree, which is only sound when both roles are the same coding agent
121
+ // (mode='symmetric'). Review loops DEFAULT to asymmetric, where the
122
+ // reviewer must NOT self-fix. For asymmetric, fall back to the PR1
123
+ // behavior: record the verdict, advance linearly to `author_response`,
124
+ // and DO NOT keep the claim / emit a next_turn — the author-fix dispatch
125
+ // is a planned follow-up, so a human drives it. (No re-dispatch means no
126
+ // worktree reuse, so the claim is released by harvest as usual.)
127
+ const symmetric = loop.protocol?.review_mode === 'symmetric';
128
+ complete_turn({
129
+ id: loopId, slot_id: slot.slot_id, actor,
130
+ artifact: { phase: loop.current_phase, type: 'verdict', body: `changes-requested${summary ? `: ${summary}` : ''}` },
131
+ }, cwd);
132
+ if (!symmetric) {
133
+ const advancedAsym = advance({ id: loopId, actor }, cwd);
134
+ return {
135
+ loop_id: loopId,
136
+ verdict,
137
+ action: advancedAsym.auto_closed ? 'closed' : 'advanced',
138
+ reason: advancedAsym.auto_closed
139
+ ? `request_changes → loop ${advancedAsym.loop.status}`
140
+ : `request_changes (asymmetric) → advanced to "${advancedAsym.loop.current_phase}"; author-fix dispatch is a follow-up (drive manually)`,
141
+ loop_status: advancedAsym.loop.status,
142
+ };
87
143
  }
88
- else if (!(verdict === 'approve' && acceptedVerdictExists)) {
89
- // No reviewer slot is ours to complete. Resume ONLY the approveclose
90
- // case: a prior pass recorded an accepted verdict but died before
91
- // advancing. For request_changes (or no accepted verdict), the single
92
- // advance already happened on the first pass do not re-advance.
93
- return noop('already processed (no active reviewer slot to (re)advance)', loop.status);
144
+ // Symmetric: bump the round counter by advancing to the SAME phase
145
+ // (advance treats to_phase <= current as a backward iteration
146
+ // iteration_count += 1). The post-advance stop_condition (max_iterations
147
+ // n=3) auto-closes the loop as `blocked` once the cap is hit; otherwise
148
+ // the loop stays open and we hand harvest a `next_turn` to re-dispatch
149
+ // into the SAME (kept) worktree so fixes accumulate on one branch.
150
+ const advanced = advance({ id: loopId, to_phase: loop.current_phase, actor }, cwd);
151
+ if (advanced.auto_closed) {
152
+ return {
153
+ loop_id: loopId,
154
+ verdict,
155
+ action: 'closed',
156
+ reason: `request_changes hit iteration cap → loop ${advanced.loop.status} (needs human)`,
157
+ loop_status: advanced.loop.status,
158
+ };
94
159
  }
95
- // Advance: closes on reviewer_green (approve), else moves one phase.
96
- // Convergent — safe whether we just recorded the verdict or are resuming
97
- // an interrupted approve.
98
- const advanced = advance({ id: loopId, actor }, cwd);
99
160
  return {
100
161
  loop_id: loopId,
101
162
  verdict,
102
- action: advanced.auto_closed ? 'closed' : 'advanced',
103
- reason: advanced.auto_closed
104
- ? `reviewer_green → loop ${advanced.loop.status}`
105
- : `verdict recorded → advanced to phase "${advanced.loop.current_phase}" (awaiting fix cycle — PR2)`,
163
+ action: 'advanced',
164
+ reason: `request_changes (round ${advanced.loop.iteration_count}) → re-dispatch same reviewer into kept worktree`,
106
165
  loop_status: advanced.loop.status,
166
+ keep_claim: true,
167
+ next_turn: {
168
+ slot_id: slot.slot_id,
169
+ role: slot.role,
170
+ agent: slot.agent ?? '',
171
+ agent_id: slot.agent_id,
172
+ phase: advanced.loop.current_phase,
173
+ iteration: advanced.loop.iteration_count,
174
+ task: buildFixCycleTask(summary, advanced.loop.iteration_count),
175
+ },
107
176
  };
108
177
  },
109
178
  });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * pln#628 Focus 4B PR2 — reusable "dispatch one review-loop turn".
3
+ *
4
+ * PR1 wired the harvest→loop direction (a reviewer verdict advances/closes the
5
+ * loop). PR2 makes the request_changes→fix→re-review cycle autonomous, which
6
+ * means the harvest close path must be able to SPAWN the next turn's worker
7
+ * (the author to apply fixes, then the reviewer to re-review). The full spawn
8
+ * chain — coordinator claim + assignment + slot binding + brief + queued inbox
9
+ * message + CLI spawn — used to live only as closures inside the
10
+ * bclaw_coordinate review handler (mcp-write-coordination.ts). This module
11
+ * lifts that chain into a standalone, closure-free function the harvest path
12
+ * can call.
13
+ *
14
+ * Layering (mirrors review-loop-close.ts's cycle-avoidance note): this is a
15
+ * core module that imports the heavy dispatch primitives (execution, dispatcher,
16
+ * claims, messaging, assignments). review-loop-close.ts stays PURE (loops +
17
+ * schema only) and merely RETURNS a `NextTurn` descriptor; harvest.ts is the
18
+ * command-level orchestrator that owns both and calls this to spawn. Nothing
19
+ * here imports harvest or review-loop-close, so no import cycle is introduced.
20
+ */
21
+ import { createCoordinatorClaim, attachAssignmentMessageToClaim, linkClaimToAssignment } from './claims.js';
22
+ import { createAssignment, transitionAssignment, generateAssignmentId, patchAssignmentMessageId } from './assignments.js';
23
+ import { turn } from './loops/verbs.js';
24
+ import { generateDispatchBrief } from './dispatcher.js';
25
+ import { sendMessage } from './messaging.js';
26
+ import { buildInvokeCommand, resolveModel } from './agent-capability.js';
27
+ import { attemptExecution } from './execution.js';
28
+ /**
29
+ * The structured signal a reviewer must emit in LANE-RESULT.json so harvest can
30
+ * map its lane back onto the loop. Shared by the initial dispatch and every
31
+ * re-review turn (identical wording keeps the reviewer contract stable).
32
+ */
33
+ export const REVIEW_VERDICT_BRIEF_SUFFIX = '\n\n## Review verdict (required — drives autonomous loop convergence)\n'
34
+ + 'In your LANE-RESULT.json set "status":"completed" AND add "review_verdict": '
35
+ + '"approve" (change is good to merge) or "request_changes" (needs fixes), plus '
36
+ + '"review_summary":"<one-line rationale>". The coordinator reads review_verdict '
37
+ + 'to close the review loop on approve, or continue the fix cycle on request_changes.';
38
+ /**
39
+ * Dispatch a single review-loop turn: create the coordinator claim + assignment,
40
+ * bind the slot to them (so harvest resolves the exact slot by assignment_id),
41
+ * build + queue the brief, and spawn the worker CLI.
42
+ *
43
+ * Best-effort and non-throwing: any failure is returned in `.error` so the
44
+ * caller (harvest) can record it as a warning without aborting the harvest —
45
+ * the loop simply stays open awaiting a manual turn. Mirrors the resilience of
46
+ * the initial reviewer dispatch (which pushes a warning and leaves the loop open
47
+ * on failure) rather than the fail-fast style of a user-facing command.
48
+ */
49
+ export async function dispatchReviewLoopTurn(input) {
50
+ const { loopId, slot, phase } = input;
51
+ // createCoordinatorClaim / sendMessage require a concrete cwd; the harvest
52
+ // caller always supplies one — default defensively for direct callers/tests.
53
+ const cwd = input.cwd ?? process.cwd();
54
+ const agent = slot.agent ?? '';
55
+ const scope = `review-loop:${loopId}`;
56
+ const isReviewer = slot.role === 'reviewer';
57
+ const result = {
58
+ loop_id: loopId,
59
+ slot_id: slot.slot_id,
60
+ role: slot.role,
61
+ agent,
62
+ phase,
63
+ };
64
+ try {
65
+ const description = `Review loop turn for ${loopId} slot ${slot.slot_id} phase ${phase}. ${input.task}`;
66
+ const claimResult = createCoordinatorClaim({
67
+ agent,
68
+ scope,
69
+ description,
70
+ dispatcherAgent: input.dispatcherAgent,
71
+ sessionId: input.sessionId,
72
+ cwd,
73
+ worktreeBaseRef: input.worktreeBaseRef,
74
+ });
75
+ result.claim_id = claimResult.claimId;
76
+ result.worktree_path = claimResult.worktreePath;
77
+ let assignmentId;
78
+ try {
79
+ const preId = generateAssignmentId(cwd);
80
+ const assignment = createAssignment({
81
+ id: preId.id,
82
+ short_label: preId.short_label,
83
+ claim_id: claimResult.claimId,
84
+ agent,
85
+ dispatcher_agent: input.dispatcherAgent,
86
+ dispatcher_session_id: input.sessionId,
87
+ scope,
88
+ description,
89
+ tags: ['coordinate', 'review', 'loop', isReviewer ? 're-review' : 'author-fix'],
90
+ }, cwd);
91
+ assignmentId = assignment.id;
92
+ result.assignment_id = assignment.id;
93
+ }
94
+ catch (asgErr) {
95
+ result.error = `assignment creation failed: ${asgErr instanceof Error ? asgErr.message : String(asgErr)}`;
96
+ }
97
+ // Bind the slot to the new claim/assignment (PR1 BLOCKING 2 invariant): a
98
+ // later harvest must resolve THIS slot by assignment_id, not by agent name
99
+ // (which is ambiguous under symmetric multi-reviewer loops). Runs even if
100
+ // assignment creation failed (undefined id → legacy agent-match fallback).
101
+ turn({
102
+ id: loopId,
103
+ slot_id: slot.slot_id,
104
+ actor: input.dispatcherAgentId ?? input.dispatcherAgent,
105
+ input: input.task,
106
+ assignment_id: assignmentId,
107
+ claim_id: claimResult.claimId,
108
+ }, cwd);
109
+ // Reviewer turns must carry the verdict contract; author-fix turns must not
110
+ // (an author lane has no verdict — it's mapped by scope+slot instead).
111
+ const briefTask = isReviewer ? input.task + REVIEW_VERDICT_BRIEF_SUFFIX : input.task;
112
+ const brief = generateDispatchBrief({
113
+ task: briefTask,
114
+ agent,
115
+ claimId: claimResult.claimId,
116
+ scope,
117
+ worktreePath: claimResult.worktreePath,
118
+ assignmentId,
119
+ });
120
+ const msg = sendMessage({
121
+ from: input.dispatcherAgent,
122
+ to: agent,
123
+ type: 'review',
124
+ text: brief,
125
+ ref: loopId,
126
+ scope,
127
+ requires_ack: true,
128
+ claim_id: claimResult.claimId,
129
+ assignment_id: assignmentId,
130
+ tags: ['coordinate', 'review', 'loop', isReviewer ? 're-review' : 'author-fix'],
131
+ author_id: input.dispatcherAgentId,
132
+ session_id: input.sessionId,
133
+ payload: {
134
+ intent: 'review',
135
+ loop_id: loopId,
136
+ slot_id: slot.slot_id,
137
+ phase,
138
+ scope,
139
+ claim_id: claimResult.claimId,
140
+ ...(assignmentId ? { assignment_id: assignmentId } : {}),
141
+ worktree_path: claimResult.worktreePath,
142
+ },
143
+ }, cwd);
144
+ result.message_id = msg.id;
145
+ if (assignmentId) {
146
+ try {
147
+ attachAssignmentMessageToClaim(claimResult.claimId, msg.id, cwd);
148
+ linkClaimToAssignment(claimResult.claimId, assignmentId, cwd);
149
+ transitionAssignment(assignmentId, 'offered', { actor: input.dispatcherAgent }, cwd);
150
+ patchAssignmentMessageId(assignmentId, msg.id, cwd);
151
+ }
152
+ catch (linkErr) {
153
+ result.error = `assignment linkage failed: ${linkErr instanceof Error ? linkErr.message : String(linkErr)}`;
154
+ }
155
+ }
156
+ const invoke = buildInvokeCommand(agent, brief, {
157
+ mode: 'worker',
158
+ model: resolveModel(agent, { override: input.model }),
159
+ });
160
+ const execResult = await attemptExecution(invoke, {
161
+ agent,
162
+ autoExecute: true,
163
+ worktreePath: claimResult.worktreePath,
164
+ claimId: claimResult.claimId,
165
+ assignmentId,
166
+ dispatcherAgent: input.dispatcherAgent,
167
+ dispatcherAgentId: input.dispatcherAgentId,
168
+ cwd,
169
+ requireWorktree: true, // never spawn a worker in the integration repo (pln#531)
170
+ });
171
+ result.execution_status = execResult.execution_status;
172
+ result.command = execResult.command;
173
+ result.shell = execResult.shell;
174
+ if (execResult.error && !result.error)
175
+ result.error = execResult.error;
176
+ return result;
177
+ }
178
+ catch (err) {
179
+ result.error = `review-loop turn dispatch failed: ${err instanceof Error ? err.message : String(err)}`;
180
+ return result;
181
+ }
182
+ }
183
+ //# sourceMappingURL=review-loop-turn-dispatch.js.map
@@ -1454,6 +1454,16 @@ export const ConfigSchema = z.object({
1454
1454
  worktree: z.object({
1455
1455
  shared_paths: z.array(z.string()).default([]),
1456
1456
  exclude_shared: z.array(z.string()).default([]),
1457
+ /**
1458
+ * How a dispatched worktree gets its JS dependencies (`node_modules`).
1459
+ * trp_37b05a15 — the default `link` (out-of-root junction) is rejected by
1460
+ * `next dev` / Turbopack. Set `install` (real per-worktree install, native
1461
+ * package manager) or `copy` (recursive copy from the main tree) for a
1462
+ * Turbopack-compatible in-root `node_modules`; `none` provisions no deps.
1463
+ * Env `BRAINCLAW_WORKTREE_DEPS_MODE` overrides this; `BRAINCLAW_NO_LINK_DEPS=1`
1464
+ * still forces `none`.
1465
+ */
1466
+ deps_mode: z.enum(['link', 'install', 'copy', 'none']).optional(),
1457
1467
  }).optional(),
1458
1468
  // Event-log store (pln#543). Absent ⇒ off — fresh and existing stores keep
1459
1469
  // today's behavior; the journal only activates when explicitly set here (or
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import yaml from 'yaml';
7
7
  import { logger } from './logger.js';
8
+ import { loadConfig } from './config.js';
8
9
  import { parsePorcelainZ, isSystemDirtyPath } from './dirty-scope.js';
9
10
  /** Normalizes a path for use in git CLI arguments (forward slashes on Windows). */
10
11
  function gitPath(p) {
@@ -93,6 +94,67 @@ export function detectStackSharedPaths(projectRoot) {
93
94
  }
94
95
  return [...result];
95
96
  }
97
+ const WORKTREE_DEPS_MODES = ['link', 'install', 'copy', 'none'];
98
+ /**
99
+ * Resolves the JS dependency provisioning mode for a worktree.
100
+ *
101
+ * Precedence (first match wins):
102
+ * 1. env `BRAINCLAW_WORKTREE_DEPS_MODE` (link|install|copy|none)
103
+ * 2. env `BRAINCLAW_NO_LINK_DEPS=1` → `none` (backward compat)
104
+ * 3. config `worktree.deps_mode` in `.brainclaw/config.yaml`
105
+ * 4. `link` (default — unchanged behavior)
106
+ *
107
+ * An unrecognized env value is ignored (falls through) with a warning, so a
108
+ * typo never silently changes provisioning.
109
+ */
110
+ export function resolveWorktreeDepsMode(projectRoot) {
111
+ const envMode = process.env.BRAINCLAW_WORKTREE_DEPS_MODE?.trim().toLowerCase();
112
+ if (envMode) {
113
+ if (WORKTREE_DEPS_MODES.includes(envMode)) {
114
+ return envMode;
115
+ }
116
+ logger.warn(`[worktree] Ignoring invalid BRAINCLAW_WORKTREE_DEPS_MODE='${envMode}' `
117
+ + `(expected one of ${WORKTREE_DEPS_MODES.join('|')}).`);
118
+ }
119
+ if (process.env.BRAINCLAW_NO_LINK_DEPS === '1')
120
+ return 'none';
121
+ try {
122
+ const configured = loadConfig(projectRoot).worktree?.deps_mode;
123
+ if (configured && WORKTREE_DEPS_MODES.includes(configured)) {
124
+ return configured;
125
+ }
126
+ }
127
+ catch { /* no / invalid config — fall through to default */ }
128
+ return 'link';
129
+ }
130
+ /**
131
+ * Detects the JS package manager for a project from its lockfile, falling back
132
+ * to the `packageManager` field of package.json, then to `npm`. Lockfile wins
133
+ * because it reflects what actually produced the main tree's `node_modules`.
134
+ */
135
+ export function detectPackageManager(projectRoot) {
136
+ const lockfiles = [
137
+ ['pnpm-lock.yaml', 'pnpm'],
138
+ ['yarn.lock', 'yarn'],
139
+ ['bun.lockb', 'bun'],
140
+ ['bun.lock', 'bun'],
141
+ ['package-lock.json', 'npm'],
142
+ ['npm-shrinkwrap.json', 'npm'],
143
+ ];
144
+ for (const [file, pm] of lockfiles) {
145
+ if (fs.existsSync(path.join(projectRoot, file)))
146
+ return pm;
147
+ }
148
+ try {
149
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8'));
150
+ const declared = pkg.packageManager?.split('@')[0]?.trim();
151
+ if (declared === 'pnpm' || declared === 'yarn' || declared === 'bun' || declared === 'npm') {
152
+ return declared;
153
+ }
154
+ }
155
+ catch { /* no / invalid package.json — default below */ }
156
+ return 'npm';
157
+ }
96
158
  /**
97
159
  * pln#523 — read declared monorepo workspace globs from npm/yarn/bun
98
160
  * `workspaces` (package.json) and pnpm-workspace.yaml. Returns the raw
@@ -555,6 +617,106 @@ export function projectUsesNextjs(projectRoot) {
555
617
  return false;
556
618
  }
557
619
  }
620
+ /**
621
+ * Timeout for a per-worktree package-manager install (`deps_mode=install`).
622
+ * Defaults to 10 minutes; override with BRAINCLAW_WORKTREE_INSTALL_TIMEOUT_MS.
623
+ */
624
+ export function resolveWorktreeInstallTimeoutMs() {
625
+ const raw = process.env.BRAINCLAW_WORKTREE_INSTALL_TIMEOUT_MS;
626
+ const n = raw ? Number.parseInt(raw, 10) : NaN;
627
+ return Number.isFinite(n) && n > 0 ? n : 600_000;
628
+ }
629
+ /**
630
+ * Provisions a worktree's JS dependencies for `install` / `copy` deps modes,
631
+ * yielding a real in-root `node_modules` that `next dev` / Turbopack accepts
632
+ * (unlike the out-of-root junction of `link` mode; trp_37b05a15).
633
+ *
634
+ * Best-effort: any failure is recorded as a warning (the worker can still
635
+ * install by hand) and NEVER thrown — worktree creation must not fail over
636
+ * dependency provisioning. Returns human-readable warnings (empty on success).
637
+ *
638
+ * - `install` runs ONE package-manager install at the worktree root, which
639
+ * natively populates monorepo workspace `node_modules` too — so it ignores
640
+ * `nodeModulesRelPaths`. No-op when the project has no `package.json`.
641
+ * - `copy` recursively mirrors each existing `node_modules` dir from the main
642
+ * tree (symlinks copied verbatim so pnpm's relative link farm stays valid).
643
+ */
644
+ export function provisionWorktreeDeps(mode, mainWorktreePath, targetPath, nodeModulesRelPaths) {
645
+ const warnings = [];
646
+ if (mode === 'install') {
647
+ if (!fs.existsSync(path.join(targetPath, 'package.json')))
648
+ return warnings;
649
+ const pm = detectPackageManager(mainWorktreePath);
650
+ const timeoutMs = resolveWorktreeInstallTimeoutMs();
651
+ // Windows: npm/pnpm/yarn/bun are `.cmd` shims, only found via the shell — so
652
+ // pass ONE static command string (pm is validated; 'install' is literal → no
653
+ // injection) and NO args array (avoids DEP0190). Unix: the binaries are on
654
+ // PATH, so spawn directly with an args array and no shell.
655
+ const result = process.platform === 'win32'
656
+ ? spawnSync(`${pm} install`, { cwd: targetPath, encoding: 'utf-8', timeout: timeoutMs, shell: true })
657
+ : spawnSync(pm, ['install'], { cwd: targetPath, encoding: 'utf-8', timeout: timeoutMs });
658
+ if (result.error?.code === 'ETIMEDOUT') {
659
+ const msg = `deps_mode=install: '${pm} install' timed out after ${timeoutMs}ms and was killed `
660
+ + `(raise BRAINCLAW_WORKTREE_INSTALL_TIMEOUT_MS). Run '${pm} install' in the worktree manually.`;
661
+ warnings.push(msg);
662
+ logger.warn(`[worktree] ${msg}`);
663
+ }
664
+ else if (result.error) {
665
+ const msg = `deps_mode=install: could not run '${pm} install' (${result.error.message}). `
666
+ + `Is ${pm} on PATH? Run '${pm} install' in the worktree manually.`;
667
+ warnings.push(msg);
668
+ logger.warn(`[worktree] ${msg}`);
669
+ }
670
+ else if (result.status !== 0) {
671
+ const tail = (result.stderr || result.stdout || '').trim().split(/\r?\n/).filter(Boolean).slice(-3).join(' | ');
672
+ const msg = `deps_mode=install: '${pm} install' exited ${result.status ?? '?'}${tail ? ` — ${tail}` : ''}. `
673
+ + `Run '${pm} install' in the worktree manually.`;
674
+ warnings.push(msg);
675
+ logger.warn(`[worktree] ${msg}`);
676
+ }
677
+ return warnings;
678
+ }
679
+ // copy
680
+ const copyable = nodeModulesRelPaths.filter((rel) => fs.existsSync(path.join(mainWorktreePath, rel)));
681
+ if (copyable.length === 0) {
682
+ if (fs.existsSync(path.join(targetPath, 'package.json'))) {
683
+ const pm = detectPackageManager(mainWorktreePath);
684
+ const msg = `deps_mode=copy: no node_modules found in the main tree to copy — `
685
+ + `run '${pm} install' in the worktree.`;
686
+ warnings.push(msg);
687
+ logger.warn(`[worktree] ${msg}`);
688
+ }
689
+ return warnings;
690
+ }
691
+ for (const rel of copyable) {
692
+ const src = path.join(mainWorktreePath, rel);
693
+ const dest = path.join(targetPath, rel);
694
+ if (fs.existsSync(dest))
695
+ continue;
696
+ try {
697
+ const parentDir = path.dirname(dest);
698
+ if (parentDir !== targetPath)
699
+ fs.mkdirSync(parentDir, { recursive: true });
700
+ // Codex review P1: if the SOURCE node_modules is itself a symlink/junction
701
+ // (e.g. a main tree that is itself a linked worktree, or a user-linked
702
+ // node_modules), a verbatim copy would reproduce that out-of-root link and
703
+ // Turbopack would still reject it — defeating copy mode. Dereference the
704
+ // TOP-LEVEL entry to its real directory before copying, then copy with
705
+ // verbatimSymlinks so the tree's INTERNAL relative links (pnpm's farm)
706
+ // stay intact. A real dir source copies straight through.
707
+ const srcReal = fs.lstatSync(src).isSymbolicLink() ? fs.realpathSync(src) : src;
708
+ fs.cpSync(srcReal, dest, { recursive: true, verbatimSymlinks: true });
709
+ }
710
+ catch (err) {
711
+ const reason = err instanceof Error ? err.message : String(err);
712
+ const msg = `deps_mode=copy: failed to copy '${rel}' into worktree (${reason}). `
713
+ + `Run the package manager's install in the worktree manually.`;
714
+ warnings.push(msg);
715
+ logger.warn(`[worktree] ${msg}`);
716
+ }
717
+ }
718
+ return warnings;
719
+ }
558
720
  export function createWorktree(mainWorktreePath, branchName, options = {}) {
559
721
  // pln#614: resolve the true git toplevel first, so an in-tree project (project
560
722
  // dir ≠ git root) creates its worktree from the real repo root — `git worktree
@@ -669,35 +831,59 @@ export function createWorktree(mainWorktreePath, branchName, options = {}) {
669
831
  // `dist` intentionally excluded — build outputs must be per-worktree
670
832
  // (EBUSY during clean:dist when MCP/extension holds a handle on junction target).
671
833
  // pln#523: also link per-package node_modules for JS/TS monorepos so workers
672
- // can build/typecheck sub-packages, not just the root. Set
673
- // BRAINCLAW_NO_LINK_DEPS=1 to disable auto dependency linking (e.g. when the
674
- // worktree lives on a different volume and central validation is preferred);
675
- // explicit options.sharedPaths are still honored.
676
- const linkDepsDisabled = process.env.BRAINCLAW_NO_LINK_DEPS === '1';
677
- const detected = linkDepsDisabled
834
+ // can build/typecheck sub-packages, not just the root.
835
+ //
836
+ // trp_37b05a15: the JS dependency provisioning mode (link | install | copy |
837
+ // none) is opt-in via BRAINCLAW_WORKTREE_DEPS_MODE / config worktree.deps_mode
838
+ // (BRAINCLAW_NO_LINK_DEPS=1 still maps to `none`). `link` (default) junctions
839
+ // node_modules from the main tree — an out-of-root symlink `next dev` rejects;
840
+ // `install`/`copy` provision a real in-root node_modules (Turbopack-ok);
841
+ // `none` provisions no deps (central validation). Explicit options.sharedPaths
842
+ // are always honored.
843
+ const isNodeModulesPath = (p) => p === 'node_modules' || p.endsWith('/node_modules');
844
+ const depsMode = resolveWorktreeDepsMode(mainWorktreePath);
845
+ const detected = depsMode === 'none'
678
846
  ? []
679
847
  : [...detectStackSharedPaths(mainWorktreePath), ...detectWorkspaceNodeModules(mainWorktreePath)];
680
848
  const extra = options.sharedPaths ?? [];
681
849
  const excluded = new Set(options.excludeShared ?? []);
682
- const sharedPaths = [...new Set([...detected, ...extra])].filter((p) => !excluded.has(p));
850
+ const requested = [...new Set([...detected, ...extra])].filter((p) => !excluded.has(p));
851
+ // In install/copy mode, node_modules becomes a REAL in-root directory instead
852
+ // of an out-of-root junction — so it is excluded from the symlink pass and
853
+ // provisioned separately. Other stack dirs (venv, vendor, …) still link.
854
+ const provisionDeps = depsMode === 'install' || depsMode === 'copy';
855
+ const nodeModulesPaths = requested.filter(isNodeModulesPath);
856
+ const sharedPaths = provisionDeps ? requested.filter((p) => !isNodeModulesPath(p)) : requested;
683
857
  for (const entry of sharedPaths) {
684
858
  trySymlinkSharedPath(entry);
685
859
  }
686
- // trp_37b05a15 (field report, Next.js 16 / Turbopack) the node_modules link
687
- // brainclaw provisions is an out-of-worktree-root symlink to the main repo.
688
- // tsc / vitest / build follow it fine, but `next dev` (Turbopack) PANICS on a
689
- // node_modules link that points outside the worktree root. Surface a warning
690
- // (not a failure — the link is still correct for build/typecheck) so a worker
691
- // or operator doing dev-server work knows the workaround up front. A full
692
- // Turbopack-compatible per-worktree dependency mode is a planned follow-up.
693
- const linkedNodeModules = sharedPaths.some((p) => p === 'node_modules' || p.endsWith('/node_modules'));
694
- if (linkedNodeModules && projectUsesNextjs(mainWorktreePath)) {
695
- const msg = 'Next.js detected: node_modules is linked as an out-of-worktree-root symlink, which '
696
- + '`next dev` / Turbopack rejects (it requires node_modules under the worktree root). '
697
- + 'tsc / vitest / build are unaffected. For dev-server work in this worktree, run '
698
- + '`npm install` here (optionally with BRAINCLAW_NO_LINK_DEPS=1), or smoke-test on the merged branch.';
699
- symlinkWarnings.push(msg);
700
- logger.warn(`[worktree] ${msg}`);
860
+ // Codex review P1: track whether in-root provisioning actually succeeded, so
861
+ // the dispatch brief can tell the worker the truth. A failed install/copy is
862
+ // best-effort (non-fatal) but the worker must then install itself the brief
863
+ // must NOT claim "node_modules is real, do not reinstall" over a failure.
864
+ let depsProvisioned;
865
+ if (provisionDeps) {
866
+ const provisionWarnings = provisionWorktreeDeps(depsMode, mainWorktreePath, targetPath, nodeModulesPaths);
867
+ symlinkWarnings.push(...provisionWarnings);
868
+ depsProvisioned = provisionWarnings.length === 0;
869
+ }
870
+ else if (depsMode === 'link') {
871
+ // trp_37b05a15 (field report, Next.js 16 / Turbopack) the node_modules link
872
+ // brainclaw provisions is an out-of-worktree-root symlink to the main repo.
873
+ // tsc / vitest / build follow it fine, but `next dev` (Turbopack) PANICS on a
874
+ // node_modules link that points outside the worktree root. Surface a warning
875
+ // (not a failure — the link is still correct for build/typecheck) so a worker
876
+ // or operator doing dev-server work knows the workaround up front.
877
+ const linkedNodeModules = sharedPaths.some(isNodeModulesPath);
878
+ if (linkedNodeModules && projectUsesNextjs(mainWorktreePath)) {
879
+ const msg = 'Next.js detected: node_modules is linked as an out-of-worktree-root symlink, which '
880
+ + '`next dev` / Turbopack rejects (it requires node_modules under the worktree root). '
881
+ + 'tsc / vitest / build are unaffected. For dev-server work, set deps_mode=install '
882
+ + '(config worktree.deps_mode or BRAINCLAW_WORKTREE_DEPS_MODE=install), run `npm install` '
883
+ + 'here, or smoke-test on the merged branch.';
884
+ symlinkWarnings.push(msg);
885
+ logger.warn(`[worktree] ${msg}`);
886
+ }
701
887
  }
702
888
  // NOTE: .brainclaw/ is intentionally NOT symlinked.
703
889
  // Symlinking .brainclaw/ causes hooks and session_start to trigger on the
@@ -737,6 +923,14 @@ export function createWorktree(mainWorktreePath, branchName, options = {}) {
737
923
  ...(baseRefSha ? { base_ref_sha: baseRefSha } : {}),
738
924
  reset_existing_branch: options.resetExistingBranch === true,
739
925
  git_advice: 'git add ONLY specific files, NEVER git add -A.',
926
+ // trp_37b05a15: how JS deps were provisioned (link junction / real install /
927
+ // copy / none) — non-default modes are recorded so a worker/supervisor knows
928
+ // whether node_modules is an out-of-root link (dev-server caveat) or in-root.
929
+ // `deps_provisioned` (install/copy only) records whether the in-root
930
+ // provisioning actually succeeded — false means best-effort failed and the
931
+ // worker must install itself (Codex review P1).
932
+ ...(depsMode !== 'link' ? { deps_mode: depsMode } : {}),
933
+ ...(depsProvisioned !== undefined ? { deps_provisioned: depsProvisioned } : {}),
740
934
  // pln#523: surface any shared-path link failures (e.g. node_modules junction
741
935
  // that could not be created) so the worker / supervisor can see why a build
742
936
  // might fail, instead of an invisible degradation.
package/dist/facts.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // Generated by scripts/emit-site-facts.mjs at build time. Do not edit manually.
2
- // Source: brainclaw v1.16.0 on 2026-07-18T18:21:20.312Z
2
+ // Source: brainclaw v1.17.0 on 2026-07-19T20:01:41.172Z
3
3
  export const FACTS = {
4
- "version": "1.16.0",
5
- "generated_at": "2026-07-18T18:21:20.312Z",
4
+ "version": "1.17.0",
5
+ "generated_at": "2026-07-19T20:01:41.172Z",
6
6
  "tools": {
7
7
  "count": 67,
8
8
  "published_count": 65,
@@ -221,7 +221,7 @@ export const FACTS = {
221
221
  "workflow_model": "task-based",
222
222
  "tier": "A",
223
223
  "has_mcp": true,
224
- "has_hooks": false,
224
+ "has_hooks": true,
225
225
  "has_skills": true,
226
226
  "has_rules": true,
227
227
  "instruction_file": "AGENTS.md",
@@ -474,7 +474,7 @@ export const FACTS = {
474
474
  },
475
475
  "bench": {
476
476
  "schema": "brainclaw.bench.v1",
477
- "generated_at": "2026-07-18T18:21:18.026Z",
477
+ "generated_at": "2026-07-19T20:01:39.038Z",
478
478
  "node_version": "v24.18.0",
479
479
  "platform": "linux-x64",
480
480
  "repeats": 3,
@@ -483,7 +483,7 @@ export const FACTS = {
483
483
  "name": "cold_onboard",
484
484
  "volume": "empty",
485
485
  "description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
486
- "duration_ms_median": 82,
486
+ "duration_ms_median": 76,
487
487
  "payload_chars_median": 1640,
488
488
  "payload_tokens_est_median": 410
489
489
  },
@@ -491,7 +491,7 @@ export const FACTS = {
491
491
  "name": "warm_work",
492
492
  "volume": "medium",
493
493
  "description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
494
- "duration_ms_median": 146,
494
+ "duration_ms_median": 135,
495
495
  "payload_chars_median": 2626,
496
496
  "payload_tokens_est_median": 657
497
497
  },
@@ -499,7 +499,7 @@ export const FACTS = {
499
499
  "name": "first_edit",
500
500
  "volume": "medium",
501
501
  "description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
502
- "duration_ms_median": 9,
502
+ "duration_ms_median": 7,
503
503
  "payload_chars_median": 442,
504
504
  "payload_tokens_est_median": 111
505
505
  }
package/dist/facts.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.16.0",
3
- "generated_at": "2026-07-18T18:21:20.312Z",
2
+ "version": "1.17.0",
3
+ "generated_at": "2026-07-19T20:01:41.172Z",
4
4
  "tools": {
5
5
  "count": 67,
6
6
  "published_count": 65,
@@ -219,7 +219,7 @@
219
219
  "workflow_model": "task-based",
220
220
  "tier": "A",
221
221
  "has_mcp": true,
222
- "has_hooks": false,
222
+ "has_hooks": true,
223
223
  "has_skills": true,
224
224
  "has_rules": true,
225
225
  "instruction_file": "AGENTS.md",
@@ -472,7 +472,7 @@
472
472
  },
473
473
  "bench": {
474
474
  "schema": "brainclaw.bench.v1",
475
- "generated_at": "2026-07-18T18:21:18.026Z",
475
+ "generated_at": "2026-07-19T20:01:39.038Z",
476
476
  "node_version": "v24.18.0",
477
477
  "platform": "linux-x64",
478
478
  "repeats": 3,
@@ -481,7 +481,7 @@
481
481
  "name": "cold_onboard",
482
482
  "volume": "empty",
483
483
  "description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
484
- "duration_ms_median": 82,
484
+ "duration_ms_median": 76,
485
485
  "payload_chars_median": 1640,
486
486
  "payload_tokens_est_median": 410
487
487
  },
@@ -489,7 +489,7 @@
489
489
  "name": "warm_work",
490
490
  "volume": "medium",
491
491
  "description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
492
- "duration_ms_median": 146,
492
+ "duration_ms_median": 135,
493
493
  "payload_chars_median": 2626,
494
494
  "payload_tokens_est_median": 657
495
495
  },
@@ -497,7 +497,7 @@
497
497
  "name": "first_edit",
498
498
  "volume": "medium",
499
499
  "description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
500
- "duration_ms_median": 9,
500
+ "duration_ms_median": 7,
501
501
  "payload_chars_median": 442,
502
502
  "payload_tokens_est_median": 111
503
503
  }
@@ -330,9 +330,11 @@ When `bclaw_coordinate(intent='review', open_loop: true)` is called, it:
330
330
  3. Links the provided handoff/candidate to the loop as an artifact at `change_summary`.
331
331
  4. Advances to `findings` and calls `bclaw_loop(intent: 'turn')` to dispatch to the reviewer.
332
332
  5. On turn completion with a verdict artifact, auto-advances; `reviewer_green` stop closes.
333
- 6. On non-green verdict with `iteration_count < max`, advances to `author_response`, dispatches to author.
333
+ 6. On a `request_changes` verdict, the fix cycle re-dispatches the reviewer into the same worktree until `approve` or the `max_iterations` cap.
334
334
 
335
- **How the verdict reaches the loop (shipped, pln#628 Focus 4B).** A dispatched reviewer worker does not call `bclaw_loop` itself — it writes its outcome to `LANE-RESULT.json` at the worktree root, now including an optional `review_verdict` (`approve` | `request_changes`) and `review_summary`. When the coordinator runs `brainclaw harvest <assignment_id>` (both the report-only path and `--integrate`), a review lane carrying a `review_verdict` is mapped onto its loop: brainclaw records a `verdict` artifact on the reviewer slot (`approve` → an `accepted…` body) and calls `advance`, which **auto-closes the loop on `reviewer_green` for `approve`** — no human driving `complete_turn`/`advance`. `request_changes` records the verdict and advances to `author_response` (the automated fix→re-review cycle is a follow-up). The mapping is idempotent, resolves the reviewer slot strictly by `assignment_id` (so symmetric multi-reviewer loops target the right slot), and runs the `complete_turn`+`advance` pair under the loop lock so an interrupted pass resumes rather than stalls.
335
+ **How the verdict reaches the loop (shipped, pln#628 Focus 4B).** A dispatched reviewer worker does not call `bclaw_loop` itself — it writes its outcome to `LANE-RESULT.json` at the worktree root, including an optional `review_verdict` (`approve` | `request_changes`) and `review_summary`. When the coordinator runs `brainclaw harvest <assignment_id>` (report-only path and `--integrate`), a review lane carrying a `review_verdict` is mapped onto its loop: brainclaw records a `verdict` artifact on the reviewer slot (`approve` → an `accepted…` body) and calls `advance`, which **auto-closes the loop on `reviewer_green` for `approve`** — no human driving `complete_turn`/`advance`.
336
+
337
+ **The autonomous fix cycle (PR2, `--integrate` only).** On `request_changes`, `harvest --integrate` bumps the loop's round counter, **keeps the claim + worktree alive**, and re-dispatches the same reviewer slot into that **same worktree** (symmetric mode) with a findings-aware brief: apply the requested changes in place, then re-review. Commits accumulate on one branch — no fresh worktree per turn, so the branch-per-scope / refuse-unharvested-commits invariants are never tripped. The cycle repeats until `approve` (→ `reviewer_green` close) or the `max_iterations` cap (n=3 → auto-close `blocked`, handed to a human). The report-only harvest path never cycles (it can neither re-dispatch nor retain the claim); it defers `request_changes` to `--integrate` and still closes on `approve`. The mapping is idempotent, resolves the reviewer slot strictly by `assignment_id` (so symmetric multi-reviewer loops target the right slot), and runs the `complete_turn`+`advance` pair under the loop lock so an interrupted pass resumes rather than stalls. Asymmetric (author ≠ reviewer) cross-agent worktree sharing is a planned follow-up.
336
338
 
337
339
  ### Symmetric review-AND-fix mode
338
340
 
@@ -1,13 +1,13 @@
1
1
  # Codex Integration
2
2
 
3
- brainclaw integrates with OpenAI's Codex CLI through MCP tools and shared instruction files. Codex has MCP access, universal skills support, and headless CLI spawn capability, but no native lifecycle hook surface.
3
+ brainclaw integrates with OpenAI's Codex CLI through MCP tools, shared instruction files, and native lifecycle hooks. Codex has MCP access, universal skills support, headless CLI spawn capability, and a native lifecycle hook surface (added upstream in 2026 — [developers.openai.com/codex/hooks](https://developers.openai.com/codex/hooks)).
4
4
 
5
5
  ## Auto-setup
6
6
 
7
7
  Codex setup is split across machine and project scope:
8
8
 
9
9
  - `brainclaw setup-machine --agents codex --yes` writes the machine-level MCP config at `~/.codex/config.toml`
10
- - `brainclaw init` creates or refreshes the current project's Brainclaw state and writes `AGENTS.md`
10
+ - `brainclaw init` creates or refreshes the current project's Brainclaw state, writes `AGENTS.md`, and writes project-level lifecycle hooks to `.codex/hooks.json` (git-ignored)
11
11
 
12
12
  If the project already has `.brainclaw/`, rerunning `brainclaw init` is safe and refreshes the managed Brainclaw/Codex files for the current machine.
13
13
 
@@ -61,7 +61,7 @@ Since pln#476 (1.0.13+), spawned Codex workers are marked `delivered_and_started
61
61
  |-------|-------|
62
62
  | Tier | A |
63
63
  | MCP | yes |
64
- | Hooks | no |
64
+ | Hooks | yes (`.codex/hooks.json`, project scope) |
65
65
  | Auto-approve | manual (per-tool approval) |
66
66
  | Skills | yes |
67
67
  | CLI spawnable | yes |
@@ -70,6 +70,22 @@ Since pln#476 (1.0.13+), spawned Codex workers are marked `delivered_and_started
70
70
  | MCP config scope | machine |
71
71
  | Prompt delivery | `stdin_pipe` (preferred), `inline_arg` (fallback) |
72
72
 
73
+ ## Lifecycle hooks
74
+
75
+ `brainclaw init` writes project-level hooks to `.codex/hooks.json` (git-ignored, machine-specific command paths). Codex reads hooks from `hooks.json` or an inline `[hooks]` table at user (`~/.codex/`) and project (`<repo>/.codex/`) scope ([Codex hooks docs](https://developers.openai.com/codex/hooks)). brainclaw wires three events:
76
+
77
+ | Event | brainclaw command | Purpose |
78
+ |-------|-------------------|---------|
79
+ | `SessionStart` | `brainclaw session-start --include-context` | Load shared context (constraints, decisions, traps, plans, handoffs) when a session begins |
80
+ | `UserPromptSubmit` | `brainclaw context-diff` | Surface what changed since the last turn |
81
+ | `Stop` | `brainclaw session-end --auto-release --reflect --reflect-handoff --dispatch-review` | Release claims, reflect, and dispatch review at turn end |
82
+
83
+ The file shape is `{ "hooks": { "<Event>": [ { "matcher": "", "hooks": [ { "type": "command", "command": "…" } ] } ] } }` (`matcher: ""` = match all occurrences). brainclaw **owns** these three event arrays: reruns overwrite them (idempotent, no cross-upgrade pile-up) — the same contract as the Cursor / Antigravity hook writers. A user's own hook placed on one of these three events is replaced on the next `init`; hooks on any **other** event are left untouched.
84
+
85
+ **Scope — interactive sessions, not headless dispatch.** These hooks serve an *interactive* Codex session. Non-managed command hooks require a one-time trust in Codex (`/hooks` — inspect and trust) before they run, so a fresh `.codex/hooks.json` is inert until the user trusts it. Headless dispatched workers (`codex exec`, used by `bclaw_dispatch` / `bclaw_coordinate`) do **not** rely on these hooks at all — they receive their context in the dispatch brief and report via `LANE-RESULT.json`; an untrusted project hook is simply skipped there, which is harmless. (A "managed" hook path via `requirements.toml`/MDM could bypass the trust step for fleets — a possible future enhancement.)
86
+
87
+ **Per-event output contract.** `SessionStart` and `UserPromptSubmit` emit their stdout as **model-visible context** — that is exactly the point (inject shared brainclaw state / the context diff). `Stop`, by contrast, expects a **JSON** response from Codex to shape turn-end behavior; brainclaw's `session-end` runs mainly for its side effects (release claims, reflect, dispatch review) and does not emit that JSON, so it does not gate the turn. Emitting a conformant per-event JSON response (notably for `Stop`) is a planned follow-up.
88
+
73
89
  ## Caveats
74
90
 
75
91
  - **Sandbox blocks `git commit`, not MCP** (dec#133): a sandboxed Codex run reaches brainclaw MCP (the server is a separate out-of-sandbox process; `approval_policy=never` auto-approves). What the sandbox *does* block is direct writes to paths outside the worktree root — notably `.git`, so the worker cannot `git commit`. Leave fixes uncommitted; the coordinator integrates + commits the worktree diff at harvest. A LANE-RESULT.json / filesystem-direct candidate write remains a valid fallback for reporting.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainclaw",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "description": "Shared project memory for humans and coding agents.",
5
5
  "type": "module",
6
6
  "repository": {