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.
@@ -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
- * Tries master then main as the reference branch.
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 detectCommitsBehindMain(cwd, currentBranch, runner) {
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
- // Try both master and main, return the highest count found.
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) && (maxBehind === undefined || count > maxBehind)) {
169
- maxBehind = count;
172
+ if (!isNaN(count) && (best === undefined || count > best.count)) {
173
+ best = { branch: mainBranch, count };
170
174
  }
171
175
  }
172
176
  }
173
- return maxBehind;
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) {
@@ -260,7 +260,15 @@ function resolveCurrentAgentName() {
260
260
  return process.env.BRAINCLAW_AGENT_NAME;
261
261
  return detectAiAgent()?.name;
262
262
  }
263
- function resolveExplicitSessionId(env = process.env) {
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
- const ENTITY_DIR_MAP = {
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 = path.join(resolveEntityDir(sd, fromCwd, 'read'), `${input.id}.json`);
70
- if (fs.existsSync(candidate)) {
71
- srcFile = candidate;
72
- foundSubdir = sd;
73
- break;
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
- if (fs.existsSync(dstFile)) {
92
- throw new Error(`${input.entity} '${input.id}' already exists in the target project — refusing to overwrite.`);
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).
@@ -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(),