brainclaw 1.20.0 → 1.20.2

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.
Binary file
@@ -6,7 +6,7 @@ import { loadConfig, saveConfig } from '../core/config.js';
6
6
  import { isAgentIntegrationName, upsertAgentIntegrationDeclaration } from '../core/agent-integrations.js';
7
7
  import { resolveInstructions, loadInstructions } from '../core/instructions.js';
8
8
  import { detectAiAgent } from '../core/ai-agent-detection.js';
9
- import { AGENT_EXPORT_REGISTRY, resolveExportTarget, resolveExportTargetByFormat, writeExportFile, writeLiveCompanionFile, buildHygieneSection, describeAutoConfigWrite, writeExportCompanionFiles, collectExportGitignoreEntries, ensureGitignoreEntries, BRAINCLAW_EXCLUSIVE_DIRECTORIES, } from '../core/agent-files.js';
9
+ import { AGENT_EXPORT_REGISTRY, resolveExportTarget, resolveExportTargetByFormat, resolveLiveCompanionPath, writeExportFile, writeLiveCompanionFile, buildHygieneSection, describeAutoConfigWrite, writeExportCompanionFiles, collectExportGitignoreEntries, ensureGitignoreEntries, BRAINCLAW_EXCLUSIVE_DIRECTORIES, } from '../core/agent-files.js';
10
10
  import { buildCoordinationSnapshot } from '../core/coordination.js';
11
11
  import { listClaims } from '../core/claims.js';
12
12
  import { listCandidates } from '../core/candidates.js';
@@ -187,18 +187,23 @@ export function refreshLiveCompanions(cwd) {
187
187
  const instructions = getInstructionText({ project: undefined, agent: undefined }, effectiveCwd);
188
188
  const activeClaims = listClaims(effectiveCwd).filter((c) => c.status === 'active');
189
189
  const pendingCandidates = listCandidates('pending', effectiveCwd);
190
- const seen = new Set();
191
- const targets = AGENT_EXPORT_REGISTRY.filter((t) => {
192
- if (seen.has(t.format))
193
- return false;
194
- seen.add(t.format);
195
- return true;
196
- });
197
190
  let written = 0;
198
191
  const errors = [];
199
192
  const liveGitignoreEntries = [];
200
- for (const target of targets) {
193
+ // trp_6a49f976 F1 (codex review): dedupe by RESOLVED LIVE PATH, never by
194
+ // stable export format. Format-dedup dropped every agents-md agent after
195
+ // codex — including mistral-vibe, whose REGISTERED live companion
196
+ // (.vibe/live.md) then never got refreshed, while the stale-surface
197
+ // advisory told the operator `brainclaw refresh` would fix exactly that
198
+ // file. A path counts as taken only once something was actually RENDERED
199
+ // for it, so a tier with no live companion (codex, Tier A) does not shadow
200
+ // a rendering agent that shares its default path (hermes → AGENTS.live.md).
201
+ const renderedLivePaths = new Set();
202
+ for (const target of AGENT_EXPORT_REGISTRY) {
201
203
  try {
204
+ const livePath = resolveLiveCompanionPath(target.agentName, target.relativePath);
205
+ if (renderedLivePaths.has(livePath))
206
+ continue;
202
207
  const profile = getAgentCapabilityProfile(target.agentName);
203
208
  if (!profile)
204
209
  continue;
@@ -214,7 +219,8 @@ export function refreshLiveCompanions(cwd) {
214
219
  };
215
220
  const live = renderLiveSection(input);
216
221
  if (!live)
217
- continue; // Tier A — no live companion needed
222
+ continue; // no live companion for this tier
223
+ renderedLivePaths.add(livePath);
218
224
  const writeResult = writeLiveCompanionFile(live.content, target.agentName, target.relativePath, effectiveCwd);
219
225
  if (writeResult.created || writeResult.updated) {
220
226
  written++;
@@ -40,6 +40,7 @@ import { createRuntimeEvent } from './events.js';
40
40
  import { nowISO } from './ids.js';
41
41
  import { readHeartbeat, readLogTail, signalExists, latestActivityMs, readCompletionSignals } from './runtime-signals.js';
42
42
  import { findReservationByRunId, evidenceMatchesAttempt, launchGrant, revokeLaunchGrant } from './loops/attempt-reservation.js';
43
+ import { reconcileFailedTurn } from './loops/reconcile-turn.js';
43
44
  // ── Constants ──────────────────────────────────────────────────────────────
44
45
  /**
45
46
  * Minimum age before a run is eligible for reconciliation. Below this, the
@@ -250,12 +251,33 @@ function fsActiveWithin(evidence, windowMs) {
250
251
  * accumulating for manual cleanup. Best-effort + idempotent: only an active claim
251
252
  * is released, and any error is swallowed (GC must never break reconciliation).
252
253
  * Inference only fires after the stale window with no life evidence, so this is
253
- * conservative. (Loop auto-close on failure is a follow-up.)
254
+ * conservative.
255
+ *
256
+ * pln#641 (dec#151 option b): a TURN-OWNED run's claim is business state owned
257
+ * by its LOOP, and the pln#638 6c effects boundary forbids a transport verdict
258
+ * from carrying business effects. So a run with a reservation routes through
259
+ * reconcileFailedTurn — the failure is recorded ON the loop (complete_turn
260
+ * outcome:'failed') and the release is that convergence's business decision,
261
+ * in the same lazy pass. When the loop path DECLINES (lock contention, loop
262
+ * missing, containment), the claim deliberately stays for the next read-path
263
+ * pass — never a forced fallback release, that would re-open the boundary hole.
264
+ * Non-turn-owned runs keep the trp#433 cascade unchanged.
254
265
  */
255
- function cascadeReleaseOnFailure(run, actor, cwd, terminalStatus = 'failed') {
266
+ function cascadeReleaseOnFailure(run, actor, cwd, terminalStatus = 'failed', reason) {
256
267
  if (!run.claim_id)
257
268
  return;
258
269
  try {
270
+ const reservation = findReservationByRunId(run.id, cwd);
271
+ if (reservation) {
272
+ reconcileFailedTurn({
273
+ reservation,
274
+ run,
275
+ reason: reason ?? `run reconciled to ${terminalStatus}`,
276
+ actor,
277
+ cwd,
278
+ });
279
+ return;
280
+ }
259
281
  const claim = loadClaim(run.claim_id, cwd);
260
282
  if (claim && claim.status === 'active') {
261
283
  releaseClaim(run.claim_id, cwd);
@@ -274,6 +296,46 @@ function cascadeReleaseOnFailure(run, actor, cwd, terminalStatus = 'failed') {
274
296
  }
275
297
  catch { /* best-effort — never let GC break reconciliation */ }
276
298
  }
299
+ /**
300
+ * pln#641 review P1 — the stranded-failure retry the read path was missing.
301
+ *
302
+ * `cascadeReleaseOnFailure` fires exactly once, on the failed/cancelled
303
+ * TRANSITION. When the business convergence declines in that pass (loop-lock
304
+ * contention, transient loop read failure) or the process crashes between the
305
+ * loop's WAL record and the release, the run is already terminal — and every
306
+ * read path deliberately skips terminal runs, so the "next pass retries"
307
+ * promise was unreachable: the claim stranded until the 24h stale sweep,
308
+ * contrary to the same-lazy-pass/non-famine guarantee of dec#151.
309
+ *
310
+ * This is that retry, called from the read-path walk ON terminal runs
311
+ * (entity-operations.ts loadAgentRunsWithReconciliation). Cheap by
312
+ * construction: in-memory status/claim_id/recency guards first, ONE claim
313
+ * read only for RECENT failed/cancelled runs that still name a claim; the
314
+ * cascade re-checks everything and stays idempotent. The recency window keeps
315
+ * the walk from re-reading a claim file for every historical failure forever —
316
+ * anything older has long been the stale sweep's business anyway.
317
+ */
318
+ export const STRANDED_RELEASE_RETRY_WINDOW_MS = 48 * 60 * 60_000;
319
+ export function reconcileStrandedFailureClaimAtRead(run, cwd, options = {}) {
320
+ if (run.status !== 'failed' && run.status !== 'cancelled')
321
+ return false;
322
+ if (!run.claim_id)
323
+ return false;
324
+ const now = options.nowMs ?? Date.now();
325
+ const anchor = Date.parse(run.completed_at ?? run.updated_at ?? run.created_at);
326
+ if (Number.isFinite(anchor) && now - anchor > STRANDED_RELEASE_RETRY_WINDOW_MS)
327
+ return false;
328
+ try {
329
+ const claim = loadClaim(run.claim_id, cwd);
330
+ if (!claim || claim.status !== 'active')
331
+ return false;
332
+ }
333
+ catch {
334
+ return false;
335
+ }
336
+ cascadeReleaseOnFailure(run, options.actor ?? 'reconciler', cwd, run.status, run.status_reason ?? `stranded ${run.status} run — read-path retry`);
337
+ return true;
338
+ }
277
339
  function anyCompletionEvidence(evidence) {
278
340
  // pln#630 PR2b-c (§13 R3): a turn-owned run is completed ONLY on turn-keyed
279
341
  // evidence — never a bare presence sentinel or an assignment-keyed proxy
@@ -462,7 +524,7 @@ export function reconcileAgentRun(runId, cwd, options = {}) {
462
524
  const failHere = (reason) => {
463
525
  try {
464
526
  transitionAgentRun(runId, 'failed', { actor, status_reason: reason }, cwd);
465
- cascadeReleaseOnFailure(run, actor, cwd);
527
+ cascadeReleaseOnFailure(run, actor, cwd, 'failed', reason);
466
528
  return { run_id: runId, action: 'inferred_failed', reason, evidence, previous_status, current_status: 'failed' };
467
529
  }
468
530
  catch (err) {
@@ -579,7 +641,7 @@ function reconcileTurnOwnedPreRunLease(run, reservation, evidence, cwd, options)
579
641
  }
580
642
  try {
581
643
  transitionAgentRun(run.id, targetStatus, { actor, status_reason: reason }, cwd);
582
- cascadeReleaseOnFailure(run, actor, cwd, targetStatus);
644
+ cascadeReleaseOnFailure(run, actor, cwd, targetStatus, reason);
583
645
  return { run_id: run.id, action, reason, evidence, previous_status, current_status: targetStatus };
584
646
  }
585
647
  catch (err) {
@@ -638,7 +700,7 @@ export function reconcileDeadPidRunningAgentRunAtRead(runId, cwd, options = {})
638
700
  const failRun = (reason) => {
639
701
  try {
640
702
  transitionAgentRun(run.id, 'failed', { actor, status_reason: reason }, cwd);
641
- cascadeReleaseOnFailure(run, actor, cwd);
703
+ cascadeReleaseOnFailure(run, actor, cwd, 'failed', reason);
642
704
  return { run_id: run.id, action: 'inferred_failed', reason, evidence, previous_status: run.status, current_status: 'failed' };
643
705
  }
644
706
  catch (err) {
@@ -275,6 +275,38 @@ export function releaseClaim(id, cwd, auth) {
275
275
  }
276
276
  return released;
277
277
  }
278
+ /**
279
+ * Release a claim ONLY if it is still `active`, reporting whether THIS call
280
+ * performed the active→released transition.
281
+ *
282
+ * pln#641 review round-2 P1: `releaseClaim` sets `released` unconditionally —
283
+ * an already-released claim is silently re-released — so any caller that
284
+ * check-then-releases to decide whether to AUDIT the transition lies under a
285
+ * concurrent external release (the check passes, the write "succeeds", the
286
+ * caller emits an event for a transition it never performed). Here the status
287
+ * check runs INSIDE the same store mutation that writes the release, so the
288
+ * answer is exact: `released: true` means this call, and no one else, moved
289
+ * the claim out of `active`.
290
+ */
291
+ export function releaseClaimIfActive(id, cwd, auth) {
292
+ let overrideUsed = false;
293
+ let transitioned = false;
294
+ const result = mutate({ cwd }, () => {
295
+ const claim = loadClaim(id, cwd);
296
+ if (!claim || claim.status !== 'active')
297
+ return claim;
298
+ overrideUsed = assertReleaseOwnership(claim, auth).overrideUsed;
299
+ claim.status = 'released';
300
+ claim.released_at = nowISO();
301
+ saveClaimUnlocked(claim, cwd);
302
+ transitioned = true;
303
+ return claim;
304
+ });
305
+ if (transitioned && overrideUsed && auth && result) {
306
+ auditReleaseOverride(result, auth, cwd);
307
+ }
308
+ return { released: transitioned, claim: result };
309
+ }
278
310
  /**
279
311
  * Mark an active claim as `stale` — a distinct terminal state from `released`
280
312
  * used when a claim is being torn down because its owner is gone (session
@@ -844,6 +876,10 @@ export function releaseStaleClaimsFromOtherAgents(currentAgent, cwd, currentSess
844
876
  export function createCoordinatorClaim(options) {
845
877
  // Scope lock is GLOBAL: any active claim on the same scope blocks, regardless of agent.
846
878
  const existingScopeClaim = listClaims(options.cwd).find((claim) => claim.status === 'active' && claim.scope === options.scope);
879
+ // Set when the reuse candidate turns out to be RELEASED under the store lock
880
+ // (review PR#167 P1): the scope is genuinely free, so control falls through
881
+ // to the fresh-claim path instead of handing the dispatcher a dead claim.
882
+ let reuseCandidateGone = false;
847
883
  if (existingScopeClaim) {
848
884
  if (existingScopeClaim.agent === options.agent) {
849
885
  // Same agent already has this scope — reuse the claim (backward compat,
@@ -853,33 +889,75 @@ export function createCoordinatorClaim(options) {
853
889
  // worktree — the same silent false-negative the guard exists to prevent
854
890
  // (pln#520 Tier 2 / codex r2).
855
891
  let reuseWarning;
856
- if (options.worktreeBaseRef) {
857
- if (existingScopeClaim.worktree_path) {
858
- const reset = resetWorktreeToRef(existingScopeClaim.worktree_path, options.worktreeBaseRef);
892
+ let reusedWorktreePath = existingScopeClaim.worktree_path;
893
+ if (reusedWorktreePath) {
894
+ if (options.worktreeBaseRef) {
895
+ const reset = resetWorktreeToRef(reusedWorktreePath, options.worktreeBaseRef);
859
896
  if (!reset.ok) {
860
897
  reuseWarning = `Reused claim ${existingScopeClaim.id} pinned to ref "${options.worktreeBaseRef}": ${reset.stderr.trim()}`;
861
898
  }
862
899
  }
863
- else {
864
- reuseWarning = `Reused claim ${existingScopeClaim.id} has no worktree to pin to ref "${options.worktreeBaseRef}".`;
900
+ }
901
+ else {
902
+ // trp_72b4e9b3(2) — a claim persisted after a failed worktree creation
903
+ // (e.g. the path collision this trap documents) used to WEDGE the scope:
904
+ // every later dispatch reused this worktree-less claim and the spawn
905
+ // refused ("Reused claim has no worktree to pin"). Heal it here:
906
+ // provision the worktree the claim should have had (createWorktree now
907
+ // ADOPTS an existing same-branch worktree, so the original collision
908
+ // cause resolves too) and patch the claim. On failure the old warning
909
+ // stands — visible, and the next call retries.
910
+ try {
911
+ reusedWorktreePath = createWorktree(options.cwd, `feat/${sanitizeBranchComponent(options.scope)}`, {
912
+ sessionId: options.sessionId,
913
+ agent: options.agent,
914
+ baseRef: options.worktreeBaseRef,
915
+ resetExistingBranch: options.resetExistingWorktreeBranch || Boolean(options.worktreeBaseRef),
916
+ });
917
+ const healedPath = reusedWorktreePath;
918
+ // Review PR#167 P1 — the patch REVALIDATES under the store lock.
919
+ // Provisioning takes seconds; if a release wins that window, the
920
+ // reused claim id must NEVER reach the dispatcher (it would spawn an
921
+ // assignment bound to a RELEASED claim). 'gone' falls through to the
922
+ // fresh-claim path below — the scope is genuinely free now, and the
923
+ // just-provisioned worktree is re-found by createWorktree's adoption.
924
+ const healed = mutate({ cwd: options.cwd }, () => {
925
+ const fresh = loadClaim(existingScopeClaim.id, options.cwd);
926
+ if (!fresh || fresh.status !== 'active')
927
+ return 'gone';
928
+ if (!fresh.worktree_path) {
929
+ fresh.worktree_path = healedPath;
930
+ saveClaimUnlocked(fresh, options.cwd);
931
+ }
932
+ return fresh.worktree_path === healedPath ? 'patched' : 'already';
933
+ });
934
+ if (healed === 'gone')
935
+ reuseCandidateGone = true;
936
+ }
937
+ catch (err) {
938
+ reuseWarning = `Reused claim ${existingScopeClaim.id} has no worktree, and provisioning one failed: ${err instanceof Error ? err.message : String(err)}`;
865
939
  }
866
940
  }
941
+ if (!reuseCandidateGone) {
942
+ return {
943
+ claimId: existingScopeClaim.id,
944
+ worktreePath: reusedWorktreePath,
945
+ worktreeWarning: reuseWarning,
946
+ reusedExisting: true,
947
+ };
948
+ }
949
+ }
950
+ else {
951
+ // DIFFERENT agent has an active claim on this scope — scope is locked.
952
+ // Return the existing claim info + a conflict flag so the dispatcher can skip.
867
953
  return {
868
954
  claimId: existingScopeClaim.id,
869
955
  worktreePath: existingScopeClaim.worktree_path,
870
- worktreeWarning: reuseWarning,
871
956
  reusedExisting: true,
957
+ scopeConflict: true,
958
+ conflictAgent: existingScopeClaim.agent,
872
959
  };
873
960
  }
874
- // DIFFERENT agent has an active claim on this scope — scope is locked.
875
- // Return the existing claim info + a conflict flag so the dispatcher can skip.
876
- return {
877
- claimId: existingScopeClaim.id,
878
- worktreePath: existingScopeClaim.worktree_path,
879
- reusedExisting: true,
880
- scopeConflict: true,
881
- conflictAgent: existingScopeClaim.agent,
882
- };
883
961
  }
884
962
  const claimId = generateClaimId();
885
963
  // Resolved OUTSIDE the lock below, for the same reason acquireClaimScope does
@@ -428,11 +428,22 @@ export function getDispatchStatus(options) {
428
428
  }
429
429
  // pln#532 — the #1 verdict signal: a LANE-RESULT.json at the worktree root means
430
430
  // the worker FINISHED (even if it couldn't self-update the run). Read + validate it.
431
+ // trp_e824d2af — validate OWNERSHIP too: assignment_id is required on the schema,
432
+ // and a reused worktree keeps the prior turn's file at the root. Unmatched, that
433
+ // file declared a freshly-spawned round 2 terminal with round 1's verdict (observed
434
+ // live 2026-08-02, lop_626271ee10ad09d8). A mismatch is surfaced as stale, never
435
+ // as this dispatch's result.
431
436
  let laneResult;
437
+ let laneResultStale;
432
438
  if (worktreeForFs) {
433
439
  try {
434
440
  const parsed = LaneResultSchema.parse(JSON.parse(fs.readFileSync(path.join(worktreeForFs, 'LANE-RESULT.json'), 'utf-8')));
435
- laneResult = { status: parsed.status, summary: parsed.summary };
441
+ if (parsed.assignment_id === assignmentId) {
442
+ laneResult = { status: parsed.status, summary: parsed.summary };
443
+ }
444
+ else {
445
+ laneResultStale = { assignment_id: parsed.assignment_id, status: parsed.status, summary: parsed.summary };
446
+ }
436
447
  }
437
448
  catch { /* no / invalid LANE-RESULT.json */ }
438
449
  }
@@ -451,6 +462,7 @@ export function getDispatchStatus(options) {
451
462
  },
452
463
  last_fs_activity_ms: lastFsActivityMs,
453
464
  lane_result: laneResult,
465
+ ...(laneResultStale ? { lane_result_stale: laneResultStale } : {}),
454
466
  commits_ahead: evidence?.commitsAhead,
455
467
  commits_ahead_raw: evidence?.commitsAheadRaw,
456
468
  commits_ahead_base: evidence?.baseRef,
@@ -26,7 +26,7 @@ import { loadAllSessions } from './identity.js';
26
26
  import { loadInstructions } from './instructions.js';
27
27
  import { deleteAssignment, listAssignments, loadAssignment, saveAssignment, transitionAssignment } from './assignments.js';
28
28
  import { listAgentRuns } from './agentruns.js';
29
- import { reconcileAgentRun, reconcileDeadPidRunningAgentRunAtRead, TERMINAL_STATUSES } from './agentrun-reconciler.js';
29
+ import { reconcileAgentRun, reconcileDeadPidRunningAgentRunAtRead, reconcileStrandedFailureClaimAtRead, TERMINAL_STATUSES } from './agentrun-reconciler.js';
30
30
  import { isObserverMode } from './observer-mode.js';
31
31
  import { deleteRuntimeNote, listRuntimeNotes, saveRuntimeNote, } from './runtime.js';
32
32
  import { createSequence, deleteSequence, listSequences, updateSequence, } from './sequence.js';
@@ -244,7 +244,17 @@ function loadAgentRunsWithReconciliation(cwd) {
244
244
  reconcileAgentRun(run.id, cwd);
245
245
  }
246
246
  catch { /* best-effort: never block reads on reconciliation errors */ }
247
+ continue;
248
+ }
249
+ // pln#641 review P1 — the failure cascade fires only on the terminal
250
+ // TRANSITION, and this walk used to skip terminal runs entirely, so a
251
+ // declined/crashed business release stranded its claim until the 24h
252
+ // sweep. Recent failed/cancelled runs still holding an active claim
253
+ // retry the (idempotent) convergence here.
254
+ try {
255
+ reconcileStrandedFailureClaimAtRead(run, cwd);
247
256
  }
257
+ catch { /* best-effort: never block reads on reconciliation errors */ }
248
258
  }
249
259
  // Re-list to capture any transitions made above.
250
260
  return listAgentRuns(cwd);
@@ -8,10 +8,12 @@
8
8
  * exactly the reflex we cannot afford.
9
9
  *
10
10
  * Contract: an entry is served in detail `k` times, then folded into a single
11
- * aggregate line that carries the exact next_action (`bclaw_find …`) so the
12
- * agent still has one clear pointer. The counter is a small JSON file, safe to
13
- * lose hitting it twice per stale item is worse than losing it once, and a
14
- * missing file just resets counts to zero.
11
+ * aggregate line that carries the folded IDS themselves (trp_336e8054: it used
12
+ * to point at `bclaw_find(status:'stale')`, a filter that returns nothing
13
+ * because staleness is computed, not stored) so the agent still has one clear,
14
+ * WORKING pointer. The counter is a small JSON file, safe to lose — hitting it
15
+ * twice per stale item is worse than losing it once, and a missing file just
16
+ * resets counts to zero.
15
17
  *
16
18
  * @module
17
19
  */
@@ -63,8 +65,9 @@ function bump(counter, nowIso) {
63
65
  }
64
66
  /**
65
67
  * Fold stale warnings served ≥ k times into a single aggregate line. The
66
- * aggregate carries a bclaw_find pointer so the agent still has one exact
67
- * next-action instead of guessing which filter to use.
68
+ * aggregate carries the folded item IDS (grouped by entity) so the agent has
69
+ * an exact, executable next-action never a filter the engine cannot resolve
70
+ * (trp_336e8054).
68
71
  *
69
72
  * Idempotence: calling with recordServe=false is pure — the returned split is
70
73
  * derived from the current registry alone. With recordServe=true the counter
@@ -103,11 +106,24 @@ export function ageStaleWarnings(warnings, cwd, options = {}) {
103
106
  }
104
107
  let aggregate;
105
108
  if (folded.length > 0) {
109
+ // trp_336e8054 — the aggregate used to recommend `bclaw_find(status:'stale')`,
110
+ // a filter that returns NOTHING: staleness is COMPUTED at session-start, never
111
+ // stored as a status, so the operator could not retrieve the very items the
112
+ // line announced. The folded items are in hand right here — carry their ids,
113
+ // grouped by entity, so the recovery (`bclaw_get` each id) works verbatim.
106
114
  const byEntity = new Map();
107
- for (const w of folded)
108
- byEntity.set(w.entity, (byEntity.get(w.entity) ?? 0) + 1);
109
- const parts = [...byEntity.entries()].map(([entity, n]) => `${n} ${entity}${n === 1 ? '' : 's'}`);
110
- aggregate = `${folded.length} stale item${folded.length === 1 ? '' : 's'} you've already been offered (${parts.join(', ')}) — bclaw_find(status:'stale') to review, or bclaw_transition to retire.`;
115
+ for (const w of folded) {
116
+ const ids = byEntity.get(w.entity) ?? [];
117
+ ids.push(w.id);
118
+ byEntity.set(w.entity, ids);
119
+ }
120
+ const ID_CAP = 8;
121
+ const parts = [...byEntity.entries()].map(([entity, ids]) => {
122
+ const shown = ids.slice(0, ID_CAP);
123
+ const overflow = ids.length - shown.length;
124
+ return `${ids.length} ${entity}${ids.length === 1 ? '' : 's'}: ${shown.join(', ')}${overflow > 0 ? ` +${overflow} more` : ''}`;
125
+ });
126
+ aggregate = `${folded.length} stale item${folded.length === 1 ? '' : 's'} you've already been offered (${parts.join('; ')}) — bclaw_get each id to review, or bclaw_transition to retire.`;
111
127
  }
112
128
  return { warnings: detail, aggregate, served_ids, folded_ids };
113
129
  }
@@ -220,15 +220,18 @@ function renderHeader(input) {
220
220
  function renderLiveHeader(input) {
221
221
  // pln#638 volet 2a — HONESTY FIX. This header used to say "auto-refreshed",
222
222
  // but regeneration is EXPLICIT: it happens on session-end, handoff, and
223
- // `export --write`. An agent tier that never fires those events (no hooks, no
224
- // MCP) read a file claiming to be fresh while being arbitrarily stale. A claim
225
- // that is false for half the tiers is worse than no claim, so the header now
223
+ // `brainclaw refresh`. An agent tier that never fires those events (no hooks,
224
+ // no MCP) read a file claiming to be fresh while being arbitrarily stale. A
225
+ // claim that is false for half the tiers is worse than no claim, so the header
226
226
  // names the actual triggers and tells the reader how to force a refresh.
227
+ // trp_6a49f976: the 1.20.0 header cited `brainclaw export --write`, which the
228
+ // CLI rejects outright AND which never wrote a live companion — the honest
229
+ // recovery for THIS file is `brainclaw refresh`.
227
230
  // Guarded by tests/unit/guidance-engine-consistency.test.ts.
228
231
  return [
229
- `> Brainclaw live state — do not edit. Regenerated on: session-end, handoff, \`brainclaw export --write\`.`,
232
+ `> Brainclaw live state — do not edit. Regenerated on: session-end, handoff, \`brainclaw refresh\`.`,
230
233
  `> Written by brainclaw v${input.brainclawVersion} at ${new Date().toISOString().slice(0, 19)}`,
231
- `> Older than your last session? It is stale — run \`brainclaw export --write\` to refresh.`,
234
+ `> Older than your last session? It is stale — run \`brainclaw refresh\`.`,
232
235
  ].join('\n');
233
236
  }
234
237
  // Kept deliberately small (pln#542): entry point + grammar + escalation
@@ -5,7 +5,7 @@ import { complete_turn, add_artifact, advance } from './verbs.js';
5
5
  import { reducerForKind } from './result-reducers.js';
6
6
  import { loadAgentRun, transitionAgentRun } from '../agentruns.js';
7
7
  import { loadAssignment, transitionAssignment } from '../assignments.js';
8
- import { loadClaim, releaseClaim } from '../claims.js';
8
+ import { loadClaim, releaseClaim, releaseClaimIfActive } from '../claims.js';
9
9
  import { createRuntimeEvent } from '../events.js';
10
10
  import { readCompletionSignals } from '../runtime-signals.js';
11
11
  import { buildFixCycleTask } from '../review-loop-close.js';
@@ -366,4 +366,139 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
366
366
  ...(next_turn ? { next_turn } : {}),
367
367
  };
368
368
  }
369
+ /**
370
+ * Converge a TURN-OWNED lane whose worker died at the TRANSPORT level (no lane
371
+ * result will ever arrive). dec#151, operator-decided option (b): the lane's
372
+ * claim is business state owned by its loop, so its release must be a business
373
+ * decision recorded ON the loop — through the same convergence family harvest
374
+ * uses (reconcileTurn) — never a side-effect of a transport verdict. Before
375
+ * this, the trp#433 GC cascade released the claim straight from the transport
376
+ * reconciler, which is exactly what the pln#638 6c effects boundary forbids.
377
+ *
378
+ * What "business decision" means concretely here: complete_turn(outcome:
379
+ * 'failed') writes the failure into the loop journal FIRST (crash-atomic WAL),
380
+ * and only then is the claim released — so a released claim always has a loop
381
+ * record explaining WHY, and a crash between the two converges on the next
382
+ * read-path pass (release is idempotent). Retry lanes are not starved: the
383
+ * release happens in the same lazy pass that inferred the failure, just via
384
+ * the loop's machinery instead of around it.
385
+ *
386
+ * Declines (converged:false — the claim STAYS, a later read-path pass retries):
387
+ * - containment mismatch (never converge another store's loop/claim);
388
+ * - lock contention (LockTimeout/LockLost);
389
+ * - loop missing (a claim should not be stripped on evidence we cannot read).
390
+ * SUPERSEDED is converged:true WITHOUT release: a newer turn owns the slot, and
391
+ * claim REUSE across rounds is real (trp_e824d2af) — releasing the old round's
392
+ * claim would strip the live one.
393
+ */
394
+ export function reconcileFailedTurn(input) {
395
+ const { reservation, run, cwd } = input;
396
+ const actor = input.actor ?? 'reconciler';
397
+ // Containment gate — same rule as reconcileTurn (§8 Q6), same win32 case-fold.
398
+ const operatingRoot = path.resolve(cwd ?? process.cwd());
399
+ const reservationRoot = path.resolve(reservation.store_root);
400
+ const sameStore = process.platform === 'win32'
401
+ ? reservationRoot.toLowerCase() === operatingRoot.toLowerCase()
402
+ : reservationRoot === operatingRoot;
403
+ if (!sameStore) {
404
+ return { converged: false, claim_released: false, reason: `containment: reservation store_root ${reservation.store_root} != operating store ${operatingRoot}` };
405
+ }
406
+ try {
407
+ return withLoopLock({
408
+ cwd,
409
+ intent: 'reconcile-failed-turn',
410
+ agentId: actor,
411
+ scope: { kind: 'loop', loopId: reservation.loop_id },
412
+ work: () => convergeFailedLockedTurn(reservation, run, input.reason, actor, cwd),
413
+ });
414
+ }
415
+ catch (err) {
416
+ if (err instanceof LockTimeoutError || err instanceof LockLostError) {
417
+ return { converged: false, claim_released: false, reason: `deferred (${err.name}); the next read-path pass retries` };
418
+ }
419
+ throw err;
420
+ }
421
+ }
422
+ function convergeFailedLockedTurn(reservation, run, transportReason, actor, cwd) {
423
+ // Audit-exact release (review PR#166 round-2 P1): the event is emitted only
424
+ // when THIS call performed the active→released transition. The check lives
425
+ // INSIDE the claim store's own mutation (releaseClaimIfActive), so an
426
+ // external bclaw_release_claim landing concurrently can no longer slip
427
+ // between a caller-side check and the write and produce a phantom
428
+ // business-release event.
429
+ const releaseAudited = (claimId, why) => {
430
+ if (!claimId)
431
+ return false;
432
+ try {
433
+ if (!releaseClaimIfActive(claimId, cwd).released)
434
+ return false;
435
+ }
436
+ catch {
437
+ return false;
438
+ }
439
+ try {
440
+ createRuntimeEvent({
441
+ agent: actor,
442
+ event_type: 'run_failed',
443
+ text: `Released claim ${claimId} as the BUSINESS convergence of failed turn ${reservation.turn_id}: ${why}`,
444
+ tags: ['loops', 'reconcile', 'claim-release', 'effects-boundary'],
445
+ assignment_id: reservation.child_ids.assignment_id,
446
+ run_id: run.id,
447
+ claim_id: claimId,
448
+ status_reason: 'turn_failure_business_release',
449
+ }, cwd);
450
+ }
451
+ catch { /* observability best-effort — never undo the release */ }
452
+ return true;
453
+ };
454
+ const loop = getLoop(reservation.loop_id, cwd);
455
+ if (!loop) {
456
+ // No loop to record on — do NOT strip the claim on evidence we cannot read.
457
+ // A genuinely deleted loop leaves the claim to the staleness sweep.
458
+ return { converged: false, claim_released: false, reason: `loop ${reservation.loop_id} not found — claim retained for the staleness sweep` };
459
+ }
460
+ const authoritativeClaimId = loadAssignment(reservation.child_ids.assignment_id, cwd)?.claim_id ?? reservation.claim_id;
461
+ // Terminal loop: the business story is already over — releasing is pure
462
+ // idempotent cleanup, mirroring reconcileTurn's terminal early-return.
463
+ if (LOOP_TERMINAL.has(loop.status)) {
464
+ const released = releaseAudited(authoritativeClaimId, `loop already ${loop.status}`);
465
+ return { converged: true, claim_released: released, reason: `loop already ${loop.status} — release-only cleanup` };
466
+ }
467
+ const slot = loop.slots.find((s) => s.slot_id === reservation.slot_id);
468
+ if (!slot) {
469
+ return { converged: false, claim_released: false, reason: `slot ${reservation.slot_id} not in loop ${reservation.loop_id} — claim retained` };
470
+ }
471
+ // Superseded: a NEWER turn owns this slot. Claim reuse across rounds is real
472
+ // (trp_e824d2af — round 2 rode round 1's claim), so releasing here would strip
473
+ // the LIVE attempt. Nothing to do for the dead round; the live one converges it.
474
+ if (slot.current_turn_id !== undefined && slot.current_turn_id !== reservation.turn_id) {
475
+ return { converged: true, claim_released: false, reason: `turn ${reservation.turn_id} superseded by ${slot.current_turn_id} — claim belongs to the live turn` };
476
+ }
477
+ // Record the business failure on the loop FIRST (crash-atomic WAL), then
478
+ // release. A slot already terminal means a prior pass recorded it — skip the
479
+ // double-record, still release (idempotent).
480
+ const slotTerminal = slot.status === 'done' || slot.status === 'failed' || slot.status === 'cancelled';
481
+ if (!slotTerminal) {
482
+ try {
483
+ complete_turn({
484
+ id: loop.id,
485
+ slot_id: slot.slot_id,
486
+ actor,
487
+ outcome: 'failed',
488
+ failure_reason: transportReason,
489
+ }, cwd);
490
+ }
491
+ catch (err) {
492
+ return { converged: false, claim_released: false, reason: `complete_turn failed: ${err instanceof Error ? err.message : String(err)} — claim retained, next pass retries` };
493
+ }
494
+ }
495
+ const released = releaseAudited(authoritativeClaimId, transportReason);
496
+ return {
497
+ converged: true,
498
+ claim_released: released,
499
+ reason: slotTerminal
500
+ ? `turn ${reservation.turn_id} failure already recorded — release re-attempted`
501
+ : `turn ${reservation.turn_id} failure recorded on loop ${loop.id}; claim ${released ? 'released' : 'not active'}`,
502
+ };
503
+ }
369
504
  //# sourceMappingURL=reconcile-turn.js.map
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * WHY THIS EXISTS. 2a made the live header HONEST: it stopped claiming
5
5
  * "auto-refreshed" and started naming its real triggers (session-end, handoff,
6
- * `export --write`) plus the version and timestamp that wrote it. Honesty alone
6
+ * `brainclaw refresh`) plus the version and timestamp that wrote it. Honesty alone
7
7
  * does not help an agent tier that never fires any of those triggers, though — it
8
8
  * just tells that tier, truthfully, that the file might be arbitrarily old. 2b
9
9
  * closes the loop by USING the stamp: compare it against the running version and
@@ -63,16 +63,23 @@ export function assessSurfaceFreshness(content, currentVersion) {
63
63
  return { kind: 'fresh', version };
64
64
  return { kind: 'stale', stampedVersion: version, currentVersion };
65
65
  }
66
+ /** The command that regenerates every stable surface. `export --write` alone is rejected by the CLI (a mode flag is required — see runExport). */
67
+ export const STABLE_SURFACE_REFRESH_COMMAND = 'brainclaw export --all --write';
68
+ /** The command that regenerates every live companion. */
69
+ export const LIVE_SURFACE_REFRESH_COMMAND = 'brainclaw refresh';
66
70
  /**
67
71
  * The set of surfaces this project could have on disk, derived from the export
68
72
  * registries rather than listed here. Deduplicated because several agents share
69
- * a target (four of them write AGENTS.md).
73
+ * a target (four of them write AGENTS.md); a path claimed by both registries
74
+ * counts as stable, since `export` regenerates it.
70
75
  */
71
- function candidateSurfacePaths() {
72
- return [...new Set([
73
- ...AGENT_EXPORT_REGISTRY.map((t) => t.relativePath),
74
- ...LIVE_COMPANION_EXPORT_REGISTRY.map((t) => t.relativePath),
75
- ])];
76
+ function candidateSurfaces() {
77
+ const stable = new Set(AGENT_EXPORT_REGISTRY.map((t) => t.relativePath));
78
+ const live = new Set(LIVE_COMPANION_EXPORT_REGISTRY.map((t) => t.relativePath));
79
+ return [
80
+ ...[...stable].map((relativePath) => ({ relativePath, kind: 'stable' })),
81
+ ...[...live].filter((p) => !stable.has(p)).map((relativePath) => ({ relativePath, kind: 'live' })),
82
+ ];
76
83
  }
77
84
  /**
78
85
  * Scan the project's generated surfaces and report the ones stamped with a
@@ -85,7 +92,7 @@ function candidateSurfacePaths() {
85
92
  */
86
93
  export function reconcileSurfaceFreshness(cwd, currentVersion) {
87
94
  const result = { stale: [], freshCount: 0, unknownCount: 0 };
88
- for (const relativePath of candidateSurfacePaths()) {
95
+ for (const { relativePath, kind } of candidateSurfaces()) {
89
96
  const full = path.join(cwd, relativePath);
90
97
  let head;
91
98
  try {
@@ -107,7 +114,7 @@ export function reconcileSurfaceFreshness(cwd, currentVersion) {
107
114
  }
108
115
  const verdict = assessSurfaceFreshness(head, currentVersion);
109
116
  if (verdict.kind === 'stale')
110
- result.stale.push({ relativePath, stampedVersion: verdict.stampedVersion });
117
+ result.stale.push({ relativePath, stampedVersion: verdict.stampedVersion, kind });
111
118
  else if (verdict.kind === 'fresh')
112
119
  result.freshCount += 1;
113
120
  else
@@ -119,31 +126,48 @@ export function reconcileSurfaceFreshness(cwd, currentVersion) {
119
126
  * Build the advisory for a stale-surface scan, or `undefined` when there is
120
127
  * nothing to say.
121
128
  *
122
- * NO `next_actions`, deliberately. The recovery is `brainclaw export --write`,
123
- * and there is no MCP tool that performs it — `bclaw_setup` is the onboarding
124
- * wizard and takes no write flag. Pointing at it anyway would ship a next_action
125
- * whose args the engine rejects, which is the precise class of drift this plan
126
- * exists to eliminate; and per pln#634's own rule, a builder with no genuine
127
- * follow-up returns nothing rather than inventing one. The command therefore
128
- * travels in the message, where it is true.
129
+ * NO `next_actions`, deliberately. The recovery is a CLI command, and there is
130
+ * no MCP tool that performs it — `bclaw_setup` is the onboarding wizard and
131
+ * takes no write flag. Pointing at it anyway would ship a next_action whose
132
+ * args the engine rejects, which is the precise class of drift this plan exists
133
+ * to eliminate; and per pln#634's own rule, a builder with no genuine follow-up
134
+ * returns nothing rather than inventing one. The command therefore travels in
135
+ * the message, where it is true.
136
+ *
137
+ * WHICH command depends on what is stale (trp_6a49f976): this advisory shipped
138
+ * in 1.20.0 recommending `brainclaw export --write`, which the CLI rejects
139
+ * (a mode flag is required) and which — even corrected to `--all` — never
140
+ * touches live companions, the very files the first real-world firing listed.
141
+ * The recovery must be per kind, and only for the kinds actually stale.
129
142
  */
130
143
  export function staleSurfaceWarning(result, currentVersion) {
131
144
  if (result.stale.length === 0)
132
145
  return undefined;
133
146
  const shown = result.stale.slice(0, 8);
134
147
  const overflow = result.stale.length - shown.length;
148
+ const kinds = new Set(result.stale.map((s) => s.kind));
149
+ const commands = [
150
+ ...(kinds.has('stable') ? [STABLE_SURFACE_REFRESH_COMMAND] : []),
151
+ ...(kinds.has('live') ? [LIVE_SURFACE_REFRESH_COMMAND] : []),
152
+ ];
153
+ const recovery = kinds.size === 2
154
+ ? ` Run \`${STABLE_SURFACE_REFRESH_COMMAND}\` (stable surfaces) and \`${LIVE_SURFACE_REFRESH_COMMAND}\` (live companions) to refresh them.`
155
+ : ` Run \`${commands[0]}\` to refresh them.`;
135
156
  return {
136
157
  code: 'generated_surfaces_stale',
137
158
  message: `${result.stale.length} generated guidance surface(s) were written by an older brainclaw than v${currentVersion}: `
138
159
  + shown.map((s) => `${s.relativePath} (v${s.stampedVersion})`).join(', ')
139
160
  + (overflow > 0 ? ` (+${overflow} more)` : '')
140
161
  + '. An agent tier that never triggers a regeneration is reading them as-is.'
141
- + ' Run `brainclaw export --write` to refresh them.',
162
+ + recovery,
142
163
  data: {
143
164
  current_version: currentVersion,
144
- stale_surfaces: shown.map((s) => ({ path: s.relativePath, stamped_version: s.stampedVersion })),
165
+ stale_surfaces: shown.map((s) => ({ path: s.relativePath, stamped_version: s.stampedVersion, kind: s.kind })),
145
166
  ...(overflow > 0 ? { stale_surfaces_omitted: overflow } : {}),
146
- refresh_command: 'brainclaw export --write',
167
+ // Kept as a single runnable string for consumers that shipped against
168
+ // 1.20.0; refresh_commands is the structured form.
169
+ refresh_command: commands.join(' && '),
170
+ refresh_commands: commands,
147
171
  },
148
172
  };
149
173
  }
@@ -515,6 +515,24 @@ export function resetWorktreeToRef(worktreePath, ref) {
515
515
  if (!res.ok) {
516
516
  return { ok: false, stderr: res.stderr };
517
517
  }
518
+ // trp_e824d2af — LANE-RESULT.json is a TERMINAL SIGNAL, and `reset --hard`
519
+ // does not touch untracked files: left at the root, the PRIOR turn's result
520
+ // reads as the NEXT turn's completion (dispatch_status declared a
521
+ // freshly-spawned round 2 "worker reported done" with round 1's verdict,
522
+ // observed live 2026-08-02). Archive it into the worktree's .brainclaw/
523
+ // sidecar — preserved for forensics, out of the signal path, and already
524
+ // excluded from the residue check below. Best-effort: an archive failure
525
+ // falls through to the residue check, which then names the file loudly
526
+ // instead of passing it silently.
527
+ try {
528
+ const laneResultPath = path.join(worktreePath, 'LANE-RESULT.json');
529
+ if (fs.existsSync(laneResultPath)) {
530
+ const archiveDir = path.join(worktreePath, '.brainclaw');
531
+ fs.mkdirSync(archiveDir, { recursive: true });
532
+ fs.renameSync(laneResultPath, path.join(archiveDir, `LANE-RESULT.prev-${fs.statSync(laneResultPath).mtimeMs.toFixed(0)}.json`));
533
+ }
534
+ }
535
+ catch { /* best-effort — the residue check below surfaces what remains */ }
518
536
  // `reset --hard` realigns HEAD + tracked files, but leaves UNTRACKED residue
519
537
  // from a prior use of the worktree — files the worker could still compile or
520
538
  // test against even though they don't exist at the pinned ref (codex r3).
@@ -555,6 +573,32 @@ export function resetWorktreeToRef(worktreePath, ref) {
555
573
  }
556
574
  return { ok: true, stderr: '' };
557
575
  }
576
+ /**
577
+ * trp_72b4e9b3 — on worktree ADOPTION, re-stamp the sidecar's base anchor.
578
+ *
579
+ * The sidecar's `base_ref_sha` (trp#926) is the anchor `commits_ahead` counts
580
+ * from. An adopted worktree keeps its ROUND-1 creation stamp, so round 2's git
581
+ * evidence counted commits since the previous round's base — observed live as
582
+ * a nonsensical `commits_ahead: 2` on a freshly re-pointed worktree. Best-effort:
583
+ * a missing/unreadable sidecar (hand-made worktree) is left untouched.
584
+ */
585
+ function refreshSidecarBaseSha(worktreePath, mainWorktreePath, baseRef) {
586
+ const sidecarPath = path.join(worktreePath, '.brainclaw-worktree.json');
587
+ try {
588
+ if (!fs.existsSync(sidecarPath))
589
+ return;
590
+ const meta = JSON.parse(fs.readFileSync(sidecarPath, 'utf-8'));
591
+ const rev = runGit(['rev-parse', baseRef], mainWorktreePath);
592
+ meta.base_ref = baseRef;
593
+ if (rev.ok)
594
+ meta.base_ref_sha = rev.stdout.trim();
595
+ else
596
+ delete meta.base_ref_sha;
597
+ meta.adopted_at = new Date().toISOString();
598
+ fs.writeFileSync(sidecarPath, JSON.stringify(meta, null, 2));
599
+ }
600
+ catch { /* best-effort — stale anchor is observability-only */ }
601
+ }
558
602
  /**
559
603
  * Detects whether multiple distinct brainclaw sessions are using the same
560
604
  * physical worktree directory (shared-checkout risk).
@@ -765,6 +809,68 @@ export function createWorktree(mainWorktreePath, branchName, options = {}) {
765
809
  }
766
810
  const targetPath = resolveWorktreePath(mainWorktreePath, branchName);
767
811
  if (fs.existsSync(targetPath)) {
812
+ // trp_72b4e9b3 — the path derives from the branch name, and loop-scoped
813
+ // dispatches derive the SAME branch every round (feat/review-loop-<lop>).
814
+ // A fresh claim on round 2 therefore collided here and the whole spawn
815
+ // failed (spawn_no_worktree) — worse, the claim persisted without a
816
+ // worktree and wedged every later dispatch on the scope. When the existing
817
+ // path is a REGISTERED worktree of this repo checked out on EXACTLY the
818
+ // requested branch, ADOPT it: honor the reused-branch contract first
819
+ // (can_2e282880 — never destroy unharvested commits unless the caller
820
+ // explicitly pinned a reset), then re-point it to the base. Anything else
821
+ // at the path (foreign dir, other branch) still refuses loudly.
822
+ const attachedPath = findWorktreePathForBranch(listWorktrees(mainWorktreePath), branchName);
823
+ const sameTarget = attachedPath !== undefined && (process.platform === 'win32'
824
+ ? path.resolve(attachedPath).toLowerCase() === path.resolve(targetPath).toLowerCase()
825
+ : path.resolve(attachedPath) === path.resolve(targetPath));
826
+ if (sameTarget) {
827
+ // Resolve the base to a SHA in the MAIN repo first: resetWorktreeToRef
828
+ // runs inside the worktree, where a symbolic ref like "HEAD" resolves to
829
+ // the WORKTREE's own tip — a silent no-op reset onto the stale round.
830
+ const requestedBase = options.baseRef ?? 'HEAD';
831
+ const baseRev = runGit(['rev-parse', requestedBase], mainWorktreePath);
832
+ if (!baseRev.ok) {
833
+ throw new Error(`Cannot adopt worktree ${targetPath}: base ref ${requestedBase} does not resolve: ${baseRev.stderr.trim()}`);
834
+ }
835
+ const adoptBase = baseRev.stdout.trim();
836
+ // Review PR#167 P1 — TRACKED dirt is unharvested work, unconditionally.
837
+ // A sandboxed codex CANNOT commit (.git read-only): its entire review
838
+ // output lives as staged/unstaged edits the coordinator harvests via
839
+ // `git diff HEAD`. The commit guard below never sees those, and the
840
+ // hard reset would destroy them silently — even under an explicit
841
+ // reset pin, because the pin means "start from this base", never
842
+ // "discard a worker's unharvested output". Untracked files are fine:
843
+ // the reset leaves them and resetWorktreeToRef's residue check governs.
844
+ const dirt = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=no'], targetPath);
845
+ if (!dirt.ok) {
846
+ throw new Error(`Cannot adopt worktree ${targetPath}: dirty-state check failed: ${dirt.stderr.trim()}`);
847
+ }
848
+ if (dirt.stdout.length > 0) {
849
+ const files = dirt.stdout.split('\0').filter(Boolean).map((e) => e.slice(3)).slice(0, 5).join(', ');
850
+ throw new Error(`Refusing to adopt worktree ${targetPath}: it has uncommitted TRACKED changes (${files}) — a sandboxed worker's ` +
851
+ `unharvested output. Harvest the diff first (git diff HEAD in that worktree) or remove the worktree.`);
852
+ }
853
+ if (!options.resetExistingBranch) {
854
+ const ahead = runGit(['rev-list', '--count', `${adoptBase}..${branchName}`], mainWorktreePath);
855
+ const aheadCount = ahead.ok ? parseInt(ahead.stdout.trim(), 10) : NaN;
856
+ if (!Number.isFinite(aheadCount)) {
857
+ throw new Error(`Cannot assess divergence of existing worktree branch ${branchName} vs ${adoptBase}: ${ahead.stderr.trim()}`);
858
+ }
859
+ if (aheadCount > 0) {
860
+ const commits = runGit(['log', '--oneline', '-n', '5', `${adoptBase}..${branchName}`], mainWorktreePath);
861
+ throw new Error(`Refusing to adopt worktree ${targetPath}: branch ${branchName} has ${aheadCount} commit(s) not on ${adoptBase} (unharvested work). ` +
862
+ `Harvest/merge or remove the worktree first. Divergent commits:\n${commits.stdout.trim()}`);
863
+ }
864
+ }
865
+ const reset = resetWorktreeToRef(targetPath, adoptBase);
866
+ if (!reset.ok) {
867
+ throw new Error(`Worktree path already exists and could not be adopted (reset to ${adoptBase} failed: ${reset.stderr.trim()}). ` +
868
+ `Remove it first with 'brainclaw worktree remove'.`);
869
+ }
870
+ logger.warn(`[worktree] adopted existing worktree ${targetPath} (branch ${branchName}) and re-pointed it to ${adoptBase}`);
871
+ refreshSidecarBaseSha(targetPath, mainWorktreePath, adoptBase);
872
+ return targetPath;
873
+ }
768
874
  throw new Error(`Worktree path already exists: ${targetPath}. Remove it first with 'brainclaw worktree remove'.`);
769
875
  }
770
876
  // Ensure parent directory exists
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.20.0 on 2026-08-02T17:17:41.989Z
2
+ // Source: brainclaw v1.20.2 on 2026-08-03T09:22:06.116Z
3
3
  export const FACTS = {
4
- "version": "1.20.0",
5
- "generated_at": "2026-08-02T17:17:41.989Z",
4
+ "version": "1.20.2",
5
+ "generated_at": "2026-08-03T09:22:06.116Z",
6
6
  "tools": {
7
7
  "count": 67,
8
8
  "published_count": 65,
@@ -474,7 +474,7 @@ export const FACTS = {
474
474
  },
475
475
  "bench": {
476
476
  "schema": "brainclaw.bench.v1",
477
- "generated_at": "2026-08-02T17:17:40.300Z",
477
+ "generated_at": "2026-08-03T09:22:04.503Z",
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": 65,
486
+ "duration_ms_median": 51,
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": 101,
494
+ "duration_ms_median": 80,
495
495
  "payload_chars_median": 2625,
496
496
  "payload_tokens_est_median": 656
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": 10,
502
+ "duration_ms_median": 9,
503
503
  "payload_chars_median": 499,
504
504
  "payload_tokens_est_median": 125
505
505
  }
package/dist/facts.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.20.0",
3
- "generated_at": "2026-08-02T17:17:41.989Z",
2
+ "version": "1.20.2",
3
+ "generated_at": "2026-08-03T09:22:06.116Z",
4
4
  "tools": {
5
5
  "count": 67,
6
6
  "published_count": 65,
@@ -472,7 +472,7 @@
472
472
  },
473
473
  "bench": {
474
474
  "schema": "brainclaw.bench.v1",
475
- "generated_at": "2026-08-02T17:17:40.300Z",
475
+ "generated_at": "2026-08-03T09:22:04.503Z",
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": 65,
484
+ "duration_ms_median": 51,
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": 101,
492
+ "duration_ms_median": 80,
493
493
  "payload_chars_median": 2625,
494
494
  "payload_tokens_est_median": 656
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": 10,
500
+ "duration_ms_median": 9,
501
501
  "payload_chars_median": 499,
502
502
  "payload_tokens_est_median": 125
503
503
  }
@@ -8,6 +8,46 @@ guarantees this changelog follows.
8
8
 
9
9
  ---
10
10
 
11
+ ## [1.20.2] — 2026-08-03
12
+
13
+ **Added — `lane_result_stale` on the dispatch_status runtime snapshot (#167)**
14
+ - Additive sibling of `runtime.lane_result`: a LANE-RESULT.json found at the
15
+ worktree root that belongs to a DIFFERENT assignment (a prior turn in a
16
+ reused worktree) now lands here — `{ assignment_id, status, summary }` —
17
+ and is NEVER treated as this dispatch's terminal signal. `lane_result`
18
+ itself is unchanged in shape; it is simply no longer populated by a foreign
19
+ file (that population was the trp_e824d2af defect, not a contract).
20
+
21
+ **Added — new audited runtime-event reason (#166)**
22
+ - `status_reason: "turn_failure_business_release"` on `run_failed` events:
23
+ the business release of a turn-owned lane's claim after a transport
24
+ failure, emitted by the loop convergence (replaces the GC cascade's
25
+ `gc_cascade_release_on_failure` for turn-owned lanes only; non-turn-owned
26
+ runs keep emitting the GC reason).
27
+
28
+ **Changed — stale-warnings aggregate text now carries ids (#167)**
29
+ - The `stale_warnings_aggregate` string embeds the folded item ids grouped by
30
+ entity (cap 8 per entity + overflow note) instead of recommending
31
+ `bclaw_find(status:'stale')`, which the engine cannot resolve. Prose-only;
32
+ no structured field changed.
33
+ - Read contract only across all three: no tool added/removed/renamed, no
34
+ inputSchema change, no surface-fingerprint movement.
35
+
36
+ ## [1.20.1] — 2026-08-02
37
+
38
+ **Changed — `generated_surfaces_stale` recovery data is now true, and structured (#163)**
39
+ - The VALUE of `data.refresh_command` changes: it was `brainclaw export --write`,
40
+ which the CLI rejects (a mode flag is required), and is now a runnable command
41
+ derived from what is actually stale — `brainclaw export --all --write` (stable
42
+ surfaces), `brainclaw refresh` (live companions), or both joined with ` && `
43
+ when both kinds are stale. The field keeps its type (string); consumers that
44
+ displayed it verbatim now display something that works.
45
+ - Additive fields on the same warning's `data`: `refresh_commands: string[]`
46
+ (the structured form of the above) and `kind: "stable" | "live"` on each
47
+ `stale_surfaces[]` entry.
48
+ - Read contract only: no tool added/removed/renamed, no inputSchema change,
49
+ no surface-fingerprint movement.
50
+
11
51
  ## [1.20.0] — 2026-08-02
12
52
 
13
53
  **Fixed — 1.19.0 contract entries now actually emitted (#158)**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainclaw",
3
- "version": "1.20.0",
3
+ "version": "1.20.2",
4
4
  "description": "Shared project memory for humans and coding agents.",
5
5
  "type": "module",
6
6
  "repository": {