brainclaw 1.12.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.
@@ -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
@@ -175,6 +175,11 @@ const PROFILES = {
175
175
  // Aligning with the regular spawn template (workspace-write) is the
176
176
  // accepted pattern per agent_spawn_inventory memory.
177
177
  invoke_review_template: 'codex exec -c approval_policy="never" --sandbox workspace-write "{prompt}"',
178
+ // pln#606: `codex exec -m <MODEL>` / `--model` (verified empirically on
179
+ // codex 0.130). We use the long form `--model` for symmetry with the
180
+ // other agent profiles and readability.
181
+ model_flag: '--model',
182
+ model_flag_insert_index: 2,
178
183
  },
179
184
  antigravity: {
180
185
  name: 'antigravity', category: 'code-agent', workflowModel: 'interactive',
@@ -204,6 +209,10 @@ const PROFILES = {
204
209
  invoke_template: 'copilot -p "{prompt}" --allow-all --no-ask-user',
205
210
  invoke_binary: 'copilot',
206
211
  invoke_review_template: 'copilot -p "{prompt}" --allow-all --no-ask-user',
212
+ // pln#606: `copilot --model <model>` (verified on Copilot CLI 1.0.35+).
213
+ // 'auto' lets Copilot pick automatically; concrete ids come from the
214
+ // entitled catalog fetched by the CLI at startup.
215
+ model_flag: '--model',
207
216
  },
208
217
  kilocode: {
209
218
  name: 'kilocode', category: 'code-agent', workflowModel: 'interactive',
@@ -564,11 +573,13 @@ export function buildInvokeCommand(name, prompt, options = {}) {
564
573
  const rawTokens = parseTemplateString(templateStr);
565
574
  if (rawTokens.length === 0)
566
575
  return undefined;
567
- // pln#520 step 3: inject the resolved model right after the binary so model
568
- // choice is decoupled from agent identity. Only when the profile declares a
569
- // `model_flag` and the template doesn't already pin a model (don't double it).
576
+ // pln#520 step 3: inject the resolved model at the profile's model argument
577
+ // position so model choice is decoupled from agent identity. Only when the
578
+ // profile declares a `model_flag` and the template doesn't already pin a model
579
+ // (don't double it).
570
580
  if (options.model && profile.model_flag && !rawTokens.includes(profile.model_flag)) {
571
- rawTokens.splice(1, 0, profile.model_flag, options.model);
581
+ const insertIndex = Math.min(Math.max(profile.model_flag_insert_index ?? 1, 1), rawTokens.length);
582
+ rawTokens.splice(insertIndex, 0, profile.model_flag, options.model);
572
583
  }
573
584
  const executable = rawTokens[0];
574
585
  const interpolatedTokens = rawTokens.slice(1).map((tok) => tok === '{prompt}' ? embeddedPrompt : tok);
@@ -150,7 +150,13 @@ function buildIdentityKey(agentId, env = process.env, forceRegenerate = false) {
150
150
  let publicKeyPem;
151
151
  if (!forceRegenerate && fs.existsSync(filepath)) {
152
152
  const privateKey = crypto.createPrivateKey(fs.readFileSync(filepath, 'utf-8'));
153
- publicKeyPem = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'pem' }).toString();
153
+ // @types/node 26 dropped the KeyObject overload from createPublicKey's signature
154
+ // (regression — Node accepts a private KeyObject to derive its public key, as documented).
155
+ // Cast to a parameter type the .d.ts still accepts; runtime behaviour is unchanged.
156
+ publicKeyPem = crypto
157
+ .createPublicKey(privateKey)
158
+ .export({ type: 'spki', format: 'pem' })
159
+ .toString();
154
160
  }
155
161
  else {
156
162
  const generated = crypto.generateKeyPairSync('ed25519');
@@ -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
@@ -153,9 +153,15 @@ function assertReleaseOwnership(claim, auth) {
153
153
  return { overrideUsed: false };
154
154
  if (auth.override)
155
155
  return { overrideUsed: true };
156
+ // pln#607 rule + trp#928 — the error must be executable as-is: the caller
157
+ // should be able to copy the coordinator_override:true param straight from the
158
+ // message into their next bclaw_release_claim call. "Coordinator-level callers
159
+ // may release with override" was diagnostically useless before — no param name,
160
+ // no path forward. Ghost claim clm_ed9b8386 stayed active for weeks because
161
+ // this error was raised, swallowed by a best-effort catch, and never surfaced.
156
162
  throw new Error(`claim '${claim.id}' is held by '${claim.agent}'${claim.session_id ? ` (session ${claim.session_id})` : ''}; `
157
163
  + `caller '${auth.agent ?? auth.agent_id ?? auth.session_id ?? 'unknown'}' does not own it. `
158
- + 'Coordinator-level callers may release with override.');
164
+ + `Retry with coordinator_override:true (requires trusted+ trust level; the release is audited).`);
159
165
  }
160
166
  function auditReleaseOverride(claim, auth, cwd) {
161
167
  appendAuditEntry({
@@ -184,6 +190,43 @@ export function releaseClaim(id, cwd, auth) {
184
190
  }
185
191
  return released;
186
192
  }
193
+ /**
194
+ * Mark an active claim as `stale` — a distinct terminal state from `released`
195
+ * used when a claim is being torn down because its owner is gone (session
196
+ * expired, worker died, coordinator abandoned the lane). Same ownership rules
197
+ * as releaseClaim (trusted+ coordinators may override with audit).
198
+ *
199
+ * trp#928 — the `active → stale` transition documented on the entity registry
200
+ * had no imperative path before; callers had to fall back to mass sweeps
201
+ * (`expireStaleActiveClaims`) or write status directly. Now bclaw_transition
202
+ * (entity=claim, to='stale') reaches this function via entity-operations.
203
+ */
204
+ export function markClaimStale(id, cwd, auth) {
205
+ let overrideUsed = false;
206
+ const staled = mutate({ cwd }, () => {
207
+ const claim = loadClaim(id, cwd);
208
+ overrideUsed = assertReleaseOwnership(claim, auth).overrideUsed;
209
+ claim.status = 'stale';
210
+ claim.released_at = nowISO();
211
+ saveClaimUnlocked(claim, cwd);
212
+ return claim;
213
+ });
214
+ appendAuditEntry({
215
+ actor: staled.agent,
216
+ actor_id: staled.agent_id,
217
+ action: 'release_claim',
218
+ item_id: staled.id,
219
+ item_type: 'claim',
220
+ scope: staled.scope,
221
+ session_id: staled.session_id,
222
+ host_id: staled.host_id,
223
+ after: { status: 'stale' },
224
+ }, cwd);
225
+ if (overrideUsed && auth) {
226
+ auditReleaseOverride(staled, auth, cwd);
227
+ }
228
+ return staled;
229
+ }
187
230
  /**
188
231
  * Release a claim and optionally cascade the status to its linked plan.
189
232
  *
@@ -295,6 +338,122 @@ export function isClaimExpired(claim) {
295
338
  return false;
296
339
  return new Date(claim.expires_at) < new Date();
297
340
  }
341
+ /**
342
+ * Release every ACTIVE claim linked to a given target (plan / assignment / loop
343
+ * slot claim). trp#928 — the cascade must LOG per-claim (released or
344
+ * skipped+reason) so a silent ownership failure is observable at the harvest /
345
+ * loop-close boundary. Ownership follows the same ReleaseClaimAuth contract as
346
+ * releaseClaim: a system caller (auth undefined) bypasses the check; a caller
347
+ * with auth honors ownership + coordinator_override.
348
+ */
349
+ export function releaseClaimsCascade(claimIds, options = {}) {
350
+ const entries = [];
351
+ // Deduplicate — callers may pass the same claim id via both an assignment and
352
+ // a slot; a duplicate would double-audit.
353
+ const seen = new Set();
354
+ for (const id of claimIds) {
355
+ if (!id || seen.has(id))
356
+ continue;
357
+ seen.add(id);
358
+ let claim;
359
+ try {
360
+ claim = loadClaim(id, options.cwd);
361
+ }
362
+ catch {
363
+ entries.push({ claim_id: id, released: false, reason: 'not_found' });
364
+ continue;
365
+ }
366
+ if (claim.status !== 'active') {
367
+ entries.push({ claim_id: id, released: false, reason: 'already_terminal' });
368
+ continue;
369
+ }
370
+ try {
371
+ const rel = releaseClaimWithCascade(id, {
372
+ planStatus: options.planStatus,
373
+ cwd: options.cwd,
374
+ auth: options.auth,
375
+ });
376
+ const overrideUsed = options.auth?.override === true
377
+ && !ownerMatches(claim, options.auth);
378
+ entries.push({
379
+ claim_id: id,
380
+ released: rel.claim.status === 'released',
381
+ reason: rel.claim.status === 'released' ? 'released' : 'error',
382
+ ...(overrideUsed ? { override_used: true } : {}),
383
+ });
384
+ }
385
+ catch (err) {
386
+ const message = err instanceof Error ? err.message : String(err);
387
+ // The specific ownership-check error thrown by assertReleaseOwnership
388
+ // gets its own reason bucket so a caller can surface an executable hint
389
+ // (retry with coordinator_override:true) instead of a generic error.
390
+ const reason = /coordinator_override/i.test(message) ? 'ownership_denied' : 'error';
391
+ entries.push({ claim_id: id, released: false, reason, error: message });
392
+ }
393
+ }
394
+ const released_count = entries.filter((e) => e.released).length;
395
+ const error_count = entries.filter((e) => e.reason === 'error' || e.reason === 'ownership_denied').length;
396
+ return {
397
+ entries,
398
+ released_count,
399
+ skipped_count: entries.length - released_count - error_count,
400
+ error_count,
401
+ };
402
+ }
403
+ /**
404
+ * Extract of assertReleaseOwnership's owner check without the throw. Used by
405
+ * releaseClaimsCascade to know whether a successful release used the override
406
+ * path (so it can be reported in the per-claim log).
407
+ */
408
+ function ownerMatches(claim, auth) {
409
+ return ((auth.session_id !== undefined && claim.session_id !== undefined && auth.session_id === claim.session_id)
410
+ || (auth.agent_id !== undefined && claim.agent_id !== undefined && auth.agent_id === claim.agent_id)
411
+ || (auth.agent !== undefined && claim.agent === auth.agent));
412
+ }
413
+ /**
414
+ * Find every active claim linked to a plan (via plan_id). Used by
415
+ * bclaw_transition(entity='plan', to='done') to implement the
416
+ * `release_linked_claims_if_last` cascade tag advertised on the entity
417
+ * registry (before trp#928 the tag was documentation only; the imperative
418
+ * cascade never ran).
419
+ */
420
+ export function findActiveClaimsForPlan(planId, cwd) {
421
+ return listClaims(cwd).filter((c) => c.plan_id === planId && c.status === 'active');
422
+ }
423
+ /**
424
+ * Emit a runtime event summarising a cascade release outcome, one entry per
425
+ * claim in the metadata. trp#928 — every cascade caller (plan-done,
426
+ * loop close, assignment→completed, harvest --integrate) MUST log per-claim
427
+ * status so silent failures are observable via bclaw_find(entity='agent_run')
428
+ * / bclaw_find(entity='claim'). Best-effort: never breaks the parent flow.
429
+ */
430
+ export function logCascadeReleaseResult(input) {
431
+ const { released_count, skipped_count, error_count, entries } = input.cascade;
432
+ if (entries.length === 0)
433
+ return;
434
+ const text = `cascade[${input.trigger}]: released=${released_count} skipped=${skipped_count} errors=${error_count}`
435
+ + ` — ${entries.map((e) => `${e.claim_id}:${e.reason}`).join(', ')}`;
436
+ try {
437
+ createRuntimeEvent({
438
+ agent: input.actor,
439
+ event_type: 'assignment_progress',
440
+ text,
441
+ tags: ['cascade', 'claim-release', input.trigger, ...(error_count > 0 ? ['ownership-issue'] : [])],
442
+ plan_id: input.plan_id,
443
+ assignment_id: input.assignment_id,
444
+ claim_id: input.claim_id,
445
+ metadata: {
446
+ trigger: input.trigger,
447
+ released_count,
448
+ skipped_count,
449
+ error_count,
450
+ entries,
451
+ ...(input.loop_id ? { loop_id: input.loop_id } : {}),
452
+ },
453
+ }, input.cwd);
454
+ }
455
+ catch { /* best-effort logging — never break the parent flow */ }
456
+ }
298
457
  /** Mark active claims past their expires_at as released. Returns count of expired claims. */
299
458
  export function expireStaleActiveClaims(cwd) {
300
459
  return mutate({ cwd }, () => {
@@ -243,8 +243,12 @@ export function buildContext(options = {}) {
243
243
  },
244
244
  });
245
245
  }
246
+ // pln#578 — single pending-candidates read, reused by the includePending
247
+ // items, scoped activity, and staleness passes below (same idiom as the
248
+ // pln#564 runtime-notes reuse: one scan, three consumers).
249
+ const pendingCandidates = listCandidates('pending', contextCwd);
246
250
  if (options.includePending) {
247
- for (const p of listCandidates('pending', contextCwd)) {
251
+ for (const p of pendingCandidates) {
248
252
  const meta = [`${p.type}`, `stars:${p.star_count ?? 0}`, `uses:${p.usage_count ?? 0}`];
249
253
  if (p.author_id)
250
254
  meta.push(`author_id:${p.author_id}`);
@@ -438,7 +442,7 @@ export function buildContext(options = {}) {
438
442
  project,
439
443
  state,
440
444
  runtimeNotes,
441
- pendingCandidates: listCandidates('pending', contextCwd),
445
+ pendingCandidates,
442
446
  });
443
447
  // Density reflects what the store HAS, not what the char budget keeps:
444
448
  // classify pre-budget so a tight budget_tokens on a rich store never
@@ -585,7 +589,7 @@ export function buildContext(options = {}) {
585
589
  // flows through the same surface.
586
590
  let staleWarnings;
587
591
  try {
588
- const pendingCandidatesForStaleness = listCandidates('pending', contextCwd);
592
+ const pendingCandidatesForStaleness = pendingCandidates;
589
593
  // pln#564 step A — reuse the runtime notes already loaded above (line ~316)
590
594
  // instead of a second unfiltered full scan of the runtime-note tree. On a
591
595
  // store with thousands of notes that 2nd scan dominated buildContext cost
@@ -669,7 +673,10 @@ export function buildContext(options = {}) {
669
673
  : undefined,
670
674
  estimation_calibration: (() => {
671
675
  try {
672
- const report = buildEstimationReport({ agent, cwd: contextCwd });
676
+ // pln#578 reuse the state loaded at the top of buildContext; the
677
+ // report only reads plan_items and a fresh loadState here was one of
678
+ // the four full-store passes per context build.
679
+ const report = buildEstimationReport({ agent, cwd: contextCwd, state });
673
680
  return report.summary.with_both >= 3 ? report.summary.calibration_hint : undefined;
674
681
  }
675
682
  catch {
@@ -34,24 +34,128 @@ const DEFAULT_TAIL = 20;
34
34
  const DEFAULT_STALL_MS = 5 * 60_000;
35
35
  const DEFAULT_BASE_REF = 'master';
36
36
  /**
37
- * pln#554 — worktree git evidence, the signal that beats process/administrative
38
- * status: a worker that committed everything to its lane branch has DELIVERED,
39
- * whatever its pid/heartbeat/assignment.status say. Shared by dispatch-status
40
- * and `brainclaw dispatch watch`. Returns undefined when there is no worktree
41
- * or git could not be queried (never conclude "no commits" from a failed read).
37
+ * trp#926read the worktree's recorded creation ref (SHA) from its brainclaw
38
+ * sidecar so gitEvidence can measure "commits the worker added" against the
39
+ * anchor the worktree was BORN at, not the caller's moving default (which is
40
+ * usually `master`). Comparing to master after master advanced was the
41
+ * observed false-positive on rtn_c5542b05: lane HEAD's commits still appeared
42
+ * "ahead of master" long after they were squash-merged, so dispatch_status
43
+ * reported "worker delivered" for a fully integrated lane.
44
+ *
45
+ * Returns the SHA when the sidecar records `base_ref_sha`. A legacy sidecar
46
+ * without that field means "unknown": falling back to the caller's moving
47
+ * `master` would recreate the false `worker delivered` signal this fixes.
48
+ */
49
+ function readWorktreeBaseRef(worktreePath) {
50
+ const sidecar = path.join(worktreePath, '.brainclaw-worktree.json');
51
+ try {
52
+ const meta = JSON.parse(fs.readFileSync(sidecar, 'utf-8'));
53
+ if (typeof meta.base_ref_sha === 'string' && meta.base_ref_sha.length > 0) {
54
+ return { ref: meta.base_ref_sha, legacySidecar: false };
55
+ }
56
+ return { legacySidecar: true };
57
+ }
58
+ catch {
59
+ return { legacySidecar: fs.existsSync(sidecar) };
60
+ }
61
+ }
62
+ function aggregateChangesMatchIntegrationBase(worktreePath, creationBase, integrationBase) {
63
+ try {
64
+ const changed = execFileSync('git', ['-C', worktreePath, 'diff', '--name-only', '-z', creationBase, 'HEAD'], {
65
+ encoding: 'utf-8', timeout: 15000,
66
+ });
67
+ const paths = changed.split('\0').filter(Boolean);
68
+ if (paths.length === 0)
69
+ return true;
70
+ for (let i = 0; i < paths.length; i += 100) {
71
+ const chunk = paths.slice(i, i + 100);
72
+ execFileSync('git', ['-C', worktreePath, 'diff', '--quiet', 'HEAD', integrationBase, '--', ...chunk], {
73
+ encoding: 'utf-8', timeout: 15000,
74
+ });
75
+ }
76
+ return true;
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
82
+ /**
83
+ * pln#554 + trp#926 — worktree git evidence, the signal that beats process /
84
+ * administrative status: a worker that committed everything to its lane branch
85
+ * has DELIVERED, whatever its pid/heartbeat/assignment.status say. Shared by
86
+ * dispatch-status and `brainclaw dispatch watch`. Returns undefined when there
87
+ * is no worktree or git could not be queried (never conclude "no commits" from
88
+ * a failed read).
89
+ *
90
+ * The comparison anchor is:
91
+ * 1. the worktree sidecar's recorded creation SHA (`base_ref_sha`) — the
92
+ * truthful anchor a worker was born at;
93
+ * 2. otherwise `commits_ahead_base` (caller-supplied, default `master`) ONLY
94
+ * when there is no brainclaw sidecar at all (plain/non-brainclaw git
95
+ * evidence callers).
96
+ * A legacy sidecar without `base_ref_sha` returns undefined. That is deliberate:
97
+ * unknown is safer than silently comparing to a moving `master`.
98
+ * Anchoring on the creation ref is what avoids the "worker delivered"
99
+ * false-positive after a squash-merge advanced master.
100
+ *
101
+ * Additionally, `commitsAhead` is refined via `git cherry <base> HEAD`
102
+ * (patch-id): a commit whose patch is already on `base` is treated as
103
+ * integrated even if its SHA is not an ancestor of `base` (squash-merge case).
104
+ * `commitsAheadRaw` preserves the historical ancestry-only count for callers /
105
+ * telemetry that need it.
42
106
  */
43
107
  export function gitEvidence(worktreePath, baseRef) {
44
108
  if (!worktreePath)
45
109
  return undefined;
110
+ // Two DIFFERENT anchors:
111
+ // - creationBase: sidecar `base_ref_sha`, else caller `baseRef` for
112
+ // non-brainclaw paths only. A legacy sidecar without the SHA is unknown.
113
+ // stable anchor for "how much did the worker add?" (raw ahead count).
114
+ // - integrationBase: caller `baseRef` (default `master`) — the moving
115
+ // integration target the patch-id refinement compares against ("still
116
+ // un-integrated?"). Using the creation SHA here would falsely count a
117
+ // squash-merged commit as un-integrated (its patch is on master, but
118
+ // master isn't the creation ref).
119
+ const recordedBase = readWorktreeBaseRef(worktreePath);
120
+ if (recordedBase.legacySidecar && !recordedBase.ref) {
121
+ logger.debug('dispatch status: git evidence unavailable: legacy worktree sidecar lacks base_ref_sha');
122
+ return undefined;
123
+ }
124
+ const creationBase = recordedBase.ref ?? baseRef;
125
+ const integrationBase = baseRef;
46
126
  try {
47
- const ahead = execFileSync('git', ['-C', worktreePath, 'rev-list', '--count', `${baseRef}..HEAD`], {
127
+ const aheadRaw = execFileSync('git', ['-C', worktreePath, 'rev-list', '--count', `${creationBase}..HEAD`], {
48
128
  encoding: 'utf-8', timeout: 15000,
49
129
  }).trim();
50
130
  const status = execFileSync('git', ['-C', worktreePath, 'status', '--short'], {
51
131
  encoding: 'utf-8', timeout: 15000,
52
132
  });
53
133
  const dirty = status.split('\n').filter((l) => l.trim() && !l.startsWith('??')).length;
54
- return { commitsAhead: Number.parseInt(ahead, 10) || 0, dirtyTracked: dirty };
134
+ const rawCount = Number.parseInt(aheadRaw, 10) || 0;
135
+ // Patch-id refinement against the INTEGRATION base: `git cherry` marks
136
+ // each commit in `base..HEAD` as `-` (patch on base — squash-merged /
137
+ // cherry-picked) or `+` (still un-integrated). Refined count = `+` lines.
138
+ // Best-effort — a failed cherry falls back to the raw ancestry count.
139
+ let refined = rawCount;
140
+ if (rawCount > 0) {
141
+ try {
142
+ const cherry = execFileSync('git', ['-C', worktreePath, 'cherry', integrationBase, 'HEAD'], {
143
+ encoding: 'utf-8', timeout: 15000,
144
+ });
145
+ const plusLines = cherry.split(/\r?\n/).filter((l) => l.startsWith('+ '));
146
+ refined = plusLines.length;
147
+ if (refined > 0 && aggregateChangesMatchIntegrationBase(worktreePath, creationBase, integrationBase)) {
148
+ refined = 0;
149
+ }
150
+ }
151
+ catch { /* keep raw count */ }
152
+ }
153
+ return {
154
+ commitsAhead: refined,
155
+ commitsAheadRaw: rawCount,
156
+ dirtyTracked: dirty,
157
+ baseRef: creationBase,
158
+ };
55
159
  }
56
160
  catch (err) {
57
161
  logger.debug('dispatch status: git evidence unavailable:', err);
@@ -348,6 +452,8 @@ export function getDispatchStatus(options) {
348
452
  last_fs_activity_ms: lastFsActivityMs,
349
453
  lane_result: laneResult,
350
454
  commits_ahead: evidence?.commitsAhead,
455
+ commits_ahead_raw: evidence?.commitsAheadRaw,
456
+ commits_ahead_base: evidence?.baseRef,
351
457
  dirty_tracked: evidence?.dirtyTracked,
352
458
  };
353
459
  let diagnosis = computeDiagnosis(assignment, agentRun, runtime, { stallMs, nowMs });