brainclaw 1.13.0 → 1.14.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
@@ -431,6 +431,14 @@ npm run test:coverage # with coverage report
431
431
 
432
432
  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)`).
433
433
 
434
+ ### v1.14.0
435
+
436
+ Coordination hygiene, a dispatch-supervisor spec, and a monorepo worktree fix — from continued cross-machine dogfooding, each Codex-reviewed before merge:
437
+
438
+ - **Coordination hygiene v1** (pln#602) — family-level TTL sweep (park-don't-delete, `config.hygiene`-overridable), lazy at the `bclaw_work` read path (zero extra reads on a healthy store) + full at session-start, K-times aging of stale warnings/hints into one actionable aggregate, and `brainclaw doctor --hygiene`.
439
+ - **Worktree creation for in-tree projects** (pln#614) — `bclaw_coordinate(assign|review)` on a project dir that isn't the git root (app inside a monorepo) now resolves the real toplevel (`resolveGitToplevel` with a parent-walk past an invalid nested `.git`), so it spawns instead of failing with "not a git repository". Standalone projects unchanged; validated E2E on the reporting monorepo.
440
+ - **Dispatch-supervisor round-3 spec** (pln#545, docs) — `docs/concepts/dispatch-supervisor.md`: honest worker-liveness attribution (Node supervisor + `run_id`-keyed sentinels), A0→B hard dependency, Windows Job Object FAIL-CLOSED, complete behavior matrix. Implementation lands later as A0-first increments.
441
+
434
442
  ### v1.13.0
435
443
 
436
444
  Operator-maturity batch from two days of heavy multi-agent dogfooding — dispatch/worktree lifecycle, claim parity, write-path auto-repair, model routing, benchmark gate, 2× faster context reads:
Binary file
package/dist/cli.js CHANGED
@@ -782,12 +782,13 @@ program
782
782
  .option('--verify-journal', 'Phase-2 cutover gate (pln#565): rebuild state from the event journal and diff vs live projections; exits non-zero on any drift')
783
783
  .option('--spawn-check', 'Real spawn round-trip per installed agent before dispatch (pln#520 step 2): validates delivery + handshake on this host, exits non-zero on any installed-agent failure')
784
784
  .option('--spawn-check-timeout <ms>', 'Per-agent timeout for --spawn-check (default 15000)', parseInt)
785
+ .option('--hygiene', 'Coordination-hygiene snapshot (pln#602): counts per family, park candidates, serve-count aging stats. Read-only.')
785
786
  .action(async (options) => {
786
787
  if (options.spawnCheck) {
787
788
  await runDoctorSpawnCheck({ cwd: options.cwd, json: options.json, timeoutMs: options.spawnCheckTimeout });
788
789
  return;
789
790
  }
790
- runDoctor({ ...options, afterMigration: options.afterMigration, dispatch: options.dispatch, verifyJournal: options.verifyJournal });
791
+ runDoctor({ ...options, afterMigration: options.afterMigration, dispatch: options.dispatch, verifyJournal: options.verifyJournal, hygiene: options.hygiene });
791
792
  });
792
793
  // --- repair (Phase 4 Sprint 2 Lane C / pln#397) ---
793
794
  program
@@ -4,6 +4,10 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import * as childProcess from 'node:child_process';
6
6
  import { reconcileAllOpenRuns } from '../core/agentrun-reconciler.js';
7
+ import { loadHygienePolicy } from '../core/hygiene-policy.js';
8
+ import { computeServeStats, loadServeRegistry } from '../core/hint-aging.js';
9
+ import { listAssignments } from '../core/assignments.js';
10
+ import { parkClosedAutoHandoffs } from '../core/gc-semantic.js';
7
11
  import { runSpawnCheck, renderSpawnCheckReport } from '../core/spawn-check.js';
8
12
  import { loadAgentRun } from '../core/agentruns.js';
9
13
  import { listAgentIdentities, listDebrisAgentIdentities, resolveCurrentAgentIdentity } from '../core/agent-registry.js';
@@ -638,7 +642,101 @@ function runJournalVerification(options) {
638
642
  if (drift.length > 0)
639
643
  process.exit(1);
640
644
  }
645
+ function medianAgeDays(items, nowMs) {
646
+ if (items.length === 0)
647
+ return 0;
648
+ const ages = items
649
+ .map((a) => Math.floor((nowMs - new Date(a.created_at).getTime()) / 86_400_000))
650
+ .sort((a, b) => a - b);
651
+ const mid = Math.floor(ages.length / 2);
652
+ return ages.length % 2 === 1 ? ages[mid] : (ages[mid - 1] + ages[mid]) / 2;
653
+ }
654
+ export function runHygieneReport(options = {}) {
655
+ const cwd = options.cwd;
656
+ const policy = loadHygienePolicy(cwd);
657
+ const nowMs = Date.now();
658
+ const allAssignments = listAssignments(cwd);
659
+ const open = allAssignments.filter((a) => a.status === 'offered' || a.status === 'accepted' || a.status === 'started');
660
+ const offered = open.filter((a) => a.status === 'offered');
661
+ const accepted = open.filter((a) => a.status === 'accepted');
662
+ const started = open.filter((a) => a.status === 'started');
663
+ const heartbeatAgeMs = (a) => {
664
+ const anchor = a.last_heartbeat_at ?? a.offered_at ?? a.created_at;
665
+ return nowMs - new Date(anchor).getTime();
666
+ };
667
+ const offered_park_candidates = offered.filter((a) => heartbeatAgeMs(a) > policy.assignment_offered_ttl_ms).length;
668
+ const accepted_park_candidates = accepted.filter((a) => heartbeatAgeMs(a) > policy.assignment_accepted_ttl_ms).length;
669
+ const handoffParkDry = parkClosedAutoHandoffs(cwd ?? process.cwd(), Math.floor(policy.handoff_closed_ttl_ms / 86_400_000), true);
670
+ const registry = loadServeRegistry(cwd);
671
+ const staleStats = computeServeStats(registry.warnings, policy.stale_warning_serve_k);
672
+ const hintsStats = computeServeStats(registry.hints, policy.workflow_hint_serve_k);
673
+ return {
674
+ generated_at: new Date(nowMs).toISOString(),
675
+ disabled: policy.disabled,
676
+ policy,
677
+ families: {
678
+ assignments: {
679
+ total_open: open.length,
680
+ offered: offered.length,
681
+ accepted: accepted.length,
682
+ started: started.length,
683
+ offered_park_candidates,
684
+ accepted_park_candidates,
685
+ median_open_age_days: medianAgeDays(open, nowMs),
686
+ },
687
+ handoffs: {
688
+ closed_park_candidates: handoffParkDry.candidates,
689
+ },
690
+ stale_warnings: {
691
+ total_tracked: staleStats.total,
692
+ over_threshold: staleStats.over_threshold,
693
+ median_count: staleStats.median_count,
694
+ oldest_first_at: staleStats.oldest_first_at,
695
+ },
696
+ workflow_hints: {
697
+ total_tracked: hintsStats.total,
698
+ over_threshold: hintsStats.over_threshold,
699
+ median_count: hintsStats.median_count,
700
+ oldest_first_at: hintsStats.oldest_first_at,
701
+ },
702
+ },
703
+ };
704
+ }
705
+ function renderHygieneReport(report) {
706
+ const lines = [];
707
+ lines.push(`Coordination hygiene — snapshot ${report.generated_at}`);
708
+ if (report.disabled) {
709
+ lines.push(' ✗ policy.disabled=true — hygiene sweep + aging are opted out via config.hygiene.disabled.');
710
+ }
711
+ lines.push('');
712
+ const a = report.families.assignments;
713
+ lines.push(`Assignments (open ${a.total_open}: offered ${a.offered} / accepted ${a.accepted} / started ${a.started}, median age ${a.median_open_age_days}d)`);
714
+ lines.push(` Park candidates: offered=${a.offered_park_candidates}, accepted=${a.accepted_park_candidates} — next sweep at session-start or bclaw_work will converge.`);
715
+ const h = report.families.handoffs;
716
+ lines.push(`Handoffs closed park candidates: ${h.closed_park_candidates} (auto-generated, older than ${Math.floor(report.policy.handoff_closed_ttl_ms / 86_400_000)}d)`);
717
+ const sw = report.families.stale_warnings;
718
+ lines.push(`Stale warnings tracked: ${sw.total_tracked} (over serve-K=${report.policy.stale_warning_serve_k}: ${sw.over_threshold}, median count ${sw.median_count}${sw.oldest_first_at ? `, oldest first-served ${sw.oldest_first_at.slice(0, 10)}` : ''})`);
719
+ const wh = report.families.workflow_hints;
720
+ lines.push(`Workflow hints tracked: ${wh.total_tracked} (over serve-K=${report.policy.workflow_hint_serve_k}: ${wh.over_threshold}, median count ${wh.median_count}${wh.oldest_first_at ? `, oldest first-served ${wh.oldest_first_at.slice(0, 10)}` : ''})`);
721
+ lines.push('');
722
+ lines.push('Read-only: no state was mutated. Session-start and bclaw_work drive the actual sweep/park; the counters age at bclaw_work read paths.');
723
+ return lines.join('\n');
724
+ }
641
725
  export function runDoctor(options = {}) {
726
+ if (options.hygiene) {
727
+ if (!memoryExists(options.cwd)) {
728
+ console.error('Error: .brainclaw/ not found. Run `brainclaw init` first.');
729
+ process.exit(1);
730
+ }
731
+ const report = runHygieneReport(options);
732
+ if (options.json) {
733
+ console.log(JSON.stringify(report, null, 2));
734
+ }
735
+ else {
736
+ console.log(renderHygieneReport(report));
737
+ }
738
+ return;
739
+ }
642
740
  if (options.verifyJournal) {
643
741
  runJournalVerification(options);
644
742
  return;
@@ -21,13 +21,19 @@ import { memoryExists } from '../core/io.js';
21
21
  import { loadAssignment, transitionAssignment } from '../core/assignments.js';
22
22
  import { loadClaim, releaseClaimsCascade, logCascadeReleaseResult } from '../core/claims.js';
23
23
  import { getCapabilityProfile, dispatchCanCommit } from '../core/agent-capability.js';
24
- import { commitWorktreeOnBehalf, worktreesBaseDir } from '../core/worktree.js';
24
+ import { commitWorktreeOnBehalf, worktreesBaseDir, resolveGitToplevel } from '../core/worktree.js';
25
25
  /**
26
26
  * Auto-detect all worktree directories under the brainclaw-managed base dir.
27
27
  * Returns subdirectories that exist on disk (may or may not have an inbox).
28
28
  */
29
29
  function autoDetectWorktreePaths(cwd) {
30
- const base = worktreesBaseDir(cwd);
30
+ // Codex review of PR #49 (MED): createWorktree now writes in-tree worktrees
31
+ // under the git-TOPLEVEL hash (pln#614), so the scan base must resolve the
32
+ // toplevel too — otherwise `harvest --all` / candidates from an in-tree
33
+ // project subdir scan the stale subdir hash and miss every lane result. Only
34
+ // the scan base is toplevel-resolved; .brainclaw store reads/writes elsewhere
35
+ // keep the original project cwd.
36
+ const base = worktreesBaseDir(resolveGitToplevel(cwd));
31
37
  if (!fs.existsSync(base))
32
38
  return [];
33
39
  return fs.readdirSync(base, { withFileTypes: true })
@@ -9,6 +9,10 @@ import { generatedSchemas } from './mcp-schemas.generated.js';
9
9
  import { getTriggeredItems, renderTriggeredItems } from '../core/lifecycle.js';
10
10
  import { resolveCrossProjectLinks, resolveCrossProjectWritableTarget, resolveProjectCwd, writeCrossProjectSignal } from '../core/cross-project.js';
11
11
  import { buildContext, renderContextMarkdown, renderContextPromptTemplate, renderContextBriefing } from '../core/context.js';
12
+ import { ageStaleWarnings, ageWorkflowHints, loadServeRegistry } from '../core/hint-aging.js';
13
+ import { loadHygienePolicy } from '../core/hygiene-policy.js';
14
+ import { sweepAssignmentsAtReadPath, selectReadPathSweepCandidates } from '../core/assignment-sweeper.js';
15
+ import { loadAssignment } from '../core/assignments.js';
12
16
  import { buildCoordinationSnapshot } from '../core/coordination.js';
13
17
  import { checkBrainclawInstallableUpdate, getInstalledBrainclawVersion, readDiskBrainclawVersion, renderBrainclawInstallableUpdateNotice } from '../core/brainclaw-version.js';
14
18
  import { loadConfig } from '../core/config.js';
@@ -5095,6 +5099,45 @@ async function _executeMcpToolCallInner(payload) {
5095
5099
  }
5096
5100
  }
5097
5101
  }
5102
+ let stalePostAging;
5103
+ let staleAggregate;
5104
+ let hintsPostAging;
5105
+ let hintsAggregate;
5106
+ if (contextResult) {
5107
+ try {
5108
+ const policy = loadHygienePolicy(targetCwd);
5109
+ if (!policy.disabled) {
5110
+ // Filter candidates from the projection (no extra reads) — only
5111
+ // assignments whose surfaced last_heartbeat_at is old enough to
5112
+ // possibly cross a family TTL. Zero read overhead when the open
5113
+ // work is fresh (the common case).
5114
+ const openAssignments = contextResult.open_work?.active_assignments ?? [];
5115
+ // Codex PR#48 finding 3 (pln#578 guardrail): select candidate ids
5116
+ // from the already-surfaced projection — created/terminal rows are
5117
+ // dropped BEFORE any full loadAssignment, so a healthy store costs
5118
+ // zero extra file reads. Selection logic is unit-tested in
5119
+ // selectReadPathSweepCandidates.
5120
+ const candidateIds = selectReadPathSweepCandidates(openAssignments, policy, Date.now());
5121
+ if (candidateIds.length > 0) {
5122
+ const full = candidateIds
5123
+ .map((id) => loadAssignment(id, targetCwd))
5124
+ .filter((a) => a !== undefined);
5125
+ sweepAssignmentsAtReadPath(full, targetCwd, {
5126
+ actor: 'bclaw_work-readpath',
5127
+ policy,
5128
+ });
5129
+ }
5130
+ const registry = loadServeRegistry(targetCwd);
5131
+ const aged = ageStaleWarnings(contextResult.stale_warnings ?? [], targetCwd, { policy, registry });
5132
+ stalePostAging = aged.warnings;
5133
+ staleAggregate = aged.aggregate;
5134
+ const agedHints = ageWorkflowHints(contextResult.workflow_hints ?? [], targetCwd, { policy, registry });
5135
+ hintsPostAging = agedHints.hints;
5136
+ hintsAggregate = agedHints.aggregate;
5137
+ }
5138
+ }
5139
+ catch { /* non-fatal — hygiene must never break bclaw_work */ }
5140
+ }
5098
5141
  // Build the full context result, then compact it if requested.
5099
5142
  // Compact mode (default) strips the heavy ContextResult down to a
5100
5143
  // minimal summary that fits within MCP token limits (~25k chars).
@@ -5110,7 +5153,8 @@ async function _executeMcpToolCallInner(payload) {
5110
5153
  status: item.extra ?? 'unknown',
5111
5154
  plan_id: item.plan_id,
5112
5155
  }));
5113
- const staleTop3 = (contextResult.stale_warnings ?? []).slice(0, 3).map((w) => ({
5156
+ const stalePool = stalePostAging ?? contextResult.stale_warnings ?? [];
5157
+ const staleTop3 = stalePool.slice(0, 3).map((w) => ({
5114
5158
  id: w.id,
5115
5159
  entity: w.entity,
5116
5160
  text: w.text.slice(0, 80),
@@ -5134,6 +5178,7 @@ async function _executeMcpToolCallInner(payload) {
5134
5178
  : {}),
5135
5179
  }
5136
5180
  : undefined;
5181
+ const hintsPool = hintsPostAging ?? contextResult.workflow_hints ?? [];
5137
5182
  resultPayload = {
5138
5183
  context_schema: contextResult.context_schema,
5139
5184
  profile: contextResult.profile,
@@ -5142,7 +5187,9 @@ async function _executeMcpToolCallInner(payload) {
5142
5187
  context_diff: trimmedDiff ?? null,
5143
5188
  plan_summary: planItems,
5144
5189
  stale_warnings: staleTop3,
5145
- workflow_hints: (contextResult.workflow_hints ?? []).slice(0, 3),
5190
+ ...(staleAggregate ? { stale_warnings_aggregate: staleAggregate } : {}),
5191
+ workflow_hints: hintsPool.slice(0, 3),
5192
+ ...(hintsAggregate ? { workflow_hints_aggregate: hintsAggregate } : {}),
5146
5193
  claim_conflicts: contextResult.claim_conflicts ?? [],
5147
5194
  open_work: contextResult.open_work ?? null,
5148
5195
  _compact: true,
@@ -16,7 +16,9 @@ import { releaseStaleClaimsFromOtherAgents } from '../core/claims.js';
16
16
  import { SessionSnapshotSchema } from '../core/schema.js';
17
17
  import { auditLocalAgentWorkspaceFiles } from '../core/agent-files.js';
18
18
  import { buildAgentInventory, loadAgentInventory, saveAgentInventory, diffInventory } from '../core/agent-inventory.js';
19
- import { checkMemoryPressure, enforceRuntimeNoteRetention } from '../core/gc-semantic.js';
19
+ import { checkMemoryPressure, enforceRuntimeNoteRetention, parkClosedAutoHandoffs } from '../core/gc-semantic.js';
20
+ import { sweepAssignments } from '../core/assignment-sweeper.js';
21
+ import { loadHygienePolicy } from '../core/hygiene-policy.js';
20
22
  import { maybeCreateCheckpoint } from '../core/events/checkpoint.js';
21
23
  import { pullSignalsFromLinkedProjects, markSignalProcessed } from '../core/federation-transport.js';
22
24
  import { pullSignalsFromCloud, isCloudSyncEnabled } from '../core/federation-cloud.js';
@@ -207,6 +209,19 @@ export async function startSession(options = {}) {
207
209
  enforceRuntimeNoteRetention({ cwd: options.cwd });
208
210
  }
209
211
  catch { /* non-fatal — retention sweep must never block session start */ }
212
+ // pln#602 — coordination hygiene pass. Converge orphan offered/accepted
213
+ // assignments (workers that died without a self-report — fable-audit-2026-07
214
+ // witnesses) and park closed auto-generated handoffs so bclaw_work stops
215
+ // serving debris. Runs at session-start ONLY (not on the hot read path);
216
+ // opt-out via config.hygiene.disabled honoured through the policy load.
217
+ try {
218
+ const policy = loadHygienePolicy(options.cwd);
219
+ if (!policy.disabled) {
220
+ sweepAssignments(options.cwd, { actor: 'session-start', policy });
221
+ parkClosedAutoHandoffs(options.cwd ?? process.cwd(), Math.floor(policy.handoff_closed_ttl_ms / (24 * 60 * 60 * 1000)));
222
+ }
223
+ }
224
+ catch { /* non-fatal — hygiene sweep must never block session start */ }
210
225
  // pln#566 Inc0 — keep a recent journal-derived checkpoint available off the
211
226
  // hot path so the (capability-gated, OFF by default) checkpointRead read
212
227
  // path has something to serve once enabled. Gated by a growth threshold so
@@ -16,6 +16,7 @@
16
16
  import { spawnSync } from 'node:child_process';
17
17
  import { listAssignments, transitionAssignment } from './assignments.js';
18
18
  import { signalExists, readHeartbeat, latestActivityMs } from './runtime-signals.js';
19
+ import { DEFAULT_HYGIENE_POLICY } from './hygiene-policy.js';
19
20
  function lastCommitAgeMs(worktreePath, nowMs) {
20
21
  if (!worktreePath)
21
22
  return undefined;
@@ -104,28 +105,60 @@ function collectImplicitEvidence(assignment, cwd, nowMs, sinceMs, freshTtlMs) {
104
105
  * @param options.actor - Actor name for audit trail (default: 'sweeper')
105
106
  */
106
107
  export function sweepAssignments(cwd, options) {
108
+ return sweepAssignmentsFromList(listAssignments(cwd), cwd, options);
109
+ }
110
+ /**
111
+ * Read-path variant: sweep only the assignments the caller ALREADY loaded
112
+ * (typically open_work.active_assignments). No `listAssignments` call, so no
113
+ * additional store scan on the hot bclaw_work path (pln#602 perf guardrail
114
+ * per the pln#578 read-path optimisation). Use `sweepAssignmentsFromList`
115
+ * with a bounded slice when a full pass would violate the budget.
116
+ *
117
+ * @param assignments - pre-loaded assignments to consider (only non-terminal ones matter)
118
+ * @param cwd - project root
119
+ * @param options.nowMs - Override current time for testing
120
+ * @param options.actor - Actor for the audit trail (default: 'sweeper-readpath')
121
+ * @param options.policy - Family-level TTL/policy overrides
122
+ */
123
+ export function sweepAssignmentsFromList(assignments, cwd, options) {
124
+ const policy = options?.policy;
125
+ if (policy?.disabled) {
126
+ return { timed_out: [], expired: [], implicitly_advanced: [] };
127
+ }
107
128
  const now = options?.nowMs ?? Date.now();
108
129
  const actor = options?.actor ?? 'sweeper';
109
130
  const result = { timed_out: [], expired: [], implicitly_advanced: [] };
110
- const all = listAssignments(cwd);
111
- for (const assignment of all) {
131
+ for (const assignment of assignments) {
132
+ // pln#602 / Codex PR#48 finding 2: when a hygiene `policy` is supplied
133
+ // (session-start full sweep, bclaw_work read-path), the age comparison,
134
+ // the implicit-evidence freshness window, AND the status_reason MUST use
135
+ // the family TTLs (offered 3d / accepted 1d / started 1d by default), NOT
136
+ // the assignment's embedded heartbeat_ttl_ms/acceptance_ttl_ms (~30/15min).
137
+ // Otherwise a 20-min offered assignment that `doctor --hygiene` does not
138
+ // list as a candidate could still be expired here — the exact incoherence
139
+ // Codex flagged. Without a policy (the dispatcher convergence sweep,
140
+ // dispatcher.ts), fall back to the embedded TTLs so short-window dispatch
141
+ // convergence is unchanged.
142
+ const startedTtl = policy?.assignment_started_ttl_ms ?? assignment.heartbeat_ttl_ms;
143
+ const acceptedTtl = policy?.assignment_accepted_ttl_ms ?? assignment.acceptance_ttl_ms;
144
+ const offeredTtl = policy?.assignment_offered_ttl_ms ?? assignment.acceptance_ttl_ms;
112
145
  // Check started assignments for heartbeat timeout
113
146
  if (assignment.status === 'started') {
114
147
  const lastBeat = assignment.last_heartbeat_at ?? assignment.started_at;
115
148
  if (!lastBeat)
116
149
  continue;
117
150
  const ageMs = now - new Date(lastBeat).getTime();
118
- if (ageMs > assignment.heartbeat_ttl_ms) {
151
+ if (ageMs > startedTtl) {
119
152
  // can_948acfd6: a worker without MCP cannot bump last_heartbeat_at —
120
153
  // its file evidence is the heartbeat. Fresh file activity vetoes the
121
154
  // administrative timeout.
122
155
  const sinceMs = new Date(assignment.started_at ?? assignment.created_at).getTime();
123
- const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs, assignment.heartbeat_ttl_ms);
156
+ const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs, startedTtl);
124
157
  if (evidence.fresh)
125
158
  continue;
126
159
  try {
127
160
  transitionAssignment(assignment.id, 'timed_out', {
128
- status_reason: `No heartbeat for ${Math.round(ageMs / 60_000)} minutes (TTL: ${Math.round(assignment.heartbeat_ttl_ms / 60_000)}min); implicit evidence: ${evidence.description}`,
161
+ status_reason: `No heartbeat for ${Math.round(ageMs / 60_000)} minutes (TTL: ${Math.round(startedTtl / 60_000)}min); implicit evidence: ${evidence.description}`,
129
162
  actor,
130
163
  }, cwd);
131
164
  result.timed_out.push({ assignment_id: assignment.id, agent: assignment.agent, age_ms: ageMs });
@@ -139,10 +172,12 @@ export function sweepAssignments(cwd, options) {
139
172
  if (!acceptedAt)
140
173
  continue;
141
174
  const ageMs = now - new Date(acceptedAt).getTime();
142
- // Use acceptance_ttl for accepted→timed_out (same window: agent should start quickly after accepting)
143
- if (ageMs > assignment.acceptance_ttl_ms) {
175
+ // Use the accepted-family TTL for accepted→timed_out (agent should start
176
+ // soon after accepting; family default 1d, or embedded acceptance_ttl_ms
177
+ // for the policy-less convergence sweep).
178
+ if (ageMs > acceptedTtl) {
144
179
  const sinceMs = new Date(acceptedAt).getTime();
145
- const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs, assignment.acceptance_ttl_ms);
180
+ const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs, acceptedTtl);
146
181
  if (evidence.fresh) {
147
182
  // Working without MCP — record the implicit start so the FSM matches reality.
148
183
  try {
@@ -171,13 +206,13 @@ export function sweepAssignments(cwd, options) {
171
206
  if (!offeredAt)
172
207
  continue;
173
208
  const ageMs = now - new Date(offeredAt).getTime();
174
- if (ageMs > assignment.acceptance_ttl_ms) {
209
+ if (ageMs > offeredTtl) {
175
210
  // can_948acfd6: ANY worker evidence (ack sentinel touched pre-exec,
176
211
  // heartbeat written, files edited, commit landed) is an implicit
177
212
  // acceptance — the worker just couldn't say so via MCP. Expiring it
178
213
  // is the false-administrative-death observed three times in sprint 1.
179
214
  const sinceMs = new Date(offeredAt).getTime();
180
- const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs, assignment.acceptance_ttl_ms);
215
+ const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs, offeredTtl);
181
216
  if (evidence.any) {
182
217
  try {
183
218
  transitionAssignment(assignment.id, 'accepted', {
@@ -191,7 +226,7 @@ export function sweepAssignments(cwd, options) {
191
226
  }
192
227
  try {
193
228
  transitionAssignment(assignment.id, 'expired', {
194
- status_reason: `Not accepted within ${Math.round(ageMs / 60_000)} minutes (TTL: ${Math.round(assignment.acceptance_ttl_ms / 60_000)}min); no implicit evidence`,
229
+ status_reason: `Not accepted within ${Math.round(ageMs / 60_000)} minutes (TTL: ${Math.round(offeredTtl / 60_000)}min); no implicit evidence`,
195
230
  actor,
196
231
  }, cwd);
197
232
  result.expired.push({ assignment_id: assignment.id, agent: assignment.agent, age_ms: ageMs });
@@ -202,4 +237,50 @@ export function sweepAssignments(cwd, options) {
202
237
  }
203
238
  return result;
204
239
  }
240
+ /**
241
+ * Pure candidate selection for the bclaw_work read-path sweep (Codex PR#48
242
+ * finding 3, pln#578 guardrail). Given ONLY the in-memory projections that
243
+ * buildContext already surfaced, return the ids worth a full loadAssignment:
244
+ * - status must be sweepable (offered/accepted/started) — created/terminal
245
+ * rows can never transition and are dropped BEFORE any file read, so a
246
+ * healthy store full of `created` assignments costs zero extra I/O;
247
+ * - among those, only rows whose surfaced heartbeat is older than the
248
+ * smallest family TTL (or that carry no heartbeat) are suspicious;
249
+ * - capped at read_path_sweep_budget.
250
+ * Extracted so the hot-path zero-read guarantee is unit-testable without the
251
+ * MCP handler.
252
+ */
253
+ export function selectReadPathSweepCandidates(projections, policy, nowMs) {
254
+ if (policy.disabled)
255
+ return [];
256
+ const minTtl = Math.min(policy.assignment_offered_ttl_ms, policy.assignment_accepted_ttl_ms, policy.assignment_started_ttl_ms);
257
+ return projections
258
+ .filter((a) => {
259
+ if (a.status !== 'offered' && a.status !== 'accepted' && a.status !== 'started')
260
+ return false;
261
+ const beat = a.last_heartbeat_at;
262
+ if (!beat)
263
+ return true;
264
+ return nowMs - new Date(beat).getTime() > minTtl;
265
+ })
266
+ .slice(0, policy.read_path_sweep_budget)
267
+ .map((a) => a.id);
268
+ }
269
+ export function sweepAssignmentsAtReadPath(assignments, cwd, options) {
270
+ const policy = options?.policy ?? DEFAULT_HYGIENE_POLICY;
271
+ if (policy.disabled) {
272
+ return { timed_out: [], expired: [], implicitly_advanced: [] };
273
+ }
274
+ const budget = policy.read_path_sweep_budget;
275
+ // Prefer offered/accepted (the empirical debris class); the sweep is a no-op
276
+ // for terminal statuses so filtering is a perf hygiene, not correctness.
277
+ const eligible = assignments
278
+ .filter((a) => a.status === 'offered' || a.status === 'accepted' || a.status === 'started')
279
+ .slice(0, budget);
280
+ return sweepAssignmentsFromList(eligible, cwd, {
281
+ ...options,
282
+ actor: options?.actor ?? 'sweeper-readpath',
283
+ policy,
284
+ });
285
+ }
205
286
  //# sourceMappingURL=assignment-sweeper.js.map
@@ -543,6 +543,85 @@ export function enforceRuntimeNoteRetention(options = {}) {
543
543
  result.backup_path = backupPath;
544
544
  return result;
545
545
  }
546
+ /**
547
+ * pln#602 — park closed auto-generated handoffs older than the cutoff.
548
+ *
549
+ * The stale-warning surface counts every open handoff older than 14d as
550
+ * something the agent should act on. Once a handoff is `closed` (accepted +
551
+ * fulfilled OR explicitly retired), it stops being a workflow signal and
552
+ * starts being noise: the fable-audit trace repeatedly rendered fully-served
553
+ * handoffs from months prior. Park them next to the released-claims archive
554
+ * (same JSONL + backup pattern) so `bclaw_find` still surfaces them on demand
555
+ * but the compact bclaw_work context can move on.
556
+ *
557
+ * Auto-generated detection matches the same "Session sess_ … auto-generated
558
+ * handoff" prefix that `dedupAutoHandoffs` uses — human-authored handoffs
559
+ * stay put regardless of age (they may carry decisions the agent needs).
560
+ */
561
+ export function parkClosedAutoHandoffs(cwd, minAgeDays = DEFAULT_MIN_AGE_DAYS, dryRun = false) {
562
+ const cutoff = new Date(Date.now() - minAgeDays * 24 * 60 * 60 * 1000).toISOString();
563
+ const handoffsDir = path.join(cwd, '.brainclaw', 'coordination', 'handoffs');
564
+ if (!fs.existsSync(handoffsDir))
565
+ return { candidates: 0, parked: 0 };
566
+ const eligible = [];
567
+ const files = fs.readdirSync(handoffsDir).filter((f) => f.endsWith('.json'));
568
+ for (const file of files) {
569
+ const filePath = path.join(handoffsDir, file);
570
+ try {
571
+ const content = fs.readFileSync(filePath, 'utf-8');
572
+ const parsed = JSON.parse(content);
573
+ const status = typeof parsed.status === 'string' ? parsed.status : '';
574
+ if (status !== 'closed')
575
+ continue;
576
+ const text = typeof parsed.text === 'string' ? parsed.text : '';
577
+ const isAutoGenerated = text.startsWith('Session sess_') && text.includes('auto-generated handoff');
578
+ if (!isAutoGenerated)
579
+ continue;
580
+ const updatedAt = typeof parsed.updated_at === 'string' ? parsed.updated_at
581
+ : typeof parsed.created_at === 'string' ? parsed.created_at : '';
582
+ if (!updatedAt || updatedAt > cutoff)
583
+ continue;
584
+ eligible.push({ filePath, content });
585
+ }
586
+ catch {
587
+ // Skip unparseable — a separate check (loadDirectoryItems) will surface it.
588
+ }
589
+ }
590
+ if (dryRun || eligible.length === 0) {
591
+ return { candidates: eligible.length, parked: 0 };
592
+ }
593
+ const archivePath = path.join(handoffsDir, 'compacted.jsonl');
594
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
595
+ const backupPath = path.join(cwd, '.brainclaw', 'gc-backups', `compact-handoffs-closed-${timestamp}.jsonl`);
596
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
597
+ let parked = 0;
598
+ for (const { filePath, content } of eligible) {
599
+ // Codex PR#48 finding 4: order the steps so a partial failure can never
600
+ // leave the source on disk AND its record already in the compaction log
601
+ // (which produced a duplicate compacted record on the next pass). The safe
602
+ // order is backup → unlink → archive:
603
+ // 1. backup first — park-don't-delete safety net is written before any
604
+ // removal, so the raw handoff is always recoverable.
605
+ // 2. unlink next — if this throws, we do NOT archive, so no compacted
606
+ // record exists for a source that is still present → no duplicate.
607
+ // 3. archive last — if this throws after a successful unlink, the source
608
+ // is gone (in the backup) and simply absent from compacted.jsonl; the
609
+ // next pass cannot re-see it, so still no duplicate.
610
+ try {
611
+ const parsed = JSON.parse(content);
612
+ parsed._compacted_at = new Date().toISOString();
613
+ parsed._compaction_type = 'closed-auto-handoff';
614
+ fs.appendFileSync(backupPath, content.trim() + '\n', 'utf-8');
615
+ fs.unlinkSync(filePath);
616
+ fs.appendFileSync(archivePath, JSON.stringify(parsed) + '\n', 'utf-8');
617
+ parked += 1;
618
+ }
619
+ catch (err) {
620
+ logger.debug('parkClosedAutoHandoffs: failed to park', err);
621
+ }
622
+ }
623
+ return { candidates: eligible.length, parked, backup_path: backupPath };
624
+ }
546
625
  /**
547
626
  * Deduplicate auto-generated session-end handoffs. These carry the same
548
627
  * commits list when several sessions close on the same project state, so the