brainclaw 1.20.2 → 1.20.3

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
@@ -27,6 +27,7 @@ import { closeIdeationLoopFromLaneResult } from '../core/ideation-loop-close.js'
27
27
  import { dispatchReviewLoopTurn, turnOwnedReviewEnabled } from '../core/review-loop-turn-dispatch.js';
28
28
  import { reconcileTurn } from '../core/loops/reconcile-turn.js';
29
29
  import { findReservationByAssignmentId } from '../core/loops/attempt-reservation.js';
30
+ import { getLoop } from '../core/loops/store.js';
30
31
  import { readCompletionSignals } from '../core/runtime-signals.js';
31
32
  import { reconcileClaimConformity } from '../core/claim-conformity.js';
32
33
  import { toWarningDetail } from '../core/warnings.js';
@@ -70,8 +71,8 @@ function turnOwnedLaneEvidence(lane, cwd) {
70
71
  return undefined; // reservation but NO turn-keyed evidence → legacy finalization
71
72
  return { reservation, nonce };
72
73
  }
73
- function reconcileTurnOwnedReviewLane(lane, cwd) {
74
- const ev = turnOwnedLaneEvidence(lane, cwd);
74
+ function reconcileTurnOwnedReviewLane(lane, cwd, evidence) {
75
+ const ev = evidence ?? turnOwnedLaneEvidence(lane, cwd);
75
76
  if (!ev)
76
77
  return undefined; // legacy lane OR no turn-keyed evidence — caller runs the legacy path
77
78
  const { reservation, nonce } = ev;
@@ -99,6 +100,62 @@ function reconcileToReviewLoopResult(reservation, rr, lane) {
99
100
  loop_status: rr.loop_status,
100
101
  };
101
102
  }
103
+ /**
104
+ * pln#644 — the warn-only branches must stay QUIET for a lane whose turn is no
105
+ * longer the live one: a prior round's LANE-RESULT re-scanned by `harvest --all`
106
+ * after the loop advanced (superseded) or closed (terminal) is a healthy flow,
107
+ * not a stall. Warning there would train operators to ignore the one warning
108
+ * that matters. Only an OPEN loop whose slot still points at THIS turn is
109
+ * actually awaiting convergence.
110
+ */
111
+ function loopTurnAwaitsConvergence(reservation, cwd) {
112
+ const loop = getLoop(reservation.loop_id, cwd);
113
+ if (!loop)
114
+ return false;
115
+ // Only a live, advancing loop is awaiting convergence (PR #171 review P2-1).
116
+ // 'blocked' (iteration cap) is in reconcileTurn's LOOP_TERMINAL set, and a
117
+ // 'paused' loop refuses advancement until resumed — advising `--integrate`
118
+ // on either would be a false alarm. 'open' is the only non-terminal,
119
+ // advancing status (LoopStatus non-terminal = 'open' | 'paused').
120
+ if (loop.status !== 'open')
121
+ return false;
122
+ const slot = loop.slots.find((s) => s.slot_id === reservation.slot_id);
123
+ if (!slot)
124
+ return false;
125
+ // Mirror reconcileTurn's superseded guard exactly: an UNSET current_turn_id is
126
+ // "not superseded" (a dispatch whose turn() never rebound the slot pointer is
127
+ // still this turn's attempt), only a pointer to a DIFFERENT turn means the
128
+ // slot moved on and the stale lane deserves silence, not a warning.
129
+ return slot.current_turn_id === undefined || slot.current_turn_id === reservation.turn_id;
130
+ }
131
+ /**
132
+ * pln#644 — the loud half of "converge or fail loudly". The message names the
133
+ * exact command that finalizes the turn (`harvest --integrate`) AND the manual
134
+ * loop-drive alternative, because the two lived stalls (2026-08-02/03) were
135
+ * both resolved manually once the coordinator finally noticed the open turn.
136
+ */
137
+ function reviewTurnNotConvergedWarning(lane, reservation, why) {
138
+ return toWarningDetail({
139
+ code: 'review_turn_not_converged',
140
+ message: `Turn-owned review lane ${lane.assignment_id} harvested report-only: loop ${reservation.loop_id} ` +
141
+ `turn ${reservation.turn_id} is NOT converged — ${why}. ` +
142
+ `Run \`brainclaw harvest --integrate ${lane.assignment_id}\` to finalize it ` +
143
+ `(records the verdict; on request_changes it also drives the fix cycle), ` +
144
+ `or drive the loop manually (bclaw_loop add_artifact + complete_turn).`,
145
+ data: {
146
+ assignment_id: lane.assignment_id,
147
+ loop_id: reservation.loop_id,
148
+ turn_id: reservation.turn_id,
149
+ review_verdict: lane.review_verdict ?? null,
150
+ reason: why,
151
+ },
152
+ next_actions: [{
153
+ tool: 'bclaw_loop',
154
+ args: { intent: 'get', loop_id: reservation.loop_id },
155
+ when: 'inspect the open turn before converging it via --integrate or a manual complete_turn',
156
+ }],
157
+ });
158
+ }
102
159
  /**
103
160
  * Auto-detect all worktree directories under the brainclaw-managed base dir.
104
161
  * Returns subdirectories that exist on disk (may or may not have an inbox).
@@ -358,20 +415,54 @@ export function harvestLaneResults(options = {}) {
358
415
  // PR2: cycleOnRequestChanges=false — the report path only closes on approve;
359
416
  // it must NOT advance a request_changes cycle it cannot follow through on
360
417
  // (no re-dispatch, no claim retention). `harvest --integrate` owns the cycle.
418
+ // Hoisted for the catch below (PR #171 review P2-2): if turn-owned evidence was
419
+ // found before a throw, the swallowed failure must still surface as a warning.
420
+ let turnEvidenceForCatch;
361
421
  try {
362
422
  const laneAssignment = loadAssignment(lane.assignment_id, cwd);
363
423
  if (laneAssignment) {
364
- // pln#630 PR3a — a TURN-OWNED review lane (reservation + turn-keyed evidence) is finalized
365
- // ONLY by reconcileTurn on the `--integrate` path (which owns claim/worktree teardown). The
366
- // report path must NOT pre-empt it with a legacy close (which would terminalize the loop and
367
- // strand the turn-owned run). So skip the legacy review-close for such a lane. Kill-switch
368
- // (=0), a legacy lane (no reservation), OR a reservation WITHOUT evidence (review Finding 1:
369
- // an inbox_only/non-ack-wrapped dispatch that never wrote a sentinel) false the lane
370
- // takes the unchanged legacy close so it still converges. Ideation stays legacy (review-only).
371
- const laneIsTurnOwned = turnOwnedReviewEnabled() && !!turnOwnedLaneEvidence(lane, cwd);
372
- if (!laneIsTurnOwned) {
424
+ // pln#644 (supersedes the pln#630 PR3a report-path deferral) — a TURN-OWNED review lane
425
+ // used to be skipped ENTIRELY here (finalization deferred to `--integrate`) with no
426
+ // signal at all: the operator ran `brainclaw harvest <asgn>`, read "1 harvested", and
427
+ // the loop turn silently stayed open. That stalled two live loops on 2026-08-02/03
428
+ // (lop_626271ee10ad09d8, lop_4d869568bd99ddc0), both converged by hand. The report
429
+ // path now converges what it safely CAN and says what it can't:
430
+ // - APPROVE reconcileTurn right here the same exactly-once idempotent finalizer
431
+ // `--integrate` uses (mirrors the pln#638 1c ideation closer firing on this path).
432
+ // Read-strict is NOT weakened: evidence still comes from the lane keys or the
433
+ // wrapper sentinel, and a mismatch still refuses (loudly, below).
434
+ // - REQUEST_CHANGES / no verdict / refused evidence → the loop is left alone (the
435
+ // report path still cannot follow through on a fix cycle: no re-dispatch, no
436
+ // commit-on-behalf — `--integrate` owns that) but a `review_turn_not_converged`
437
+ // WARNING now names the open turn and the recovery. Never a silent stall.
438
+ // Kill-switch (=0), a legacy lane (no reservation), OR a reservation WITHOUT evidence
439
+ // (review Finding 1: an inbox_only/non-ack-wrapped dispatch that never wrote a
440
+ // sentinel) → the lane takes the unchanged legacy close so it still converges.
441
+ // Ideation stays legacy (review-only).
442
+ const laneTurnEvidence = turnOwnedReviewEnabled() ? turnOwnedLaneEvidence(lane, cwd) : undefined;
443
+ turnEvidenceForCatch = laneTurnEvidence;
444
+ if (!laneTurnEvidence) {
373
445
  closeReviewLoopFromLaneResult(laneAssignment, lane, agent, cwd, { cycleOnRequestChanges: false });
374
446
  }
447
+ else if (lane.review_verdict === 'approve') {
448
+ const rr = reconcileTurnOwnedReviewLane(lane, cwd, laneTurnEvidence);
449
+ // Reason-based quietness (PR #171 review P2-1 refinement): a terminal loop
450
+ // returns reconciled:true (idempotent no-op) and a superseded turn is the one
451
+ // healthy decline (`harvest --all` over a prior round's lane) — everything
452
+ // else (refused evidence, deferred lock, loop/slot not found, contradiction,
453
+ // paused complete_turn refusal) is a live approve that did NOT land and must
454
+ // say so. No getLoop re-read here: the decline reason already discriminates,
455
+ // and the loop store itself may be the failing component.
456
+ if (rr && !rr.result.reconciled && !/superseded/.test(rr.result.reason ?? '')) {
457
+ result.warnings.push(reviewTurnNotConvergedWarning(lane, laneTurnEvidence.reservation, `reconcileTurn refused: ${rr.result.reason ?? 'no reason given'}`));
458
+ }
459
+ }
460
+ else if (loopTurnAwaitsConvergence(laneTurnEvidence.reservation, cwd)) {
461
+ const why = lane.review_verdict === 'request_changes'
462
+ ? 'a request_changes fix cycle needs re-dispatch + commit-on-behalf, which the report path does not do'
463
+ : 'the lane carries no review_verdict, so nothing proves the reviewer reached a verdict';
464
+ result.warnings.push(reviewTurnNotConvergedWarning(lane, laneTurnEvidence.reservation, why));
465
+ }
375
466
  // pln#521 P2-bis — the ideation analog: a critic lane records its critique +
376
467
  // advances the ideation loop. Returns undefined for non-ideate scopes (no-op here).
377
468
  ideationLoop = closeIdeationLoopFromLaneResult(laneAssignment, lane, agent, cwd);
@@ -393,7 +484,22 @@ export function harvestLaneResults(options = {}) {
393
484
  }
394
485
  }
395
486
  }
396
- catch { /* never block harvest on loop-close */ }
487
+ catch (err) {
488
+ // Never block harvest on loop-close — but a swallowed failure must not be
489
+ // SILENT for a turn-owned lane (PR #171 review P2-2): before this warning,
490
+ // an unexpected reconcile/store error left the operator with "1 harvested,
491
+ // 0 error(s)" and an open loop turn — the exact stall pln#644 exists to
492
+ // kill. Deliberately no liveness/superseded re-check here: that would
493
+ // re-read the loop store, which may be the very component that just threw
494
+ // (getLoop propagates parse errors). A rare over-warning on a broken store
495
+ // beats a silent stall on a live turn.
496
+ if (turnEvidenceForCatch) {
497
+ try {
498
+ result.warnings.push(reviewTurnNotConvergedWarning(lane, turnEvidenceForCatch.reservation, `loop-close failed unexpectedly: ${err instanceof Error ? err.message : String(err)}`));
499
+ }
500
+ catch { /* truly never block harvest */ }
501
+ }
502
+ }
397
503
  const marker = laneHarvestedMarkerPath(cwd, lane.assignment_id);
398
504
  if (fs.existsSync(marker)) {
399
505
  result.skipped.push(lane.assignment_id);
@@ -1043,6 +1149,10 @@ export async function runHarvestLane(assignmentId, options = {}) {
1043
1149
  harvested: result.harvested,
1044
1150
  skipped: result.skipped,
1045
1151
  errors: result.errors,
1152
+ // pln#644 — warnings (review_turn_not_converged, claim conformity) were
1153
+ // collected but never emitted on ANY channel; the silent half of the
1154
+ // 2026-08-02/03 review-loop stalls.
1155
+ warnings: result.warnings,
1046
1156
  }, null, 2));
1047
1157
  return;
1048
1158
  }
@@ -1074,6 +1184,12 @@ export async function runHarvestLane(assignmentId, options = {}) {
1074
1184
  for (const err of result.errors) {
1075
1185
  console.error(` ✗ ${err}`);
1076
1186
  }
1077
- console.log(`\n✔ Lane harvest complete${dryTag}: ${result.harvested.length} harvested, ${result.skipped.length} skipped, ${result.errors.length} error(s).`);
1187
+ // pln#644 warnings must reach the operator's eyes: a turn-owned review lane
1188
+ // whose loop turn did not converge used to vanish behind "N harvested".
1189
+ for (const w of result.warnings) {
1190
+ console.log(` ⚠ ${w.message}`);
1191
+ }
1192
+ const warnTag = result.warnings.length > 0 ? `, ${result.warnings.length} warning(s)` : '';
1193
+ console.log(`\n✔ Lane harvest complete${dryTag}: ${result.harvested.length} harvested, ${result.skipped.length} skipped, ${result.errors.length} error(s)${warnTag}.`);
1078
1194
  }
1079
1195
  //# sourceMappingURL=harvest.js.map
@@ -324,7 +324,27 @@ function computeDiagnosis(assignment, agentRun, runtime, options) {
324
324
  // status is running / launching / waiting_input / blocked → check liveness
325
325
  const lastEventMs = new Date(agentRun.last_event_at ?? agentRun.started_at ?? agentRun.created_at).getTime();
326
326
  const stallAge = options.nowMs - lastEventMs;
327
+ // pln#527 — a stale last_event_at is NOT "stalled" when the filesystem is still
328
+ // active (logs streaming / worktree files edited). Workers emit no heartbeat
329
+ // during a long single operation (codex→stderr, claude -p buffering stdout),
330
+ // so fs activity is the truer liveness signal and vetoes the false-stalled.
331
+ const fsAge = runtime.last_fs_activity_ms;
332
+ const fsActive = fsAge !== undefined && fsAge < options.stallMs;
327
333
  if (runtime.pid_alive === false) {
334
+ // pln#621 pack (review of PR #170) — the fs-activity veto applies HERE too,
335
+ // not only to the stalled branch below. On an ack-wrapped spawn the tracked
336
+ // pid is the WRAPPER, dead by design while the worker keeps writing — the
337
+ // exact pln#520 shape (6 workers "dead", committing minutes later). This
338
+ // branch used to skip the veto and advise "cancel + reroute" on a worker
339
+ // that was demonstrably mid-write: a destructive recommendation on
340
+ // ambiguous evidence, which is the one thing the regression pack forbids.
341
+ if (fsActive) {
342
+ return {
343
+ health: 'healthy',
344
+ summary: `pid ${runtime.pid} is dead but the filesystem is ACTIVE (${Math.round((fsAge ?? 0) / 1000)}s ago) — on an ack-wrapped spawn the tracked pid is the wrapper, which exits by design while the worker keeps writing (pln#520)`,
345
+ recommended_next_action: 'No destructive action — the worker is writing. Re-check until a terminal signal appears (LANE-RESULT, completion sentinel, or a commit on the lane branch).',
346
+ };
347
+ }
328
348
  // pln#527 (#5) — surface a TARGETED diagnosis when the captured stderr matches
329
349
  // a known fatal boot signature (codex model/service_tier mismatch, API 400)
330
350
  // instead of a generic "silent_death".
@@ -338,12 +358,6 @@ function computeDiagnosis(assignment, agentRun, runtime, options) {
338
358
  ?? 'Read .stderr.log for the exit reason; then trigger reconciliation by calling bclaw_find(entity="agent_run") again, or cancel + reroute.',
339
359
  };
340
360
  }
341
- // pln#527 — a stale last_event_at is NOT "stalled" when the filesystem is still
342
- // active (logs streaming / worktree files edited). Workers emit no heartbeat
343
- // during a long single operation (codex→stderr, claude -p buffering stdout),
344
- // so fs activity is the truer liveness signal and vetoes the false-stalled.
345
- const fsAge = runtime.last_fs_activity_ms;
346
- const fsActive = fsAge !== undefined && fsAge < options.stallMs;
347
361
  if (runtime.pid_alive === true && stallAge > options.stallMs && fsActive) {
348
362
  return {
349
363
  health: 'healthy',
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.2 on 2026-08-03T09:22:06.116Z
2
+ // Source: brainclaw v1.20.3 on 2026-08-03T12:57:46.375Z
3
3
  export const FACTS = {
4
- "version": "1.20.2",
5
- "generated_at": "2026-08-03T09:22:06.116Z",
4
+ "version": "1.20.3",
5
+ "generated_at": "2026-08-03T12:57:46.375Z",
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-03T09:22:04.503Z",
477
+ "generated_at": "2026-08-03T12:57:44.250Z",
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": 51,
486
+ "duration_ms_median": 79,
487
487
  "payload_chars_median": 1640,
488
488
  "payload_tokens_est_median": 410
489
489
  },
@@ -491,15 +491,15 @@ 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": 80,
495
- "payload_chars_median": 2625,
496
- "payload_tokens_est_median": 656
494
+ "duration_ms_median": 129,
495
+ "payload_chars_median": 2626,
496
+ "payload_tokens_est_median": 657
497
497
  },
498
498
  {
499
499
  "name": "first_edit",
500
500
  "volume": "medium",
501
501
  "description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
502
- "duration_ms_median": 9,
502
+ "duration_ms_median": 13,
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.2",
3
- "generated_at": "2026-08-03T09:22:06.116Z",
2
+ "version": "1.20.3",
3
+ "generated_at": "2026-08-03T12:57:46.375Z",
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-03T09:22:04.503Z",
475
+ "generated_at": "2026-08-03T12:57:44.250Z",
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": 51,
484
+ "duration_ms_median": 79,
485
485
  "payload_chars_median": 1640,
486
486
  "payload_tokens_est_median": 410
487
487
  },
@@ -489,15 +489,15 @@
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": 80,
493
- "payload_chars_median": 2625,
494
- "payload_tokens_est_median": 656
492
+ "duration_ms_median": 129,
493
+ "payload_chars_median": 2626,
494
+ "payload_tokens_est_median": 657
495
495
  },
496
496
  {
497
497
  "name": "first_edit",
498
498
  "volume": "medium",
499
499
  "description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
500
- "duration_ms_median": 9,
500
+ "duration_ms_median": 13,
501
501
  "payload_chars_median": 499,
502
502
  "payload_tokens_est_median": 125
503
503
  }
@@ -8,6 +8,21 @@ guarantees this changelog follows.
8
8
 
9
9
  ---
10
10
 
11
+ ## [1.20.3] — 2026-08-03
12
+
13
+ **Changed — `bclaw_dispatch_status` diagnosis values under the fs-activity veto (#170)**
14
+ - A dead tracked pid combined with FRESH filesystem activity (log/worktree
15
+ mtime within the activity window) now yields `diagnosis.health: "healthy"`
16
+ with a "worker is writing" summary — previously this combination could
17
+ reach the `silent_death` branch and recommend "cancel + reroute" against a
18
+ live worker. Value change only on `diagnosis.health` /
19
+ `diagnosis.recommended_next_action` for that evidence combination; no field
20
+ added/removed, no inputSchema change. Consumers gating on
21
+ `health === "silent_death"` see strictly FEWER (more accurate) firings.
22
+ - No MCP contract change from pln#644 (#171): the `review_turn_not_converged`
23
+ warning lives on the CLI harvest surface (`brainclaw harvest` text/`--json`
24
+ output), not on an MCP tool response.
25
+
11
26
  ## [1.20.2] — 2026-08-03
12
27
 
13
28
  **Added — `lane_result_stale` on the dispatch_status runtime snapshot (#167)**
@@ -0,0 +1,91 @@
1
+ # Autonomy-safety regression pack (pln#621)
2
+
3
+ Every scenario below is a REAL incident from the dogfood store (trap ids), mapped
4
+ to the non-destruction invariant it taught us and to the test that now pins it.
5
+ The acceptance bar: **zero false destructive verdicts on this corpus** — a
6
+ coordination engine may be wrong about liveness, but it must never destroy work
7
+ (kill a working agent, reset an unharvested diff, strand or steal a claim,
8
+ double-apply a verdict) on ambiguous evidence.
9
+
10
+ Classification (step 3 of the plan): ✅ pinned = trap resolved with a
11
+ counterfactual/regression test · 📋 mitigated = documented operator rule, not
12
+ engine-enforceable · 🔴 red = open defect → the only candidates for new
13
+ implementation work.
14
+
15
+ Baseline corpus: snapshot `2026-08-03T09-38-35-129Z`
16
+ (hash `399599d1…`, see docs/playbooks/store-snapshot.md). Shape fixtures for
17
+ synthetic scenario stores: `tests/fixtures/store-corpus/`.
18
+
19
+ ## Kill / reroute — liveness verdicts must never destroy work
20
+
21
+ | Incident | Invariant | Status |
22
+ |---|---|---|
23
+ | pln#520 — 6 workers killed on a dead WRAPPER pid, they committed 4-7 min later | Commits ahead + clean tree ⇒ verdict "harvest it", never kill/reroute, even with a dead pid | ✅ `dispatch-status.test.ts` "never kill-and-reroute" |
24
+ | pln#520 variant — dead pid but the worker is WRITING (logs/worktree mtime fresh) | Fresh fs activity ⇒ the recommendation never contains ANY destructive instruction (kill/cancel/reroute) and the verdict is never silent_death | ✅ `dispatch-status.test.ts` "never says kill" — building this pin found a REAL gap: the silent_death branch skipped the fs-activity veto and advised "cancel + reroute" on a writing worker; fixed with the pin (review of PR #170) |
25
+ | pln#527 — stale heartbeat during a long single operation | Stale heartbeat + fresh fs activity ⇒ "working, not stalled" (no fail inference) | ✅ `agentrun-reconciler.test.ts` heartbeat/fs-veto cases |
26
+ | trp#292 — spawn deaths mis-diagnosed; stderr never read | Known fatal boot signatures in stderr yield a TARGETED diagnosis instead of a generic silent_death | ✅ `fs-activity-liveness.test.ts` recognizeStderrSignature cases (narrower than "every failure verdict carries the tail" — the reconciler's logTailSuffix covers the fail-inference side) |
27
+ | Operator rule — never blanket-kill agents by process name (IDE runs them too) | Kill only pids from `agent_run.pid` cross-checked with `launched_at` | 📋 mitigated (operator rule; engine cannot see foreign processes) |
28
+
29
+ ## Harvest — verdicts and results are exactly-once and owned
30
+
31
+ | Incident | Invariant | Status |
32
+ |---|---|---|
33
+ | trp_e824d2af — round 1's LANE-RESULT read as round 2's terminal signal | A lane result is terminal ONLY for the assignment named in its own `assignment_id`; foreign ⇒ `lane_result_stale` | ✅ `dispatch-status-lane-result.test.ts` |
34
+ | Same trap, write side — stale terminal file survives worktree reuse | `resetWorktreeToRef` archives the prior LANE-RESULT out of the signal path | ✅ `worktree.test.ts` re-dispatch hygiene |
35
+ | Double harvest of one lane | Re-ingesting the same LANE-RESULT is an idempotent no-op (marker) | ✅ `lane-result-harvest.test.ts` "skips on re-run" |
36
+ | Double integration of a turn-owned approve lane | Exactly-once finalization — no duplicate verdict, loop stays terminal | ✅ `loops-pr3a-harvest-reconcile.test.ts` T7 |
37
+ | pln#638 1c — CLI-harvested ideation lane never converged its loop | The ideation closer fires on the CLI harvest path too | ✅ `lane-harvest-cli-convergence.test.ts` |
38
+ | 2026-08-02/03 ×2 — CLI-harvested REVIEW lane (file protocol, no turn keys) left its loop turn open; coordinator converged manually both times | A review lane harvested by assignment_id converges its turn on the report path (approve → reconcileTurn) or warns loudly naming the recovery (`review_turn_not_converged`), never a silent stall | ✅ `loops-pr3a-harvest-reconcile.test.ts` pln#644 suite (counterfactual: 8 tests red pre-fix) |
39
+
40
+ ## Worktrees — the workspace carries unharvested work
41
+
42
+ | Incident | Invariant | Status |
43
+ |---|---|---|
44
+ | trp_72b4e9b3 — round-2 path collision wedged the scope | Same-branch registered worktree is ADOPTED, not refused | ✅ `worktree.test.ts` adoption |
45
+ | PR#167 review P1 — adoption would hard-reset a sandboxed worker's uncommitted output | ANY tracked dirt refuses adoption, unconditionally (a reset pin is not a discard order) | ✅ `worktree.test.ts` tracked-dirt refusal |
46
+ | can_2e282880 — branch reuse ran a worker on an April base / would destroy commits | Unharvested commits refuse silent reuse/adoption | ✅ `worktree.test.ts` unharvested-commits guards |
47
+ | trp (2026-08-01) — `git worktree remove` follows Windows junctions, wiped main-repo node_modules | brainclaw's own removal detaches junctions first; raw git remove remains dangerous | ✅ engine path (`detachWorktreeJunctions`) · 📋 the raw-git variant stays an operator rule |
48
+ | trp#950 — two >48-char scopes collapsed to one branch slug | Distinct scopes yield distinct valid slugs | ✅ `worktree.test.ts` slug suite |
49
+ | trp#926 — a squash-merged lane read as "worker delivered" again (`commits_ahead` counted the ancestry, not the patches) | Squash-merged work counts 0 via patch-id refinement — no false delivered verdict, no false GC | ✅ `worktree-squash-aware.test.ts` (row added on review of PR #170 — the audit found the incident missing from the catalog) |
50
+ | Duplicate spawn — a respawned assignment ran beside its "dead" predecessor in one worktree | Two live workers must never share a worktree unknowingly | 📋 mitigated (heartbeat-file coordination; pln#630 launch fence covers the turn-owned path: reservation + grant make a duplicate launch DENIED) — engine-wide guard tracked in pln#644 classification notes |
51
+
52
+ ## Claims / cascades — advisory locks that cannot lie or leak
53
+
54
+ | Incident | Invariant | Status |
55
+ |---|---|---|
56
+ | trp#433 — dead runs left active claims accumulating | Failed runs release their claim (GC cascade, non-turn-owned) | ✅ `agentrun-reconciler.test.ts` trp#433 cases |
57
+ | pln#638 6c — transport completion released claims / triggered reviews | Transport completion never RELEASES the claim (`inferred_completed` with the claim still active) | ✅ `agentrun-reconciler.test.ts` 6c pin — narrower than the full boundary: the no-review-trigger half was audited true in the 6c pass but has no dedicated pin (honest gap, not a defect) |
58
+ | dec#151 — turn-owned failure released via transport GC | Turn-owned release is a LOOP business decision (recorded on the loop first, audited) | ✅ `agentrun-reconciler.test.ts` pln#641 cases |
59
+ | PR#166 review P1 — the promised lazy retry was unreachable (read paths skip terminal runs) | A stranded claim converges from a plain `bclaw_find(agent_run)` read | ✅ `agentrun-reconciler.test.ts` P1 cases incl. `listEntities` surface test |
60
+ | PR#166 review P1 round 2 — the release audit could lie under a concurrent release | `releaseClaimIfActive`: check+transition atomic; the event fires only for the call that transitioned | ✅ `loops-reconcile-turn.test.ts` atomic-contract cases |
61
+ | trp_72b4e9b3(2) — a worktree-less claim wedged all dispatch on its scope | Reuse heals the claim (provisions + patches under the store lock) | ✅ `worktree.test.ts` heal test — the concurrently-released-claim window is guarded by locked revalidation ('gone' → fresh-claim path, codex-verified control flow, PR #167) but is not injection-testable; the heal pin covers the wedge only |
62
+ | trp#928 — any caller could release any claim | Ownership-checked release; coordinator override is explicit + audited | ✅ claim auth suites |
63
+ | Claim reuse across review rounds (trp_e824d2af context) | A SUPERSEDED turn's convergence never releases the live turn's reused claim | ✅ `loops-reconcile-turn.test.ts` superseded case |
64
+
65
+ ## Loop closure — verdicts recorded once, loops never wedged
66
+
67
+ | Incident | Invariant | Status |
68
+ |---|---|---|
69
+ | pln#630 — double reconcile spawned both rounds | Exactly-once iteration bump under the loop lock | ✅ `loops-reconcile-turn.test.ts` findings 1/2 |
70
+ | §13 R4 — completed+failed contradiction auto-accepted | Contradiction WITHHOLDS convergence, journals a conflict | ✅ `loops-reconcile-turn.test.ts` R4 cases |
71
+ | pln#639 — empty artifacts satisfied phase gates | Gates reject empty artifact bodies | ✅ gate suites |
72
+ | Stale/mismatched lane evidence converging the wrong attempt | Read-strict turn keys (turn_id+run_id+nonce) or no convergence | ✅ `loops-reconcile-turn.test.ts` read-strict case |
73
+
74
+ ## Red defects opened by this classification
75
+
76
+ - **pln#644 — review-loop CLI-harvest turn convergence** *(RESOLVED)*: a review
77
+ lane delivered via the file protocol without turn keys was harvested into the
78
+ assignment but its loop turn stayed open silently; the coordinator converged
79
+ by hand twice on 2026-08-02/03 (loops `lop_626271ee10ad09d8`,
80
+ `lop_4d869568bd99ddc0`). Fixed at the CLI harvest site: the report path now
81
+ finalizes an APPROVE turn-owned lane via the same exactly-once
82
+ `reconcileTurn` that `--integrate` uses (evidence sourced from the lane keys
83
+ or the wrapper sentinel — read-strict untouched), and every other
84
+ non-converged case (request_changes, missing verdict, refused evidence)
85
+ emits a `review_turn_not_converged` warning naming the open turn and the
86
+ recovery, surfaced on both the CLI text and `--json` outputs. Pinned in
87
+ `loops-pr3a-harvest-reconcile.test.ts` (pln#644 suite, counterfactual-red).
88
+
89
+ Everything else in the corpus is pinned or explicitly an operator rule. New
90
+ incidents: add the trap, add the row, add the counterfactual test — in that
91
+ order.
@@ -0,0 +1,68 @@
1
+ # Store snapshot — the snapshot-before-curation rule (pln#619)
2
+
3
+ The loaded dogfood store IS a corpus: months of real claims, loops, assignments,
4
+ journals and debris that no synthetic fixture reproduces. Every curation
5
+ (candidate triage, GC, migration, mass-ack) destroys part of it. The rule:
6
+
7
+ > **Take a snapshot before ANY curation of the store.** Cleanup must be
8
+ > reversible and traceable; the regression pack (pln#621) needs a stable
9
+ > baseline to reproduce metrics against.
10
+
11
+ ## Tool
12
+
13
+ `scripts/store-snapshot.mjs` — zero dependencies, four commands:
14
+
15
+ ```
16
+ node scripts/store-snapshot.mjs create [--store <dir>] [--out <dir>] [--brainclaw-version <v>]
17
+ node scripts/store-snapshot.mjs verify --snapshot <dir>
18
+ node scripts/store-snapshot.mjs restore --snapshot <dir> --to <empty-dir>
19
+ node scripts/store-snapshot.mjs fixtures [--store <dir>] --out <dir>
20
+ ```
21
+
22
+ - **create** copies the store into `<out>/store/`, writes `manifest.json`
23
+ (schema version, timestamp, source path + git HEAD, per-directory totals,
24
+ per-entity counts, one deterministic `corpus_hash` over path+size+sha256 of
25
+ every file), then marks the snapshot files read-only. Default destination is
26
+ `~/.brainclaw/snapshots/<project>/<stamp>/` — deliberately OUTSIDE any git
27
+ repo: the store carries private coordination content and must never transit
28
+ through a public remote. The hash is computed on the COPY, so a live store
29
+ mutating mid-copy still yields an internally consistent manifest (for a
30
+ perfectly quiescent capture, snapshot while the MCP is idle).
31
+ - **verify** recomputes totals, entity counts and the corpus hash and compares
32
+ them to the manifest — immutability is checked, never assumed. Exit 1 on any
33
+ divergence.
34
+ - **restore** copies into an EMPTY target only (never in place), clears the
35
+ read-only bits, then verifies the restored tree against the manifest — the
36
+ pln#619 acceptance criterion ("a restored store reproduces the metrics")
37
+ executed on every restore.
38
+ - **fixtures** exports SHAPE summaries per entity collection (field names,
39
+ value types, presence ratios) and observed values for an ALLOWLIST of
40
+ enum-like field names only (status, type, severity, …). Free text and
41
+ identity values are never exported, whatever their length — the rule is
42
+ allowlist-only because two heuristics failed before it: a length gate leaked
43
+ the hostname and OS username, and its identity-field patch still leaked
44
+ short free text (adversarial review of PR #169 caught it in the shipped
45
+ fixtures). Committed under `tests/fixtures/store-corpus/` for the
46
+ regression pack.
47
+
48
+ The corpus hash serializes records NUL-delimited: a POSIX filename may legally
49
+ contain a newline, so newline-delimited records would let two distinct trees
50
+ share a preimage (same review, finding 2).
51
+
52
+ ## Baseline of record
53
+
54
+ The pre-curation baseline of 2026-08-03 (brainclaw 1.20.2, store at ~1.9 GB /
55
+ 12 121 files, 644 claims / 296 assignments / 243 loops):
56
+
57
+ ```
58
+ ~/.brainclaw/snapshots/shared_agent_memory_mvp/2026-08-03T09-53-14-067Z
59
+ corpus_hash 2732797349b1d61c38ba838f8d03ccba6fe901c755788ba212f0375db479094c
60
+ ```
61
+
62
+ (The candidate-triage curation of 2026-08-03 was performed against the earlier
63
+ capture of the same morning; this retake follows the hash-encoding fix and
64
+ supersedes it.)
65
+
66
+ Restore-verified on capture day (hash + entity counts reproduced in an
67
+ isolated target). Any later "did the curation lose something?" question is
68
+ answered against this manifest.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainclaw",
3
- "version": "1.20.2",
3
+ "version": "1.20.3",
4
4
  "description": "Shared project memory for humans and coding agents.",
5
5
  "type": "module",
6
6
  "repository": {