brainclaw 1.7.5 → 1.9.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.
Files changed (143) hide show
  1. package/README.md +28 -11
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +139 -13
  4. package/dist/commands/add-step.js +1 -1
  5. package/dist/commands/bootstrap.js +2 -26
  6. package/dist/commands/check-security-mcp.js +50 -33
  7. package/dist/commands/check-security.js +86 -43
  8. package/dist/commands/claim.js +22 -21
  9. package/dist/commands/confirm.js +26 -0
  10. package/dist/commands/context-diff.js +1 -1
  11. package/dist/commands/dispatch-watch.js +142 -0
  12. package/dist/commands/doctor.js +113 -2
  13. package/dist/commands/estimation-report.js +115 -16
  14. package/dist/commands/harvest.js +502 -16
  15. package/dist/commands/init.js +123 -21
  16. package/dist/commands/loops-handlers.js +4 -0
  17. package/dist/commands/mcp-read-handlers.js +198 -29
  18. package/dist/commands/mcp.js +615 -92
  19. package/dist/commands/memory.js +21 -17
  20. package/dist/commands/migrate.js +81 -17
  21. package/dist/commands/prune.js +78 -4
  22. package/dist/commands/reflect.js +26 -20
  23. package/dist/commands/register-agent.js +57 -1
  24. package/dist/commands/repair.js +20 -0
  25. package/dist/commands/session-end.js +15 -6
  26. package/dist/commands/session-start.js +18 -1
  27. package/dist/commands/setup-security.js +39 -18
  28. package/dist/commands/setup.js +26 -27
  29. package/dist/commands/stale.js +16 -2
  30. package/dist/commands/uninstall.js +126 -34
  31. package/dist/commands/update-step.js +6 -0
  32. package/dist/commands/worktree.js +60 -0
  33. package/dist/core/actions.js +12 -3
  34. package/dist/core/agent-capability.js +11 -13
  35. package/dist/core/agent-files.js +844 -547
  36. package/dist/core/agent-integrations.js +0 -3
  37. package/dist/core/agent-inventory.js +67 -0
  38. package/dist/core/agent-registry.js +163 -29
  39. package/dist/core/agentrun-reconciler.js +33 -2
  40. package/dist/core/agentruns.js +7 -1
  41. package/dist/core/ai-agent-detection.js +31 -44
  42. package/dist/core/archival.js +15 -9
  43. package/dist/core/assignment-reconciler.js +56 -0
  44. package/dist/core/assignment-sweeper.js +127 -4
  45. package/dist/core/assignments.js +69 -11
  46. package/dist/core/bootstrap.js +233 -67
  47. package/dist/core/brainclaw-version.js +22 -0
  48. package/dist/core/candidates.js +21 -1
  49. package/dist/core/claims.js +313 -150
  50. package/dist/core/config.js +6 -1
  51. package/dist/core/context-diff.js +148 -20
  52. package/dist/core/context.js +129 -8
  53. package/dist/core/coordination.js +22 -3
  54. package/dist/core/dispatch-status.js +109 -5
  55. package/dist/core/dispatcher.js +65 -11
  56. package/dist/core/entity-operations.js +45 -24
  57. package/dist/core/entity-registry.js +31 -5
  58. package/dist/core/event-log.js +138 -21
  59. package/dist/core/events/checkpoint.js +258 -0
  60. package/dist/core/events/genesis.js +220 -0
  61. package/dist/core/events/journal.js +507 -0
  62. package/dist/core/events/materialize.js +126 -0
  63. package/dist/core/events/registry-post-image.js +110 -0
  64. package/dist/core/events/verify.js +109 -0
  65. package/dist/core/execution-adapters.js +23 -0
  66. package/dist/core/execution.js +25 -0
  67. package/dist/core/facade-schema.js +48 -0
  68. package/dist/core/gc-semantic.js +130 -5
  69. package/dist/core/handoff-snapshot.js +68 -0
  70. package/dist/core/ids.js +19 -8
  71. package/dist/core/instruction-templates.js +34 -115
  72. package/dist/core/io.js +39 -3
  73. package/dist/core/json-store.js +10 -1
  74. package/dist/core/lock.js +153 -28
  75. package/dist/core/loops/bootstrap-acquire.js +25 -1
  76. package/dist/core/loops/facade-schema.js +2 -0
  77. package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
  78. package/dist/core/loops/index.js +1 -0
  79. package/dist/core/loops/presets/bootstrap.js +7 -0
  80. package/dist/core/loops/store.js +17 -0
  81. package/dist/core/loops/verbs.js +24 -1
  82. package/dist/core/markdown.js +8 -76
  83. package/dist/core/mcp-command-resolution.js +245 -0
  84. package/dist/core/memory-compactor.js +5 -3
  85. package/dist/core/memory-lifecycle.js +282 -0
  86. package/dist/core/merge-risk.js +150 -0
  87. package/dist/core/messaging.js +8 -1
  88. package/dist/core/migration.js +11 -1
  89. package/dist/core/observer-mode.js +26 -0
  90. package/dist/core/operations/memory-mutation.js +90 -65
  91. package/dist/core/operations/plan.js +27 -1
  92. package/dist/core/protocol-skills.js +210 -0
  93. package/dist/core/reflection-safety.js +6 -7
  94. package/dist/core/reputation.js +84 -2
  95. package/dist/core/runtime-signals.js +71 -9
  96. package/dist/core/runtime.js +84 -1
  97. package/dist/core/schema.js +125 -0
  98. package/dist/core/security-detectors.js +125 -0
  99. package/dist/core/security-extract.js +189 -0
  100. package/dist/core/security-guard.js +107 -29
  101. package/dist/core/security-packages.js +121 -0
  102. package/dist/core/security-scoring.js +76 -9
  103. package/dist/core/security.js +34 -2
  104. package/dist/core/sequence.js +11 -2
  105. package/dist/core/setup-flow.js +141 -13
  106. package/dist/core/spawn-check.js +110 -4
  107. package/dist/core/staleness.js +109 -1
  108. package/dist/core/state.js +250 -54
  109. package/dist/core/store-resolution.js +19 -5
  110. package/dist/core/worktree.js +169 -7
  111. package/dist/facts.js +8 -8
  112. package/dist/facts.json +7 -7
  113. package/docs/PROTOCOL.md +223 -0
  114. package/docs/cli.md +11 -10
  115. package/docs/concepts/coordinator-runbook.md +129 -0
  116. package/docs/concepts/dispatch-lifecycle.md +17 -0
  117. package/docs/concepts/event-log-store-critique-A.md +333 -0
  118. package/docs/concepts/event-log-store-critique-B.md +353 -0
  119. package/docs/concepts/event-log-store-phase0-measurements.md +58 -0
  120. package/docs/concepts/event-log-store-proposal-A.md +365 -0
  121. package/docs/concepts/event-log-store-proposal-B.md +404 -0
  122. package/docs/concepts/event-log-store.md +928 -0
  123. package/docs/concepts/identity-model-proposal.md +371 -0
  124. package/docs/concepts/memory.md +5 -4
  125. package/docs/concepts/observer-protocol.md +361 -0
  126. package/docs/concepts/parallel-merge-protocol.md +71 -0
  127. package/docs/concepts/plans-and-claims.md +43 -0
  128. package/docs/concepts/skills.md +78 -0
  129. package/docs/concepts/workspace-bootstrapping.md +61 -0
  130. package/docs/integrations/agents.md +4 -4
  131. package/docs/integrations/cline.md +10 -11
  132. package/docs/integrations/codex.md +2 -2
  133. package/docs/integrations/continue.md +5 -5
  134. package/docs/integrations/copilot.md +14 -12
  135. package/docs/integrations/openclaw.md +7 -6
  136. package/docs/integrations/overview.md +7 -7
  137. package/docs/integrations/roo.md +3 -3
  138. package/docs/integrations/windsurf.md +6 -6
  139. package/docs/mcp-schema-changelog.md +51 -20
  140. package/docs/quickstart.md +48 -47
  141. package/docs/security.md +174 -15
  142. package/docs/storage.md +4 -2
  143. package/package.json +8 -6
@@ -2,10 +2,9 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { readAuditLog } from './audit.js';
4
4
  import { listCandidates } from './candidates.js';
5
- import { readContextMarker } from './freshness.js';
6
- import { memoryDir, resolveEntityDir } from './io.js';
7
- import { logger } from './logger.js';
5
+ import { resolveEntityDir } from './io.js';
8
6
  import { loadVersionedJsonFile } from './migration.js';
7
+ import { buildNotificationSummary, hasEventCursor, readUnseenEvents, seedCursorToEnd } from './event-log.js';
9
8
  import { SessionSnapshotSchema } from './schema.js';
10
9
  import { loadState } from './state.js';
11
10
  export function resolveContextDiffSince(options) {
@@ -23,10 +22,10 @@ export function resolveContextDiffSince(options) {
23
22
  }
24
23
  return { since_session: options.session };
25
24
  }
26
- const marker = readContextMarker(options.cwd);
27
- if (marker?.read_at) {
28
- return { since: marker.read_at };
29
- }
25
+ // No global marker fallback: the "what's new" default lives on the
26
+ // per-agent event-log cursors (buildContextDiffFromEvents). The store-global
27
+ // .last-context marker cross-contaminated agents — one agent's read reset
28
+ // everyone's diff baseline.
30
29
  return {};
31
30
  }
32
31
  function loadSessionSnapshot(sessionId, cwd) {
@@ -66,33 +65,160 @@ export function buildContextDiff(options = {}) {
66
65
  decisions: decisions.length,
67
66
  traps: traps.length,
68
67
  handoffs: handoffs.length,
68
+ plans: 0,
69
69
  pending_candidates: pendingCandidates.length,
70
70
  total: constraints.length + decisions.length + traps.length + handoffs.length + pendingCandidates.length,
71
71
  };
72
72
  return {
73
73
  since: resolved.since,
74
74
  since_session: resolved.since_session,
75
+ source: 'timestamp',
75
76
  summary: buildContextDiffSummary(counts),
76
77
  counts,
77
78
  changed_items: changedItems,
78
79
  };
79
80
  }
80
- export function readLastContextTimestamp(cwd) {
81
- const marker = readContextMarker(cwd);
82
- if (marker?.read_at) {
83
- return marker.read_at;
81
+ const EVENT_SECTION_BY_ITEM_TYPE = {
82
+ constraint: 'constraint',
83
+ decision: 'decision',
84
+ trap: 'trap',
85
+ handoff: 'handoff',
86
+ candidate: 'candidate',
87
+ plan: 'plan',
88
+ };
89
+ const EVENT_ACTION_LABEL = {
90
+ create: 'created',
91
+ update: 'updated',
92
+ delete: 'deleted',
93
+ accept: 'accepted',
94
+ reject: 'rejected',
95
+ };
96
+ /**
97
+ * Build a per-agent "what's new" diff from the event-log cursors
98
+ * (src/core/event-log.ts). This is the converged novelty mechanism: it
99
+ * replaces the store-global .last-context marker for the default diff path
100
+ * and natively covers status transitions (logged as `update` events by
101
+ * bclaw_transition / bclaw_update via the audit→event bridge).
102
+ *
103
+ * NOTE: reading ADVANCES the agent's cursor — events surfaced here are
104
+ * considered seen. Returns undefined when there is nothing new.
105
+ *
106
+ * First contact (no cursor for this agent yet): the diff would otherwise
107
+ * replay the ENTIRE event log from genesis — on a mature store that means
108
+ * thousands of stale claim/session events summarized into noise, and the
109
+ * agent's single chance to triage history is consumed by the cursor advance.
110
+ * Instead we emit a curated arrival digest (active constraints/traps,
111
+ * in-progress plans, latest open handoffs) and seed the cursor at log end so
112
+ * subsequent diffs are genuinely incremental.
113
+ */
114
+ export function buildContextDiffFromEvents(agent, cwd, options = {}) {
115
+ if (!hasEventCursor(agent, cwd)) {
116
+ return buildArrivalDigest(agent, cwd, options);
84
117
  }
85
- const markerPath = path.join(memoryDir(cwd), '.last-context');
86
- if (fs.existsSync(markerPath)) {
87
- try {
88
- const parsed = JSON.parse(fs.readFileSync(markerPath, 'utf-8'));
89
- return parsed.read_at;
90
- }
91
- catch (error) {
92
- logger.debug('Failed to parse context marker fallback:', error);
118
+ const events = readUnseenEvents(agent, cwd);
119
+ if (events.length === 0) {
120
+ return undefined;
121
+ }
122
+ // Latest relevant event per item (an item created then updated counts once).
123
+ const latestByItem = new Map();
124
+ for (const event of events) {
125
+ if (!event.item_id)
126
+ continue;
127
+ if (!EVENT_SECTION_BY_ITEM_TYPE[event.item_type] || !EVENT_ACTION_LABEL[event.action])
128
+ continue;
129
+ latestByItem.set(event.item_id, event);
130
+ }
131
+ const state = loadState(cwd);
132
+ const pendingCandidates = listCandidates('pending', cwd);
133
+ const textById = new Map();
134
+ for (const collection of [state.active_constraints, state.recent_decisions, state.known_traps, state.open_handoffs, state.plan_items, pendingCandidates]) {
135
+ for (const item of collection) {
136
+ textById.set(item.id, { text: item.text, created_at: item.created_at });
93
137
  }
94
138
  }
95
- return undefined;
139
+ const counts = { constraints: 0, decisions: 0, traps: 0, handoffs: 0, plans: 0, pending_candidates: 0, total: 0 };
140
+ const sectionToCountKey = {
141
+ constraint: 'constraints',
142
+ decision: 'decisions',
143
+ trap: 'traps',
144
+ handoff: 'handoffs',
145
+ plan: 'plans',
146
+ candidate: 'pending_candidates',
147
+ };
148
+ const changedItems = [];
149
+ for (const [itemId, event] of latestByItem) {
150
+ const section = EVENT_SECTION_BY_ITEM_TYPE[event.item_type];
151
+ const current = textById.get(itemId);
152
+ counts[sectionToCountKey[section]] += 1;
153
+ counts.total += 1;
154
+ changedItems.push({
155
+ section,
156
+ id: itemId,
157
+ text: current?.text ?? event.summary ?? `(${EVENT_ACTION_LABEL[event.action]} — no longer in state)`,
158
+ created_at: event.ts,
159
+ action: EVENT_ACTION_LABEL[event.action],
160
+ });
161
+ }
162
+ changedItems.sort((a, b) => b.created_at.localeCompare(a.created_at));
163
+ const oldest = events[0]?.ts;
164
+ return {
165
+ since: oldest,
166
+ source: 'event_cursor',
167
+ summary: buildContextDiffSummary(counts),
168
+ counts,
169
+ changed_items: options.includeItems === false ? undefined : changedItems,
170
+ event_summary: buildNotificationSummary(events),
171
+ unseen_event_count: events.length,
172
+ };
173
+ }
174
+ /** Hard cap on arrival-digest items — the digest informs, it must not drown. */
175
+ const ARRIVAL_DIGEST_MAX_ITEMS = 12;
176
+ const ARRIVAL_DIGEST_MAX_HANDOFFS = 3;
177
+ /**
178
+ * First-contact digest for an agent arriving on a store it has never read.
179
+ * Curated active state (constraints, traps, in-progress plans, latest open
180
+ * handoffs) instead of an event-log replay; seeds the agent's cursor at the
181
+ * end of the log so the next diff is incremental. Returns undefined only when
182
+ * there is nothing to say (empty store with no event history) — that case is
183
+ * the bootstrap hint's job, not the diff's.
184
+ */
185
+ export function buildArrivalDigest(agent, cwd, options = {}) {
186
+ const skippedBytes = seedCursorToEnd(agent, cwd);
187
+ const state = loadState(cwd);
188
+ const constraints = state.active_constraints.filter((item) => (item.status ?? 'active') === 'active');
189
+ const traps = state.known_traps.filter((item) => (item.status ?? 'active') === 'active');
190
+ const plans = state.plan_items.filter((item) => item.status === 'in_progress');
191
+ const handoffs = state.open_handoffs
192
+ .filter((item) => (item.status ?? 'open') === 'open')
193
+ .sort((a, b) => b.created_at.localeCompare(a.created_at))
194
+ .slice(0, ARRIVAL_DIGEST_MAX_HANDOFFS);
195
+ const counts = {
196
+ constraints: constraints.length,
197
+ decisions: 0,
198
+ traps: traps.length,
199
+ handoffs: handoffs.length,
200
+ plans: plans.length,
201
+ pending_candidates: 0,
202
+ total: constraints.length + traps.length + plans.length + handoffs.length,
203
+ };
204
+ if (counts.total === 0 && skippedBytes === 0) {
205
+ return undefined;
206
+ }
207
+ const changedItems = [
208
+ ...constraints.map((item) => toChangedItem('constraint', item)),
209
+ ...traps.map((item) => toChangedItem('trap', item)),
210
+ ...plans.map((item) => toChangedItem('plan', item)),
211
+ ...handoffs.map((item) => toChangedItem('handoff', item)),
212
+ ].slice(0, ARRIVAL_DIGEST_MAX_ITEMS);
213
+ const skippedKb = Math.round(skippedBytes / 1024);
214
+ const stateSummary = counts.total > 0 ? buildContextDiffSummary(counts) : 'no active items';
215
+ const summary = `First contact — arrival digest: ${stateSummary}. Event history skipped (${skippedKb} KB); cursor initialized at log end, future diffs are incremental.`;
216
+ return {
217
+ source: 'arrival_digest',
218
+ summary,
219
+ counts,
220
+ changed_items: options.includeItems === false ? undefined : changedItems,
221
+ };
96
222
  }
97
223
  export function buildContextDiffSummary(counts) {
98
224
  if (counts.total === 0) {
@@ -107,6 +233,8 @@ export function buildContextDiffSummary(counts) {
107
233
  parts.push(`${counts.traps} trap${counts.traps > 1 ? 's' : ''}`);
108
234
  if (counts.handoffs > 0)
109
235
  parts.push(`${counts.handoffs} handoff${counts.handoffs > 1 ? 's' : ''}`);
236
+ if (counts.plans > 0)
237
+ parts.push(`${counts.plans} plan${counts.plans > 1 ? 's' : ''}`);
110
238
  if (counts.pending_candidates > 0)
111
239
  parts.push(`${counts.pending_candidates} pending candidate${counts.pending_candidates > 1 ? 's' : ''}`);
112
240
  return parts.join(', ');
@@ -7,7 +7,7 @@ import { checkBrainclawInstallableUpdate, renderBrainclawInstallableUpdateNotice
7
7
  import { loadConfig } from './config.js';
8
8
  import { loadCurrentSession, loadAllSessions } from './identity.js';
9
9
  import { resolveCrossProjectLinks, loadCrossProjectState } from './cross-project.js';
10
- import { buildContextDiff } from './context-diff.js';
10
+ import { buildContextDiff, buildContextDiffFromEvents } from './context-diff.js';
11
11
  import { resolveContextStoreCwd, resolveStoreChain } from './store-resolution.js';
12
12
  import { findAgentIdentityByName, resolveCurrentAgentIdentity } from './agent-registry.js';
13
13
  import { hasReusableBootstrapProfile, runBootstrapProfile, selectDerivedSignals } from './bootstrap.js';
@@ -17,11 +17,13 @@ import { getVisibleMemoryVersion } from './freshness.js';
17
17
  import { resolveCurrentHostId } from './host.js';
18
18
  import { inferProjectFromTarget, loadInstructions, resolveInstructions } from './instructions.js';
19
19
  import { buildCurrentAgentResumeSummary, buildReputationRankingLookup } from './reputation.js';
20
+ import { buildMemoryLifecycleMetricsForState, getLifecycleStats } from './memory-lifecycle.js';
20
21
  import { loadState } from './state.js';
21
22
  import { readAuditLog } from './audit.js';
22
23
  import { listCandidates } from './candidates.js';
23
24
  import { listClaims, isClaimExpired, assessClaimLiveness } from './claims.js';
24
25
  import { listAssignments } from './assignments.js';
26
+ import { reconcileOrphanedLoopAssignmentsFromList } from './assignment-reconciler.js';
25
27
  import { listRuntimeNotes } from './runtime.js';
26
28
  import { isTrapActive, listOperationalTraps } from './traps.js';
27
29
  import { buildEstimationReport } from '../commands/estimation-report.js';
@@ -102,6 +104,16 @@ export function buildContext(options = {}) {
102
104
  host_id: c.host_id,
103
105
  session_id: c.session_id,
104
106
  },
107
+ lifecycle: {
108
+ entity: 'constraint',
109
+ created_at: c.created_at,
110
+ last_confirmed_at: c.last_confirmed_at,
111
+ last_infirmed_at: c.last_infirmed_at,
112
+ confirmation_count: c.confirmation_count,
113
+ infirmation_count: c.infirmation_count,
114
+ saved_me_count: c.saved_me_count,
115
+ misled_me_count: c.misled_me_count,
116
+ },
105
117
  });
106
118
  }
107
119
  for (const d of state.recent_decisions) {
@@ -122,6 +134,16 @@ export function buildContext(options = {}) {
122
134
  host_id: d.host_id,
123
135
  session_id: d.session_id,
124
136
  },
137
+ lifecycle: {
138
+ entity: 'decision',
139
+ created_at: d.created_at,
140
+ last_confirmed_at: d.last_confirmed_at,
141
+ last_infirmed_at: d.last_infirmed_at,
142
+ confirmation_count: d.confirmation_count,
143
+ infirmation_count: d.infirmation_count,
144
+ saved_me_count: d.saved_me_count,
145
+ misled_me_count: d.misled_me_count,
146
+ },
125
147
  });
126
148
  }
127
149
  for (const t of state.known_traps.filter((trap) => isTrapActive(trap))) {
@@ -142,6 +164,16 @@ export function buildContext(options = {}) {
142
164
  host_id: t.host_id,
143
165
  session_id: t.session_id,
144
166
  },
167
+ lifecycle: {
168
+ entity: 'trap',
169
+ created_at: t.created_at,
170
+ last_confirmed_at: t.last_confirmed_at,
171
+ last_infirmed_at: t.last_infirmed_at,
172
+ confirmation_count: t.confirmation_count,
173
+ infirmation_count: t.infirmation_count,
174
+ saved_me_count: t.saved_me_count,
175
+ misled_me_count: t.misled_me_count,
176
+ },
145
177
  });
146
178
  }
147
179
  for (const trap of listOperationalTraps({ hostId: options.host, includeAllHosts: options.allHosts }, contextCwd).filter((entry) => isTrapActive(entry))) {
@@ -371,6 +403,21 @@ export function buildContext(options = {}) {
371
403
  item.reasons = uniqueReasons([...item.reasons, `reputation signal:+${trustBonus.toFixed(2)}`]);
372
404
  }
373
405
  }
406
+ // pln#544 — memory lifecycle: items confirmed-recent + reinforced rise;
407
+ // stale-unconfirmed and explicitly-infirmed sink in the same ranking.
408
+ // Only items we tagged with a lifecycle payload (primary-store decision/
409
+ // constraint/trap) carry a delta — parent-store items rank by other signals.
410
+ if (item.score >= 0 && item.lifecycle) {
411
+ const stats = getLifecycleStats(item.lifecycle);
412
+ if (stats.ranking_delta !== 0) {
413
+ item.score += stats.ranking_delta;
414
+ const sign = stats.ranking_delta >= 0 ? '+' : '';
415
+ item.reasons = uniqueReasons([
416
+ ...item.reasons,
417
+ `lifecycle ${stats.classification}:${sign}${stats.ranking_delta}`,
418
+ ]);
419
+ }
420
+ }
374
421
  // Layer 3: boost machine-scoped items for broader visibility (+1)
375
422
  if (item.score >= 0) {
376
423
  const itemScope = item.scope;
@@ -393,7 +440,10 @@ export function buildContext(options = {}) {
393
440
  runtimeNotes,
394
441
  pendingCandidates: listCandidates('pending', contextCwd),
395
442
  });
396
- const memoryDensity = classifyMemoryDensity(selected.length);
443
+ // Density reflects what the store HAS, not what the char budget keeps:
444
+ // classify pre-budget so a tight budget_tokens on a rich store never
445
+ // misreads as 'low' and triggers a bootstrap re-scan (pln#542 interaction).
446
+ const memoryDensity = classifyMemoryDensity(ranked.length);
397
447
  const bootstrapEnabled = options.bootstrap !== false;
398
448
  const testMode = process.env.BRAINCLAW_TEST_MODE === '1';
399
449
  let bootstrapAvailable = false;
@@ -445,7 +495,19 @@ export function buildContext(options = {}) {
445
495
  const currentSession = loadCurrentSession(contextCwd);
446
496
  if (currentAgentIdentity || agent) {
447
497
  const claimPlanIds = new Set(myClaims.map((c) => c.plan_id).filter(Boolean));
448
- const activeAssignments = listAssignments(contextCwd, { agent: agentName }).filter((assignment) => !['completed', 'failed', 'cancelled', 'expired', 'rerouted'].includes(assignment.status));
498
+ // pln#563 layer B: converge review-loop assignments orphaned in
499
+ // offered/accepted/started whose loop is already terminal, before listing
500
+ // active work. Reuse one assignment scan for reconcile and rendering;
501
+ // best-effort (swallow errors); steady state is zero writes.
502
+ const allAssignments = listAssignments(contextCwd);
503
+ let reconciledAssignmentIds = new Set();
504
+ try {
505
+ reconciledAssignmentIds = new Set(reconcileOrphanedLoopAssignmentsFromList(allAssignments, contextCwd));
506
+ }
507
+ catch { /* read path must not break on reconcile */ }
508
+ const activeAssignments = allAssignments.filter((assignment) => assignment.agent === agentName
509
+ && !reconciledAssignmentIds.has(assignment.id)
510
+ && !['completed', 'failed', 'cancelled', 'expired', 'rerouted', 'timed_out'].includes(assignment.status));
449
511
  const inProgressPlans = state.plan_items.filter((p) => p.status === 'in_progress' &&
450
512
  (p.assignee === agentName || claimPlanIds.has(p.id)));
451
513
  if (myClaims.length > 0 || activeAssignments.length > 0 || inProgressPlans.length > 0) {
@@ -524,13 +586,46 @@ export function buildContext(options = {}) {
524
586
  let staleWarnings;
525
587
  try {
526
588
  const pendingCandidatesForStaleness = listCandidates('pending', contextCwd);
527
- const runtimeNotesForStaleness = listRuntimeNotes(undefined, contextCwd);
528
- const staleReport = detectStaleness(state.plan_items, state.known_traps, state.open_handoffs, pendingCandidatesForStaleness, Date.now(), runtimeNotesForStaleness);
589
+ // pln#564 step A — reuse the runtime notes already loaded above (line ~316)
590
+ // instead of a second unfiltered full scan of the runtime-note tree. On a
591
+ // store with thousands of notes that 2nd scan dominated buildContext cost
592
+ // (readAgentNotes ~11s / 6166 files, trp_439fec51). The earlier list is the
593
+ // broader one (honours options.host/allHosts), so staleness is at least as
594
+ // complete as before.
595
+ const runtimeNotesForStaleness = runtimeNotes;
596
+ const staleReport = detectStaleness(state.plan_items, state.known_traps, state.open_handoffs, pendingCandidatesForStaleness, Date.now(), runtimeNotesForStaleness,
597
+ // pln#557 step 2 — dead related_paths detection. Surfaces "confident
598
+ // but wrong" memory (paths deleted by a refactor) in stale_warnings,
599
+ // which both the steady-state context and the arrival digest carry.
600
+ {
601
+ decisions: state.recent_decisions,
602
+ constraints: state.active_constraints,
603
+ projectRoot: contextCwd,
604
+ });
529
605
  if (staleReport.warnings.length > 0) {
530
606
  staleWarnings = staleReport.warnings.slice(0, 5);
531
607
  }
532
608
  }
533
609
  catch { /* non-fatal */ }
610
+ // pln#544 — memory lifecycle metrics. Best-effort: build once, surface in
611
+ // the result + thread to workflow_hints so the curation hint can quote the
612
+ // oldest unconfirmed id. Empty stores still pass through (total_items=0).
613
+ let memoryLifecycleMetrics;
614
+ try {
615
+ memoryLifecycleMetrics = buildMemoryLifecycleMetricsForState(state);
616
+ }
617
+ catch {
618
+ memoryLifecycleMetrics = {
619
+ total_items: 0,
620
+ confirmed_items: 0,
621
+ confirmed_ratio: 0,
622
+ average_age_days: 0,
623
+ total_saved_me: 0,
624
+ total_misled_me: 0,
625
+ total_infirmed_active: 0,
626
+ recall_precision_proxy: 0,
627
+ };
628
+ }
534
629
  const result = {
535
630
  context_schema: CONTEXT_SCHEMA_VERSION,
536
631
  profile,
@@ -552,13 +647,19 @@ export function buildContext(options = {}) {
552
647
  execution_context: executionContext,
553
648
  agent_tooling: agentTooling,
554
649
  scoped_activity: scopedActivity,
650
+ // "What's new" — explicit session reference keeps the timestamp diff;
651
+ // otherwise the per-agent event-log cursor is the converged default
652
+ // (covers status transitions, no store-global marker). Note: the cursor
653
+ // read marks the surfaced events as seen for this agent.
555
654
  context_diff: options.sinceSession
556
655
  ? buildContextDiff({
557
656
  session: options.sinceSession,
558
657
  cwd: contextCwd,
559
658
  includeItems: true,
560
659
  })
561
- : undefined,
660
+ : agent
661
+ ? buildContextDiffFromEvents(agent, contextCwd, { includeItems: true })
662
+ : undefined,
562
663
  resolved_instructions: resolvedInstructions,
563
664
  resume_summary: resumeSummary,
564
665
  open_work: openWork,
@@ -578,8 +679,9 @@ export function buildContext(options = {}) {
578
679
  active_project: findActiveProjectInChain(contextCwd, storeChain),
579
680
  cross_project_items: crossProjectItems.length > 0 ? crossProjectItems : undefined,
580
681
  claim_conflicts: detectClaimConflicts(myClaims, otherActiveClaims),
581
- workflow_hints: buildWorkflowHints(myClaims, openWork, state.plan_items),
682
+ workflow_hints: buildWorkflowHints(myClaims, openWork, state.plan_items, memoryLifecycleMetrics),
582
683
  stale_warnings: staleWarnings,
684
+ memory_lifecycle: memoryLifecycleMetrics.total_items > 0 ? memoryLifecycleMetrics : undefined,
583
685
  selected,
584
686
  };
585
687
  if (options.digest) {
@@ -972,6 +1074,7 @@ export function renderContextPromptTemplate(result, compact = false) {
972
1074
  lines.push(` decisions: ${result.context_diff.counts.decisions}`);
973
1075
  lines.push(` traps: ${result.context_diff.counts.traps}`);
974
1076
  lines.push(` handoffs: ${result.context_diff.counts.handoffs}`);
1077
+ lines.push(` plans: ${result.context_diff.counts.plans}`);
975
1078
  lines.push(` pending_candidates: ${result.context_diff.counts.pending_candidates}`);
976
1079
  lines.push(` total: ${result.context_diff.counts.total}`);
977
1080
  }
@@ -1724,7 +1827,7 @@ function findActiveProjectInChain(contextCwd, _storeChain) {
1724
1827
  return undefined;
1725
1828
  }
1726
1829
  // --- Workflow hints ---
1727
- function buildWorkflowHints(myClaims, openWork, plans) {
1830
+ function buildWorkflowHints(myClaims, openWork, plans, memoryLifecycle) {
1728
1831
  const hints = [];
1729
1832
  // No claims — suggest claiming before editing
1730
1833
  if (myClaims.length === 0) {
@@ -1744,6 +1847,24 @@ function buildWorkflowHints(myClaims, openWork, plans) {
1744
1847
  hints.push(`${unclaimedInProgress.length} in-progress plan(s) without a claim — consider claiming the scope you're editing`);
1745
1848
  }
1746
1849
  }
1850
+ // pln#544 — memory curation surfacing. Pick the strongest signal:
1851
+ // - an oldest unconfirmed item past the half-life-ish horizon, OR
1852
+ // - an item that an agent flagged as misleading (still active).
1853
+ if (memoryLifecycle && memoryLifecycle.total_items > 0) {
1854
+ if (memoryLifecycle.total_infirmed_active > 0) {
1855
+ hints.push(`${memoryLifecycle.total_infirmed_active} active memory item(s) were infirmed after their last confirmation — review with bclaw_find(status:'active') and consider archiving via bclaw_transition`);
1856
+ }
1857
+ else if (memoryLifecycle.oldest_unconfirmed_id
1858
+ && memoryLifecycle.oldest_unconfirmed_age_days !== undefined
1859
+ && memoryLifecycle.oldest_unconfirmed_age_days >= 30) {
1860
+ const ent = memoryLifecycle.oldest_unconfirmed_entity ?? 'item';
1861
+ hints.push(`Oldest unconfirmed ${ent} ${memoryLifecycle.oldest_unconfirmed_id} is ${memoryLifecycle.oldest_unconfirmed_age_days}d old — confirm or retire when you next encounter it`);
1862
+ }
1863
+ else if (memoryLifecycle.confirmed_ratio < 0.2
1864
+ && memoryLifecycle.total_items >= 10) {
1865
+ hints.push(`Memory health: only ${Math.round(memoryLifecycle.confirmed_ratio * 100)}% of ${memoryLifecycle.total_items} item(s) have been confirmed — start attesting in passing to age memory honestly`);
1866
+ }
1867
+ }
1747
1868
  return hints.length > 0 ? hints : undefined;
1748
1869
  }
1749
1870
  //# sourceMappingURL=context.js.map
@@ -16,6 +16,7 @@ import { loadAllSessions } from './identity.js';
16
16
  import { countActionable } from './messaging.js';
17
17
  import { listCandidates } from './candidates.js';
18
18
  import { pullSignalsFromLinkedProjects } from './federation-transport.js';
19
+ import { isObserverMode } from './observer-mode.js';
19
20
  export function buildCoordinationSnapshot(options = {}) {
20
21
  const config = loadConfig(options.cwd);
21
22
  const state = loadState(options.cwd);
@@ -58,8 +59,13 @@ export function buildCoordinationSnapshot(options = {}) {
58
59
  const filteredHandoffs = agent
59
60
  ? openHandoffs.filter((h) => (!project || !h.project || h.project === project) && (h.to === agent || h.from === agent))
60
61
  : (project ? openHandoffs.filter((h) => !h.project || h.project === project) : openHandoffs);
61
- // perf.2: auto-acknowledge shown handoffs
62
- if (options.autoAcknowledge && filteredHandoffs.length > 0) {
62
+ // perf.2: auto-acknowledge shown handoffs.
63
+ // Observer mode (BRAINCLAW_OBSERVER=1) suppresses this a dashboard reading
64
+ // the board must never mutate the store it observes. The 2026-06-10 lock
65
+ // storm was caused by the VS Code extension polling kind='board' (which sets
66
+ // autoAcknowledge=true) and triggering persistState → full store rewrite +
67
+ // git commit on every refresh.
68
+ if (options.autoAcknowledge && filteredHandoffs.length > 0 && !isObserverMode()) {
63
69
  const toAckIds = new Set(filteredHandoffs.map((h) => h.id));
64
70
  let changed = false;
65
71
  for (const h of state.open_handoffs) {
@@ -88,7 +94,7 @@ export function buildCoordinationSnapshot(options = {}) {
88
94
  : filteredClaims,
89
95
  active_assignments: (agent
90
96
  ? listAssignments(options.cwd, { agent })
91
- : listAssignments(options.cwd)).filter((assignment) => !['completed', 'failed', 'cancelled', 'expired', 'rerouted'].includes(assignment.status) &&
97
+ : listAssignments(options.cwd)).filter((assignment) => !['completed', 'failed', 'cancelled', 'expired', 'rerouted', 'timed_out'].includes(assignment.status) &&
92
98
  (!project || !assignment.plan_id || filteredPlans.some((plan) => plan.id === assignment.plan_id))),
93
99
  active_runs: (agent
94
100
  ? listAgentRuns(options.cwd, { agent })
@@ -229,6 +235,19 @@ function buildOtherAgentsSummary(claims, notes, currentAgent, cwd) {
229
235
  const result = [...agentMap.values()];
230
236
  return result.length > 0 ? result : undefined;
231
237
  }
238
+ /**
239
+ * Lightweight cross-project snapshot — linked_projects + incoming_signals only.
240
+ * Used by the VS Code extension's SYSTEM section so it does not have to fetch
241
+ * the full coordination snapshot (pln#558 step 3). Loads two linked-project
242
+ * states plus the incoming-signals scan; never builds the agent/handoff/claim
243
+ * summaries.
244
+ */
245
+ export function buildCrossProjectSnapshot(cwd) {
246
+ return {
247
+ linked_projects: buildLinkedProjectsSummary(cwd),
248
+ incoming_signals: buildIncomingSignalsSummary(cwd),
249
+ };
250
+ }
232
251
  function buildLinkedProjectsSummary(cwd) {
233
252
  const links = resolveCrossProjectLinks(cwd);
234
253
  if (links.length === 0)