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 +17 -3
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-review.js +2 -2
- package/dist/commands/harvest.js +83 -21
- package/dist/core/agent-capability.js +7 -2
- package/dist/core/agent-files.js +53 -2
- package/dist/core/agent-integrations.js +1 -0
- package/dist/core/dispatcher.js +33 -7
- package/dist/core/review-loop-close.js +103 -34
- package/dist/core/review-loop-turn-dispatch.js +183 -0
- package/dist/core/schema.js +10 -0
- package/dist/core/worktree.js +216 -22
- package/dist/facts.js +8 -8
- package/dist/facts.json +7 -7
- package/docs/concepts/loop-engine.md +4 -2
- package/docs/integrations/codex.md +19 -3
- package/package.json +1 -1
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
|
| [](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
|
-
| [](https://openai.com/codex/) | **[Codex](https://openai.com/codex/)** | A | MCP + AGENTS.md + skills
|
|
106
|
+
| [](https://openai.com/codex/) | **[Codex](https://openai.com/codex/)** | A | MCP + AGENTS.md + hooks + skills |
|
|
107
107
|
| [](https://cursor.com/en-US) | **[Cursor](https://cursor.com/en-US)** | A | MCP (machine) + .cursor/rules/ + hooks + skills |
|
|
108
108
|
| [](https://windsurf.com/) | **[Windsurf](https://windsurf.com/)** | A | MCP (machine) + .windsurfrules + .windsurf/rules/ |
|
|
109
109
|
| [](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)
|
|
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
|
-
|
|
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
|
package/dist/commands/harvest.js
CHANGED
|
@@ -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
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
package/dist/core/agent-files.js
CHANGED
|
@@ -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
|
-
|
|
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': [
|
package/dist/core/dispatcher.js
CHANGED
|
@@ -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
|
|
283
|
-
// stall trying to install them.
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
78
|
+
const summary = (lane.review_summary ?? '').trim();
|
|
79
|
+
// ── approve → close 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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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:
|
|
103
|
-
reason: advanced.
|
|
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
|
package/dist/core/schema.js
CHANGED
|
@@ -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
|
package/dist/core/worktree.js
CHANGED
|
@@ -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.
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
|
|
677
|
-
|
|
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
|
|
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
|
-
//
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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.
|
|
2
|
+
// Source: brainclaw v1.17.0 on 2026-07-19T20:01:41.172Z
|
|
3
3
|
export const FACTS = {
|
|
4
|
-
"version": "1.
|
|
5
|
-
"generated_at": "2026-07-
|
|
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":
|
|
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-
|
|
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":
|
|
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":
|
|
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":
|
|
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.
|
|
3
|
-
"generated_at": "2026-07-
|
|
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":
|
|
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-
|
|
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":
|
|
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":
|
|
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":
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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 |
|
|
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.
|