brainclaw 1.11.1 → 1.13.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.
@@ -3,16 +3,36 @@ import { mutate } from '../core/mutation-pipeline.js';
3
3
  import { loadClaim, listClaims, releaseClaim } from '../core/claims.js';
4
4
  import { rebuildProjectMd } from '../core/markdown.js';
5
5
  import { loadState, mutateState } from '../core/state.js';
6
+ import { requireMinimumTrustLevel, requireRegisteredAgentIdentity } from '../core/agent-registry.js';
6
7
  export function runReleaseClaim(id, options = {}) {
7
8
  if (!memoryExists(options.cwd)) {
8
9
  console.error('Error: .brainclaw/ not found. Run `brainclaw init` first.');
9
10
  process.exit(1);
10
11
  }
11
12
  try {
13
+ // Surface split (trp#928 follow-up): the ownership gate lives on the MCP
14
+ // surface (bclaw_release_claim / bclaw_transition), where agent callers
15
+ // carry a session-bound identity. The CLI `release-claim <id>` is the
16
+ // operator/scripting surface and keeps its historic unguarded semantics —
17
+ // the e2e contract (collaboration.test.ts) has always released cross-agent
18
+ // claims from an env-identified CLI. Deriving an ambient identity here and
19
+ // gating on it turned every operator release into a false ownership
20
+ // mismatch (silent-default anti-pattern, pln#607). `--coordinator-override`
21
+ // stays available to make a cross-agent release explicit and audited.
22
+ let releaseAuth;
23
+ if (options.coordinatorOverride) {
24
+ const identity = requireRegisteredAgentIdentity({ cwd: options.cwd, allowCurrent: true, allowEnv: true });
25
+ requireMinimumTrustLevel(identity, 'trusted');
26
+ releaseAuth = {
27
+ agent: identity.agent_name,
28
+ agent_id: identity.agent_id,
29
+ override: true,
30
+ };
31
+ }
12
32
  let claim = loadClaim(id, options.cwd);
13
33
  mutate({ cwd: options.cwd }, () => {
14
34
  const existing = loadClaim(id, options.cwd);
15
- claim = releaseClaim(id, options.cwd);
35
+ claim = releaseClaim(id, options.cwd, releaseAuth);
16
36
  if (existing.plan_id) {
17
37
  const updated = mutateState((state) => {
18
38
  const plan = state.plan_items.find((item) => item.id === existing.plan_id);
@@ -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');
@@ -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 });
@@ -15,7 +15,7 @@ import path from 'node:path';
15
15
  import { loadState, mutateState } from './state.js';
16
16
  import { archiveCandidate, listCandidates, loadCandidate, saveCandidate, } from './candidates.js';
17
17
  import { addCrossProjectLink, removeCrossProjectLink, resolveCrossProjectLinks, } from './cross-project.js';
18
- import { listClaims, loadClaim, saveClaim } from './claims.js';
18
+ import { findActiveClaimsForPlan, listClaims, loadClaim, logCascadeReleaseResult, markClaimStale, releaseClaimsCascade, releaseClaimWithCascade, saveClaim, } from './claims.js';
19
19
  import { listActionRequired } from './actions.js';
20
20
  import { deleteAssignment, listAssignments, loadAssignment, saveAssignment, transitionAssignment } from './assignments.js';
21
21
  import { listAgentRuns } from './agentruns.js';
@@ -599,8 +599,7 @@ export function removeEntity(name, id, cwd, purge = false) {
599
599
  throw new EntityOperationUnsupportedError(name, 'remove');
600
600
  }
601
601
  }
602
- // ─── TRANSITION ───────────────────────────────────────────────────────
603
- export function transitionEntity(name, id, to, cwd, _reason) {
602
+ export function transitionEntity(name, id, to, cwd, _reason, auth) {
604
603
  const spec = ENTITY_REGISTRY[name];
605
604
  if (!spec.statusField) {
606
605
  throw new Error(`${name} has no lifecycle (statusField is undefined)`);
@@ -619,6 +618,32 @@ export function transitionEntity(name, id, to, cwd, _reason) {
619
618
  switch (name) {
620
619
  case 'plan': {
621
620
  updatePlan({ id, status: to }, cwd);
621
+ // trp#928 — implement the `release_linked_claims_if_last` cascade tag.
622
+ // Before this landing the tag was advertised by the entity registry but
623
+ // the imperative cascade never ran, so a plan closed while its worker
624
+ // claims stayed active (ghost claims). The cascade now:
625
+ // - runs only on plan → done (the tag's actual trigger)
626
+ // - releases each active claim linked via plan_id
627
+ // - LOGS per claim (released / skipped+reason / error) via the runtime
628
+ // event journal so `bclaw_find(entity=agent_run)` and dashboards can
629
+ // observe silent ownership failures instead of guessing at them.
630
+ // Ownership check: this path runs from bclaw_transition, so it inherits
631
+ // the caller's TransitionAuth (populated for entity='claim' but not for
632
+ // entity='plan'). auth undefined = system convergence (bypass ownership),
633
+ // matching the historical implicit contract for plan cascades.
634
+ if (to === 'done') {
635
+ const linked = findActiveClaimsForPlan(id, cwd);
636
+ if (linked.length > 0) {
637
+ const cascade = releaseClaimsCascade(linked.map((c) => c.id), { cwd });
638
+ logCascadeReleaseResult({
639
+ actor: 'system',
640
+ trigger: 'plan_done',
641
+ plan_id: id,
642
+ cascade,
643
+ cwd,
644
+ });
645
+ }
646
+ }
622
647
  return { entity: name, id, from, to, side_effects: sideEffects };
623
648
  }
624
649
  case 'decision':
@@ -656,6 +681,29 @@ export function transitionEntity(name, id, to, cwd, _reason) {
656
681
  updateSequence({ id, status: to }, cwd);
657
682
  return { entity: name, id, from, to, side_effects: sideEffects };
658
683
  }
684
+ case 'claim': {
685
+ // trp#928 — the entity registry advertised `active → released|stale` but
686
+ // transitionEntity never routed for entity=claim. The isValidTransition
687
+ // check above passed for anyone calling `bclaw_transition(entity='claim',
688
+ // to='released')`, but the transition then fell through to the
689
+ // EntityOperationUnsupportedError default. Now: released hits the same
690
+ // cascade path bclaw_release_claim uses (audit + plan-done cascade); stale
691
+ // uses markClaimStale (audit + `stale` terminal status). Reuses ReleaseClaimAuth
692
+ // so a trusted+ coordinator can release across ownership with override.
693
+ const releaseAuth = auth
694
+ ? { agent: auth.agent, agent_id: auth.agent_id, session_id: auth.session_id, override: auth.override }
695
+ : undefined;
696
+ if (to === 'released') {
697
+ releaseClaimWithCascade(id, { planStatus: _reason === 'done' ? 'done' : undefined, cwd, auth: releaseAuth });
698
+ return { entity: name, id, from, to, side_effects: sideEffects };
699
+ }
700
+ if (to === 'stale') {
701
+ markClaimStale(id, cwd, releaseAuth);
702
+ return { entity: name, id, from, to, side_effects: sideEffects };
703
+ }
704
+ // isValidTransition already excluded every other target — belt-and-braces:
705
+ throw new InvalidTransitionError(name, from, to);
706
+ }
659
707
  default:
660
708
  throw new EntityOperationUnsupportedError(name, 'transition', `Lifecycle transitions for ${name} not yet wired.`);
661
709
  }
@@ -5,6 +5,7 @@ import { memoryDir, writeFileAtomic } from '../io.js';
5
5
  import { nowISO } from '../ids.js';
6
6
  import { logger } from '../logger.js';
7
7
  import { convergeAssignmentToTerminal, loadAssignment } from '../assignments.js';
8
+ import { logCascadeReleaseResult, releaseClaimsCascade } from '../claims.js';
8
9
  import { gcWorktreeIfHarvested } from '../worktree.js';
9
10
  import { writeProjectMdSafe } from './hooks/bootstrap-write.js';
10
11
  import { notifyOperatorOnInputRequested } from './hooks/notify-operator.js';
@@ -435,6 +436,38 @@ export function closeLoop(input, cwd) {
435
436
  }
436
437
  catch { /* never block loop close on assignment convergence */ }
437
438
  }
439
+ // trp#928 — cascade-release claims linked to slots + slot-linked assignments.
440
+ // Before this landing, loop close converged the assignment lifecycle but left
441
+ // reviewer claims active indefinitely (dogfooding 2026-07: 23 ghost claims,
442
+ // most from closed review loops). System-actor release: no auth → the
443
+ // ownership check is skipped, matching convergeAssignmentToTerminal's contract
444
+ // above (loop close is a system action, not a user-driven release).
445
+ const claimIdsFromSlots = [];
446
+ for (const slot of next.slots) {
447
+ if (slot.claim_id)
448
+ claimIdsFromSlots.push(slot.claim_id);
449
+ if (slot.assignment_id) {
450
+ try {
451
+ const assignment = loadAssignment(slot.assignment_id, cwd);
452
+ if (assignment?.claim_id)
453
+ claimIdsFromSlots.push(assignment.claim_id);
454
+ }
455
+ catch { /* assignment gone — skip */ }
456
+ }
457
+ }
458
+ if (claimIdsFromSlots.length > 0) {
459
+ try {
460
+ const cascade = releaseClaimsCascade(claimIdsFromSlots, { cwd });
461
+ logCascadeReleaseResult({
462
+ actor: input.actor,
463
+ trigger: 'loop_close',
464
+ loop_id: input.id,
465
+ cascade,
466
+ cwd,
467
+ });
468
+ }
469
+ catch { /* never block loop close on cascade release */ }
470
+ }
438
471
  // pln#594: GC the dispatched sub-agent worktrees now the loop is done, so
439
472
  // review/dispatch worktrees stop accumulating under ~/.brainclaw/worktrees/.
440
473
  // Only on a COMPLETED close — cancelled/blocked keep their worktree (+ run
@@ -281,6 +281,24 @@ export function buildReputationSnapshot(cwd) {
281
281
  resume_weight: 0.35,
282
282
  mcp_exposure: false,
283
283
  };
284
+ // pln#578 — disabled reputation (the default) must not pay for the sweep.
285
+ // Every consumer already treats a disabled snapshot as empty: agents is []
286
+ // (line below gates on enabled), so ranking bonuses are 0 and the resume
287
+ // summary is undefined. Yet the full signal sweep (pending + archived
288
+ // candidates, all runtime notes, all claims, a complete loadState) was still
289
+ // running — two of the four full-store read passes a single buildContext
290
+ // performed on a large store. Exit before any store read when disabled.
291
+ if (!reputationConfig.enabled) {
292
+ return {
293
+ enabled: false,
294
+ visibility: reputationConfig.visibility,
295
+ window_days: reputationConfig.decay_days,
296
+ generated_at: nowISO(),
297
+ project_id: config.project_id,
298
+ current_agent_id: resolveCurrentAgentIdentity(cwd)?.agent_id,
299
+ agents: [],
300
+ };
301
+ }
284
302
  const registered = listAgentIdentities(cwd);
285
303
  const currentAgent = resolveCurrentAgentIdentity(cwd);
286
304
  const resolvers = buildIdentityResolvers(registered);
@@ -1122,6 +1122,13 @@ export const CurrentSessionStateSchema = z.object({
1122
1122
  branch: z.string().optional(),
1123
1123
  /** Isolation mode: shared-checkout (default) or dedicated-worktree. */
1124
1124
  isolation_mode: IsolationModeSchema.optional(),
1125
+ /**
1126
+ * True when the session was materialized by an auto-repair path (e.g. a
1127
+ * canonical write arriving without a prior session). Tag lets aggressive
1128
+ * harvesting distinguish worker-authored sessions from operator sessions
1129
+ * (pln#602 lesson on the pln#578 887-file blowup).
1130
+ */
1131
+ auto_created: z.boolean().optional(),
1125
1132
  });
1126
1133
  export const MemorySeedKindSchema = z.enum([
1127
1134
  'command',