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
@@ -0,0 +1,282 @@
1
+ /**
2
+ * pln#544 — Memory lifecycle (confirm/decay/reinforce).
3
+ *
4
+ * Three primitives that combine into the "memory ages honestly" loop:
5
+ *
6
+ * 1. `recordMemoryEvent` — one-call confirm/infirm/saved_me/misled_me write
7
+ * against an existing decision/constraint/trap. Updates the denormalised
8
+ * counters and `last_confirmed_at` / `last_infirmed_at`, appends a
9
+ * bounded event log entry, and re-saves the item.
10
+ *
11
+ * 2. `getLifecycleStats` — pure read of the lifecycle state for a single
12
+ * item (age since confirmation, decay multiplier, "fresh" / "stale" /
13
+ * "infirmed" classification). Context ranking and the curation hints
14
+ * both consume this.
15
+ *
16
+ * 3. `buildMemoryLifecycleMetrics` — aggregate over the project's active
17
+ * memory items: confirmed_ratio, average age, oldest unconfirmed, total
18
+ * saved_me/misled_me signals. Surfaced in workflow hints and reputation.
19
+ *
20
+ * Decay curves are intentionally entity-specific (traps describe environment
21
+ * facts that mostly stay true, decisions/constraints describe project
22
+ * intent that drifts faster as the codebase evolves).
23
+ */
24
+ import { mutateState } from './state.js';
25
+ import { nowISO } from './ids.js';
26
+ /** Maximum events kept inline on a memory item — older events are dropped
27
+ * from the log. The counters stay accurate; the log is a recent-evidence
28
+ * buffer, not an audit trail (audit.ts covers durable provenance). */
29
+ export const MAX_INLINE_CONFIRMATIONS = 8;
30
+ /** Per-entity decay half-life (days). Tuned by intuition + the rationale in
31
+ * pln#544: traps describe stable environmental risks, decisions/constraints
32
+ * describe project intent that goes stale faster as code evolves. */
33
+ export const DECAY_HALF_LIFE_DAYS = {
34
+ trap: 90,
35
+ constraint: 60,
36
+ decision: 60,
37
+ };
38
+ /**
39
+ * Append a confirm/infirm/saved_me/misled_me event onto a memory item and
40
+ * update its denormalised counters. Returns the updated counters so the
41
+ * caller (CLI/MCP layer, tests) can echo them back to the operator.
42
+ *
43
+ * Throws if the item id is not present in the project's active state.
44
+ */
45
+ export function recordMemoryEvent(input) {
46
+ const cwd = input.cwd ?? process.cwd();
47
+ const at = input.at ?? nowISO();
48
+ let captured;
49
+ mutateState((state) => {
50
+ const bucket = input.entity === 'decision' ? state.recent_decisions
51
+ : input.entity === 'constraint' ? state.active_constraints
52
+ : state.known_traps;
53
+ const item = bucket.find((x) => x.id === input.id);
54
+ if (!item) {
55
+ throw new Error(`memory-lifecycle: ${input.entity} '${input.id}' not found in active state`);
56
+ }
57
+ const event = {
58
+ at,
59
+ by: input.by,
60
+ kind: input.kind,
61
+ ...(input.by_id ? { by_id: input.by_id } : {}),
62
+ ...(input.session_id ? { session_id: input.session_id } : {}),
63
+ ...(input.evidence ? { evidence: input.evidence } : {}),
64
+ ...(input.note ? { note: input.note } : {}),
65
+ };
66
+ const next = Array.isArray(item.confirmations)
67
+ ? [...item.confirmations, event]
68
+ : [event];
69
+ // Bounded log: keep the most recent MAX_INLINE_CONFIRMATIONS.
70
+ item.confirmations = next.slice(-MAX_INLINE_CONFIRMATIONS);
71
+ if (input.kind === 'confirm' || input.kind === 'saved_me') {
72
+ item.last_confirmed_at = at;
73
+ item.confirmation_count = (item.confirmation_count ?? 0) + 1;
74
+ if (input.kind === 'saved_me') {
75
+ item.saved_me_count = (item.saved_me_count ?? 0) + 1;
76
+ }
77
+ }
78
+ else {
79
+ item.last_infirmed_at = at;
80
+ item.infirmation_count = (item.infirmation_count ?? 0) + 1;
81
+ if (input.kind === 'misled_me') {
82
+ item.misled_me_count = (item.misled_me_count ?? 0) + 1;
83
+ }
84
+ }
85
+ captured = {
86
+ entity: input.entity,
87
+ id: input.id,
88
+ kind: input.kind,
89
+ last_confirmed_at: item.last_confirmed_at,
90
+ last_infirmed_at: item.last_infirmed_at,
91
+ confirmation_count: item.confirmation_count ?? 0,
92
+ infirmation_count: item.infirmation_count ?? 0,
93
+ saved_me_count: item.saved_me_count ?? 0,
94
+ misled_me_count: item.misled_me_count ?? 0,
95
+ };
96
+ }, cwd);
97
+ if (!captured) {
98
+ // Defensive: mutateState should have thrown on a missing item, but if
99
+ // mutateState swallows the throw we still surface a useful error.
100
+ throw new Error(`memory-lifecycle: ${input.entity} '${input.id}' could not be updated`);
101
+ }
102
+ return captured;
103
+ }
104
+ /**
105
+ * Compute the lifecycle stats for a single memory item. Pure function — no
106
+ * I/O, drivable from the schema fields alone. Context ranking calls this on
107
+ * every item; curation hints call it to surface the oldest unconfirmed.
108
+ */
109
+ export function getLifecycleStats(input) {
110
+ const nowMs = input.nowMs ?? Date.now();
111
+ const createdMs = Date.parse(input.created_at);
112
+ const confirmedMs = input.last_confirmed_at ? Date.parse(input.last_confirmed_at) : undefined;
113
+ const infirmedMs = input.last_infirmed_at ? Date.parse(input.last_infirmed_at) : undefined;
114
+ const anchorMs = Number.isFinite(createdMs)
115
+ ? Math.max(createdMs, confirmedMs ?? -Infinity)
116
+ : (confirmedMs ?? nowMs);
117
+ const anchorAt = new Date(anchorMs).toISOString();
118
+ const ageDays = Math.max(0, Math.floor((nowMs - anchorMs) / 86_400_000));
119
+ const infirmed = infirmedMs !== undefined
120
+ && (confirmedMs === undefined || infirmedMs > confirmedMs);
121
+ const infirmationAgeDays = infirmedMs !== undefined
122
+ ? Math.max(0, Math.floor((nowMs - infirmedMs) / 86_400_000))
123
+ : Number.POSITIVE_INFINITY;
124
+ const halfLife = DECAY_HALF_LIFE_DAYS[input.entity];
125
+ // 2^(-age / halfLife) — pure exponential decay, capped to [0, 1].
126
+ const decayFactor = Math.max(0, Math.min(1, Math.pow(2, -ageDays / halfLife)));
127
+ const confirmationCount = input.confirmation_count ?? 0;
128
+ const infirmationCount = input.infirmation_count ?? 0;
129
+ const savedMeCount = input.saved_me_count ?? 0;
130
+ const misledMeCount = input.misled_me_count ?? 0;
131
+ // Ranking delta combines:
132
+ // decay — gentle aging penalty up to ~-2 at +2 half-lives.
133
+ // freshness — boost for items confirmed in the last 30d (+2).
134
+ // reinforce — +1 per saved_me up to +3.
135
+ // infirmed — heavy penalty (-5) when infirmed after last confirm.
136
+ // misled_me — extra penalty (-1 per misled_me up to -3).
137
+ // stale — -1 when never confirmed and older than half-life.
138
+ let rankingDelta = -2 * (1 - decayFactor);
139
+ const recentlyConfirmed = confirmedMs !== undefined && (nowMs - confirmedMs) <= 30 * 86_400_000;
140
+ if (recentlyConfirmed)
141
+ rankingDelta += 2;
142
+ rankingDelta += Math.min(savedMeCount, 3);
143
+ if (infirmed)
144
+ rankingDelta -= 5;
145
+ rankingDelta -= Math.min(misledMeCount, 3);
146
+ if (confirmationCount === 0 && ageDays > halfLife)
147
+ rankingDelta -= 1;
148
+ // Round to keep score reasons readable (e.g. "lifecycle:-2.5").
149
+ rankingDelta = Math.round(rankingDelta * 10) / 10;
150
+ let classification;
151
+ if (infirmed)
152
+ classification = 'infirmed';
153
+ else if (confirmationCount === 0 && ageDays > halfLife)
154
+ classification = 'never_confirmed';
155
+ else if (recentlyConfirmed)
156
+ classification = 'fresh';
157
+ else if (ageDays > 2 * halfLife)
158
+ classification = 'stale';
159
+ else
160
+ classification = 'aging';
161
+ return {
162
+ anchor_at: anchorAt,
163
+ age_days: ageDays,
164
+ infirmed,
165
+ infirmation_age_days: infirmationAgeDays,
166
+ decay_factor: Number(decayFactor.toFixed(3)),
167
+ ranking_delta: rankingDelta,
168
+ classification,
169
+ confirmation_count: confirmationCount,
170
+ infirmation_count: infirmationCount,
171
+ saved_me_count: savedMeCount,
172
+ misled_me_count: misledMeCount,
173
+ };
174
+ }
175
+ /**
176
+ * Aggregate lifecycle health across a project's active memory items. Pure
177
+ * function — callers (context.ts workflow hints, reputation.ts dashboard)
178
+ * pass the items they already loaded.
179
+ */
180
+ export function buildMemoryLifecycleMetrics(items, nowMs = Date.now()) {
181
+ const active = items.filter((item) => !item.status || item.status === 'active');
182
+ const total = active.length;
183
+ if (total === 0) {
184
+ return {
185
+ total_items: 0,
186
+ confirmed_items: 0,
187
+ confirmed_ratio: 0,
188
+ average_age_days: 0,
189
+ total_saved_me: 0,
190
+ total_misled_me: 0,
191
+ total_infirmed_active: 0,
192
+ recall_precision_proxy: 0,
193
+ };
194
+ }
195
+ let confirmed = 0;
196
+ let ageSum = 0;
197
+ let savedMe = 0;
198
+ let misledMe = 0;
199
+ let infirmedActive = 0;
200
+ let oldestUnconfirmed;
201
+ for (const item of active) {
202
+ const stats = getLifecycleStats({
203
+ entity: item.entity,
204
+ created_at: item.created_at,
205
+ last_confirmed_at: item.last_confirmed_at,
206
+ last_infirmed_at: item.last_infirmed_at,
207
+ confirmation_count: item.confirmation_count,
208
+ infirmation_count: item.infirmation_count,
209
+ saved_me_count: item.saved_me_count,
210
+ misled_me_count: item.misled_me_count,
211
+ nowMs,
212
+ });
213
+ if (stats.confirmation_count > 0)
214
+ confirmed += 1;
215
+ if (stats.infirmed)
216
+ infirmedActive += 1;
217
+ ageSum += stats.age_days;
218
+ savedMe += stats.saved_me_count;
219
+ misledMe += stats.misled_me_count;
220
+ if (stats.confirmation_count === 0
221
+ && (!oldestUnconfirmed || stats.age_days > oldestUnconfirmed.age)) {
222
+ oldestUnconfirmed = { id: item.id, entity: item.entity, age: stats.age_days };
223
+ }
224
+ }
225
+ return {
226
+ total_items: total,
227
+ confirmed_items: confirmed,
228
+ confirmed_ratio: Number((confirmed / total).toFixed(3)),
229
+ average_age_days: Math.round(ageSum / total),
230
+ ...(oldestUnconfirmed ? {
231
+ oldest_unconfirmed_id: oldestUnconfirmed.id,
232
+ oldest_unconfirmed_entity: oldestUnconfirmed.entity,
233
+ oldest_unconfirmed_age_days: oldestUnconfirmed.age,
234
+ } : {}),
235
+ total_saved_me: savedMe,
236
+ total_misled_me: misledMe,
237
+ total_infirmed_active: infirmedActive,
238
+ recall_precision_proxy: savedMe - misledMe,
239
+ };
240
+ }
241
+ /**
242
+ * Convenience: load state and build the metrics in one call. Used by the
243
+ * curation surfacing in context.ts; tests can prefer the pure variant above.
244
+ */
245
+ export function buildMemoryLifecycleMetricsForState(state, nowMs = Date.now()) {
246
+ const items = [];
247
+ for (const d of state.recent_decisions) {
248
+ items.push({
249
+ entity: 'decision', id: d.id, created_at: d.created_at,
250
+ last_confirmed_at: d.last_confirmed_at,
251
+ last_infirmed_at: d.last_infirmed_at,
252
+ confirmation_count: d.confirmation_count,
253
+ infirmation_count: d.infirmation_count,
254
+ saved_me_count: d.saved_me_count,
255
+ misled_me_count: d.misled_me_count,
256
+ });
257
+ }
258
+ for (const c of state.active_constraints) {
259
+ items.push({
260
+ entity: 'constraint', id: c.id, created_at: c.created_at, status: c.status,
261
+ last_confirmed_at: c.last_confirmed_at,
262
+ last_infirmed_at: c.last_infirmed_at,
263
+ confirmation_count: c.confirmation_count,
264
+ infirmation_count: c.infirmation_count,
265
+ saved_me_count: c.saved_me_count,
266
+ misled_me_count: c.misled_me_count,
267
+ });
268
+ }
269
+ for (const t of state.known_traps) {
270
+ items.push({
271
+ entity: 'trap', id: t.id, created_at: t.created_at, status: t.status,
272
+ last_confirmed_at: t.last_confirmed_at,
273
+ last_infirmed_at: t.last_infirmed_at,
274
+ confirmation_count: t.confirmation_count,
275
+ infirmation_count: t.infirmation_count,
276
+ saved_me_count: t.saved_me_count,
277
+ misled_me_count: t.misled_me_count,
278
+ });
279
+ }
280
+ return buildMemoryLifecycleMetrics(items, nowMs);
281
+ }
282
+ //# sourceMappingURL=memory-lifecycle.js.map
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Pre-merge conflict detection for worktree-based parallel execution
3
+ * (pln#396). Before lanes are merged back, surface which worktrees touch
4
+ * overlapping files — the textual-conflict risk — and which claim / session /
5
+ * agent owns each side, so the operator (or the merge path) sees the danger
6
+ * before `git merge`, not after.
7
+ *
8
+ * This is a *risk signal*, not a merge engine: file-level overlap is a
9
+ * necessary-not-sufficient predictor (two lanes editing the same file usually
10
+ * conflict; disjoint hunks sometimes don't). It deliberately over-reports
11
+ * rather than miss — a flagged non-conflict costs a glance; a missed conflict
12
+ * costs the trp_merge_wipes_node_modules / parasitic-deletion class of pain.
13
+ */
14
+ import { spawnSync } from 'node:child_process';
15
+ import fs from 'node:fs';
16
+ import { listWorktrees } from './worktree.js';
17
+ import { listClaims } from './claims.js';
18
+ import { logger } from './logger.js';
19
+ function git(args, cwd) {
20
+ const r = spawnSync('git', args, { cwd, encoding: 'utf-8', timeout: 15000 });
21
+ return { ok: r.status === 0, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
22
+ }
23
+ /** Paths never counted as conflict risk: store-internal + birth noise. */
24
+ function isIgnorablePath(file) {
25
+ return file === '.gitignore'
26
+ || file.startsWith('.brainclaw/')
27
+ || file.startsWith('.git/');
28
+ }
29
+ function committedChangedFiles(mainPath, baseRef, branch) {
30
+ // base...branch = changes on `branch` since it diverged from baseRef.
31
+ // --no-renames: a committed rename old→new must surface BOTH paths so a lane
32
+ // that EDITS `old` overlaps with a lane that RENAMES `old`→`new` (otherwise
33
+ // the rename-vs-modify conflict is invisible to overlap detection). Default
34
+ // rename detection collapses this to the destination only.
35
+ const r = git(['diff', '--name-only', '--no-renames', `${baseRef}...${branch}`], mainPath);
36
+ if (r.ok) {
37
+ return r.stdout.split('\n').map(s => s.trim()).filter(Boolean);
38
+ }
39
+ // Three-dot diff requires a merge base; an unrelated-history branch errors
40
+ // out (`fatal: no merge base`). Fall back to a plain two-arg diff, which
41
+ // works across unrelated histories and yields the union of touched paths —
42
+ // over-reporting (every file in either tree) rather than silently missing
43
+ // the real divergence with an empty list.
44
+ const fallback = git(['diff', '--name-only', '--no-renames', baseRef, branch], mainPath);
45
+ if (fallback.ok) {
46
+ logger.warn(`[merge-risk] no merge base between ${baseRef} and ${branch}; falling back to two-arg diff (over-reports rather than miss).`);
47
+ return fallback.stdout.split('\n').map(s => s.trim()).filter(Boolean);
48
+ }
49
+ logger.warn(`[merge-risk] git diff failed for ${branch} vs ${baseRef}: ${r.stderr.trim() || fallback.stderr.trim()}`);
50
+ return [];
51
+ }
52
+ function dirtyTrackedFiles(worktreePath) {
53
+ const r = git(['status', '--short', '--untracked-files=no'], worktreePath);
54
+ if (!r.ok)
55
+ return [];
56
+ return r.stdout.split('\n')
57
+ .filter(Boolean)
58
+ .map(line => line.slice(3).trim()) // strip the XY status prefix
59
+ // a rename shows "old -> new"; keep the destination path
60
+ .map(p => (p.includes(' -> ') ? p.split(' -> ')[1] : p))
61
+ .filter(Boolean);
62
+ }
63
+ /**
64
+ * Analyze pre-merge conflict risk across the parallel worktree lanes.
65
+ * Pure read: runs only `git diff`/`git status` (no lock, no mutation).
66
+ */
67
+ export function analyzeMergeRisk(mainWorktreePath, options = {}) {
68
+ const includeDirty = options.includeDirty ?? true;
69
+ // Resolve the base ref: explicit, else the main worktree's current branch, else master.
70
+ let baseRef = options.baseRef;
71
+ if (!baseRef) {
72
+ const head = git(['rev-parse', '--abbrev-ref', 'HEAD'], mainWorktreePath);
73
+ baseRef = head.ok && head.stdout.trim() && head.stdout.trim() !== 'HEAD' ? head.stdout.trim() : 'master';
74
+ }
75
+ const claims = listClaims(mainWorktreePath).filter(c => c.status === 'active');
76
+ const claimByWorktree = new Map();
77
+ for (const c of claims) {
78
+ if (c.worktree_path)
79
+ claimByWorktree.set(normalizePath(c.worktree_path), { id: c.id, scope: c.scope });
80
+ }
81
+ const worktrees = listWorktrees(mainWorktreePath).filter((w) => !w.is_main && w.branch !== '(detached)' && w.branch !== '(bare)'
82
+ && (!options.branches || options.branches.includes(w.branch)));
83
+ const lanes = [];
84
+ for (const w of worktrees) {
85
+ const committed = committedChangedFiles(mainWorktreePath, baseRef, w.branch).filter(f => !isIgnorablePath(f));
86
+ const dirty = includeDirty ? dirtyTrackedFiles(w.path).filter(f => !isIgnorablePath(f)) : [];
87
+ const changed = [...new Set([...committed, ...dirty])].sort();
88
+ const claim = claimByWorktree.get(normalizePath(w.path));
89
+ lanes.push({
90
+ branch: w.branch,
91
+ path: w.path,
92
+ session_id: w.session_id,
93
+ agent: w.agent,
94
+ claim_id: claim?.id,
95
+ claim_scope: claim?.scope,
96
+ committed_files: committed.sort(),
97
+ dirty_files: dirty.sort(),
98
+ changed_files: changed,
99
+ });
100
+ }
101
+ // File → lanes touching it; an overlap is any file in ≥2 lanes.
102
+ const fileToLanes = new Map();
103
+ for (const lane of lanes) {
104
+ for (const file of lane.changed_files) {
105
+ const arr = fileToLanes.get(file) ?? [];
106
+ arr.push(lane);
107
+ fileToLanes.set(file, arr);
108
+ }
109
+ }
110
+ const overlaps = [];
111
+ for (const [file, touching] of fileToLanes) {
112
+ if (touching.length < 2)
113
+ continue;
114
+ overlaps.push({
115
+ file,
116
+ branches: touching.map(l => l.branch),
117
+ claims: [...new Set(touching.map(l => l.claim_id).filter((x) => !!x))],
118
+ });
119
+ }
120
+ overlaps.sort((a, b) => b.branches.length - a.branches.length || a.file.localeCompare(b.file));
121
+ const hasRisk = overlaps.length > 0;
122
+ const summary = lanes.length === 0
123
+ ? 'No parallel worktree lanes to analyze.'
124
+ : hasRisk
125
+ ? `${overlaps.length} file(s) touched by multiple lanes across ${lanes.length} lane(s) — review before merge.`
126
+ : `${lanes.length} lane(s), no overlapping files — lanes are disjoint, safe to merge in any order.`;
127
+ return { base_ref: baseRef, lanes, overlaps, has_risk: hasRisk, summary };
128
+ }
129
+ function normalizePath(p) {
130
+ // Slash + trailing-slash normalisation is universal. Case-folding only on
131
+ // win32: POSIX filesystems are case-sensitive, so lowercasing `/Users/Foo`
132
+ // and `/users/foo` would collapse two genuinely distinct directories and
133
+ // mis-attribute a claim to the wrong lane.
134
+ // Resolve to the canonical real path when it exists, so a stored
135
+ // worktree_path in Windows 8.3 short form (e.g. C:\Users\RUNNER~1\…, as
136
+ // GitHub runners' TEMP expands) reconciles with the long-form path that
137
+ // `git worktree list` reports. Without this the claim never matches its lane
138
+ // on Windows CI (pln#576). Falls back to the literal path for stale claims
139
+ // whose worktree has been removed.
140
+ let resolved = p;
141
+ try {
142
+ resolved = fs.realpathSync.native(p);
143
+ }
144
+ catch {
145
+ // Path no longer exists — keep the literal value.
146
+ }
147
+ const normalized = resolved.replace(/\\/g, '/').replace(/\/+$/, '');
148
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
149
+ }
150
+ //# sourceMappingURL=merge-risk.js.map
@@ -134,7 +134,7 @@ export function readInbox(input, cwd) {
134
134
  const page = messages.slice(offset, offset + limit);
135
135
  return { total, offset, limit, messages: page };
136
136
  }
137
- export function ackMessage(messageId, agent, cwd) {
137
+ export function ackMessage(messageId, agent, cwd, options = {}) {
138
138
  return mutate({ cwd }, () => {
139
139
  const dir = agentInboxDir(agent, cwd);
140
140
  const messages = loadMessagesFromDir(dir);
@@ -142,6 +142,13 @@ export function ackMessage(messageId, agent, cwd) {
142
142
  if (!msg) {
143
143
  throw new Error(`Message '${messageId}' not found in ${agent}'s inbox.`);
144
144
  }
145
+ const callerClaimId = options.claimId?.trim();
146
+ const messageClaimId = msg.claim_id
147
+ ?? msg.payload?.claim_id;
148
+ if (callerClaimId && messageClaimId && messageClaimId !== callerClaimId) {
149
+ throw new Error(`Message '${msg.id}' is bound to claim '${messageClaimId}' but the caller holds claim '${callerClaimId}'. `
150
+ + 'Only the instance holding the message\'s claim may acknowledge it.');
151
+ }
145
152
  const timestamp = nowISO();
146
153
  msg.status = 'acknowledged';
147
154
  msg.ack_at = timestamp;
@@ -139,8 +139,18 @@ export function preparePersistedDocument(documentType, document) {
139
139
  schema_version: currentDocumentVersion(documentType),
140
140
  };
141
141
  }
142
+ /**
143
+ * The exact on-disk bytes for a versioned JSON document — the single
144
+ * serialization source of truth shared by the writer and the
145
+ * dirty-tracking skip check (pln#543 step 3). Computing the would-be
146
+ * content here means the skip comparison can never drift from what the
147
+ * writer actually produces.
148
+ */
149
+ export function serializeVersionedJson(documentType, document) {
150
+ return `${JSON.stringify(preparePersistedDocument(documentType, document), null, 2)}\n`;
151
+ }
142
152
  export function saveVersionedJsonFile(documentType, filepath, document) {
143
- writeFileAtomic(filepath, `${JSON.stringify(preparePersistedDocument(documentType, document), null, 2)}\n`);
153
+ writeFileAtomic(filepath, serializeVersionedJson(documentType, document));
144
154
  }
145
155
  export function saveVersionedYamlFile(documentType, filepath, document) {
146
156
  const yaml = YAML.stringify(preparePersistedDocument(documentType, document), { lineWidth: 0 });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Observer-mode helper (asgn_8a3790f6 / pln#558 step 1).
3
+ *
4
+ * When `BRAINCLAW_OBSERVER=1` is set in the process environment, the brainclaw
5
+ * store treats this process as a passive observer (a dashboard, the VS Code
6
+ * extension, an inspection script) and suppresses every read-path side effect:
7
+ *
8
+ * - auto-acknowledge of open handoffs in the coordination snapshot
9
+ * - lazy reconciliation of agent_run records during read paths
10
+ * - cursor advancement in readUnseenEvents
11
+ * - implicit heartbeat / auto-registration of identity
12
+ *
13
+ * A dashboard is not an agent. Reading the board must never mutate the store
14
+ * it observes — that loop is what caused the 2026-06-10 lock-contention storm
15
+ * (the VS Code extension's poll re-wrote and git-committed the entire store
16
+ * under the mutation lock, holding it >5s and timing out every other writer).
17
+ *
18
+ * The flag is intentionally an environment variable — workers inherit it from
19
+ * the parent, so a single setting at MCP-server spawn time covers every read
20
+ * dispatched through the worker pool.
21
+ */
22
+ export function isObserverMode(env = process.env) {
23
+ const raw = (env.BRAINCLAW_OBSERVER ?? '').trim().toLowerCase();
24
+ return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
25
+ }
26
+ //# sourceMappingURL=observer-mode.js.map