brainclaw 1.7.5 → 1.8.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
@@ -345,6 +345,22 @@ npm run test:coverage # with coverage report
345
345
 
346
346
  For older releases (v0.x and the early v1.0 launch series), `git log` on `master` is the source of truth — every release commit follows the `chore(release): bump version to <semver>` convention, and the matching feature/fix commits reference their plan id (e.g. `feat(mcp): self-heal ... (pln#478)`).
347
347
 
348
+ ### v1.8.0
349
+
350
+ - **Multi-agent dispatch convergence — "worktree-as-contract"** (from a real
351
+ cross-project field session where a sandboxed worker could neither commit nor
352
+ reach MCP). The worker's contract shrinks to "edit files in this worktree +
353
+ drop `LANE-RESULT.json`": `brainclaw harvest --integrate` commits the worktree
354
+ diff on behalf of a worker that can't self-commit (hard-guarded to the linked
355
+ worktree, never the main repo), then completes the assignment and releases the
356
+ claim with plan cascade. A `LANE-RESULT.json` is now the #1 verdict signal in
357
+ `bclaw_dispatch_status` (worker FINISHED, even without self-update); the
358
+ dispatcher refuses to spawn without an isolated worktree; `open_loop` reviews
359
+ pre-flight each reviewer agent with a trivial validation spawn (clear
360
+ boot-failure reason instead of a generic loop timeout); and decisions/traps
361
+ gain `verified_at`/`verify_cmd` so perishable facts can be flagged stale.
362
+ Additive + opt-in throughout. (pln#530, pln#531, pln#532, pln#533, pln#534, trp#468)
363
+
348
364
  ### v1.7.5
349
365
 
350
366
  - **Security patch (recommended upgrade)** — fixes a git command-injection / RCE
Binary file
package/dist/cli.js CHANGED
@@ -1014,6 +1014,7 @@ program
1014
1014
  .command('harvest [assignment_id]')
1015
1015
  .description('Harvest a worker LANE-RESULT.json from its worktree into the project (pass an assignment id, or --all)')
1016
1016
  .option('--all', 'Harvest every lane result found across worktrees')
1017
+ .option('--integrate', 'Worktree-as-contract (pln#534): commit the worktree diff on behalf of a sandboxed worker, lifecycle the assignment, and release the claim')
1017
1018
  .option('--dry-run', 'Preview without writing events/markers')
1018
1019
  .option('--worktree <path>', 'Explicit worktree path to scan (repeatable)', collect, [])
1019
1020
  .option('--json', 'Output as JSON')
@@ -18,6 +18,10 @@ import { CandidateSchema, LaneResultSchema } from '../core/schema.js';
18
18
  import { listCandidates, listArchivedCandidates, saveCandidate } from '../core/candidates.js';
19
19
  import { createRuntimeEvent } from '../core/events.js';
20
20
  import { memoryExists } from '../core/io.js';
21
+ import { loadAssignment, transitionAssignment } from '../core/assignments.js';
22
+ import { releaseClaimWithCascade } from '../core/claims.js';
23
+ import { getCapabilityProfile, dispatchCanCommit } from '../core/agent-capability.js';
24
+ import { commitWorktreeOnBehalf } from '../core/worktree.js';
21
25
  /**
22
26
  * Returns the base directory where brainclaw-managed worktrees are stored
23
27
  * for the given project root: `~/.brainclaw/worktrees/<sha1-hash>/`.
@@ -264,6 +268,193 @@ export function harvestLaneResults(options = {}) {
264
268
  }
265
269
  return result;
266
270
  }
271
+ // ─────────────────────────────────────────────────────────────────────────────
272
+ // pln#534 — worktree-as-contract: integrate a worker's lane on its behalf.
273
+ //
274
+ // LEVER #1 from the LeaseUp frontier (can_100f1e8c). The worker's contract is
275
+ // reduced to "edit files in this worktree + drop LANE-RESULT.json". brainclaw
276
+ // carries the rest for a worker that cannot (a sandboxed agent whose root
277
+ // excludes `.git`, i.e. dispatchCanCommit=false): it COMMITS the worktree diff
278
+ // on the worker's behalf so the code lands on the lane branch, then lifecycles
279
+ // the assignment and releases the claim (with plan cascade). Self-commit / MCP /
280
+ // self-lifecycle become PROGRESSIVE enrichments, not prerequisites.
281
+ //
282
+ // Strictly ADDITIVE + opt-in: nothing here runs unless a caller invokes
283
+ // integrateLaneResults / `brainclaw harvest --integrate`. Existing harvest stays
284
+ // report-only.
285
+ // ─────────────────────────────────────────────────────────────────────────────
286
+ /** Happy-path assignment FSM chain walked when force-completing on behalf. */
287
+ const ASSIGNMENT_COMPLETE_CHAIN = ['created', 'offered', 'accepted', 'started', 'completed'];
288
+ /**
289
+ * Walk a still-open assignment forward to `completed` through the valid FSM
290
+ * chain (offered→accepted→started→completed), attaching artifacts on the final
291
+ * step. Idempotent (already-completed → true; transitions no-op). Returns false
292
+ * for assignments parked off the happy path (failed/blocked/timed_out) — those
293
+ * are not silently force-completed.
294
+ */
295
+ function forceCompleteAssignment(assignmentId, artifacts, statusReason, actor, cwd) {
296
+ const current = loadAssignment(assignmentId, cwd);
297
+ if (!current)
298
+ return false;
299
+ if (current.status === 'completed')
300
+ return true;
301
+ const startIdx = ASSIGNMENT_COMPLETE_CHAIN.indexOf(current.status);
302
+ if (startIdx === -1)
303
+ return false; // off the happy path (failed/blocked/…): leave it.
304
+ for (let i = startIdx + 1; i < ASSIGNMENT_COMPLETE_CHAIN.length; i++) {
305
+ const next = ASSIGNMENT_COMPLETE_CHAIN[i];
306
+ try {
307
+ transitionAssignment(assignmentId, next, next === 'completed' ? { actor, artifacts, status_reason: statusReason } : { actor }, cwd);
308
+ }
309
+ catch {
310
+ // A concurrent explicit transition may have moved it; stop walking.
311
+ break;
312
+ }
313
+ }
314
+ return loadAssignment(assignmentId, cwd)?.status === 'completed';
315
+ }
316
+ /**
317
+ * Integrate completed lanes on behalf of workers that cannot self-commit.
318
+ *
319
+ * For each LANE-RESULT.json found (optionally filtered to one assignment):
320
+ * 1. resolve the assignment + the worker agent's commit capability;
321
+ * 2. when the worker CANNOT commit (sandboxed) and the worktree is dirty,
322
+ * commit the diff on its behalf onto the lane branch (guarded to the linked
323
+ * worktree only — never the main repo);
324
+ * 3. lifecycle the assignment (status=completed → walk to completed with the
325
+ * commit + files as artifacts; status=blocked/failed → best-effort
326
+ * transition) and release the claim with plan cascade.
327
+ *
328
+ * A worker that CAN commit is left to its self-commit handoff — brainclaw only
329
+ * lifecycles/releases, it does not author commits for it.
330
+ */
331
+ export function integrateLaneResults(options = {}) {
332
+ const cwd = options.cwd ?? process.cwd();
333
+ const actor = options.agent ?? 'coordinator';
334
+ const result = { integrated: [], skipped: [], errors: [] };
335
+ const worktreePaths = (options.worktreePaths && options.worktreePaths.length > 0)
336
+ ? options.worktreePaths
337
+ : autoDetectWorktreePaths(cwd);
338
+ for (const worktreePath of worktreePaths) {
339
+ const file = getLaneResultPath(worktreePath);
340
+ if (!fs.existsSync(file))
341
+ continue;
342
+ let lane;
343
+ try {
344
+ lane = LaneResultSchema.parse(JSON.parse(fs.readFileSync(file, 'utf-8')));
345
+ }
346
+ catch (err) {
347
+ result.errors.push(`Failed to parse ${file}: ${err instanceof Error ? err.message : String(err)}`);
348
+ continue;
349
+ }
350
+ if (options.assignmentId && lane.assignment_id !== options.assignmentId)
351
+ continue;
352
+ const assignment = loadAssignment(lane.assignment_id, cwd);
353
+ if (!assignment) {
354
+ result.skipped.push(lane.assignment_id);
355
+ result.errors.push(`No assignment record for lane ${lane.assignment_id} — cannot integrate`);
356
+ continue;
357
+ }
358
+ const profile = getCapabilityProfile(assignment.agent);
359
+ // No profile ⇒ assume it can commit (conservative: don't author for an
360
+ // unknown agent), so brainclaw only lifecycles.
361
+ const workerCanCommit = profile ? dispatchCanCommit(profile) : true;
362
+ const entry = {
363
+ assignment_id: lane.assignment_id,
364
+ worker_agent: assignment.agent,
365
+ lane_status: lane.status,
366
+ worker_can_commit: workerCanCommit,
367
+ committed_on_behalf: false,
368
+ files_changed: lane.files_changed ?? [],
369
+ assignment_completed: false,
370
+ claim_released: false,
371
+ reason: '',
372
+ };
373
+ const reasons = [];
374
+ // 1. Commit on behalf (only when the worker cannot, and there is a diff).
375
+ if (!workerCanCommit) {
376
+ if (options.dryRun) {
377
+ reasons.push('(dry-run) would commit worktree diff on behalf');
378
+ }
379
+ else {
380
+ const message = `chore(lane): integrate ${assignment.agent} work for ${lane.assignment_id}\n\n`
381
+ + `${lane.summary}\n\n[brainclaw committed on behalf — worker sandbox cannot self-commit (pln#534)]`;
382
+ const commit = commitWorktreeOnBehalf(worktreePath, message, {
383
+ authorName: `${assignment.agent} (via brainclaw)`,
384
+ authorEmail: 'brainclaw@on-behalf.local',
385
+ });
386
+ entry.committed_on_behalf = commit.committed;
387
+ entry.commit_sha = commit.sha;
388
+ if (commit.committed)
389
+ entry.files_changed = commit.files_changed;
390
+ reasons.push(commit.reason);
391
+ }
392
+ }
393
+ else {
394
+ reasons.push('worker can self-commit — no on-behalf commit');
395
+ }
396
+ // 2. Lifecycle + release.
397
+ if (!options.dryRun) {
398
+ if (lane.status === 'completed') {
399
+ const artifacts = [
400
+ ...(entry.commit_sha ? [{ type: 'commit', ref: entry.commit_sha, description: 'on-behalf integration commit' }] : []),
401
+ ...entry.files_changed.slice(0, 50).map((f) => ({ type: 'file', ref: f })),
402
+ ];
403
+ entry.assignment_completed = forceCompleteAssignment(lane.assignment_id, artifacts, `pln#534 on-behalf integration: ${lane.summary.slice(0, 120)}`, actor, cwd);
404
+ try {
405
+ const rel = releaseClaimWithCascade(assignment.claim_id, { planStatus: 'done', cwd });
406
+ entry.claim_released = rel.claim.status === 'released';
407
+ }
408
+ catch (err) {
409
+ reasons.push(`claim release failed: ${err instanceof Error ? err.message : String(err)}`);
410
+ }
411
+ }
412
+ else {
413
+ // blocked / failed: best-effort lifecycle (FSM may reject from offered).
414
+ const target = lane.status === 'blocked' ? 'blocked' : 'failed';
415
+ try {
416
+ transitionAssignment(lane.assignment_id, target, { actor, status_reason: lane.summary.slice(0, 200) }, cwd);
417
+ }
418
+ catch (err) {
419
+ reasons.push(`assignment ${target} transition rejected: ${err instanceof Error ? err.message : String(err)}`);
420
+ }
421
+ try {
422
+ const rel = releaseClaimWithCascade(assignment.claim_id, {
423
+ planStatus: lane.status === 'blocked' ? 'blocked' : undefined,
424
+ cwd,
425
+ });
426
+ entry.claim_released = rel.claim.status === 'released';
427
+ }
428
+ catch (err) {
429
+ reasons.push(`claim release failed: ${err instanceof Error ? err.message : String(err)}`);
430
+ }
431
+ }
432
+ // Durable trace of the integration.
433
+ try {
434
+ createRuntimeEvent({
435
+ agent: actor,
436
+ event_type: 'lane_integrated',
437
+ text: `Integrated lane ${lane.assignment_id} (${lane.status}) on behalf of ${assignment.agent}`,
438
+ tags: ['harvest', 'integrate', 'worktree-as-contract', lane.status],
439
+ assignment_id: lane.assignment_id,
440
+ metadata: {
441
+ assignment_id: lane.assignment_id,
442
+ worker_agent: assignment.agent,
443
+ committed_on_behalf: entry.committed_on_behalf,
444
+ commit_sha: entry.commit_sha ?? null,
445
+ files_changed: entry.files_changed,
446
+ assignment_completed: entry.assignment_completed,
447
+ claim_released: entry.claim_released,
448
+ },
449
+ }, cwd);
450
+ }
451
+ catch { /* event is best-effort */ }
452
+ }
453
+ entry.reason = reasons.join('; ');
454
+ result.integrated.push(entry);
455
+ }
456
+ return result;
457
+ }
267
458
  export function runHarvestLane(assignmentId, options = {}) {
268
459
  const cwd = options.cwd ?? process.cwd();
269
460
  if (!memoryExists(cwd)) {
@@ -274,6 +465,38 @@ export function runHarvestLane(assignmentId, options = {}) {
274
465
  console.error('Error: provide an <assignment_id>, or pass --all to harvest every lane result.');
275
466
  process.exit(1);
276
467
  }
468
+ // pln#534 — `--integrate` upgrades harvest from report-only to converge-the-
469
+ // lane: commit the worktree diff on behalf of a sandboxed worker, lifecycle
470
+ // the assignment, and release the claim. Runs alongside the normal ingest.
471
+ if (options.integrate) {
472
+ const integ = integrateLaneResults({
473
+ assignmentId: options.all ? undefined : assignmentId,
474
+ worktreePaths: options.worktree,
475
+ dryRun: options.dryRun,
476
+ cwd,
477
+ });
478
+ if (options.json) {
479
+ console.log(JSON.stringify(integ, null, 2));
480
+ return;
481
+ }
482
+ const dry = options.dryRun ? ' (dry-run)' : '';
483
+ if (integ.integrated.length === 0 && integ.errors.length === 0) {
484
+ console.log(assignmentId ? `No LANE-RESULT.json to integrate for ${assignmentId}.` : 'No lane results to integrate.');
485
+ return;
486
+ }
487
+ for (const e of integ.integrated) {
488
+ console.log(` ✔ Integrated [${e.assignment_id}] ${e.lane_status} (worker=${e.worker_agent}, can_commit=${e.worker_can_commit})`);
489
+ if (e.committed_on_behalf)
490
+ console.log(` committed on behalf: ${e.commit_sha?.slice(0, 10)} (${e.files_changed.length} file(s))`);
491
+ console.log(` assignment_completed=${e.assignment_completed} claim_released=${e.claim_released}`);
492
+ if (e.reason)
493
+ console.log(` ${e.reason}`);
494
+ }
495
+ for (const err of integ.errors)
496
+ console.error(` ✗ ${err}`);
497
+ console.log(`\n✔ Lane integrate complete${dry}: ${integ.integrated.length} integrated, ${integ.errors.length} error(s).`);
498
+ return;
499
+ }
277
500
  const result = harvestLaneResults({
278
501
  assignmentId: options.all ? undefined : assignmentId,
279
502
  worktreePaths: options.worktree,
@@ -972,6 +972,7 @@ const MCP_WRITE_TOOLS = [
972
972
  autoExecute: { type: 'boolean', description: 'Attempt to spawn target agents after delivery (default: true). When false, returns command_ready_manual with bash commands for the supervisor to run.' },
973
973
  open_loop: { type: 'boolean', description: 'For intent=review only: also open a review Loop on top of the candidate (author + reviewer slots, advance to `findings`, dispatch turns). Default false — existing review callers are unaffected. See docs/concepts/loop-engine.md §Automation.' },
974
974
  review_mode: { type: 'string', enum: ['asymmetric', 'symmetric'], description: 'Optional review Loop mode when open_loop=true. `asymmetric` (default) keeps the classical author→reviewer handoff; `symmetric` lets each reviewer turn also apply fixes directly, halving round-trips for spec/doc reviews. Ignored when open_loop is false.' },
975
+ preflight: { type: 'boolean', description: 'pln#533: when open_loop=true, run a trivial validation spawn per reviewer agent BEFORE opening the loop so an environment death (config rejected, auth fail, model mismatch) surfaces instantly with a clear reason instead of a generic loop timeout. Reviewers that fail pre-flight are dropped (with a targeted warning); if all fail, loop creation is skipped. Default true; set false to skip (e.g. you already ran `brainclaw doctor --spawn-check`). Ignored when open_loop is false or BRAINCLAW_NO_SPAWN is set.' },
975
976
  agent: { type: 'string', description: 'Caller agent name.' },
976
977
  agentId: { type: 'string', description: 'Caller registered agent id.' },
977
978
  project: { type: 'string', description: 'Optional (pln#359 phase 1b): name of a linked project to dispatch into. When set, claim/assignment/message all land in the target project — the target agent picks the brief up async via its own bclaw_work. Auto-spawn is disabled in cross-project mode. Accepts cross_project_links and workspace store-chain children (see `brainclaw link list`).' },
@@ -4576,6 +4577,7 @@ async function _executeMcpToolCallInner(payload) {
4576
4577
  dispatcherAgent: opts.senderAgent,
4577
4578
  dispatcherAgentId: opts.senderAgentId,
4578
4579
  cwd: opts.cwd,
4580
+ requireWorktree: true, // pln#531: never spawn a worker in the integration repo
4579
4581
  });
4580
4582
  entry.execution_status = execResult.execution_status;
4581
4583
  if (execResult.pid)
@@ -4952,6 +4954,31 @@ async function _executeMcpToolCallInner(payload) {
4952
4954
  loopReviewerAgents = resolvedAgents.slice(0, REVIEW_OPEN_LOOP_FANOUT_CAP);
4953
4955
  preReviewWarnings.push(`open_loop: implicit reviewer fan-out capped at ${REVIEW_OPEN_LOOP_FANOUT_CAP} of ${resolvedAgents.length} spawnable agents; pass targetAgents to override`);
4954
4956
  }
4957
+ // pln#533 — pre-flight the reviewer agents with a trivial validation
4958
+ // spawn BEFORE opening the loop, so an environment death (config
4959
+ // rejected, auth fail, model mismatch) surfaces instantly with a clear
4960
+ // reason instead of a generic "did not acknowledge" loop timeout. Drop
4961
+ // the agents that fail and surface their reasons; if none survive the
4962
+ // existing length===0 guard skips loop creation. Skipped when open_loop
4963
+ // is off, preflight=false, or BRAINCLAW_NO_SPAWN is set (handled inside
4964
+ // preflightAgents). Cross-project dispatch never auto-spawns, so skip.
4965
+ if (req.open_loop === true && req.preflight !== false && !req.project && loopReviewerAgents.length > 0) {
4966
+ try {
4967
+ const { preflightAgents } = await import('../core/spawn-check.js');
4968
+ const pf = await preflightAgents(loopReviewerAgents, { cwd: dispatchCwd });
4969
+ if (pf.blocked.length > 0) {
4970
+ const healthy = loopReviewerAgents.filter((a) => !pf.blocked.some((b) => b.agent === a));
4971
+ for (const b of pf.blocked) {
4972
+ preReviewWarnings.push(`pre-flight: dropped reviewer '${b.agent}' — ${b.reason}.${b.recommended_next_action ? ` ${b.recommended_next_action}` : ''}`);
4973
+ }
4974
+ loopReviewerAgents = healthy;
4975
+ }
4976
+ }
4977
+ catch (pfErr) {
4978
+ // Pre-flight is best-effort: a failure here must not block the review.
4979
+ preReviewWarnings.push(`pre-flight: skipped (check threw: ${pfErr instanceof Error ? pfErr.message : String(pfErr)})`);
4980
+ }
4981
+ }
4955
4982
  // Lazy-import the loops module once before defining performReview so
4956
4983
  // the synchronous withLoopLock work callback can use it without
4957
4984
  // re-importing inside the callback.
@@ -26,6 +26,7 @@ import { loadClaim } from './claims.js';
26
26
  import { getLoop, listLoops } from './loops/store.js';
27
27
  import { isProcessAlive } from './agentrun-reconciler.js';
28
28
  import { latestActivityMs } from './runtime-signals.js';
29
+ import { LaneResultSchema } from './schema.js';
29
30
  const DEFAULT_TAIL = 20;
30
31
  const DEFAULT_STALL_MS = 5 * 60_000;
31
32
  // ── Internal helpers ──────────────────────────────────────────────────────
@@ -136,6 +137,24 @@ function computeDiagnosis(assignment, agentRun, runtime, options) {
136
137
  recommended_next_action: 'Verify the target_id is correct (asgn_/clm_/lop_/run_). Use bclaw_find(entity="assignment") to list available assignments.',
137
138
  };
138
139
  }
140
+ // pln#532 — RESULT is the #1 verdict signal. If the worker wrote LANE-RESULT.json
141
+ // it has FINISHED — regardless of pid / heartbeat / agent_run.status (a sandboxed
142
+ // worker frequently cannot self-update the run). This sits above every other
143
+ // signal, including the agent_run terminal/running checks below.
144
+ if (runtime.lane_result) {
145
+ const lr = runtime.lane_result;
146
+ const ok = lr.status === 'completed';
147
+ const stale = agentRun && agentRun.status !== 'completed'
148
+ ? ` (agent_run still ${agentRun.status}; the worker could not self-update — harvest reconciles it)`
149
+ : '';
150
+ return {
151
+ health: 'terminal',
152
+ summary: `worker reported done via LANE-RESULT.json: status=${lr.status} — ${lr.summary.slice(0, 140)}${stale}`,
153
+ recommended_next_action: ok
154
+ ? 'Worker finished. `brainclaw harvest <assignment_id>` to ingest the result, then commit/integrate its worktree diff and converge the lane.'
155
+ : `Worker reported "${lr.status}". Read the LANE-RESULT summary + stderr; address the blocker or reroute.`,
156
+ };
157
+ }
139
158
  if (!agentRun) {
140
159
  return {
141
160
  health: 'not_dispatched',
@@ -248,6 +267,16 @@ export function getDispatchStatus(options) {
248
267
  if (lastFs !== undefined)
249
268
  lastFsActivityMs = nowMs - lastFs;
250
269
  }
270
+ // pln#532 — the #1 verdict signal: a LANE-RESULT.json at the worktree root means
271
+ // the worker FINISHED (even if it couldn't self-update the run). Read + validate it.
272
+ let laneResult;
273
+ if (worktreeForFs) {
274
+ try {
275
+ const parsed = LaneResultSchema.parse(JSON.parse(fs.readFileSync(path.join(worktreeForFs, 'LANE-RESULT.json'), 'utf-8')));
276
+ laneResult = { status: parsed.status, summary: parsed.summary };
277
+ }
278
+ catch { /* no / invalid LANE-RESULT.json */ }
279
+ }
251
280
  const runtime = {
252
281
  pid: agentRun?.pid,
253
282
  pid_alive: isProcessAlive(agentRun?.pid),
@@ -260,6 +289,7 @@ export function getDispatchStatus(options) {
260
289
  stderr: stderrPath ? readLogTail(stderrPath, tailLines) : undefined,
261
290
  },
262
291
  last_fs_activity_ms: lastFsActivityMs,
292
+ lane_result: laneResult,
263
293
  };
264
294
  const diagnosis = computeDiagnosis(assignment, agentRun, runtime, { stallMs, nowMs });
265
295
  return {
@@ -850,6 +850,7 @@ export async function dispatch(options, cwd) {
850
850
  dispatcherAgentId: options.dispatcherAgentId,
851
851
  cwd,
852
852
  handshakeTimeoutMs: options.handshakeTimeoutMs,
853
+ requireWorktree: true, // pln#531: never spawn a worker in the integration repo
853
854
  });
854
855
  entry.execution_status = execResult.execution_status;
855
856
  if (execResult.pid)
@@ -107,7 +107,7 @@ const decision = {
107
107
  name: 'decision',
108
108
  shortLabelPrefix: 'dec',
109
109
  schema: DecisionSchema,
110
- updatable: ['text', 'tags', 'outcome', 'scope', 'related_paths'],
110
+ updatable: ['text', 'tags', 'outcome', 'scope', 'related_paths', 'verified_at', 'verify_cmd'],
111
111
  statusField: 'outcome',
112
112
  transitions: {
113
113
  pending: ['approved', 'rejected', 'deferred'],
@@ -137,7 +137,7 @@ const trap = {
137
137
  name: 'trap',
138
138
  shortLabelPrefix: 'trp',
139
139
  schema: TrapSchema,
140
- updatable: ['text', 'tags', 'severity', 'scope', 'related_paths', 'expires_at', 'platform_scope'],
140
+ updatable: ['text', 'tags', 'severity', 'scope', 'related_paths', 'expires_at', 'platform_scope', 'verified_at', 'verify_cmd'],
141
141
  statusField: 'status',
142
142
  transitions: {
143
143
  active: ['resolved', 'expired'],
@@ -156,6 +156,31 @@ export async function attemptExecution(invoke, options) {
156
156
  shell: manual.shell,
157
157
  };
158
158
  }
159
+ // pln#531 — isolation invariant: a spawned worker MUST run in its own
160
+ // worktree. If a worktree was required but none exists (creation failed, or a
161
+ // claim was reused/re-dispatched without one), REFUSE to spawn instead of
162
+ // falling back to options.cwd — which is the integration repo, where the
163
+ // worker would edit the main tree directly (dangerous for an autonomous fleet,
164
+ // debrief LeaseUp). Return the command for manual, isolated execution.
165
+ if (options.requireWorktree && !options.worktreePath) {
166
+ appendAuditEntry({
167
+ actor: options.dispatcherAgent,
168
+ actor_id: options.dispatcherAgentId,
169
+ action: 'spawn_failed',
170
+ item_id: options.claimId,
171
+ item_type: 'claim',
172
+ scope: options.agent,
173
+ after: { reason: 'no_worktree', refused: true },
174
+ }, options.cwd);
175
+ const manual = adapter.prepareManualCommand(invoke, options);
176
+ return {
177
+ execution_status: 'command_ready_manual',
178
+ command: manual.command,
179
+ shell: manual.shell,
180
+ error: 'Refusing to spawn without an isolated worktree: with no worktree the worker would run in the integration repo and edit the main tree. Fix worktree creation (see claim worktreeWarning) or run the command manually inside a worktree.',
181
+ failure_kind: 'spawn_no_worktree',
182
+ };
183
+ }
159
184
  // Capacity guard: skip if agent is at max concurrent tasks
160
185
  if (options.cwd) {
161
186
  const instanceCheck = checkActiveInstance(options.agent, options.cwd);
@@ -39,6 +39,16 @@ export const CoordinateRequestSchema = z.object({
39
39
  * Ignored when open_loop is false. Defaults to asymmetric.
40
40
  */
41
41
  review_mode: z.enum(['asymmetric', 'symmetric']).optional(),
42
+ /**
43
+ * pln#533: when opening a review Loop (open_loop=true), run a trivial
44
+ * validation spawn against each reviewer agent first so an environment death
45
+ * (config rejected, auth fail, model mismatch) surfaces instantly with a
46
+ * clear reason instead of a generic loop timeout. Defaults to true for
47
+ * open_loop reviews; set false to skip (e.g. when you have just spawn-checked
48
+ * the agents yourself). Ignored when open_loop is false or BRAINCLAW_NO_SPAWN
49
+ * is set.
50
+ */
51
+ preflight: z.boolean().optional(),
42
52
  /**
43
53
  * Caller-minted ULID/UUIDv7 for idempotent retries. Today this is observed
44
54
  * on intent='review' + open_loop=true: a retry with the same
@@ -154,6 +154,11 @@ export const DecisionSchema = z.object({
154
154
  related_paths: z.array(z.string()).optional(),
155
155
  plan_id: z.string().optional(),
156
156
  tags: TagsSchema,
157
+ // pln#530 — anti-staleness: ISO timestamp this fact was last empirically
158
+ // verified, and an optional command to re-confirm it. For fast-perishable
159
+ // facts (tool behaviour, config values), probe before trusting the memory.
160
+ verified_at: z.string().optional(),
161
+ verify_cmd: z.string().optional(),
157
162
  provenance: ProvenancePassthroughSchema,
158
163
  });
159
164
  export const TrapSchema = z.object({
@@ -177,6 +182,11 @@ export const TrapSchema = z.object({
177
182
  host_id: z.string().optional(),
178
183
  expires_at: z.string().optional(),
179
184
  platform_scope: z.string().optional(),
185
+ // pln#530 — anti-staleness (see DecisionSchema): when did we last verify this
186
+ // is still true, and how to re-confirm it. Critical for environment/tool-fix
187
+ // traps that go stale (e.g. a service_tier value that the API later rejects).
188
+ verified_at: z.string().optional(),
189
+ verify_cmd: z.string().optional(),
180
190
  provenance: ProvenancePassthroughSchema,
181
191
  });
182
192
  export const HandoffContractSchema = z.object({
@@ -856,6 +866,7 @@ export const RuntimeEventTypeSchema = z.enum([
856
866
  'plan_cascade_to_done',
857
867
  'candidate_harvested',
858
868
  'lane_result_harvested',
869
+ 'lane_integrated',
859
870
  ]);
860
871
  /**
861
872
  * pln#526 — LANE-RESULT convention. A dispatched worker writes a single
@@ -17,14 +17,33 @@
17
17
  import fs from 'node:fs';
18
18
  import os from 'node:os';
19
19
  import path from 'node:path';
20
+ import { spawnSync } from 'node:child_process';
20
21
  import { buildInvokeCommand, getSpawnableAgents, getCapabilityProfile, } from './agent-capability.js';
21
22
  import { defaultExecutionAdapter, resolveBinaryOnPath } from './execution-adapters.js';
22
23
  import { signalExists, readLogTail } from './runtime-signals.js';
24
+ import { recognizeStderrSignature } from './dispatch-status.js';
23
25
  const DEFAULT_PROBE_TIMEOUT_MS = 15_000;
24
26
  const DEFAULT_PROBE_PROMPT = 'Reply with exactly: OK';
25
27
  async function sleep(ms) {
26
28
  return new Promise((r) => setTimeout(r, ms));
27
29
  }
30
+ /**
31
+ * Make the probe's temp dir a real (empty) git repo so the round-trip is
32
+ * representative of a real dispatch (workers always run inside a git worktree)
33
+ * and so CLIs with a boot-time git-repo / trusted-directory check don't refuse
34
+ * it (pln#533 fix). Best-effort: if git is unavailable the probe still runs.
35
+ */
36
+ function initProbeGitRepo(root) {
37
+ try {
38
+ const run = (...args) => spawnSync('git', args, { cwd: root, encoding: 'utf-8', timeout: 5000 });
39
+ run('init', '-q');
40
+ run('config', 'user.email', 'spawn-check@brainclaw.local');
41
+ run('config', 'user.name', 'brainclaw spawn-check');
42
+ run('config', 'commit.gpgsign', 'false');
43
+ run('commit', '--allow-empty', '-q', '-m', 'spawn-check probe');
44
+ }
45
+ catch { /* git absent or failed — probe proceeds without it */ }
46
+ }
28
47
  /** Check one agent's spawn round-trip. Exposed for focused testing. */
29
48
  export async function checkAgentSpawn(agent, options = {}) {
30
49
  const start = Date.now();
@@ -43,6 +62,11 @@ export async function checkAgentSpawn(agent, options = {}) {
43
62
  }
44
63
  // Isolated signals root so the probe never pollutes the project's runtime dir.
45
64
  const root = fs.mkdtempSync(path.join(os.tmpdir(), `bclaw-spawncheck-${agent}-`));
65
+ // pln#533 fix: make the probe dir a real git repo. Real workers run inside a
66
+ // git worktree, and some CLIs refuse a non-git / untrusted dir at boot (codex:
67
+ // "Not inside a trusted directory and --skip-git-repo-check was not specified")
68
+ // — a non-git temp dir would otherwise produce a false-negative spawn failure.
69
+ initProbeGitRepo(root);
46
70
  const assignmentId = 'spawn_check';
47
71
  try {
48
72
  defaultExecutionAdapter.start(invoke, { agent, assignmentId, ackRoot: root, worktreePath: root });
@@ -59,17 +83,21 @@ export async function checkAgentSpawn(agent, options = {}) {
59
83
  const completed = signalExists(root, assignmentId, 'completed');
60
84
  const failed = signalExists(root, assignmentId, 'failed');
61
85
  const duration_ms = Date.now() - start;
86
+ // Capture the stderr tail once (used both for the detail string and for
87
+ // pln#533 boot-signature recognition on the preflight path).
88
+ const stderrRaw = readLogTail(root, assignmentId, 'stderr', 800).trim();
89
+ const stderrTail = stderrRaw ? stderrRaw.split(/\r?\n/).filter(Boolean) : undefined;
62
90
  if (completed) {
63
91
  return { agent, binary, status: 'ok', delivered, completed: true, duration_ms, detail: 'ack + completed round-trip' };
64
92
  }
65
93
  if (failed) {
66
- const tail = readLogTail(root, assignmentId, 'stderr', 400).trim() || readLogTail(root, assignmentId, 'stdout', 400).trim();
67
- return { agent, binary, status: 'failed', delivered, completed: false, duration_ms, detail: `wrapper reported failure${tail ? ` — ${tail.replace(/\s+/g, ' ').slice(0, 200)}` : ''}` };
94
+ const tail = stderrRaw || readLogTail(root, assignmentId, 'stdout', 400).trim();
95
+ return { agent, binary, status: 'failed', delivered, completed: false, duration_ms, detail: `wrapper reported failure${tail ? ` — ${tail.replace(/\s+/g, ' ').slice(0, 200)}` : ''}`, stderr_tail: stderrTail };
68
96
  }
69
97
  if (delivered) {
70
- return { agent, binary, status: 'delivered_no_completion', delivered: true, completed: false, duration_ms, detail: `spawned + ack but no completion within ${timeout}ms (silent-death symptom)` };
98
+ return { agent, binary, status: 'delivered_no_completion', delivered: true, completed: false, duration_ms, detail: `spawned + ack but no completion within ${timeout}ms (silent-death symptom)`, stderr_tail: stderrTail };
71
99
  }
72
- return { agent, binary, status: 'failed', delivered: false, completed: false, duration_ms, detail: `no ack within ${timeout}ms — delivery failed` };
100
+ return { agent, binary, status: 'failed', delivered: false, completed: false, duration_ms, detail: `no ack within ${timeout}ms — delivery failed`, stderr_tail: stderrTail };
73
101
  }
74
102
  finally {
75
103
  try {
@@ -122,4 +150,82 @@ export function renderSpawnCheckReport(report) {
122
150
  }
123
151
  return lines.join('\n');
124
152
  }
153
+ /**
154
+ * Pre-flight a single target agent. Pass criteria:
155
+ * - `ok` (ack + completed) → pass.
156
+ * - `delivered_no_completion` (ack but the trivial probe didn't finish in the
157
+ * short window) → PASS: the ack proves spawn + wrapper + delivery work; a
158
+ * boot death never acks. We don't want a slow-but-healthy agent to block.
159
+ * - `failed` / no-ack → BLOCK with a reason (enriched by a recognized boot
160
+ * signature when the stderr matches one).
161
+ * - `not_installed` / `no_template` → BLOCK: the agent cannot be spawned here,
162
+ * so opening a loop on it would only time out.
163
+ * When BRAINCLAW_NO_SPAWN is set (tests/CI), pre-flight is skipped (ok:true).
164
+ */
165
+ /**
166
+ * Pure mapper: SpawnCheckEntry → PreflightResult. No spawning — exposed so the
167
+ * pass/block policy (and the boot-signature enrichment) can be unit-tested with
168
+ * synthetic entries.
169
+ */
170
+ export function preflightResultFromEntry(entry) {
171
+ const agent = entry.agent;
172
+ if (entry.status === 'ok' || entry.status === 'delivered_no_completion') {
173
+ return { agent, ok: true, status: entry.status, reason: entry.detail };
174
+ }
175
+ if (entry.status === 'not_installed') {
176
+ return {
177
+ agent, ok: false, status: entry.status,
178
+ reason: `${agent} binary not on PATH — cannot spawn it here`,
179
+ recommended_next_action: `Install the ${agent} CLI (or target a different agent), then retry.`,
180
+ };
181
+ }
182
+ if (entry.status === 'no_template') {
183
+ return {
184
+ agent, ok: false, status: entry.status,
185
+ reason: `${agent} has no CLI spawn template — it cannot be auto-dispatched (IDE-only?)`,
186
+ recommended_next_action: `Target a CLI-spawnable agent, or hand this work to ${agent} interactively.`,
187
+ };
188
+ }
189
+ // failed (or no-ack) — try to attach a recognized boot signature.
190
+ const sig = recognizeStderrSignature(entry.stderr_tail);
191
+ return {
192
+ agent, ok: false, status: entry.status,
193
+ reason: sig?.summary ?? `${agent} failed its pre-flight spawn — ${entry.detail}`,
194
+ recommended_next_action: sig?.recommended_next_action
195
+ ?? `Inspect the ${agent} CLI config/auth (run \`brainclaw doctor --spawn-check\` for detail), fix it, then retry.`,
196
+ };
197
+ }
198
+ export async function preflightAgentSpawn(agent, options = {}) {
199
+ if (process.env.BRAINCLAW_NO_SPAWN === '1') {
200
+ return { agent, ok: true, status: 'skipped', reason: 'pre-flight skipped (BRAINCLAW_NO_SPAWN)' };
201
+ }
202
+ // Pre-flight uses a tighter window than the full doctor round-trip: a boot
203
+ // death fails fast, and an ack is enough to pass, so we don't need to wait
204
+ // out a healthy agent's full probe completion.
205
+ const entry = await checkAgentSpawn(agent, { timeoutMs: 8_000, ...options });
206
+ return preflightResultFromEntry(entry);
207
+ }
208
+ /**
209
+ * Pre-flight a set of target agents (deduped), one trivial probe each. Returns
210
+ * the per-agent results plus `blocked` (the agents that failed). Callers use
211
+ * `blocked` to skip those agents and surface their reasons instead of opening a
212
+ * loop / dispatching work that would only time out.
213
+ */
214
+ export async function preflightAgents(agents, options = {}) {
215
+ const unique = [...new Set(agents)];
216
+ const results = [];
217
+ for (const agent of unique) {
218
+ try {
219
+ results.push(await preflightAgentSpawn(agent, options));
220
+ }
221
+ catch (err) {
222
+ results.push({
223
+ agent, ok: false, status: 'failed',
224
+ reason: `pre-flight threw: ${err instanceof Error ? err.message : String(err)}`,
225
+ });
226
+ }
227
+ }
228
+ const blocked = results.filter((r) => !r.ok);
229
+ return { results, blocked, all_ok: blocked.length === 0 };
230
+ }
125
231
  //# sourceMappingURL=spawn-check.js.map
@@ -100,6 +100,41 @@ export function detectExpiredTraps(traps, nowIso = new Date().toISOString(), now
100
100
  }
101
101
  return warnings;
102
102
  }
103
+ /** pln#530 — a perishable fact unverified for longer than this reads as stale. */
104
+ const VERIFIED_STALE_DAYS = 30;
105
+ /**
106
+ * pln#530 — flag perishable memories (traps that opted in by carrying a
107
+ * `verify_cmd` and/or `verified_at`) whose last empirical verification is stale
108
+ * or never happened, so an agent re-probes the live system instead of trusting a
109
+ * value that may have drifted (the LeaseUp `service_tier` trap that the API later
110
+ * rejected is the motivating case). Only traps with these fields are considered —
111
+ * durable facts are untouched.
112
+ */
113
+ export function detectUnverifiedMemory(traps, nowMs = Date.now()) {
114
+ const warnings = [];
115
+ for (const trap of traps) {
116
+ if (trap.status !== 'active')
117
+ continue;
118
+ if (!trap.verify_cmd && !trap.verified_at)
119
+ continue; // opt-in: only perishable facts
120
+ const age = trap.verified_at ? ageDays(trap.verified_at, nowMs) : Infinity;
121
+ if (trap.verified_at && age < VERIFIED_STALE_DAYS)
122
+ continue; // freshly verified
123
+ warnings.push({
124
+ id: trap.id,
125
+ entity: 'trap',
126
+ text: truncate(trap.text),
127
+ age_days: Number.isFinite(age) ? age : 9999,
128
+ reason: trap.verified_at
129
+ ? `Perishable fact last verified ${age} day${age === 1 ? '' : 's'} ago — re-confirm against the live system before trusting`
130
+ : `Perishable fact never empirically verified (verify_cmd set) — confirm before trusting`,
131
+ suggested_action: trap.verify_cmd
132
+ ? `Run \`${trap.verify_cmd}\`, then bclaw_update(trap, ${trap.short_label ?? trap.id}, { verified_at: <now> })`
133
+ : `Re-verify against the live system, then set verified_at via bclaw_update`,
134
+ });
135
+ }
136
+ return warnings;
137
+ }
103
138
  /**
104
139
  * Detect open handoffs that have not been acted on for a long time.
105
140
  */
@@ -207,12 +242,14 @@ export function detectStaleness(plans, traps, handoffs, candidates, nowMs = Date
207
242
  const nowIso = new Date(nowMs).toISOString();
208
243
  const planWarnings = detectStalePlans(plans, nowMs);
209
244
  const trapWarnings = detectExpiredTraps(traps, nowIso, nowMs);
245
+ const unverifiedWarnings = detectUnverifiedMemory(traps, nowMs); // pln#530
210
246
  const handoffWarnings = detectStaleHandoffs(handoffs, nowMs);
211
247
  const candidateWarnings = detectStaleCandidates(candidates, nowMs);
212
248
  const noteWarnings = detectStaleRuntimeNotes(runtimeNotes, nowMs);
213
249
  const warnings = [
214
250
  ...planWarnings,
215
251
  ...trapWarnings,
252
+ ...unverifiedWarnings,
216
253
  ...handoffWarnings,
217
254
  ...candidateWarnings,
218
255
  ...noteWarnings,
@@ -176,6 +176,104 @@ export function hasGitLock(cwd) {
176
176
  const lockPath = path.join(gitDir.stdout.trim(), 'index.lock');
177
177
  return fs.existsSync(lockPath);
178
178
  }
179
+ /**
180
+ * True when `worktreePath` is a LINKED git worktree (created by `git worktree
181
+ * add`), NOT the main repository. The key invariant for pln#534's commit-on-
182
+ * behalf: brainclaw must NEVER commit into the integration repo, only into the
183
+ * isolated worktree it dispatched.
184
+ *
185
+ * Detection uses the canonical, platform-stable signal: in a linked worktree
186
+ * the entry at `<worktree>/.git` is a FILE (a `gitdir: …` pointer into the main
187
+ * repo's `.git/worktrees/<name>`), whereas the main repository's `.git` is a
188
+ * DIRECTORY. (An earlier implementation compared `git rev-parse
189
+ * --absolute-git-dir` against `--git-common-dir`, but those returned
190
+ * differently-normalized paths on the Windows CI runner — short 8.3 names /
191
+ * drive-letter case — so the main repo was misread as linked. The file-vs-dir
192
+ * check needs no path normalization.)
193
+ */
194
+ export function isLinkedWorktree(worktreePath) {
195
+ if (!fs.existsSync(worktreePath))
196
+ return false;
197
+ try {
198
+ const dotGit = path.join(worktreePath, '.git');
199
+ const st = fs.statSync(dotGit);
200
+ // main repo → .git is a directory; linked worktree → .git is a file pointer.
201
+ return st.isFile();
202
+ }
203
+ catch {
204
+ return false; // no .git entry → not a git worktree
205
+ }
206
+ }
207
+ /**
208
+ * pln#534 (worktree-as-contract) — commit the uncommitted diff of a dispatched
209
+ * worktree ON BEHALF of a worker that cannot commit itself (a sandboxed agent
210
+ * whose root excludes `.git`, i.e. `dispatchCanCommit=false`). The worker's only
211
+ * contract is "edit files in this worktree + drop LANE-RESULT.json"; brainclaw
212
+ * carries the commit so the code lands on the lane branch and propagates.
213
+ *
214
+ * GUARDS (defence-in-depth, since this writes git history):
215
+ * - the path must exist and be a LINKED worktree — NEVER the main repo;
216
+ * - no commit when an index.lock is present (concurrent git op);
217
+ * - no commit when the worktree is clean;
218
+ * - all git runs are `spawnSync('git', [...])` with `-C <worktree>` semantics
219
+ * (cwd-scoped, no shell) so nothing can escape the worktree.
220
+ * Returns a structured result instead of throwing so callers degrade gracefully.
221
+ */
222
+ export function commitWorktreeOnBehalf(worktreePath, message, options = {}) {
223
+ if (!fs.existsSync(worktreePath)) {
224
+ return { committed: false, files_changed: [], reason: `worktree path does not exist: ${worktreePath}` };
225
+ }
226
+ if (!isLinkedWorktree(worktreePath)) {
227
+ return { committed: false, files_changed: [], reason: `refusing to commit: ${worktreePath} is not a linked git worktree (main-repo guard)` };
228
+ }
229
+ if (hasGitLock(worktreePath)) {
230
+ return { committed: false, files_changed: [], reason: 'git index.lock present — another git operation is in progress' };
231
+ }
232
+ const status = runGit(['status', '--porcelain'], worktreePath);
233
+ if (!status.ok) {
234
+ return { committed: false, files_changed: [], reason: `git status failed: ${status.stderr.trim()}` };
235
+ }
236
+ if (status.stdout.trim().length === 0) {
237
+ return { committed: false, files_changed: [], reason: 'worktree clean — nothing to commit' };
238
+ }
239
+ // Stage everything, then UNSTAGE the transient files that must never land on
240
+ // the lane branch: the worker's own `LANE-RESULT.json` report and any
241
+ // `.brainclaw/` coordination state. Committing those would pollute the branch
242
+ // (and master, on merge) with non-deliverable artefacts.
243
+ const add = runGit(['add', '-A'], worktreePath);
244
+ if (!add.ok) {
245
+ return { committed: false, files_changed: [], reason: `git add failed: ${add.stderr.trim()}` };
246
+ }
247
+ runGit(['reset', '-q', '--', 'LANE-RESULT.json', '.brainclaw'], worktreePath);
248
+ // The files actually staged for this commit (post-exclusion) — also the
249
+ // truthful files_changed report.
250
+ const staged = runGit(['diff', '--cached', '--name-only'], worktreePath);
251
+ const files = staged.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
252
+ if (files.length === 0) {
253
+ // Only transient files changed — nothing deliverable to commit. Restore the
254
+ // index so the worktree is left exactly as the worker left it.
255
+ runGit(['reset', '-q'], worktreePath);
256
+ return { committed: false, files_changed: [], reason: 'no committable changes (only transient LANE-RESULT.json / .brainclaw)' };
257
+ }
258
+ const authorName = options.authorName ?? 'brainclaw (on behalf)';
259
+ const authorEmail = options.authorEmail ?? 'brainclaw@on-behalf.local';
260
+ const commit = runGit([
261
+ '-c', `user.name=${authorName}`,
262
+ '-c', `user.email=${authorEmail}`,
263
+ '-c', 'commit.gpgsign=false',
264
+ 'commit', '-m', message,
265
+ ], worktreePath);
266
+ if (!commit.ok) {
267
+ return { committed: false, files_changed: files, reason: `git commit failed: ${commit.stderr.trim()}` };
268
+ }
269
+ const head = runGit(['rev-parse', 'HEAD'], worktreePath);
270
+ return {
271
+ committed: true,
272
+ sha: head.ok ? head.stdout.trim() : undefined,
273
+ files_changed: files,
274
+ reason: 'committed on behalf of worker',
275
+ };
276
+ }
179
277
  /**
180
278
  * Re-points an EXISTING worktree to `ref` via a hard reset of its checked-out
181
279
  * branch + working tree. Used when a dispatch reuses an existing claim/worktree
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.7.5 on 2026-06-09T05:31:24.417Z
2
+ // Source: brainclaw v1.8.0 on 2026-06-09T07:31:59.599Z
3
3
  export const FACTS = {
4
- "version": "1.7.5",
5
- "generated_at": "2026-06-09T05:31:24.417Z",
4
+ "version": "1.8.0",
5
+ "generated_at": "2026-06-09T07:31:59.599Z",
6
6
  "tools": {
7
7
  "count": 62,
8
8
  "published_count": 61,
package/dist/facts.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.7.5",
3
- "generated_at": "2026-06-09T05:31:24.417Z",
2
+ "version": "1.8.0",
3
+ "generated_at": "2026-06-09T07:31:59.599Z",
4
4
  "tools": {
5
5
  "count": 62,
6
6
  "published_count": 61,
@@ -150,6 +150,23 @@ You called `bclaw_coordinate(intent="review", open_loop=true, …)` and got back
150
150
 
151
151
  ---
152
152
 
153
+ ## Worktree-as-contract harvest
154
+
155
+ Some dispatched workers cannot self-commit or call MCP. For example, a sandboxed Codex run may have `dispatchCanCommit=false` because its writable root is the linked worktree, while `.git` lives outside that root. In that case the worker contract is intentionally small:
156
+
157
+ 1. Edit files inside the dispatched worktree.
158
+ 2. Write `LANE-RESULT.json` at the worktree root.
159
+
160
+ The worker does not need to commit, call `bclaw_assignment_update`, or release the claim itself. The worktree is the contract.
161
+
162
+ When the coordinator runs `brainclaw harvest <assignment_id> --integrate`, brainclaw reads the worker's `LANE-RESULT.json`, commits the linked worktree diff on the worker's behalf onto the lane branch, then completes the assignment and releases the claim, including the normal plan-status cascade.
163
+
164
+ The on-behalf commit is guarded by the linked-worktree check (`isLinkedWorktree`): integration only targets the worktree associated with the assignment, never the main repository. This keeps sandboxed-worker harvesting from turning into an accidental main-repo commit path.
165
+
166
+ Integration is strictly additive and opt-in. Plain `brainclaw harvest <assignment_id>` remains report-only; it reads and reports the lane result without committing or mutating assignment / claim state. The on-behalf commit and lifecycle completion happen only when the coordinator passes `--integrate`.
167
+
168
+ ---
169
+
153
170
  ## Diagnostic playbook
154
171
 
155
172
  When a dispatch hangs, work top-down through these checks. For the symptom-driven variant see [troubleshooting.md#inbox-messages-stuck--brief-ack-never-arrived](troubleshooting.md#inbox-messages-stuck--brief-ack-never-arrived).
@@ -19,7 +19,7 @@ guarantees this changelog follows.
19
19
  to `sha256:860fbaa30a486093` (zod 4). No tool was added, removed,
20
20
  renamed, or had its required arguments change.
21
21
 
22
- **Changed — `bclaw_loop(intent: 'open')` anti-pattern gate (pln#461)**
22
+ **Changed — `bclaw_loop(intent: 'open')` anti-pattern gate (pln#461)**
23
23
  - New optional field `allow_orphan: boolean` on `BclawLoopOpenSchema`.
24
24
  - Default (absent / `false`) — handler rejects with `validation_error`
25
25
  and points to `bclaw_coordinate(intent: 'review', open_loop: true)`
@@ -28,20 +28,20 @@ guarantees this changelog follows.
28
28
  out in `CLAUDE.md`.
29
29
  - `allow_orphan: true` — explicit acknowledgement that the caller
30
30
  will drive `turn()` + dispatch manually (advanced / test use only).
31
- - Internal callers (`bclaw_coordinate`, `bclaw_dispatch`) are not
32
- affected — they bypass `handleBclawLoop` and invoke the core
33
- `openLoop()` directly.
34
-
35
- **Changed — sequence tools promoted to default discovery (pln#522)**
36
- - `bclaw_list_sequences`, `bclaw_create_sequence`,
37
- `bclaw_update_sequence`, and `bclaw_delete_sequence` move from
38
- `advanced` to `standard`, so fresh agents see them in the default
39
- `tools/list` catalog. Sequences are a core agent-first coordination
40
- primitive for parallel dispatch, not an advanced-only admin surface.
41
- - `bclaw_create_sequence.items` and `bclaw_update_sequence.items` now
42
- expose the full item shape in JSON Schema: `planId`, optional
43
- `stepId`, `rank`, `hard_after`, `soft_after`, `lane`, `scope_hint`,
44
- and `rationale`.
31
+ - Internal callers (`bclaw_coordinate`, `bclaw_dispatch`) are not
32
+ affected — they bypass `handleBclawLoop` and invoke the core
33
+ `openLoop()` directly.
34
+
35
+ **Changed — sequence tools promoted to default discovery (pln#522)**
36
+ - `bclaw_list_sequences`, `bclaw_create_sequence`,
37
+ `bclaw_update_sequence`, and `bclaw_delete_sequence` move from
38
+ `advanced` to `standard`, so fresh agents see them in the default
39
+ `tools/list` catalog. Sequences are a core agent-first coordination
40
+ primitive for parallel dispatch, not an advanced-only admin surface.
41
+ - `bclaw_create_sequence.items` and `bclaw_update_sequence.items` now
42
+ expose the full item shape in JSON Schema: `planId`, optional
43
+ `stepId`, `rank`, `hard_after`, `soft_after`, `lane`, `scope_hint`,
44
+ and `rationale`.
45
45
 
46
46
  ---
47
47
 
@@ -93,11 +93,15 @@ will still succeed. A follow-up PR will strip the dead handler code.
93
93
  changelog records the published MCP surface fingerprint. When a tool
94
94
  name, tier, category, or input schema changes, the test fails until
95
95
  this section is updated.
96
- - MCP public surface fingerprint: `sha256:a1881ff57ddce377`
97
- (updated 2026-05-27: added the `ref` property to the bclaw_coordinate
98
- inputSchema — pln#520 Tier 2 / trp#371, the scope-aware dirty guard;
99
- `ref` lets a dispatch build its worktree from an explicit git ref.
100
- Prior value `sha256:0a4ba280aeff142b` exposed `allow_dirty` in the
96
+ - MCP public surface fingerprint: `sha256:333be7c3cda7e166`
97
+ (updated 2026-06-09: added the `preflight` boolean to the bclaw_coordinate
98
+ inputSchema — pln#533, the pre-flight spawn validation for open_loop reviews
99
+ (run one trivial validation spawn per reviewer before opening the loop so an
100
+ environment death surfaces with a clear reason instead of a generic timeout).
101
+ Prior value `sha256:a1881ff57ddce377` added the `ref` property to the
102
+ bclaw_coordinate inputSchema (2026-05-27, pln#520 Tier 2 / trp#371, the
103
+ scope-aware dirty guard; `ref` lets a dispatch build its worktree from an
104
+ explicit git ref). `sha256:0a4ba280aeff142b` exposed `allow_dirty` in the
101
105
  bclaw_coordinate inputSchema. `sha256:e88c1a97fc29cfd1` came from the
102
106
  pln#520 LoopPhase/LoopSlotInput schema resync, which itself reconciled
103
107
  earlier unrecorded drift from `sha256:724085642dc3e2d7`.)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainclaw",
3
- "version": "1.7.5",
3
+ "version": "1.8.0",
4
4
  "description": "Shared project memory for humans and coding agents.",
5
5
  "type": "module",
6
6
  "bin": {