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.
- package/README.md +28 -11
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +139 -13
- package/dist/commands/add-step.js +1 -1
- package/dist/commands/bootstrap.js +2 -26
- package/dist/commands/check-security-mcp.js +50 -33
- package/dist/commands/check-security.js +86 -43
- package/dist/commands/claim.js +22 -21
- package/dist/commands/confirm.js +26 -0
- package/dist/commands/context-diff.js +1 -1
- package/dist/commands/dispatch-watch.js +142 -0
- package/dist/commands/doctor.js +113 -2
- package/dist/commands/estimation-report.js +115 -16
- package/dist/commands/harvest.js +502 -16
- package/dist/commands/init.js +123 -21
- package/dist/commands/loops-handlers.js +4 -0
- package/dist/commands/mcp-read-handlers.js +198 -29
- package/dist/commands/mcp.js +615 -92
- package/dist/commands/memory.js +21 -17
- package/dist/commands/migrate.js +81 -17
- package/dist/commands/prune.js +78 -4
- package/dist/commands/reflect.js +26 -20
- package/dist/commands/register-agent.js +57 -1
- package/dist/commands/repair.js +20 -0
- package/dist/commands/session-end.js +15 -6
- package/dist/commands/session-start.js +18 -1
- package/dist/commands/setup-security.js +39 -18
- package/dist/commands/setup.js +26 -27
- package/dist/commands/stale.js +16 -2
- package/dist/commands/uninstall.js +126 -34
- package/dist/commands/update-step.js +6 -0
- package/dist/commands/worktree.js +60 -0
- package/dist/core/actions.js +12 -3
- package/dist/core/agent-capability.js +11 -13
- package/dist/core/agent-files.js +844 -547
- package/dist/core/agent-integrations.js +0 -3
- package/dist/core/agent-inventory.js +67 -0
- package/dist/core/agent-registry.js +163 -29
- package/dist/core/agentrun-reconciler.js +33 -2
- package/dist/core/agentruns.js +7 -1
- package/dist/core/ai-agent-detection.js +31 -44
- package/dist/core/archival.js +15 -9
- package/dist/core/assignment-reconciler.js +56 -0
- package/dist/core/assignment-sweeper.js +127 -4
- package/dist/core/assignments.js +69 -11
- package/dist/core/bootstrap.js +233 -67
- package/dist/core/brainclaw-version.js +22 -0
- package/dist/core/candidates.js +21 -1
- package/dist/core/claims.js +313 -150
- package/dist/core/config.js +6 -1
- package/dist/core/context-diff.js +148 -20
- package/dist/core/context.js +129 -8
- package/dist/core/coordination.js +22 -3
- package/dist/core/dispatch-status.js +109 -5
- package/dist/core/dispatcher.js +65 -11
- package/dist/core/entity-operations.js +45 -24
- package/dist/core/entity-registry.js +31 -5
- package/dist/core/event-log.js +138 -21
- package/dist/core/events/checkpoint.js +258 -0
- package/dist/core/events/genesis.js +220 -0
- package/dist/core/events/journal.js +507 -0
- package/dist/core/events/materialize.js +126 -0
- package/dist/core/events/registry-post-image.js +110 -0
- package/dist/core/events/verify.js +109 -0
- package/dist/core/execution-adapters.js +23 -0
- package/dist/core/execution.js +25 -0
- package/dist/core/facade-schema.js +48 -0
- package/dist/core/gc-semantic.js +130 -5
- package/dist/core/handoff-snapshot.js +68 -0
- package/dist/core/ids.js +19 -8
- package/dist/core/instruction-templates.js +34 -115
- package/dist/core/io.js +39 -3
- package/dist/core/json-store.js +10 -1
- package/dist/core/lock.js +153 -28
- package/dist/core/loops/bootstrap-acquire.js +25 -1
- package/dist/core/loops/facade-schema.js +2 -0
- package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
- package/dist/core/loops/index.js +1 -0
- package/dist/core/loops/presets/bootstrap.js +7 -0
- package/dist/core/loops/store.js +17 -0
- package/dist/core/loops/verbs.js +24 -1
- package/dist/core/markdown.js +8 -76
- package/dist/core/mcp-command-resolution.js +245 -0
- package/dist/core/memory-compactor.js +5 -3
- package/dist/core/memory-lifecycle.js +282 -0
- package/dist/core/merge-risk.js +150 -0
- package/dist/core/messaging.js +8 -1
- package/dist/core/migration.js +11 -1
- package/dist/core/observer-mode.js +26 -0
- package/dist/core/operations/memory-mutation.js +90 -65
- package/dist/core/operations/plan.js +27 -1
- package/dist/core/protocol-skills.js +210 -0
- package/dist/core/reflection-safety.js +6 -7
- package/dist/core/reputation.js +84 -2
- package/dist/core/runtime-signals.js +71 -9
- package/dist/core/runtime.js +84 -1
- package/dist/core/schema.js +125 -0
- package/dist/core/security-detectors.js +125 -0
- package/dist/core/security-extract.js +189 -0
- package/dist/core/security-guard.js +107 -29
- package/dist/core/security-packages.js +121 -0
- package/dist/core/security-scoring.js +76 -9
- package/dist/core/security.js +34 -2
- package/dist/core/sequence.js +11 -2
- package/dist/core/setup-flow.js +141 -13
- package/dist/core/spawn-check.js +110 -4
- package/dist/core/staleness.js +109 -1
- package/dist/core/state.js +250 -54
- package/dist/core/store-resolution.js +19 -5
- package/dist/core/worktree.js +169 -7
- package/dist/facts.js +8 -8
- package/dist/facts.json +7 -7
- package/docs/PROTOCOL.md +223 -0
- package/docs/cli.md +11 -10
- package/docs/concepts/coordinator-runbook.md +129 -0
- package/docs/concepts/dispatch-lifecycle.md +17 -0
- package/docs/concepts/event-log-store-critique-A.md +333 -0
- package/docs/concepts/event-log-store-critique-B.md +353 -0
- package/docs/concepts/event-log-store-phase0-measurements.md +58 -0
- package/docs/concepts/event-log-store-proposal-A.md +365 -0
- package/docs/concepts/event-log-store-proposal-B.md +404 -0
- package/docs/concepts/event-log-store.md +928 -0
- package/docs/concepts/identity-model-proposal.md +371 -0
- package/docs/concepts/memory.md +5 -4
- package/docs/concepts/observer-protocol.md +361 -0
- package/docs/concepts/parallel-merge-protocol.md +71 -0
- package/docs/concepts/plans-and-claims.md +43 -0
- package/docs/concepts/skills.md +78 -0
- package/docs/concepts/workspace-bootstrapping.md +61 -0
- package/docs/integrations/agents.md +4 -4
- package/docs/integrations/cline.md +10 -11
- package/docs/integrations/codex.md +2 -2
- package/docs/integrations/continue.md +5 -5
- package/docs/integrations/copilot.md +14 -12
- package/docs/integrations/openclaw.md +7 -6
- package/docs/integrations/overview.md +7 -7
- package/docs/integrations/roo.md +3 -3
- package/docs/integrations/windsurf.md +6 -6
- package/docs/mcp-schema-changelog.md +51 -20
- package/docs/quickstart.md +48 -47
- package/docs/security.md +174 -15
- package/docs/storage.md +4 -2
- 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
|
package/dist/core/messaging.js
CHANGED
|
@@ -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;
|
package/dist/core/migration.js
CHANGED
|
@@ -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,
|
|
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
|