brainclaw 1.20.4 → 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.
@@ -28,8 +28,32 @@ function actionStore(cwd) {
28
28
  sort: (a, b) => a.created_at.localeCompare(b.created_at),
29
29
  });
30
30
  }
31
+ /**
32
+ * Honours the declared `| undefined` instead of throwing, which it did.
33
+ *
34
+ * `JsonStore.load` THROWS on a missing id (json-store.ts), so this function could never
35
+ * return undefined and every `if (!action)` branch downstream was unreachable —
36
+ * including the `throw new Error('ActionRequired not found')` in `resolveActionRequired`
37
+ * just below. `assignments.ts` documents this exact JsonStore contract and wraps it; this
38
+ * module did not. (Fable audit.)
39
+ *
40
+ * WHAT I VERIFIED BEFORE FIXING, because the finding was reported as a security-shaped
41
+ * bug and is not one: the audit claimed the self-approval guard in mcp-write-claims.ts was
42
+ * "skipped by the throw". It is not. That guard reads `if (pendingAction && pendingAction
43
+ * .agent === caller)`, and the throw only happens when the action does NOT exist — in which
44
+ * case there is nothing to self-approve and `resolveActionRequired` refuses anyway. The
45
+ * dead code was the truthiness test, never the ownership test. So the real cost was a lying
46
+ * signature plus a worse error message for a missing id, which is what this fixes.
47
+ */
31
48
  export function loadActionRequired(id, cwd) {
32
- return actionStore(cwd).load(id);
49
+ const store = actionStore(cwd);
50
+ // `exists` rather than a try/catch: a bare catch would also swallow an UNPARSEABLE
51
+ // record and report it as "not found", which is a different fact and the kind of
52
+ // silent downgrade this codebase already got burned by. Absence answers undefined;
53
+ // a corrupt record still throws, as it should.
54
+ if (!store.exists(id))
55
+ return undefined;
56
+ return store.load(id);
33
57
  }
34
58
  /** Default TTL for pending actions (1 hour). */
35
59
  const ACTION_DEFAULT_TTL_MS = 60 * 60 * 1000;
@@ -322,6 +322,25 @@ const UNIVERSAL_SKILL_RELATIVE_PATH = '.agents/skills/brainclaw/SKILL.md';
322
322
  * Directories exclusively managed by brainclaw — safe to gitignore as a whole.
323
323
  * Individual files in these directories don't need separate gitignore entries.
324
324
  */
325
+ /**
326
+ * pln#647 — coordination artifacts brainclaw writes at a WORKTREE ROOT, which must
327
+ * never be tracked and must not show up as untracked debris in a user's project.
328
+ *
329
+ * brainclaw's own repo lists these by hand (added after trp#546: a committed
330
+ * LANE-RESULT.json is inherited by every new worktree from HEAD and poisons
331
+ * dispatch-status into a false "completed" verdict). Nothing propagated them to a
332
+ * target project, so every OTHER repo got the debris — and `.brainclaw-heartbeat-*`
333
+ * was ignored nowhere at all, including here, which is what blocked worktree removal
334
+ * on 2026-08-04 (see removeWorktree).
335
+ */
336
+ export const BRAINCLAW_PROTOCOL_ARTIFACT_IGNORES = [
337
+ 'LANE-RESULT.json',
338
+ 'REVIEW-FINDINGS.md',
339
+ 'REVIEW_FINDINGS.md',
340
+ 'TRIAGE-REPORT.json',
341
+ '.brainclaw-heartbeat-*',
342
+ '.brainclaw-worktree.json',
343
+ ];
325
344
  export const BRAINCLAW_EXCLUSIVE_DIRECTORIES = [
326
345
  '.roo/',
327
346
  '.kilo/',
@@ -7,8 +7,10 @@
7
7
  * @module
8
8
  */
9
9
  import fs from 'node:fs';
10
+ import path from 'node:path';
10
11
  import { AgentRunSchema } from './schema.js';
11
- import { resolveEntityDir } from './io.js';
12
+ import { resolveOwnerProjectId } from './config.js';
13
+ import { entityRecordDirs, resolveEntityDir } from './io.js';
12
14
  import { mutate } from './mutation-pipeline.js';
13
15
  import { nowISO, generateIdWithLabel } from './ids.js';
14
16
  import { JsonStore } from './json-store.js';
@@ -25,9 +27,9 @@ function ensureAgentRunsDir(cwd) {
25
27
  fs.mkdirSync(dir, { recursive: true });
26
28
  }
27
29
  }
28
- function agentRunStore(cwd) {
30
+ function agentRunStoreForDir(dirPath) {
29
31
  return new JsonStore({
30
- dirPath: agentRunsDir(cwd, 'read'),
32
+ dirPath,
31
33
  documentType: 'agent_run',
32
34
  getId: (run) => run.id,
33
35
  sort: (a, b) => {
@@ -38,6 +40,9 @@ function agentRunStore(cwd) {
38
40
  },
39
41
  });
40
42
  }
43
+ // NOTE: no single-directory reader remains. The helper that resolved ONE dir via the
44
+ // hasContent heuristic is what let a legacy run stay invisible to the list while the by-id
45
+ // loader found it; removing it stops the next reader from reintroducing that asymmetry.
41
46
  export function saveAgentRun(run, cwd) {
42
47
  mutate({ cwd }, () => {
43
48
  ensureAgentRunsDir(cwd);
@@ -53,18 +58,67 @@ export function saveAgentRun(run, cwd) {
53
58
  emitRegistryPostImage('agent_run', parsed, { created, agent: parsed.agent, agent_id: parsed.agent_id, session_id: parsed.session_id, cwd });
54
59
  registryFaultPoint('after_registry_journal');
55
60
  store.save(parsed);
61
+ // Converge the other layout (mirrors saveClaim / saveAssignment): leaving a legacy copy
62
+ // holding the stale status is what let a deleted record be resurrected by its own zombie.
63
+ const writeDir = agentRunsDir(cwd, 'write');
64
+ for (const dirPath of entityRecordDirs('runs', cwd ?? process.cwd())) {
65
+ if (dirPath === writeDir)
66
+ continue;
67
+ const legacyPath = path.join(dirPath, `${parsed.id}.json`);
68
+ try {
69
+ if (fs.existsSync(legacyPath))
70
+ fs.unlinkSync(legacyPath);
71
+ }
72
+ catch { /* best effort — the dual-layout list keeps it visible */ }
73
+ }
56
74
  });
57
75
  }
58
76
  export function loadAgentRun(id, cwd) {
59
- try {
60
- return agentRunStore(cwd).load(id);
61
- }
62
- catch {
63
- return undefined;
77
+ // Record-specific across both layouts (pln#649, shared io.ts primitive). Resolving
78
+ // a single directory made a legacy run invisible as soon as the canonical one held
79
+ // any file — the same defect reproduced twice on assignments, found here by a Fable
80
+ // audit before it reached a field report.
81
+ for (const dirPath of entityRecordDirs('runs', cwd ?? process.cwd())) {
82
+ try {
83
+ return new JsonStore({
84
+ dirPath,
85
+ documentType: 'agent_run',
86
+ getId: (run) => run.id,
87
+ sort: (a, b) => {
88
+ const byAssignment = a.assignment_id.localeCompare(b.assignment_id);
89
+ if (byAssignment !== 0)
90
+ return byAssignment;
91
+ return a.created_at.localeCompare(b.created_at);
92
+ },
93
+ }).load(id);
94
+ }
95
+ catch { /* not in this layout — try the other */ }
64
96
  }
97
+ return undefined;
65
98
  }
99
+ /**
100
+ * BOTH LAYOUTS, canonical winning on a duplicate id (mirrors listClaims / listAssignments).
101
+ *
102
+ * This list is the one `nextAttemptIndex` and `findLatestAgentRunForAssignment` read, so a
103
+ * run visible by id but missing from the list is what lets an attempt index restart at 1
104
+ * and a run's FSM freeze. Reachability is LOW — agent runs postdate the partitioned layout,
105
+ * so brainclaw has never written them flat (measured: legacy=0 in the field) — which is why
106
+ * this is internal consistency, not a field fix.
107
+ */
66
108
  export function listAgentRuns(cwd, filter) {
67
- let runs = agentRunStore(cwd).list();
109
+ const byId = new Map();
110
+ for (const dirPath of entityRecordDirs('runs', cwd ?? process.cwd())) {
111
+ for (const run of agentRunStoreForDir(dirPath).list()) {
112
+ if (!byId.has(run.id))
113
+ byId.set(run.id, run);
114
+ }
115
+ }
116
+ let runs = Array.from(byId.values()).sort((a, b) => {
117
+ const byAssignment = a.assignment_id.localeCompare(b.assignment_id);
118
+ if (byAssignment !== 0)
119
+ return byAssignment;
120
+ return a.created_at.localeCompare(b.created_at);
121
+ });
68
122
  if (filter?.status)
69
123
  runs = runs.filter((run) => run.status === filter.status);
70
124
  if (filter?.agent)
@@ -123,7 +177,9 @@ export function createAgentRun(options, cwd) {
123
177
  const run = AgentRunSchema.parse({
124
178
  schema_version: 1,
125
179
  id: options.id ?? generated.id,
126
- short_label: options.short_label ?? generated.short_label,
180
+ // Same landmine as createAssignment: `generated` is undefined when the caller
181
+ // supplied an id, so `generated!` threw on "this id, derive the rest".
182
+ short_label: options.short_label ?? generated?.short_label ?? (options.id ?? generated.id),
127
183
  assignment_id: options.assignment_id,
128
184
  claim_id: options.claim_id,
129
185
  message_id: options.message_id,
@@ -134,6 +190,8 @@ export function createAgentRun(options, cwd) {
134
190
  agent: options.agent,
135
191
  agent_id: options.agent_id,
136
192
  session_id: options.session_id,
193
+ // OWNER project — same core-level capture as createAssignment (pln#649 step 1).
194
+ project_id: resolveOwnerProjectId(cwd),
137
195
  transport: options.transport,
138
196
  status: options.status ?? 'created',
139
197
  status_reason: options.status_reason,
@@ -11,8 +11,10 @@
11
11
  * @module
12
12
  */
13
13
  import fs from 'node:fs';
14
+ import path from 'node:path';
14
15
  import { AssignmentSchema } from './schema.js';
15
- import { resolveEntityDir } from './io.js';
16
+ import { resolveOwnerProjectId } from './config.js';
17
+ import { entityRecordDirs, resolveEntityDir } from './io.js';
16
18
  import { mutate } from './mutation-pipeline.js';
17
19
  import { nowISO, generateIdWithLabel } from './ids.js';
18
20
  import { JsonStore } from './json-store.js';
@@ -31,14 +33,18 @@ function ensureAssignmentsDir(cwd) {
31
33
  fs.mkdirSync(dir, { recursive: true });
32
34
  }
33
35
  }
34
- function assignmentStore(cwd) {
36
+ function assignmentStoreForDir(dirPath) {
35
37
  return new JsonStore({
36
- dirPath: assignmentsDir(cwd, 'read'),
38
+ dirPath,
37
39
  documentType: 'assignment',
38
40
  getId: (a) => a.id,
39
41
  sort: (a, b) => a.created_at.localeCompare(b.created_at),
40
42
  });
41
43
  }
44
+ // NOTE: there is deliberately no single-directory reader left in this module. A helper that
45
+ // resolved ONE dir via the `hasContent` heuristic is what made a legacy record invisible to
46
+ // the list while the by-id loader could see it; removing it means the next reader cannot
47
+ // reintroduce the asymmetry by reaching for the convenient function.
42
48
  // ── CRUD ─────────────────────────────────────────────────────
43
49
  export function saveAssignment(assignment, cwd) {
44
50
  mutate({ cwd }, () => {
@@ -55,21 +61,71 @@ export function saveAssignment(assignment, cwd) {
55
61
  emitRegistryPostImage('assignment', parsed, { created, agent: parsed.agent, agent_id: parsed.agent_id, session_id: parsed.session_id, cwd });
56
62
  registryFaultPoint('after_registry_journal');
57
63
  store.save(parsed);
64
+ // CONVERGE THE OTHER LAYOUT, exactly as saveClaim does. Without this, a save wrote
65
+ // canonical and LEFT a legacy copy holding the stale status: `loadAssignment` reads
66
+ // canonical first so the record looked right, but `deleteAssignment` removed only the
67
+ // canonical one and the stale copy became the record again — a zombie resurrection.
68
+ // Best effort on purpose: `listAssignments` reads both dirs, so a missed cleanup stays
69
+ // visible rather than silently dropping data. (Fable audit; claims.ts already had it.)
70
+ const writeDir = assignmentsDir(cwd, 'write');
71
+ for (const dirPath of entityRecordDirs('assignments', cwd ?? process.cwd())) {
72
+ if (dirPath === writeDir)
73
+ continue;
74
+ const legacyPath = path.join(dirPath, `${parsed.id}.json`);
75
+ try {
76
+ if (fs.existsSync(legacyPath))
77
+ fs.unlinkSync(legacyPath);
78
+ }
79
+ catch { /* best effort — the dual-layout list keeps it visible */ }
80
+ }
58
81
  });
59
82
  }
60
83
  export function loadAssignment(id, cwd) {
61
84
  // JsonStore.load throws when the id is missing; honor the declared
62
85
  // "| undefined" return type so callers (e.g. transitionAssignment)
63
86
  // can emit their own 'Assignment not found' error with the right wording.
64
- try {
65
- return assignmentStore(cwd).load(id);
66
- }
67
- catch {
68
- return undefined;
87
+ //
88
+ // RECORD-SPECIFIC ACROSS BOTH LAYOUTS (pln#649 step 3 review P1-2, reproduced).
89
+ // `assignmentStore` resolves a DIRECTORY via resolveEntityDir(..., 'read'), which
90
+ // picks the canonical one as soon as it holds ANY file — so in a store
91
+ // mid-migration a legacy `assignments/asgn_x.json` was invisible even though the
92
+ // step-2 locator had just found it. Locator said `found`, this said `not found`:
93
+ // the same defect one layer down. Asking "where is THIS record" needs both
94
+ // layouts, exactly as recordPaths does in the locator.
95
+ for (const dirPath of entityRecordDirs('assignments', cwd ?? process.cwd())) {
96
+ try {
97
+ return new JsonStore({
98
+ dirPath,
99
+ documentType: 'assignment',
100
+ getId: (a) => a.id,
101
+ sort: (a, b) => a.created_at.localeCompare(b.created_at),
102
+ }).load(id);
103
+ }
104
+ catch { /* not in this layout — try the other */ }
69
105
  }
106
+ return undefined;
70
107
  }
108
+ /**
109
+ * BOTH LAYOUTS, canonical winning on a duplicate id (mirrors listClaims).
110
+ *
111
+ * The by-id loader was fixed to read both layouts while this list still read ONE
112
+ * directory, chosen by the `hasContent` heuristic — so three layers gave three answers
113
+ * about the same store. The concrete consequence: a legacy run/assignment invisible to
114
+ * the list while visible by id, which lets `nextAttemptIndex` restart at 1 and collide,
115
+ * and makes `getActiveAssignmentForAgent` miss a live assignment so the worker's implicit
116
+ * heartbeat stops proving liveness. Reachability is LOW (assignments postdate the
117
+ * partitioned layout, so brainclaw has never written them flat — measured: legacy=0 in
118
+ * the field), which is why this is internal consistency rather than a field fix.
119
+ */
71
120
  export function listAssignments(cwd, filter) {
72
- let items = assignmentStore(cwd).list();
121
+ const byId = new Map();
122
+ for (const dirPath of entityRecordDirs('assignments', cwd ?? process.cwd())) {
123
+ for (const a of assignmentStoreForDir(dirPath).list()) {
124
+ if (!byId.has(a.id))
125
+ byId.set(a.id, a);
126
+ }
127
+ }
128
+ let items = Array.from(byId.values()).sort((a, b) => a.created_at.localeCompare(b.created_at));
73
129
  if (filter?.status)
74
130
  items = items.filter((a) => a.status === filter.status);
75
131
  if (filter?.agent)
@@ -82,21 +138,30 @@ export function listAssignments(cwd, filter) {
82
138
  items = items.filter((a) => a.sequence_id === filter.sequence_id);
83
139
  return items;
84
140
  }
141
+ /**
142
+ * Deletes the record in EVERY layout, not just the canonical one.
143
+ *
144
+ * Checking only the write dir meant a record `loadAssignment` could find returned `false`
145
+ * from delete — and worse, deleting the canonical copy of a dual-layout record promoted
146
+ * the stale legacy copy back to being the record. Deleting one layout is not deleting.
147
+ */
85
148
  export function deleteAssignment(id, cwd) {
86
149
  return mutate({ cwd }, () => {
87
- const writableStore = new JsonStore({
88
- dirPath: assignmentsDir(cwd, 'write'),
89
- documentType: 'assignment',
90
- getId: (a) => a.id,
91
- sort: (a, b) => a.created_at.localeCompare(b.created_at),
92
- });
93
- if (!writableStore.exists(id)) {
150
+ const dirs = entityRecordDirs('assignments', cwd ?? process.cwd());
151
+ const holders = dirs.filter((dirPath) => assignmentStoreForDir(dirPath).exists(id));
152
+ if (holders.length === 0) {
94
153
  return false;
95
154
  }
96
- const assignment = writableStore.load(id);
155
+ // One tombstone for the record, from the copy that wins reads.
156
+ const assignment = assignmentStoreForDir(holders[0]).load(id);
97
157
  emitRegistryTombstone('assignment', assignment.id, { agent: assignment.agent, agent_id: assignment.agent_id, session_id: assignment.session_id, cwd });
98
158
  registryFaultPoint('after_registry_journal');
99
- writableStore.delete(id);
159
+ for (const dirPath of holders) {
160
+ try {
161
+ assignmentStoreForDir(dirPath).delete(id);
162
+ }
163
+ catch { /* another layout already gone — the remaining ones still must go */ }
164
+ }
100
165
  return true;
101
166
  });
102
167
  }
@@ -295,7 +360,10 @@ export function recordProgress(id, options, cwd) {
295
360
  export function createAssignment(options, cwd) {
296
361
  const generated = options.id ? undefined : generateAssignmentId(cwd);
297
362
  const id = options.id ?? generated.id;
298
- const short_label = options.short_label ?? generated.short_label;
363
+ // `generated` is undefined whenever the caller supplied an id, so the old
364
+ // `generated!.short_label` threw a TypeError on the perfectly reasonable call
365
+ // "give me this id, derive the rest". Found while pinning the layout primitive.
366
+ const short_label = options.short_label ?? generated?.short_label ?? id;
299
367
  const assignment = AssignmentSchema.parse({
300
368
  schema_version: 1,
301
369
  id,
@@ -309,6 +377,13 @@ export function createAssignment(options, cwd) {
309
377
  agent_id: options.agent_id,
310
378
  dispatcher_agent: options.dispatcher_agent,
311
379
  dispatcher_session_id: options.dispatcher_session_id,
380
+ // OWNER project, captured HERE in core (pln#649 step 1) rather than in the
381
+ // command layer: every caller — MCP, CLI, dispatcher, loop engine — writes
382
+ // into `cwd`, so deriving it here means no path can create an assignment
383
+ // without an owner. The claim surface does this in TWO command-layer sites
384
+ // that already disagree (mcp-write-claims.ts uses loadConfig(claimCwd),
385
+ // claim.ts uses actor.project_id); core is the one place that cannot drift.
386
+ project_id: resolveOwnerProjectId(cwd),
312
387
  scope: options.scope,
313
388
  description: options.description,
314
389
  lane: options.lane,
@@ -3,7 +3,7 @@ import crypto from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { ClaimSchema } from './schema.js';
6
- import { resolveEntityDir } from './io.js';
6
+ import { entityRecordDirs, resolveEntityDir } from './io.js';
7
7
  import { mutate } from './mutation-pipeline.js';
8
8
  import { nowISO } from './ids.js';
9
9
  import { JsonStore } from './json-store.js';
@@ -33,12 +33,19 @@ function parseTtl(value) {
33
33
  function claimsDir(cwd, mode = 'read') {
34
34
  return resolveEntityDir('claims', cwd ?? process.cwd(), mode);
35
35
  }
36
+ /**
37
+ * Both layouts a claim record can occupy, canonical first (pln#649).
38
+ *
39
+ * The previous shape — `Set([claimsDir(write), claimsDir(read)])` — LOOKED like it
40
+ * covered both and did not: `write` is always canonical, and `read` also returns
41
+ * canonical as soon as that directory holds any file, so the Set collapsed to ONE
42
+ * entry and a legacy claim went invisible mid-migration. Same defect as the one
43
+ * reproduced twice on assignments; found here by a Fable audit before it reached a
44
+ * field report. Now derived from the shared io.ts primitive, so there is one
45
+ * definition instead of four look-alikes.
46
+ */
36
47
  function claimDirs(cwd) {
37
- const effectiveCwd = cwd ?? process.cwd();
38
- return Array.from(new Set([
39
- claimsDir(effectiveCwd, 'write'),
40
- claimsDir(effectiveCwd, 'read'),
41
- ]));
48
+ return entityRecordDirs('claims', cwd ?? process.cwd());
42
49
  }
43
50
  export function ensureClaimsDir(cwd) {
44
51
  const dir = claimsDir(cwd, 'write');
@@ -88,6 +88,64 @@ export function loadConfig(cwd, preferredDirName) {
88
88
  const filepath = memoryPath(CONFIG_FILE, cwd, preferredDirName);
89
89
  return loadVersionedYamlFile('config', filepath).document;
90
90
  }
91
+ /**
92
+ * pln#649 step 1 (dec#153) — the OWNER project of an execution entity: the
93
+ * `project_id` of the store the entity is being written into, captured once at
94
+ * creation and never re-derived.
95
+ *
96
+ * This is the anchor the entity-authoritative routing of dec#153 compares
97
+ * against: for any work unit, `owner_project` is fixed at creation and every
98
+ * read/mutation of that unit must reach THAT store — regardless of pid, cwd,
99
+ * session or the shared global pointer. Without a persisted owner there is
100
+ * nothing for the hard-refusal check to compare, and nothing for a worker to
101
+ * derive its project from.
102
+ *
103
+ * DELIBERATELY `project_id`, not a new field. `ClaimSchema` already carries BOTH
104
+ * `project_id` (a stable prj_* id) and `project` (a free-text namespace LABEL
105
+ * consumed by filters — mcp-read-handlers.ts / coordination.ts). Adding a third
106
+ * project-ish field would have been the same defect one level up, so this reuses
107
+ * the id and leaves the label alone. Operator decision, 2026-08-04.
108
+ *
109
+ * SWALLOWS ONLY "there is no store here" (ENOENT / ENOTDIR). Everything else —
110
+ * malformed YAML, schema-invalid config, permission denied, I/O failure — is
111
+ * RETHROWN (review P1-2). A catch-all was the first version and it was wrong in a
112
+ * way that defeated the whole point: a store with a corrupt config but writable
113
+ * coordination dirs would create entities with NO owner, and step 4 would then
114
+ * read the absent owner as "legacy", skip its refusal, and silently hand back the
115
+ * guarantee this field exists to provide. Degrading quietly is exactly the failure
116
+ * mode dec#153 is against.
117
+ *
118
+ * The field is optional on every schema so a record written before it existed
119
+ * stays loadable. (An earlier version of this comment claimed a zod-invalid record
120
+ * is DELETED on the next syncDirectory — that is false for the current code:
121
+ * state.ts preserves unparseable files precisely so a parse failure cannot corrupt
122
+ * data (trp#126). Required would make old records unloadable, not deleted.)
123
+ */
124
+ export function resolveOwnerProjectId(cwd, options = {}) {
125
+ try {
126
+ return loadConfig(cwd).project_id;
127
+ }
128
+ catch (err) {
129
+ const code = err?.code;
130
+ if (code === 'ENOENT' || code === 'ENOTDIR')
131
+ return undefined; // no store here
132
+ // A store that EXISTS but cannot be read (malformed YAML, schema-invalid,
133
+ // permission, I/O). Review P1-2 asked for a rethrow so a corrupt store could
134
+ // not quietly produce ownerless entities that a later refusal would wave
135
+ // through as "legacy". The intent is right and the placement was not: a
136
+ // rethrow HERE made `createAssignment` throw, the dispatcher swallowed it, and
137
+ // every dispatch silently lost its assignment_id — six CI jobs, caught only by
138
+ // the full suite. Breaking dispatch is worse than a missing owner.
139
+ //
140
+ // So the default is lenient — CREATION MUST NEVER FAIL because a config is
141
+ // corrupt — and the strictness moves to the consumer that actually needs it:
142
+ // pass `strict` from the refusal path (pln#649 step 4), where "I could not read
143
+ // the owner" must be a loud stop rather than an implicit "legacy".
144
+ if (options.strict)
145
+ throw err;
146
+ return undefined;
147
+ }
148
+ }
91
149
  export function saveConfig(config, cwd, preferredDirName) {
92
150
  const filepath = memoryPath(CONFIG_FILE, cwd, preferredDirName);
93
151
  saveVersionedYamlFile('config', filepath, ConfigSchema.parse(config));
@@ -1,8 +1,7 @@
1
1
  import fs from 'node:fs';
2
- import path from 'node:path';
3
2
  import { readAuditLog } from './audit.js';
4
3
  import { listCandidates } from './candidates.js';
5
- import { resolveEntityDir } from './io.js';
4
+ import { entityRecordPaths } from './io.js';
6
5
  import { loadVersionedJsonFile } from './migration.js';
7
6
  import { buildNotificationSummary, hasEventCursor, readUnseenEvents, seedCursorToEnd } from './event-log.js';
8
7
  import { SessionSnapshotSchema } from './schema.js';
@@ -28,17 +27,35 @@ export function resolveContextDiffSince(options) {
28
27
  // everyone's diff baseline.
29
28
  return {};
30
29
  }
30
+ /**
31
+ * THE LAST BY-ID SITE THAT COULD ACTUALLY FIRE, and the only one with live two-layout
32
+ * data in the field: this store holds 173 sessions in the legacy layout next to 1019
33
+ * canonical ones (dec#153-T2's dual write). `resolveEntityDir(..., 'read')` answers a
34
+ * DIRECTORY question with a `hasContent` heuristic, so one canonical file made every
35
+ * legacy record invisible — the same malformed abstraction pln#649 removed from the
36
+ * entity locator and the by-id loaders, still here because nothing routed sessions.
37
+ *
38
+ * The consequence was a SILENT WRONG ANSWER, which is why this one was worth fixing
39
+ * while the sibling sites were not: an invisible snapshot falls through to the audit-log
40
+ * scan, and if that misses too `resolveContextDiffSince` returns no `since`, so
41
+ * `buildContextDiff` returns undefined and the caller is told "no changes" over a window
42
+ * where there were changes. An agent cannot tell that apart from a quiet period.
43
+ *
44
+ * Uses the shared primitive rather than a fourth hand-rolled pair of paths (io.ts).
45
+ */
31
46
  function loadSessionSnapshot(sessionId, cwd) {
32
- const snapshotPath = path.join(resolveEntityDir('sessions', cwd ?? process.cwd(), 'read'), `${sessionId}.json`);
33
- if (!fs.existsSync(snapshotPath)) {
34
- return undefined;
35
- }
36
- try {
37
- return SessionSnapshotSchema.parse(loadVersionedJsonFile('session_snapshot', snapshotPath).document);
38
- }
39
- catch {
40
- return undefined;
47
+ for (const snapshotPath of entityRecordPaths('sessions', sessionId, cwd ?? process.cwd())) {
48
+ if (!fs.existsSync(snapshotPath))
49
+ continue;
50
+ try {
51
+ return SessionSnapshotSchema.parse(loadVersionedJsonFile('session_snapshot', snapshotPath).document);
52
+ }
53
+ catch {
54
+ // An unparseable record in one layout must not mask a good one in the other.
55
+ continue;
56
+ }
41
57
  }
58
+ return undefined;
42
59
  }
43
60
  export function buildContextDiff(options = {}) {
44
61
  const resolved = resolveContextDiffSince(options);