brainclaw 1.20.3 → 1.21.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/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/export.js +3 -3
- package/dist/commands/init.js +11 -0
- package/dist/commands/mcp-write-claims.js +191 -73
- package/dist/commands/mcp-write-entities.js +67 -0
- package/dist/commands/mcp.js +64 -1
- package/dist/commands/switch.js +41 -12
- package/dist/core/actions.js +25 -1
- package/dist/core/agent-files.js +19 -0
- package/dist/core/agentruns.js +68 -10
- package/dist/core/assignments.js +94 -19
- package/dist/core/claims.js +13 -6
- package/dist/core/config.js +58 -0
- package/dist/core/context-diff.js +28 -11
- package/dist/core/entity-locator.js +404 -0
- package/dist/core/execution-context.js +16 -9
- package/dist/core/identity.js +9 -1
- package/dist/core/io.js +39 -1
- package/dist/core/operations/relocate.js +40 -10
- package/dist/core/schema.js +24 -0
- package/dist/core/sequence.js +47 -6
- package/dist/core/store-resolution.js +99 -26
- package/dist/core/workspace-projects.js +23 -2
- package/dist/core/worktree.js +59 -1
- package/dist/facts.js +9 -9
- package/dist/facts.json +8 -8
- package/docs/cli.md +3 -1
- package/package.json +1 -1
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pln#649 step 2 (dec#153) — locate the store that OWNS an entity, by id.
|
|
3
|
+
*
|
|
4
|
+
* THE PROBLEM THIS SOLVES. dec#153 made the entity the routing authority: for any
|
|
5
|
+
* work unit, `owner_project` is fixed at creation and every read/mutation must
|
|
6
|
+
* reach THAT store, regardless of pid, cwd, session or the shared global pointer.
|
|
7
|
+
* Step 1 persisted the owner *inside* the record — but to read that field you must
|
|
8
|
+
* first find the file, which is the very thing ambient resolution gets wrong. This
|
|
9
|
+
* module breaks that circle: it finds the record by probing a BOUNDED candidate
|
|
10
|
+
* set, so a worker holding only an `assignment_id` can be routed correctly without
|
|
11
|
+
* anything ambient being trusted.
|
|
12
|
+
*
|
|
13
|
+
* WHY A PROBE AND NOT AN INDEX. The plan allowed an index; the code does not need
|
|
14
|
+
* one, and an index would be strictly worse here:
|
|
15
|
+
* - the candidate set is already enumerable and small (the store itself, the
|
|
16
|
+
* workspace's nested children, declared cross-project links) — one `existsSync`
|
|
17
|
+
* each, and the current store is probed FIRST so the overwhelmingly common
|
|
18
|
+
* single-project case costs exactly one stat;
|
|
19
|
+
* - an index is a second source of truth that can go stale, be half-written, or
|
|
20
|
+
* disagree with the filesystem — and the whole point of dec#153 is to stop
|
|
21
|
+
* trusting a derived answer over the record itself;
|
|
22
|
+
* - `resolveEntityDir` is store-LOCAL (io.ts:79-107 — it joins memoryDir(cwd)
|
|
23
|
+
* and falls back only to a legacy path inside the SAME store), so each probe
|
|
24
|
+
* is properly isolated and cannot leak a parent store's hit.
|
|
25
|
+
* If enumeration ever becomes expensive (hundreds of projects), a cache belongs in
|
|
26
|
+
* FRONT of this function, not instead of it.
|
|
27
|
+
*
|
|
28
|
+
* AMBIGUITY IS A RESULT, NOT AN ERROR. Two stores holding the same id is exactly
|
|
29
|
+
* the divergence T3 must refuse on (step 4), so it is reported as `ambiguous` with
|
|
30
|
+
* every match rather than silently resolved by first-wins. Callers decide; this
|
|
31
|
+
* module never picks a winner it cannot justify.
|
|
32
|
+
*
|
|
33
|
+
* @module
|
|
34
|
+
*/
|
|
35
|
+
import fs from 'node:fs';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import { loadConfig } from './config.js';
|
|
38
|
+
import { resolveCrossProjectLinks } from './cross-project.js';
|
|
39
|
+
import { entityRecordPaths, MEMORY_DIR } from './io.js';
|
|
40
|
+
import { resolvePrimaryStore, resolveWorkspaceRoot } from './store-resolution.js';
|
|
41
|
+
import { scanNestedBrainclawProjectsDetailed, summarizeWorkspaceProjects } from './workspace-projects.js';
|
|
42
|
+
/**
|
|
43
|
+
* Depth ceiling for nested-store scans.
|
|
44
|
+
*
|
|
45
|
+
* Review P1-2 offered two remedies for the false `not_found` at depth 7: raise the
|
|
46
|
+
* ceiling, or report incompleteness. I tried raising it to 12 and MEASURED the
|
|
47
|
+
* result — a test whose resolved workspace root was a large shared directory took
|
|
48
|
+
* 130 SECONDS in enumeration alone. Trading a false `not_found` for a pathological
|
|
49
|
+
* walk on a routing path is the worse defect, and it validated this review's own
|
|
50
|
+
* warning about hidden enumeration cost. So the ceiling stays at the underlying
|
|
51
|
+
* default and the enumeration REPORTS when it stopped early (`incomplete`), which
|
|
52
|
+
* lets a caller distinguish "not there" from "I could not look that far".
|
|
53
|
+
*/
|
|
54
|
+
export const DEFAULT_SCAN_DEPTH = 6;
|
|
55
|
+
/** Directory key in ENTITY_DIR_MAP for each mapped entity kind. */
|
|
56
|
+
function subdirFor(entity) {
|
|
57
|
+
return entity === 'assignment' ? 'assignments'
|
|
58
|
+
: entity === 'claim' ? 'claims'
|
|
59
|
+
: entity === 'agent_run' ? 'runs'
|
|
60
|
+
: 'plans';
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* EVERY file path a record with this id could occupy in one store.
|
|
64
|
+
*
|
|
65
|
+
* Deliberately NOT `resolveEntityDir(..., 'read')` any more (review P1-1, which the
|
|
66
|
+
* reviewer reproduced): that helper picks the canonical directory as soon as it
|
|
67
|
+
* contains ANY file (`hasContent`), which answers "where do records generally
|
|
68
|
+
* live" — not "where is THIS record". A store mid-migration holding
|
|
69
|
+
* `coordination/assignments/asgn_new.json` plus a legacy `assignments/asgn_old.json`
|
|
70
|
+
* made `asgn_old` unroutable: the canonical dir had content, so the legacy path was
|
|
71
|
+
* never looked at. Choosing a directory is the wrong primitive for a per-file
|
|
72
|
+
* question, so both layouts are probed by FILE and the caller sees one hit.
|
|
73
|
+
*/
|
|
74
|
+
/**
|
|
75
|
+
* The identifier shape `JsonStore` enforces before building a record path
|
|
76
|
+
* (json-store.ts:78). Duplicated HERE rather than trusted from the caller (review
|
|
77
|
+
* P2-6): this module joins an id into a filesystem path, so an id containing `..`
|
|
78
|
+
* or a separator would escape the store it is supposed to be probing. Callers on
|
|
79
|
+
* the MCP surface validate too and return a clean input_error — this is the
|
|
80
|
+
* backstop that makes the escape impossible rather than merely unlikely.
|
|
81
|
+
*/
|
|
82
|
+
export function isLocatableId(id) {
|
|
83
|
+
return /^[A-Za-z0-9_-]+$/.test(id);
|
|
84
|
+
}
|
|
85
|
+
function recordPaths(entity, id, storeCwd) {
|
|
86
|
+
if (entity === 'loop') {
|
|
87
|
+
// Loops are not in ENTITY_DIR_MAP — their threads live under loops/threads.
|
|
88
|
+
return [path.join(storeCwd, MEMORY_DIR, 'loops', 'threads', `${id}.json`)];
|
|
89
|
+
}
|
|
90
|
+
// Shared primitive (io.ts): this module had a hand-made copy, and so did two
|
|
91
|
+
// loaders. One export, four consumers — a fourth review would otherwise have
|
|
92
|
+
// found the same defect one layer further down.
|
|
93
|
+
return entityRecordPaths(subdirFor(entity), id, storeCwd);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* One candidate, all its possible layouts. A store that is mid-write,
|
|
97
|
+
* permission-denied or otherwise unreadable counts as a miss rather than an
|
|
98
|
+
* exception: routing must not depend on the health of a project the caller has
|
|
99
|
+
* nothing to do with.
|
|
100
|
+
*/
|
|
101
|
+
function recordExists(entity, id, storeCwd) {
|
|
102
|
+
for (const candidate of recordPaths(entity, id, storeCwd)) {
|
|
103
|
+
try {
|
|
104
|
+
if (fs.existsSync(candidate))
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
catch { /* unreadable — treat as a miss and keep looking */ }
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Canonical identity of a store path, for deduplication (review P2-3, reproduced).
|
|
113
|
+
* `path.resolve` is LEXICAL: a Windows directory junction — or a symlink, or a
|
|
114
|
+
* case-different spelling — declared as a cross-project link survived as a SECOND
|
|
115
|
+
* candidate pointing at the same physical store, and the same record was then found
|
|
116
|
+
* twice and reported `ambiguous`. That is a false positive that blocks a healthy
|
|
117
|
+
* mutation, i.e. the exact opposite of what the ambiguity contract is for.
|
|
118
|
+
*
|
|
119
|
+
* Falls back to the lexical form when the path cannot be realpath'd (missing or
|
|
120
|
+
* unreadable): a candidate we cannot canonicalise must still be probed, not dropped.
|
|
121
|
+
*/
|
|
122
|
+
function canonicalKey(abs) {
|
|
123
|
+
try {
|
|
124
|
+
const real = fs.realpathSync.native(abs);
|
|
125
|
+
// Windows paths are case-insensitive; lower-casing the KEY only (never the
|
|
126
|
+
// stored path) makes `C:\Repo` and `c:\repo` one candidate without changing
|
|
127
|
+
// what is reported back to the caller.
|
|
128
|
+
return process.platform === 'win32' ? real.toLowerCase() : real;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return process.platform === 'win32' ? abs.toLowerCase() : abs;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function describe(storeCwd) {
|
|
135
|
+
try {
|
|
136
|
+
const config = loadConfig(storeCwd);
|
|
137
|
+
return { cwd: storeCwd, project_id: config.project_id, project_name: config.project_name };
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// A store we can enumerate but not read config for is still a valid location.
|
|
141
|
+
return { cwd: storeCwd };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Candidate stores, in probe order: the caller's own store first (so the common
|
|
146
|
+
* single-project case stops after one stat), then the workspace's children, then
|
|
147
|
+
* declared cross-project links. Deduplicated by resolved absolute path.
|
|
148
|
+
*
|
|
149
|
+
* Enumeration mirrors the loop project-resolution gate (loops/project-resolution.ts)
|
|
150
|
+
* on purpose — two different candidate sets for "which projects exist here" is how
|
|
151
|
+
* routing surfaces drift apart.
|
|
152
|
+
*/
|
|
153
|
+
export function enumerateCandidateStores(cwd, options = {}) {
|
|
154
|
+
return enumerateCandidates(cwd, options).stores;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Per-process memo of the CANDIDATE LIST — never of the probes.
|
|
158
|
+
*
|
|
159
|
+
* This module's own header said "a cache belongs in FRONT of this function" and
|
|
160
|
+
* nobody wrote it, which a Fable audit ranked as the second live risk of the whole
|
|
161
|
+
* routing change: `enumerateCandidates` re-walks the workspace tree on EVERY routed
|
|
162
|
+
* mutation, and a worker emits repeated PROGRESS calls for the length of a run.
|
|
163
|
+
* Measured by a reviewer on Windows: p50 3.36 ms vs 0.23 ms for a direct load (~15x
|
|
164
|
+
* per call) — and a depth-12 experiment of mine spent 130 SECONDS in enumeration
|
|
165
|
+
* alone, which is the shape of the tail.
|
|
166
|
+
*
|
|
167
|
+
* WHAT IS AND IS NOT CACHED, deliberately. Only "which stores exist here" is memoised
|
|
168
|
+
* — a filesystem-topology answer that changes when someone creates a project. The
|
|
169
|
+
* PROBES (does this store hold this id?) are never cached: that is the state a
|
|
170
|
+
* mutation is about to change, and a stale answer there would route a write to the
|
|
171
|
+
* wrong store, which is the entire class dec#153 exists to kill.
|
|
172
|
+
*
|
|
173
|
+
* ASSUMED TRADE-OFF: for up to TTL milliseconds after a store is created, it is not a
|
|
174
|
+
* candidate. Consequences are bounded and safe in both directions — a `not_found`
|
|
175
|
+
* carries `enumeration_incomplete`, and an ambiguity that appears late is caught on
|
|
176
|
+
* the next call. The TTL is deliberately short: long enough to collapse a burst of
|
|
177
|
+
* calls from one operation, far too short to matter to a human.
|
|
178
|
+
*/
|
|
179
|
+
const ENUMERATION_MEMO_TTL_MS = 2_000;
|
|
180
|
+
const enumerationMemo = new Map();
|
|
181
|
+
/**
|
|
182
|
+
* Drop the memo. Called by tests, and by `runInit` — because a store that has just come
|
|
183
|
+
* into existence is exactly what a cached candidate list cannot know about.
|
|
184
|
+
*
|
|
185
|
+
* This comment used to name that second caller before it existed (Fable audit: intent
|
|
186
|
+
* described as code). Now it does.
|
|
187
|
+
*/
|
|
188
|
+
export function clearEnumerationMemo() {
|
|
189
|
+
enumerationMemo.clear();
|
|
190
|
+
}
|
|
191
|
+
export function enumerateCandidates(cwd, options = {}) {
|
|
192
|
+
const maxDepth = options.maxDepth ?? DEFAULT_SCAN_DEPTH;
|
|
193
|
+
// Keyed on the canonical cwd AND the depth, so two callers asking different
|
|
194
|
+
// questions cannot read each other's answer.
|
|
195
|
+
const memoKey = `${canonicalKey(path.resolve(cwd))}::${maxDepth}`;
|
|
196
|
+
if (!options.noMemo) {
|
|
197
|
+
const hit = enumerationMemo.get(memoKey);
|
|
198
|
+
if (hit && Date.now() - hit.at < ENUMERATION_MEMO_TTL_MS) {
|
|
199
|
+
// Copy the array so a caller mutating its result cannot corrupt the memo.
|
|
200
|
+
return { stores: [...hit.value.stores], incomplete: hit.value.incomplete };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const computed = enumerateCandidatesUncached(cwd, maxDepth);
|
|
204
|
+
if (!options.noMemo)
|
|
205
|
+
enumerationMemo.set(memoKey, { at: Date.now(), value: computed });
|
|
206
|
+
return { stores: [...computed.stores], incomplete: computed.incomplete };
|
|
207
|
+
}
|
|
208
|
+
function enumerateCandidatesUncached(cwd, maxDepth) {
|
|
209
|
+
const seen = new Set();
|
|
210
|
+
const ordered = [];
|
|
211
|
+
let incomplete = false;
|
|
212
|
+
const add = (candidate) => {
|
|
213
|
+
if (!candidate)
|
|
214
|
+
return;
|
|
215
|
+
const abs = path.resolve(candidate);
|
|
216
|
+
// Only real stores are candidates — an unreadable path costs nothing later.
|
|
217
|
+
if (!fs.existsSync(path.join(abs, MEMORY_DIR, 'config.yaml')))
|
|
218
|
+
return;
|
|
219
|
+
// Dedup on the CANONICAL path so a junction/symlink/case alias of a store
|
|
220
|
+
// already in the list cannot become a second candidate (review P2-3).
|
|
221
|
+
const key = canonicalKey(abs);
|
|
222
|
+
if (seen.has(key))
|
|
223
|
+
return;
|
|
224
|
+
seen.add(key);
|
|
225
|
+
ordered.push(abs);
|
|
226
|
+
};
|
|
227
|
+
/**
|
|
228
|
+
* ONE nested scan per root, with truncation reported BY THE WALK.
|
|
229
|
+
*
|
|
230
|
+
* An earlier version inferred it from the deepest RESULT — a store found at the
|
|
231
|
+
* ceiling depth meant deeper ones might exist. Review P1-1 reproduced why that is
|
|
232
|
+
* wrong: with a store at `root/d1/…/d7` and nothing in `d1…d6`, the walk cut the
|
|
233
|
+
* branch without returning anything near the ceiling, so the heuristic reported
|
|
234
|
+
* completeness while the target sat just below the cut — and the handler emitted a
|
|
235
|
+
* CONFIDENT `not_found`. Truncation is a property of the traversal, not of what it
|
|
236
|
+
* happened to find, so the scanner now says it (still one scan, no extra I/O).
|
|
237
|
+
*/
|
|
238
|
+
const addNested = (root) => {
|
|
239
|
+
const scan = scanNestedBrainclawProjectsDetailed(root, maxDepth);
|
|
240
|
+
for (const project of scan.projects)
|
|
241
|
+
add(project.path);
|
|
242
|
+
if (scan.truncated)
|
|
243
|
+
incomplete = true;
|
|
244
|
+
};
|
|
245
|
+
const here = path.resolve(cwd);
|
|
246
|
+
add(here);
|
|
247
|
+
let config;
|
|
248
|
+
try {
|
|
249
|
+
config = loadConfig(here);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// Uninitialised store: `here` was already rejected by `add`, and there is
|
|
253
|
+
// nothing to enumerate from. Returning what we have keeps callers simple.
|
|
254
|
+
return { stores: ordered, incomplete };
|
|
255
|
+
}
|
|
256
|
+
if (config.project_mode === 'multi-project') {
|
|
257
|
+
for (const project of summarizeWorkspaceProjects(here, config).discovered_projects) {
|
|
258
|
+
// `config`-sourced entries are declared namespaces without a path of their own.
|
|
259
|
+
if (project.source !== 'config')
|
|
260
|
+
add(project.path);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// A workspace root can host nested project stores; so can the workspace root
|
|
264
|
+
// ABOVE us, which is how a child project reaches its siblings.
|
|
265
|
+
const wsRoot = resolveWorkspaceRoot(here);
|
|
266
|
+
if (wsRoot) {
|
|
267
|
+
add(wsRoot);
|
|
268
|
+
addNested(wsRoot);
|
|
269
|
+
}
|
|
270
|
+
else if (resolvePrimaryStore(here)?.role === 'workspace') {
|
|
271
|
+
addNested(here);
|
|
272
|
+
}
|
|
273
|
+
// A cross-project link can point at a WORKSPACE, whose own children are then
|
|
274
|
+
// reachable by the linked side (review P1-2, reproduced: A → linked B → B/apps/C
|
|
275
|
+
// returned candidates [A, B] and not_found for a record living in C). Adding the
|
|
276
|
+
// link root alone was an arbitrary stop one level short of what the product can
|
|
277
|
+
// reach, so nested stores under each link are enumerated too.
|
|
278
|
+
for (const link of resolveCrossProjectLinks(here)) {
|
|
279
|
+
add(link.absolutePath);
|
|
280
|
+
addNested(link.absolutePath);
|
|
281
|
+
}
|
|
282
|
+
return { stores: ordered, incomplete };
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Find the store holding `id`. Never throws for a missing store or unreadable
|
|
286
|
+
* config — a locator that fails loudly on an unrelated broken sibling would make
|
|
287
|
+
* routing depend on the health of projects the caller has nothing to do with.
|
|
288
|
+
*/
|
|
289
|
+
/**
|
|
290
|
+
* The owner project id shared by EVERY match, or undefined when the records do not
|
|
291
|
+
* agree on one — which includes all the ways they can fail to answer:
|
|
292
|
+
*
|
|
293
|
+
* - a record with no `project_id` (legacy, created before step 1 persisted it):
|
|
294
|
+
* step 1 decided a missing owner must NOT trigger new behaviour, so one absent
|
|
295
|
+
* field makes the whole set undecided rather than letting the others vote;
|
|
296
|
+
* - records naming DIFFERENT owners — a genuine divergence, the case the hard
|
|
297
|
+
* refusal exists for;
|
|
298
|
+
* - a record that cannot be read or parsed. NEVER throws: this module refuses to
|
|
299
|
+
* make routing depend on the health of a project the caller has nothing to do
|
|
300
|
+
* with, and an unreadable record must degrade to `ambiguous`, not to a crash.
|
|
301
|
+
*/
|
|
302
|
+
function sharedRecordOwner(entity, id, matches) {
|
|
303
|
+
let owner;
|
|
304
|
+
for (const match of matches) {
|
|
305
|
+
let recordOwner;
|
|
306
|
+
for (const candidate of recordPaths(entity, id, match.cwd)) {
|
|
307
|
+
if (!fs.existsSync(candidate))
|
|
308
|
+
continue;
|
|
309
|
+
try {
|
|
310
|
+
const raw = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
|
|
311
|
+
const value = raw?.project_id;
|
|
312
|
+
if (typeof value === 'string' && value.trim())
|
|
313
|
+
recordOwner = value;
|
|
314
|
+
}
|
|
315
|
+
catch { /* unreadable → undecided, handled below */ }
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
if (!recordOwner)
|
|
319
|
+
return undefined;
|
|
320
|
+
if (owner === undefined)
|
|
321
|
+
owner = recordOwner;
|
|
322
|
+
else if (owner !== recordOwner)
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
return owner;
|
|
326
|
+
}
|
|
327
|
+
export function locateEntity(entity, id, cwd, options = {}) {
|
|
328
|
+
// An unusable id never touches the filesystem: no path is built, nothing is
|
|
329
|
+
// probed. Reported as not_found rather than thrown so an MCP handler can turn it
|
|
330
|
+
// into a clean input_error without a crash, while an internal caller that forgot
|
|
331
|
+
// to validate still cannot escape a store.
|
|
332
|
+
if (!isLocatableId(id)) {
|
|
333
|
+
return { status: 'not_found', matches: [], probed: [], enumeration_incomplete: false };
|
|
334
|
+
}
|
|
335
|
+
const enumeration = options.candidates
|
|
336
|
+
? { stores: options.candidates, incomplete: false }
|
|
337
|
+
: enumerateCandidates(cwd, { maxDepth: options.maxDepth });
|
|
338
|
+
const matches = [];
|
|
339
|
+
const probed = [];
|
|
340
|
+
// Alias-aware dedup lives HERE, not only in enumeration: a caller-supplied
|
|
341
|
+
// candidate list can contain two spellings of one physical store just as easily
|
|
342
|
+
// (found while pinning review P2-3 — the enumeration-only dedup left this path
|
|
343
|
+
// open). Probing an alias twice would report a single record as `ambiguous`.
|
|
344
|
+
const probedKeys = new Set();
|
|
345
|
+
for (const candidate of enumeration.stores) {
|
|
346
|
+
const key = canonicalKey(path.resolve(candidate));
|
|
347
|
+
if (probedKeys.has(key))
|
|
348
|
+
continue;
|
|
349
|
+
probedKeys.add(key);
|
|
350
|
+
probed.push(candidate);
|
|
351
|
+
if (recordExists(entity, id, candidate))
|
|
352
|
+
matches.push(describe(candidate));
|
|
353
|
+
}
|
|
354
|
+
const incomplete = enumeration.incomplete;
|
|
355
|
+
if (matches.length === 1) {
|
|
356
|
+
return { status: 'found', location: matches[0], matches, probed, enumeration_incomplete: incomplete };
|
|
357
|
+
}
|
|
358
|
+
if (matches.length > 1) {
|
|
359
|
+
// ── dec#155's robustness guard: ≥2 matches carrying the SAME owner route to the
|
|
360
|
+
// owner instead of refusing. Written from that sentence, not from this code.
|
|
361
|
+
//
|
|
362
|
+
// This does NOT weaken the ambiguity contract, and the distinction is the whole
|
|
363
|
+
// point of the module header: "never picks a winner it cannot justify". Here the
|
|
364
|
+
// RECORD names its owner, so the winner is justified BY THE DATA — it is not a
|
|
365
|
+
// first-wins tie-break. Every path where the data does not justify a winner
|
|
366
|
+
// stays `ambiguous`, which is what the two entity-routed surfaces refuse on.
|
|
367
|
+
//
|
|
368
|
+
// It uses the field step 1 persisted (`project_id` on assignment / agent_run /
|
|
369
|
+
// claim) and that this function has ignored ever since, and it exists to avoid
|
|
370
|
+
// REFUSING a healthy mutation when an alias or a mirror surfaces one record
|
|
371
|
+
// twice — a false refusal is the exact opposite of what the contract is for.
|
|
372
|
+
//
|
|
373
|
+
// Paid only on the ambiguous path: one read per match, in a branch that is
|
|
374
|
+
// already the rare case. The single-store hit still costs what it cost.
|
|
375
|
+
//
|
|
376
|
+
// ONE COUNTEREXAMPLE WAS RAISED AND IS EMPTY TODAY — recorded because it will not
|
|
377
|
+
// stay empty by itself. `bclaw_move` deliberately leaves a recoverable duplicate
|
|
378
|
+
// if it crashes between writing the target and removing the source, and it copies
|
|
379
|
+
// the record VERBATIM (relocate.ts) — so the residue would be two copies naming
|
|
380
|
+
// the SAME owner, which this guard would route instead of surfacing, freezing the
|
|
381
|
+
// repair signal. It cannot happen now: `plan` is the only entity both relocatable
|
|
382
|
+
// and locatable, and plan records carry NO `project_id` (checked against real
|
|
383
|
+
// records on disk, not just the schema), so `sharedRecordOwner` returns undefined
|
|
384
|
+
// and the residue stays `ambiguous`. THE DAY PLANS GAIN AN OWNER, relocate must
|
|
385
|
+
// rewrite `project_id` on the moved copy first — then the residue carries
|
|
386
|
+
// divergent owners and stays loudly ambiguous, which is the better companion fix
|
|
387
|
+
// anyway. (Fable audit; the counterexample was sound reasoning about a field the
|
|
388
|
+
// entity does not have.)
|
|
389
|
+
const owner = sharedRecordOwner(entity, id, matches);
|
|
390
|
+
if (owner) {
|
|
391
|
+
const owned = matches.filter((m) => m.project_id === owner);
|
|
392
|
+
// Exactly one match must BE the owner store. Zero means the owner is not among
|
|
393
|
+
// the stores holding the record (so routing there would name a store that does
|
|
394
|
+
// not have it); more than one means two stores claim the same project id, which
|
|
395
|
+
// is itself a divergence and not something to resolve here.
|
|
396
|
+
if (owned.length === 1) {
|
|
397
|
+
return { status: 'found', location: owned[0], matches, probed, enumeration_incomplete: incomplete };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return { status: 'ambiguous', matches, probed, enumeration_incomplete: incomplete };
|
|
401
|
+
}
|
|
402
|
+
return { status: 'not_found', matches, probed, enumeration_incomplete: incomplete };
|
|
403
|
+
}
|
|
404
|
+
//# sourceMappingURL=entity-locator.js.map
|
|
@@ -150,27 +150,34 @@ function detectGitRemote(cwd, runner) {
|
|
|
150
150
|
return result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).length > 0;
|
|
151
151
|
}
|
|
152
152
|
/**
|
|
153
|
-
* Detect how many commits the current branch is behind the main branch
|
|
154
|
-
*
|
|
153
|
+
* Detect how many commits the current branch is behind the main branch,
|
|
154
|
+
* reporting which reference branch (master/main) produced the count.
|
|
155
|
+
* Tries both and keeps the highest — handles repos where both branches
|
|
156
|
+
* exist but only one is the real reference.
|
|
155
157
|
* Returns undefined if not in a git repo or on the main branch itself.
|
|
158
|
+
*
|
|
159
|
+
* Branch names may legally contain shell metacharacters (`;`, `&`, `$()`,
|
|
160
|
+
* backticks…), so the revspec MUST stay a single argv element — never
|
|
161
|
+
* assemble it into a shell string (pln#618).
|
|
156
162
|
*/
|
|
157
|
-
function
|
|
163
|
+
export function detectCommitsBehindMainDetailed(cwd, currentBranch, runner = defaultRunner) {
|
|
158
164
|
// Don't check if already on main branch
|
|
159
165
|
if (currentBranch === 'master' || currentBranch === 'main')
|
|
160
166
|
return undefined;
|
|
161
|
-
|
|
162
|
-
// This handles repos where both branches exist but only one is the real reference.
|
|
163
|
-
let maxBehind;
|
|
167
|
+
let best;
|
|
164
168
|
for (const mainBranch of ['master', 'main']) {
|
|
165
169
|
const result = runner('git', ['rev-list', '--count', `${currentBranch}..${mainBranch}`], cwd);
|
|
166
170
|
if (result.status === 0) {
|
|
167
171
|
const count = parseInt(result.stdout.trim(), 10);
|
|
168
|
-
if (!isNaN(count) && (
|
|
169
|
-
|
|
172
|
+
if (!isNaN(count) && (best === undefined || count > best.count)) {
|
|
173
|
+
best = { branch: mainBranch, count };
|
|
170
174
|
}
|
|
171
175
|
}
|
|
172
176
|
}
|
|
173
|
-
return
|
|
177
|
+
return best;
|
|
178
|
+
}
|
|
179
|
+
function detectCommitsBehindMain(cwd, currentBranch, runner) {
|
|
180
|
+
return detectCommitsBehindMainDetailed(cwd, currentBranch, runner)?.count;
|
|
174
181
|
}
|
|
175
182
|
function detectToolchains(cwd, runner) {
|
|
176
183
|
if (runner === defaultRunner && cachedToolchains) {
|
package/dist/core/identity.js
CHANGED
|
@@ -260,7 +260,15 @@ function resolveCurrentAgentName() {
|
|
|
260
260
|
return process.env.BRAINCLAW_AGENT_NAME;
|
|
261
261
|
return detectAiAgent()?.name;
|
|
262
262
|
}
|
|
263
|
-
|
|
263
|
+
/**
|
|
264
|
+
* The session id the CALLER named, via argument or env. Exported (pln#648 review
|
|
265
|
+
* P1) because store-resolution must tell a STRONGLY identified session (exact id,
|
|
266
|
+
* or a record whose pid is this process) from a WEAKLY adopted one (the pidless
|
|
267
|
+
* candidate at line ~145, or the legacy `.current-session` fallback, which is
|
|
268
|
+
* returned with no agent/user/pid/TTL check at all). Only the former may steer
|
|
269
|
+
* resolution from a store the agent never named.
|
|
270
|
+
*/
|
|
271
|
+
export function resolveExplicitSessionId(env = process.env) {
|
|
264
272
|
return env.BRAINCLAW_SESSION_ID?.trim()
|
|
265
273
|
|| env.OPENCLAW_SESSION_ID?.trim()
|
|
266
274
|
|| env.CLAUDE_SESSION_ID?.trim()
|
package/dist/core/io.js
CHANGED
|
@@ -13,7 +13,15 @@ const TMP_ORPHAN_MIN_AGE_MS = 60_000;
|
|
|
13
13
|
* Maps legacy flat directory names to their entity-partitioned paths.
|
|
14
14
|
* Used by resolveEntityDir() for backward-compatible reads and forward writes.
|
|
15
15
|
*/
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Exported (pln#649 step 2, review P1-1) so a caller that needs the CANONICAL
|
|
18
|
+
* relative path for a kind can build a file path directly. `resolveEntityDir`
|
|
19
|
+
* answers "where do records of this kind generally live" by picking whichever
|
|
20
|
+
* directory has content — which is the wrong primitive when the question is
|
|
21
|
+
* "where is THIS record", because a mid-migration store makes the other layout
|
|
22
|
+
* invisible. Read-only by contract: never mutate this map.
|
|
23
|
+
*/
|
|
24
|
+
export const ENTITY_DIR_MAP = {
|
|
17
25
|
// memory/ — Project entity: durable knowledge
|
|
18
26
|
'constraints': 'memory/constraints',
|
|
19
27
|
'decisions': 'memory/decisions',
|
|
@@ -83,6 +91,36 @@ export function resolveEntityDir(subdir, cwd = process.cwd(), mode = 'read', pre
|
|
|
83
91
|
// Neither exists — return entity path (caller will handle missing dir)
|
|
84
92
|
return entityPath;
|
|
85
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* EVERY directory a record of `subdir` can occupy in ONE store, canonical first.
|
|
96
|
+
*
|
|
97
|
+
* THE PRIMITIVE THAT WAS MISSING (pln#649, after three reviews found the same defect
|
|
98
|
+
* at three different call sites). `resolveEntityDir(mode='read')` answers a
|
|
99
|
+
* DIRECTORY question — "where do records of this kind generally live" — using a
|
|
100
|
+
* `hasContent` heuristic. Every by-id loader used it for a FILE question — "where is
|
|
101
|
+
* THIS record" — and the two are not the same: in a store mid-migration, one file in
|
|
102
|
+
* the canonical directory makes every legacy record invisible. That produced a
|
|
103
|
+
* reproduced defect in the entity locator, then again in `loadAssignment`, and it is
|
|
104
|
+
* still latent wherever a loader resolves a directory before looking for an id.
|
|
105
|
+
*
|
|
106
|
+
* Callers that need a specific record MUST iterate these, not pick one. Writes keep
|
|
107
|
+
* using `resolveEntityDir(..., 'write')`, which is always canonical, so nothing new
|
|
108
|
+
* is ever created in the legacy layout — this is a read-compatibility primitive, not
|
|
109
|
+
* a migration.
|
|
110
|
+
*/
|
|
111
|
+
export function entityRecordDirs(subdir, cwd = process.cwd(), preferredDirName) {
|
|
112
|
+
const base = memoryDir(cwd, preferredDirName);
|
|
113
|
+
const mapped = ENTITY_DIR_MAP[subdir];
|
|
114
|
+
const legacy = path.join(base, subdir);
|
|
115
|
+
if (!mapped)
|
|
116
|
+
return [legacy];
|
|
117
|
+
const canonical = path.join(base, mapped);
|
|
118
|
+
return canonical === legacy ? [canonical] : [canonical, legacy];
|
|
119
|
+
}
|
|
120
|
+
/** The same, as record file paths for one id. */
|
|
121
|
+
export function entityRecordPaths(subdir, id, cwd, preferredDirName) {
|
|
122
|
+
return entityRecordDirs(subdir, cwd ?? process.cwd(), preferredDirName).map((d) => path.join(d, `${id}.json`));
|
|
123
|
+
}
|
|
86
124
|
export function memoryDir(cwd = process.cwd(), preferredDirName) {
|
|
87
125
|
return path.join(cwd, preferredDirName ?? MEMORY_DIR);
|
|
88
126
|
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import fs from 'node:fs';
|
|
18
18
|
import path from 'node:path';
|
|
19
|
-
import { resolveEntityDir, writeFileAtomic } from '../io.js';
|
|
19
|
+
import { entityRecordPaths, resolveEntityDir, writeFileAtomic } from '../io.js';
|
|
20
20
|
import { getEntitySpec } from '../entity-registry.js';
|
|
21
21
|
import { appendAuditEntry } from '../audit.js';
|
|
22
22
|
import { resolveProjectCwd } from '../cross-project.js';
|
|
@@ -62,16 +62,25 @@ export function relocateEntity(input) {
|
|
|
62
62
|
if (fromCwd === toCwd) {
|
|
63
63
|
throw new Error(`Source and target are the same project (${toCwd}). Nothing to move.`);
|
|
64
64
|
}
|
|
65
|
-
// Locate the source file across the entity's candidate subdirs.
|
|
65
|
+
// Locate the source file across the entity's candidate subdirs AND both layouts.
|
|
66
|
+
//
|
|
67
|
+
// `resolveEntityDir(sd, cwd, 'read')` picks the canonical directory as soon as it
|
|
68
|
+
// holds ANY file, so a record still in the pre-migration flat layout was reported
|
|
69
|
+
// "not found in source project" while sitting right there (pln#649 — same
|
|
70
|
+
// directory-vs-file confusion fixed in the locator and the by-id loaders; found
|
|
71
|
+
// here by a Fable audit).
|
|
66
72
|
let srcFile;
|
|
67
73
|
let foundSubdir;
|
|
68
74
|
for (const sd of subdirs) {
|
|
69
|
-
const candidate
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
for (const candidate of entityRecordPaths(sd, input.id, fromCwd)) {
|
|
76
|
+
if (fs.existsSync(candidate)) {
|
|
77
|
+
srcFile = candidate;
|
|
78
|
+
foundSubdir = sd;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
74
81
|
}
|
|
82
|
+
if (srcFile)
|
|
83
|
+
break;
|
|
75
84
|
}
|
|
76
85
|
if (!srcFile || !foundSubdir) {
|
|
77
86
|
throw new Error(`${input.entity} '${input.id}' not found in source project (${fromCwd}).`);
|
|
@@ -85,11 +94,32 @@ export function relocateEntity(input) {
|
|
|
85
94
|
throw new Error(`${input.entity} '${input.id}' is unreadable JSON: ${err.message}`, { cause: err });
|
|
86
95
|
}
|
|
87
96
|
getEntitySpec(input.entity).schema.parse(raw);
|
|
88
|
-
// Collision guard — never overwrite an item already in the target
|
|
97
|
+
// Collision guard — never overwrite an item already in the target, and never
|
|
98
|
+
// CREATE a second copy of the same id inside it.
|
|
99
|
+
//
|
|
100
|
+
// Checking only the canonical directory (`'write'`) was worse than a missed
|
|
101
|
+
// overwrite: if the target held the same id in the LEGACY layout, the guard passed
|
|
102
|
+
// and the move wrote a canonical copy beside it — manufacturing an intra-store
|
|
103
|
+
// duplicate id. Found by a Fable audit.
|
|
104
|
+
//
|
|
105
|
+
// THE MECHANISM RECORDED HERE BEFORE WAS WRONG, and is corrected rather than deleted
|
|
106
|
+
// because a wrong mechanism in a comment misleads the next reader more efficiently than
|
|
107
|
+
// no comment at all. It claimed the duplicate is "precisely the state the entity locator
|
|
108
|
+
// refuses as `ambiguous`, so a successful move could leave an entity permanently
|
|
109
|
+
// unroutable". It is not: `recordExists` is a per-STORE boolean and matches are collected
|
|
110
|
+
// per store, so a record duplicated across two LAYOUTS INSIDE ONE STORE collapses to a
|
|
111
|
+
// single `found`. Ambiguity needs two distinct STORES.
|
|
112
|
+
//
|
|
113
|
+
// The real harm is quieter and still worth the guard: the two copies drift, the loader
|
|
114
|
+
// reads whichever layout wins, and a delete that touches only the canonical one promotes
|
|
115
|
+
// the stale copy back to being the record (the zombie now fixed in assignments.ts).
|
|
89
116
|
const dstDir = resolveEntityDir(foundSubdir, toCwd, 'write');
|
|
90
117
|
const dstFile = path.join(dstDir, `${input.id}.json`);
|
|
91
|
-
|
|
92
|
-
|
|
118
|
+
for (const existing of entityRecordPaths(foundSubdir, input.id, toCwd)) {
|
|
119
|
+
if (fs.existsSync(existing)) {
|
|
120
|
+
throw new Error(`${input.entity} '${input.id}' already exists in the target project (${existing}) — refusing to overwrite `
|
|
121
|
+
+ 'or to create a second copy of the same id.');
|
|
122
|
+
}
|
|
93
123
|
}
|
|
94
124
|
// Reference guards (plans): refuse to move work under a live claim; warn on
|
|
95
125
|
// sequences that still point at it (v1 does not rewrite refs).
|
package/dist/core/schema.js
CHANGED
|
@@ -775,6 +775,23 @@ export const AssignmentSchema = z.object({
|
|
|
775
775
|
session_id: z.string().optional(),
|
|
776
776
|
dispatcher_agent: z.string(),
|
|
777
777
|
dispatcher_session_id: z.string().optional(),
|
|
778
|
+
/**
|
|
779
|
+
* OWNER project (pln#649 step 1, dec#153): the `project_id` of the store this
|
|
780
|
+
* assignment was created in, captured once and never re-derived. Every
|
|
781
|
+
* read/mutation of this assignment must reach THAT store — that is what makes
|
|
782
|
+
* entity-authoritative routing possible instead of ambient resolution.
|
|
783
|
+
* OPTIONAL by design: assignments written before this field existed must stay
|
|
784
|
+
* loadable — a required field would make every pre-existing record fail
|
|
785
|
+
* schema.parse and drop out of the loaded state. An absent owner means "legacy,
|
|
786
|
+
* fall back to current behaviour", never "refuse".
|
|
787
|
+
*
|
|
788
|
+
* Always derived from the store being written to (createAssignment via
|
|
789
|
+
* resolveOwnerProjectId(cwd)); there is deliberately NO caller override. An
|
|
790
|
+
* override let a record be saved in store A while declaring owner B, which the
|
|
791
|
+
* step-4 refusal would then read as a divergence and reject a correctly routed
|
|
792
|
+
* mutation (review P1-1).
|
|
793
|
+
*/
|
|
794
|
+
project_id: z.string().optional(),
|
|
778
795
|
// Task metadata
|
|
779
796
|
scope: z.string(),
|
|
780
797
|
description: z.string(),
|
|
@@ -839,6 +856,13 @@ export const AgentRunSchema = z.object({
|
|
|
839
856
|
agent: z.string(),
|
|
840
857
|
agent_id: z.string().optional(),
|
|
841
858
|
session_id: z.string().optional(),
|
|
859
|
+
/**
|
|
860
|
+
* OWNER project — same contract as Assignment.project_id (pln#649 step 1,
|
|
861
|
+
* dec#153): the `project_id` of the store this run was created in, captured
|
|
862
|
+
* once at creation, always derived from the write cwd and never overridable.
|
|
863
|
+
* Optional so pre-existing records stay loadable.
|
|
864
|
+
*/
|
|
865
|
+
project_id: z.string().optional(),
|
|
842
866
|
transport: AgentRunTransportSchema,
|
|
843
867
|
status: AgentRunStatusSchema,
|
|
844
868
|
status_reason: z.string().optional(),
|