brainclaw 1.8.0 → 1.9.1
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 +592 -505
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +138 -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 +286 -23
- package/dist/commands/hooks.js +73 -73
- package/dist/commands/init.js +124 -22
- package/dist/commands/install-hooks.js +78 -78
- package/dist/commands/loops-handlers.js +4 -0
- package/dist/commands/mcp-read-handlers.js +253 -41
- package/dist/commands/mcp.js +664 -102
- 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/switch.js +26 -5
- package/dist/commands/uninstall.js +126 -34
- package/dist/commands/update-step.js +6 -0
- package/dist/commands/version.js +1 -1
- package/dist/commands/worktree.js +60 -0
- package/dist/core/actions.js +12 -3
- package/dist/core/agent-capability.js +30 -17
- package/dist/core/agent-files.js +963 -666
- 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/codev-prompts.js +38 -38
- 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/default-profiles/doctor.yaml +11 -11
- package/dist/core/default-profiles/janitor.yaml +11 -11
- package/dist/core/default-profiles/onboarder.yaml +11 -11
- package/dist/core/default-profiles/reviewer.yaml +13 -13
- package/dist/core/dispatch-status.js +79 -5
- package/dist/core/dispatcher.js +65 -12
- package/dist/core/entity-operations.js +74 -27
- 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 +1 -1
- package/dist/core/facade-schema.js +38 -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 -2
- 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 +10 -3
- 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 +72 -10
- package/dist/core/runtime.js +84 -1
- package/dist/core/schema.js +114 -0
- package/dist/core/search.js +19 -2
- package/dist/core/security-detectors.js +125 -0
- package/dist/core/security-extract.js +189 -0
- package/dist/core/security-guard.js +217 -139
- 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 +16 -2
- package/dist/core/staleness.js +73 -2
- package/dist/core/state.js +250 -54
- package/dist/core/store-resolution.js +45 -12
- package/dist/core/worktree.js +90 -26
- package/dist/facts.js +8 -8
- package/dist/facts.json +7 -7
- package/docs/PROTOCOL.md +223 -0
- package/docs/adapters/openclaw.md +43 -43
- package/docs/architecture/project-refs.md +328 -328
- package/docs/cli.md +2097 -2096
- package/docs/concepts/coordination.md +52 -52
- package/docs/concepts/coordinator-runbook.md +129 -0
- package/docs/concepts/dispatch-lifecycle.md +245 -245
- package/docs/concepts/event-log-store.md +928 -0
- package/docs/concepts/ideation-loop.md +317 -317
- package/docs/concepts/loop-engine.md +520 -511
- package/docs/concepts/mcp-governance.md +268 -268
- package/docs/concepts/memory.md +89 -88
- package/docs/concepts/multi-agent-workflows.md +167 -167
- package/docs/concepts/observer-protocol.md +361 -0
- package/docs/concepts/parallel-merge-protocol.md +71 -0
- package/docs/concepts/plans-and-claims.md +217 -174
- package/docs/concepts/project-md-convention.md +35 -35
- package/docs/concepts/runtime-notes.md +38 -38
- package/docs/concepts/skills.md +78 -0
- package/docs/concepts/troubleshooting.md +254 -254
- package/docs/concepts/workspace-bootstrapping.md +142 -81
- package/docs/context-format-changelog.md +35 -35
- package/docs/context-format.md +48 -48
- package/docs/index.md +65 -65
- package/docs/integrations/agents.md +162 -162
- package/docs/integrations/claude-code.md +23 -23
- package/docs/integrations/cline.md +87 -88
- package/docs/integrations/codex.md +2 -2
- package/docs/integrations/continue.md +60 -60
- package/docs/integrations/copilot.md +82 -80
- package/docs/integrations/cursor.md +23 -23
- package/docs/integrations/kilocode.md +72 -72
- package/docs/integrations/mcp.md +377 -377
- package/docs/integrations/mistral-vibe.md +122 -122
- package/docs/integrations/openclaw.md +99 -98
- package/docs/integrations/opencode.md +84 -84
- package/docs/integrations/overview.md +122 -122
- package/docs/integrations/roo.md +74 -74
- package/docs/integrations/windsurf.md +83 -83
- package/docs/mcp-schema-changelog.md +360 -329
- package/docs/playbooks/integration/index.md +121 -121
- package/docs/playbooks/orchestration.md +37 -0
- package/docs/playbooks/productivity/index.md +99 -99
- package/docs/playbooks/team/index.md +117 -117
- package/docs/product/agent-first-model.md +184 -184
- package/docs/product/entity-model-audit.md +462 -462
- package/docs/product/positioning.md +86 -86
- package/docs/quickstart-existing-project.md +107 -107
- package/docs/quickstart.md +148 -147
- package/docs/release-maintenance.md +79 -79
- package/docs/reputation.md +52 -52
- package/docs/review.md +45 -45
- package/docs/security.md +212 -53
- package/docs/server-operations.md +118 -118
- package/docs/storage.md +110 -108
- package/package.json +86 -69
package/dist/core/state.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { ConstraintSchema, DecisionSchema, TrapSchema, HandoffSchema, PlanItemSchema } from './schema.js';
|
|
4
|
-
import { ensureMemoryDir, resolveEntityDir } from './io.js';
|
|
4
|
+
import { ensureMemoryDir, resolveEntityDir, writeFileAtomic } from './io.js';
|
|
5
5
|
import { mutate } from './mutation-pipeline.js';
|
|
6
6
|
import { commitMemoryChange } from './memory-git.js';
|
|
7
7
|
import { appendEvent } from './event-log.js';
|
|
8
|
-
import {
|
|
8
|
+
import { appendJournalRecords, resolveJournalMode, resolveCheckpointRead } from './events/journal.js';
|
|
9
|
+
import { materializeStateFromCheckpoint } from './events/checkpoint.js';
|
|
10
|
+
import { loadVersionedJsonFile, serializeVersionedJson, preparePersistedDocument } from './migration.js';
|
|
9
11
|
import { rebuildProjectMd } from './markdown.js';
|
|
10
12
|
import { refreshLiveCompanions } from '../commands/export.js';
|
|
11
13
|
import { logger } from './logger.js';
|
|
@@ -102,6 +104,24 @@ export function findLoadValidationWarning(entity, id, cwd) {
|
|
|
102
104
|
export function loadState(cwd) {
|
|
103
105
|
// Load from entity-aligned directories (with legacy fallback)
|
|
104
106
|
const effectiveCwd = cwd ?? process.cwd();
|
|
107
|
+
// pln#566 Inc0 s2 — checkpointRead fast path. OFF by default (dual/off mode):
|
|
108
|
+
// projection files remain the read substrate. When the capability is enabled
|
|
109
|
+
// (primary soak) AND a verified journal-derived checkpoint exists, serve from
|
|
110
|
+
// checkpoint + sealed tail instead of reading every projection file. ANY
|
|
111
|
+
// failure (no checkpoint, failed verification, replay error) falls through to
|
|
112
|
+
// the projection read below — the checkpoint is never the sole truth.
|
|
113
|
+
if (resolveCheckpointRead(effectiveCwd)) {
|
|
114
|
+
try {
|
|
115
|
+
const fast = materializeStateFromCheckpoint(effectiveCwd);
|
|
116
|
+
// Merge over emptyState so the served State carries the same envelope
|
|
117
|
+
// fields (version/write_version) a projection read produces; the
|
|
118
|
+
// checkpoint only materializes the 5 entity collections. Already sorted
|
|
119
|
+
// by projectLiveToState.
|
|
120
|
+
if (fast)
|
|
121
|
+
return { ...emptyState(), ...fast };
|
|
122
|
+
}
|
|
123
|
+
catch { /* fall through to projection read */ }
|
|
124
|
+
}
|
|
105
125
|
const state = emptyState();
|
|
106
126
|
state.active_constraints = loadDirectoryItems(resolveEntityDir('constraints', effectiveCwd, 'read'), ConstraintSchema, 'constraint');
|
|
107
127
|
state.recent_decisions = loadDirectoryItems(resolveEntityDir('decisions', effectiveCwd, 'read'), DecisionSchema, 'decision');
|
|
@@ -116,72 +136,189 @@ export function loadState(cwd) {
|
|
|
116
136
|
state.plan_items.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
117
137
|
return state;
|
|
118
138
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
139
|
+
const persistWriteStats = { written: 0, skippedUnchanged: 0 };
|
|
140
|
+
export function readPersistWriteStats() {
|
|
141
|
+
return { ...persistWriteStats };
|
|
142
|
+
}
|
|
143
|
+
export function resetPersistWriteStats() {
|
|
144
|
+
persistWriteStats.written = 0;
|
|
145
|
+
persistWriteStats.skippedUnchanged = 0;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Order-insensitive canonical form of a JSON document, for the dirty-tracking
|
|
149
|
+
* skip compare. Recursively sorts object keys; returns the raw input on parse
|
|
150
|
+
* failure so corrupt/non-JSON bytes never compare equal to a valid desired doc.
|
|
151
|
+
*/
|
|
152
|
+
function canonicalJson(raw) {
|
|
153
|
+
const sortKeys = (value) => {
|
|
154
|
+
if (Array.isArray(value))
|
|
155
|
+
return value.map(sortKeys);
|
|
156
|
+
if (value && typeof value === 'object') {
|
|
157
|
+
return Object.fromEntries(Object.entries(value)
|
|
158
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
159
|
+
.map(([k, v]) => [k, sortKeys(v)]));
|
|
160
|
+
}
|
|
161
|
+
return value;
|
|
162
|
+
};
|
|
163
|
+
try {
|
|
164
|
+
return JSON.stringify(sortKeys(JSON.parse(raw)));
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return raw; // unparseable → never equals a canonical desired doc
|
|
122
168
|
}
|
|
123
|
-
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Pure planning pass: compute which projection files WOULD change, without
|
|
172
|
+
* writing anything. Same dirty-tracking + canonical-compare + parseable-guard
|
|
173
|
+
* logic as the old syncDirectory; only the IO is deferred to applySyncPlan.
|
|
174
|
+
*/
|
|
175
|
+
function planSyncDirectory(dirPath, items, documentType, schema, deleteMissing) {
|
|
176
|
+
const plan = { dirPath, writes: [], deletes: [] };
|
|
177
|
+
// Write only the items whose on-disk bytes would change (dirty-tracking,
|
|
178
|
+
// pln#543 step 3). The comparison uses the writer's own serializer so a
|
|
179
|
+
// "skip" can never diverge from what saveVersionedJsonFile would produce.
|
|
180
|
+
// Safe against trp#126: a missing/byte-different file never matches, so an
|
|
181
|
+
// in-state entity whose projection is absent/corrupt is always rewritten.
|
|
124
182
|
const currentIds = new Set();
|
|
125
183
|
for (const item of items) {
|
|
126
184
|
currentIds.add(item.id);
|
|
127
185
|
const filepath = path.join(dirPath, `${item.id}.json`);
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
186
|
+
const desired = serializeVersionedJson(documentType, item);
|
|
187
|
+
let existing;
|
|
188
|
+
try {
|
|
189
|
+
existing = fs.readFileSync(filepath, 'utf-8');
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
existing = undefined; // missing/unreadable → write
|
|
193
|
+
}
|
|
194
|
+
// Semantic (canonical, sorted-key) compare, not byte compare: loadState
|
|
195
|
+
// re-parses through zod which can reorder keys; a byte compare would
|
|
196
|
+
// rewrite the whole store every persist. Unparseable bytes never match
|
|
197
|
+
// → rewrite (keeps trp#126 safety).
|
|
198
|
+
if (existing !== undefined && canonicalJson(existing) === canonicalJson(desired)) {
|
|
199
|
+
persistWriteStats.skippedUnchanged += 1;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
plan.writes.push({ filepath, desired, item, created: existing === undefined });
|
|
203
|
+
}
|
|
204
|
+
if (!deleteMissing)
|
|
205
|
+
return plan;
|
|
206
|
+
// Plan removals of files no longer in state. CRITICAL: distinguish an
|
|
207
|
+
// intentional drop from a file silently dropped by loadDirectoryItems on a
|
|
208
|
+
// schema.parse throw (deleting the second kind corrupts data — trp#126). Only
|
|
209
|
+
// parseable + not-in-state files are unlinked; unparseable are preserved.
|
|
210
|
+
const files = fs.existsSync(dirPath)
|
|
211
|
+
? fs.readdirSync(dirPath).filter(f => f.endsWith('.json'))
|
|
212
|
+
: [];
|
|
138
213
|
for (const file of files) {
|
|
139
214
|
const id = file.replace('.json', '');
|
|
140
215
|
if (currentIds.has(id))
|
|
141
216
|
continue;
|
|
142
217
|
const filepath = path.join(dirPath, file);
|
|
143
|
-
let parseable = false;
|
|
144
218
|
try {
|
|
145
219
|
schema.parse(loadVersionedJsonFile(documentType, filepath).document);
|
|
146
|
-
parseable = true;
|
|
147
220
|
}
|
|
148
221
|
catch {
|
|
149
222
|
// Already logged by loadDirectoryItems — leave the file in place.
|
|
223
|
+
continue;
|
|
150
224
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
225
|
+
plan.deletes.push({ filepath, id });
|
|
226
|
+
}
|
|
227
|
+
return plan;
|
|
228
|
+
}
|
|
229
|
+
/** Apply a planned sync: the actual projection-file writes/unlinks (the IO that
|
|
230
|
+
* must happen AFTER the journal append+fsync). Returns the dirty result. */
|
|
231
|
+
function applySyncPlan(plan) {
|
|
232
|
+
const result = { written: [], deleted: [] };
|
|
233
|
+
// Create the entity dir unconditionally (matches the pre-split syncDirectory,
|
|
234
|
+
// which always ensured the dir even for an empty/unchanged collection).
|
|
235
|
+
if (!fs.existsSync(plan.dirPath)) {
|
|
236
|
+
fs.mkdirSync(plan.dirPath, { recursive: true });
|
|
237
|
+
}
|
|
238
|
+
for (const { filepath, desired, item, created } of plan.writes) {
|
|
239
|
+
writeFileAtomic(filepath, desired);
|
|
240
|
+
persistWriteStats.written += 1;
|
|
241
|
+
result.written.push({ item, created });
|
|
154
242
|
}
|
|
243
|
+
for (const { filepath, id } of plan.deletes) {
|
|
244
|
+
fs.unlinkSync(filepath);
|
|
245
|
+
result.deleted.push(id);
|
|
246
|
+
}
|
|
247
|
+
return result;
|
|
155
248
|
}
|
|
156
249
|
export function saveState(state, cwd) {
|
|
157
250
|
persistState(state, cwd, { writeProjectMarkdown: false });
|
|
158
251
|
}
|
|
159
252
|
function persistStateUnlocked(state, cwd, options = {}) {
|
|
160
|
-
|
|
253
|
+
ensureMemoryDir(cwd);
|
|
254
|
+
const effectiveCwd = cwd ?? process.cwd();
|
|
255
|
+
// pln#566 F1 — JOURNAL BEFORE PROJECTIONS (invariant I2). Persist now runs in
|
|
256
|
+
// three ordered phases so the journal can never lag the projections: a crash
|
|
257
|
+
// mid-persist leaves the journal AHEAD (the only direction lazy-reconcile can
|
|
258
|
+
// recover), never projections ahead (which materialize/verify could not
|
|
259
|
+
// explain). Phase 1 PLAN (pure compute, no IO) → Phase 2 emit+fsync the
|
|
260
|
+
// per-entity post-images to the journal → Phase 3 APPLY projection writes.
|
|
261
|
+
const { plans, legacyDeletes, dirty } = planStateDirectories(state, effectiveCwd, options.deleteMissing ?? false);
|
|
262
|
+
emitPerEntityJournalRecords(dirty, options.eventAction, effectiveCwd);
|
|
263
|
+
faultPoint('after_journal'); // test-only: crash AFTER journal, BEFORE projections
|
|
264
|
+
applyStatePlans(plans, legacyDeletes);
|
|
265
|
+
faultPoint('after_projection'); // test-only: crash AFTER projections written
|
|
161
266
|
if (options.writeProjectMarkdown ?? true) {
|
|
162
|
-
rebuildProjectMd(state,
|
|
267
|
+
rebuildProjectMd(state, effectiveCwd);
|
|
163
268
|
}
|
|
269
|
+
// v1 events.jsonl: keep the coarse store event for existing consumers, but
|
|
270
|
+
// suppress its envelope-only journal dual-write — the v2 per-entity emit
|
|
271
|
+
// above is the authoritative §2.8 diff choke point.
|
|
164
272
|
appendEvent({
|
|
165
273
|
action: options.eventAction ?? 'update',
|
|
166
274
|
item_type: 'state',
|
|
167
275
|
agent: 'system',
|
|
168
276
|
summary: options.eventSummary,
|
|
169
|
-
},
|
|
170
|
-
|
|
171
|
-
//
|
|
172
|
-
//
|
|
277
|
+
}, effectiveCwd, { journalDualWrite: false });
|
|
278
|
+
// NOTE (pln#558 step 2): the git commit and refreshLiveCompanions used to
|
|
279
|
+
// run here, INSIDE the mutation lock. A single persistState was holding
|
|
280
|
+
// the lock for >5s on Juan's machine (full-store rewrite + git add -A +
|
|
281
|
+
// git commit + live-companion refresh), which serialized every other
|
|
282
|
+
// writer. They are now invoked by persistState / mutateState AFTER the
|
|
283
|
+
// lock releases — see runPostWriteHooks below. The critical section is
|
|
284
|
+
// now writes-only; commit / companion-refresh are best-effort observers.
|
|
285
|
+
}
|
|
286
|
+
function runPostWriteHooks(cwd, commitMessage) {
|
|
287
|
+
// git add + git commit. Safe outside the lock because (a) git itself
|
|
288
|
+
// serializes concurrent index access via .git/index.lock, (b) the
|
|
289
|
+
// implementation already swallows failures (see memory-git.ts), and
|
|
290
|
+
// (c) the commit is an audit trail, not the data itself.
|
|
291
|
+
try {
|
|
292
|
+
commitMemoryChange(commitMessage, cwd);
|
|
293
|
+
}
|
|
294
|
+
catch { /* best-effort */ }
|
|
295
|
+
// Live companion files (Tier B/C agent surfaces). Already best-effort.
|
|
173
296
|
try {
|
|
174
297
|
refreshLiveCompanions(cwd);
|
|
175
298
|
}
|
|
176
299
|
catch { /* best-effort */ }
|
|
177
300
|
}
|
|
178
|
-
|
|
301
|
+
/**
|
|
302
|
+
* Test-only crash injection (pln#566 F1). No-op unless BRAINCLAW_FAULT_POINT
|
|
303
|
+
* matches the label — then it throws, simulating a process death at that exact
|
|
304
|
+
* point in the persist pipeline so crash-ordering invariants can be tested
|
|
305
|
+
* deterministically without racing a real SIGKILL.
|
|
306
|
+
*/
|
|
307
|
+
function faultPoint(label) {
|
|
308
|
+
if (process.env.BRAINCLAW_FAULT_POINT === label) {
|
|
309
|
+
throw new Error(`fault-injection: crashed at "${label}" (BRAINCLAW_FAULT_POINT)`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Plan (do not apply) the removal of legacy-dir orphans. Read-only: matches
|
|
314
|
+
* syncDirectory's safety condition (only parseable records absent from state
|
|
315
|
+
* are deletable; unparseable are preserved for inspection/repair). The actual
|
|
316
|
+
* unlink is deferred to applyStatePlans so it lands AFTER the journal append.
|
|
317
|
+
*/
|
|
318
|
+
function planCleanupLegacyDir(entityName, currentIds, cwd, documentType, schema) {
|
|
319
|
+
const out = [];
|
|
179
320
|
const writeDir = resolveEntityDir(entityName, cwd, 'write');
|
|
180
321
|
const readDir = resolveEntityDir(entityName, cwd, 'read');
|
|
181
|
-
// If read resolves to a different (legacy) directory, clean orphans there too.
|
|
182
|
-
// Match syncDirectory's safety condition: only delete parseable records that
|
|
183
|
-
// are absent from the current state. Schema-invalid legacy files may be drifted
|
|
184
|
-
// data that operators still need to inspect or repair.
|
|
185
322
|
if (readDir !== writeDir && fs.existsSync(readDir)) {
|
|
186
323
|
const files = fs.readdirSync(readDir).filter(f => f.endsWith('.json'));
|
|
187
324
|
for (const file of files) {
|
|
@@ -189,51 +326,110 @@ function cleanupLegacyDir(entityName, currentIds, cwd, documentType, schema) {
|
|
|
189
326
|
if (currentIds.has(id))
|
|
190
327
|
continue;
|
|
191
328
|
const filepath = path.join(readDir, file);
|
|
192
|
-
let parseable = false;
|
|
193
329
|
try {
|
|
194
330
|
schema.parse(loadVersionedJsonFile(documentType, filepath).document);
|
|
195
|
-
parseable = true;
|
|
196
331
|
}
|
|
197
332
|
catch {
|
|
198
333
|
logger.warn(`Preserving unparseable legacy ${entityName} file ${file}`);
|
|
199
334
|
continue;
|
|
200
335
|
}
|
|
201
|
-
|
|
202
|
-
fs.unlinkSync(filepath);
|
|
203
|
-
}
|
|
336
|
+
out.push({ filepath, id }); // O3: surfaced so a delete tombstone is emitted
|
|
204
337
|
}
|
|
205
338
|
}
|
|
339
|
+
return out;
|
|
206
340
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
341
|
+
/**
|
|
342
|
+
* Phase 1 of persist: compute every projection change WITHOUT writing. The
|
|
343
|
+
* returned `dirty` (post-images + tombstone ids) lets the journal be emitted +
|
|
344
|
+
* fsync'd before applyStatePlans touches any file (pln#566 F1 / I2).
|
|
345
|
+
*/
|
|
346
|
+
function planStateDirectories(state, cwd, deleteMissing) {
|
|
210
347
|
const entities = [
|
|
211
|
-
{ name: 'constraints', items: state.active_constraints, docType: 'constraint', schema: ConstraintSchema },
|
|
212
|
-
{ name: 'decisions', items: state.recent_decisions, docType: 'decision', schema: DecisionSchema },
|
|
213
|
-
{ name: 'traps', items: state.known_traps, docType: 'trap', schema: TrapSchema },
|
|
214
|
-
{ name: 'handoffs', items: state.open_handoffs, docType: 'handoff', schema: HandoffSchema },
|
|
215
|
-
{ name: 'plans', items: state.plan_items, docType: 'plan', schema: PlanItemSchema },
|
|
348
|
+
{ name: 'constraints', itemType: 'constraint', items: state.active_constraints, docType: 'constraint', schema: ConstraintSchema },
|
|
349
|
+
{ name: 'decisions', itemType: 'decision', items: state.recent_decisions, docType: 'decision', schema: DecisionSchema },
|
|
350
|
+
{ name: 'traps', itemType: 'trap', items: state.known_traps, docType: 'trap', schema: TrapSchema },
|
|
351
|
+
{ name: 'handoffs', itemType: 'handoff', items: state.open_handoffs, docType: 'handoff', schema: HandoffSchema },
|
|
352
|
+
{ name: 'plans', itemType: 'plan', items: state.plan_items, docType: 'plan', schema: PlanItemSchema },
|
|
216
353
|
];
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
354
|
+
const plans = [];
|
|
355
|
+
const legacyDeletes = [];
|
|
356
|
+
const dirty = [];
|
|
357
|
+
for (const { name, itemType, items, docType, schema } of entities) {
|
|
358
|
+
const writeDir = resolveEntityDir(name, cwd, 'write');
|
|
359
|
+
const plan = planSyncDirectory(writeDir, items, docType, schema, deleteMissing);
|
|
360
|
+
plans.push(plan);
|
|
361
|
+
const deleted = plan.deletes.map(d => d.id);
|
|
362
|
+
if (deleteMissing) {
|
|
363
|
+
// O3: legacy-dir orphans must also emit a delete tombstone.
|
|
364
|
+
const legacy = planCleanupLegacyDir(name, new Set(items.map(i => i.id)), cwd, docType, schema);
|
|
365
|
+
for (const l of legacy) {
|
|
366
|
+
legacyDeletes.push(l);
|
|
367
|
+
deleted.push(l.id);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
dirty.push({ itemType, written: plan.writes.map(w => ({ item: w.item, created: w.created })), deleted });
|
|
371
|
+
}
|
|
372
|
+
return { plans, legacyDeletes, dirty };
|
|
373
|
+
}
|
|
374
|
+
/** Phase 3 of persist: apply the planned projection writes/unlinks (the IO that
|
|
375
|
+
* must follow the journal append+fsync). */
|
|
376
|
+
function applyStatePlans(plans, legacyDeletes) {
|
|
377
|
+
for (const plan of plans)
|
|
378
|
+
applySyncPlan(plan);
|
|
379
|
+
for (const { filepath } of legacyDeletes) {
|
|
380
|
+
try {
|
|
381
|
+
fs.unlinkSync(filepath);
|
|
382
|
+
}
|
|
383
|
+
catch { /* already gone — idempotent */ }
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Emit one journal record per dirty entity with its full post-image
|
|
388
|
+
* (entity-state class, §2.1.1 / §2.8): the persist path is where the store's
|
|
389
|
+
* source-of-truth events are minted, because only it holds the entity docs.
|
|
390
|
+
* No-op when the journal flag is off. Failures are swallowed inside
|
|
391
|
+
* appendJournalRecords (dual mode: v1 projections remain the truth).
|
|
392
|
+
*/
|
|
393
|
+
function emitPerEntityJournalRecords(dirty, storeAction, cwd) {
|
|
394
|
+
if (resolveJournalMode(cwd) === 'off')
|
|
395
|
+
return;
|
|
396
|
+
const records = [];
|
|
397
|
+
for (const { itemType, written, deleted } of dirty) {
|
|
398
|
+
for (const { item, created } of written) {
|
|
399
|
+
// Post-image = the prepared document (with schema_version), so the
|
|
400
|
+
// journal record is byte-faithful to the projection: materialize can
|
|
401
|
+
// reconstruct an identical file, and verify compares like-for-like.
|
|
402
|
+
records.push({
|
|
403
|
+
action: storeAction && storeAction !== 'update' ? storeAction : (created ? 'create' : 'update'),
|
|
404
|
+
item_type: itemType,
|
|
405
|
+
item_id: item.id,
|
|
406
|
+
agent: 'system',
|
|
407
|
+
payload: preparePersistedDocument(itemType, item),
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
for (const id of deleted) {
|
|
411
|
+
records.push({ action: 'delete', item_type: itemType, item_id: id, agent: 'system' });
|
|
412
|
+
}
|
|
222
413
|
}
|
|
414
|
+
if (records.length > 0)
|
|
415
|
+
appendJournalRecords(records, cwd);
|
|
223
416
|
}
|
|
224
417
|
export function persistState(state, cwd, options = {}) {
|
|
225
418
|
const effectiveCwd = cwd ?? process.cwd();
|
|
226
419
|
mutate({ cwd: effectiveCwd }, () => {
|
|
227
420
|
persistStateUnlocked(state, effectiveCwd, options);
|
|
228
421
|
});
|
|
422
|
+
runPostWriteHooks(effectiveCwd, options.commitMessage ?? 'state update');
|
|
229
423
|
}
|
|
230
424
|
export function mutateState(mutateFn, cwd, options = {}) {
|
|
231
425
|
const effectiveCwd = cwd ?? process.cwd();
|
|
232
|
-
|
|
426
|
+
const result = mutate({ cwd: effectiveCwd }, () => {
|
|
233
427
|
const state = loadState(effectiveCwd);
|
|
234
|
-
const
|
|
235
|
-
persistStateUnlocked(state, effectiveCwd, options);
|
|
236
|
-
return
|
|
428
|
+
const value = mutateFn(state);
|
|
429
|
+
persistStateUnlocked(state, effectiveCwd, { ...options, deleteMissing: true });
|
|
430
|
+
return value;
|
|
237
431
|
});
|
|
432
|
+
runPostWriteHooks(effectiveCwd, options.commitMessage ?? 'state update');
|
|
433
|
+
return result;
|
|
238
434
|
}
|
|
239
435
|
//# sourceMappingURL=state.js.map
|
|
@@ -3,7 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { loadActiveProject } from './active-project.js';
|
|
5
5
|
import { loadConfig } from './config.js';
|
|
6
|
-
import { loadCurrentSession } from './identity.js';
|
|
6
|
+
import { loadCurrentSession, loadSessionById } from './identity.js';
|
|
7
7
|
import { MEMORY_DIR } from './io.js';
|
|
8
8
|
import { summarizeWorkspaceProjects } from './workspace-projects.js';
|
|
9
9
|
/**
|
|
@@ -98,10 +98,18 @@ export function resolveTargetStore(cwd = process.cwd(), target = 'local', option
|
|
|
98
98
|
* 6. Workspace anchor or process.cwd()
|
|
99
99
|
*/
|
|
100
100
|
export function resolveEffectiveCwd(options = {}) {
|
|
101
|
+
return resolveEffectiveCwdInfo(options).cwd;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Resolve the effective cwd and explain which selector won. Use this for MCP
|
|
105
|
+
* facades that must echo their project scope to avoid silent cross-project reads.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveEffectiveCwdInfo(options = {}) {
|
|
101
108
|
const baseCwd = path.resolve(options.baseCwd ?? process.cwd());
|
|
102
109
|
// 1. Explicit --cwd flag
|
|
103
110
|
if (options.explicitCwd) {
|
|
104
|
-
|
|
111
|
+
const cwd = path.resolve(options.explicitCwd);
|
|
112
|
+
return { cwd, active_source: 'explicit', resolved_project: projectInfo(cwd) };
|
|
105
113
|
}
|
|
106
114
|
// 2. BRAINCLAW_CWD env var — set by MCP configs to anchor resolution to the
|
|
107
115
|
// workspace regardless of the IDE's process.cwd() at launch time. It is a
|
|
@@ -118,14 +126,16 @@ export function resolveEffectiveCwd(options = {}) {
|
|
|
118
126
|
if (envProject) {
|
|
119
127
|
const resolved = resolveProjectRef(envProject, anchorCwd, options.storeChainOptions);
|
|
120
128
|
if (resolved)
|
|
121
|
-
return resolved;
|
|
129
|
+
return { cwd: resolved, active_source: 'env_project', resolved_project: projectInfo(resolved) };
|
|
122
130
|
}
|
|
123
131
|
// 4. Session-scoped active project (per-agent, no cross-agent interference)
|
|
124
|
-
const session =
|
|
132
|
+
const session = options.sessionId
|
|
133
|
+
? loadSessionById(options.sessionId, anchorCwd)
|
|
134
|
+
: loadCurrentSession(anchorCwd);
|
|
125
135
|
if (session?.active_project) {
|
|
126
136
|
const sp = session.active_project;
|
|
127
137
|
if (fs.existsSync(path.join(sp.path, MEMORY_DIR, 'config.yaml'))) {
|
|
128
|
-
return sp.path;
|
|
138
|
+
return { cwd: sp.path, active_source: 'session', resolved_project: { path: sp.path, name: sp.name } };
|
|
129
139
|
}
|
|
130
140
|
}
|
|
131
141
|
// 5. Global active-project.json from workspace root
|
|
@@ -133,11 +143,20 @@ export function resolveEffectiveCwd(options = {}) {
|
|
|
133
143
|
if (wsRoot) {
|
|
134
144
|
const active = loadActiveProject(wsRoot);
|
|
135
145
|
if (active && fs.existsSync(path.join(active.path, MEMORY_DIR, 'config.yaml'))) {
|
|
136
|
-
return active.path;
|
|
146
|
+
return { cwd: active.path, active_source: 'global', resolved_project: { path: active.path, name: active.name } };
|
|
137
147
|
}
|
|
138
148
|
}
|
|
139
149
|
// 6. Default
|
|
140
|
-
return anchorCwd;
|
|
150
|
+
return { cwd: anchorCwd, active_source: 'cwd', resolved_project: projectInfo(anchorCwd) };
|
|
151
|
+
}
|
|
152
|
+
function projectInfo(cwd) {
|
|
153
|
+
try {
|
|
154
|
+
const config = loadConfig(cwd);
|
|
155
|
+
return { path: cwd, name: config.project_name };
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return { path: cwd };
|
|
159
|
+
}
|
|
141
160
|
}
|
|
142
161
|
/**
|
|
143
162
|
* Find the workspace root (farthest store in the chain, or the one with
|
|
@@ -166,12 +185,24 @@ export function resolveProjectRef(ref, cwd = process.cwd(), storeChainOptions) {
|
|
|
166
185
|
?? resolveWorkspaceRoot(cwd, storeChainOptions);
|
|
167
186
|
if (!wsRoot)
|
|
168
187
|
return undefined;
|
|
169
|
-
//
|
|
188
|
+
// The trust boundary for raw path refs is the provided cwd. Callers in
|
|
189
|
+
// MCP context set cwd to the workspace root, so child projects resolve
|
|
190
|
+
// naturally. Walking further up (to a user-level store at home) would
|
|
191
|
+
// allow path-injection to sibling or home stores — that is the vulnerability
|
|
192
|
+
// we are closing. Name-based lookup below is unrestricted since it matches
|
|
193
|
+
// by project_name / project_id, not by arbitrary path.
|
|
194
|
+
const trustBoundary = path.resolve(cwd);
|
|
195
|
+
// Try as absolute path — only allowed if within the cwd boundary.
|
|
170
196
|
if (path.isAbsolute(ref)) {
|
|
197
|
+
if (!isAtOrBelow(ref, trustBoundary))
|
|
198
|
+
return undefined;
|
|
171
199
|
return fs.existsSync(path.join(ref, MEMORY_DIR, 'config.yaml')) ? ref : undefined;
|
|
172
200
|
}
|
|
173
|
-
// Try as relative path from
|
|
174
|
-
|
|
201
|
+
// Try as relative path resolved from the cwd boundary.
|
|
202
|
+
// Guards against ../ traversal (e.g. "../sibling-project").
|
|
203
|
+
const asPath = path.resolve(trustBoundary, ref);
|
|
204
|
+
if (!isAtOrBelow(asPath, trustBoundary))
|
|
205
|
+
return undefined;
|
|
175
206
|
if (fs.existsSync(path.join(asPath, MEMORY_DIR, 'config.yaml'))) {
|
|
176
207
|
return asPath;
|
|
177
208
|
}
|
|
@@ -315,8 +346,10 @@ function findClosestStoreBelow(target, ceiling) {
|
|
|
315
346
|
*/
|
|
316
347
|
function isAtOrBelow(dir, ancestor) {
|
|
317
348
|
const rel = path.relative(ancestor, dir);
|
|
318
|
-
//
|
|
319
|
-
|
|
349
|
+
// '..' prefix → dir is above ancestor. An absolute result means a different
|
|
350
|
+
// Windows drive (path.relative returns the absolute `to` path then), which is
|
|
351
|
+
// also outside the boundary — without this check `D:\evil` would pass.
|
|
352
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
320
353
|
}
|
|
321
354
|
function resolveAbsoluteTargetPath(cwd, target) {
|
|
322
355
|
if (path.isAbsolute(target)) {
|