@sema-agent/core 5.26.0 → 5.28.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/dist/agents/agent-transcript-tool.d.ts +5 -2
  3. package/dist/agents/agent-transcript-tool.js +2 -1
  4. package/dist/agents/send-message-tool.d.ts +4 -1
  5. package/dist/agents/subagent.d.ts +5 -2
  6. package/dist/core/checkpoint-store.d.ts +7 -2
  7. package/dist/core/hooks.d.ts +61 -4
  8. package/dist/core/hooks.js +37 -15
  9. package/dist/core/memory-engine/engine.d.ts +8 -5
  10. package/dist/core/memory-engine/engine.js +18 -6
  11. package/dist/core/memory-engine/file-backend.d.ts +144 -4
  12. package/dist/core/memory-engine/file-backend.js +304 -36
  13. package/dist/core/memory-engine/layout.d.ts +31 -2
  14. package/dist/core/memory-engine/layout.js +132 -8
  15. package/dist/core/memory-engine/types.d.ts +9 -1
  16. package/dist/core/memory-vector.d.ts +6 -1
  17. package/dist/core/memory-vector.js +14 -4
  18. package/dist/core/memory.js +1 -6
  19. package/dist/core/permission-rule-consent.d.ts +82 -8
  20. package/dist/core/permission-rule-consent.js +92 -1
  21. package/dist/core/permission-rule-model.d.ts +87 -6
  22. package/dist/core/permission-rule-model.js +79 -0
  23. package/dist/core/permission-rule-org.d.ts +22 -3
  24. package/dist/core/permission-rule-org.js +67 -20
  25. package/dist/core/permission-rule-store.js +2 -2
  26. package/dist/core/permission-rule-sync.d.ts +15 -1
  27. package/dist/core/permission-rule-sync.js +89 -47
  28. package/dist/core/runner/prepare-memory.js +14 -9
  29. package/dist/core/runner/prepare-task.d.ts +9 -3
  30. package/dist/core/runner/prepare-task.js +37 -11
  31. package/dist/core/runner/runtask.d.ts +8 -1
  32. package/dist/core/runner/runtask.js +8 -1
  33. package/dist/core/task-registry-agent.d.ts +13 -3
  34. package/dist/core/task-registry-agent.js +51 -21
  35. package/dist/core/task-registry-monitor.js +1 -1
  36. package/dist/core/task-registry-shared.d.ts +9 -0
  37. package/dist/core/task-registry.d.ts +6 -3
  38. package/dist/core/tool-policy.d.ts +44 -4
  39. package/dist/core/tool-policy.js +37 -3
  40. package/dist/core/tool-result-store.d.ts +108 -7
  41. package/dist/core/tool-result-store.js +95 -15
  42. package/dist/core/types.d.ts +115 -17
  43. package/dist/core/types.js +30 -1
  44. package/dist/engine/loop/types.d.ts +10 -3
  45. package/dist/index.d.ts +2 -2
  46. package/dist/index.js +1 -1
  47. package/dist/orchestration/run-workflow-tool.d.ts +5 -3
  48. package/dist/orchestration/workflow.d.ts +9 -6
  49. package/dist/stores/file/checkpoint-store.d.ts +2 -1
  50. package/dist/stores/file/index.d.ts +1 -1
  51. package/dist/stores/file/tool-result-store.d.ts +41 -1
  52. package/dist/stores/file/tool-result-store.js +107 -19
  53. package/dist/tools/fs/fs-bash.d.ts +7 -0
  54. package/dist/tools/fs/fs-shared.d.ts +5 -0
  55. package/dist/tools/fs/fs-shared.js +11 -7
  56. package/dist/tools/fs/index.d.ts +6 -0
  57. package/dist/tools/fs/index.js +2 -0
  58. package/package.json +1 -1
@@ -128,6 +128,15 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
128
128
  /** The names of sibling scope subdirs to exclude when scanning the ROOT scope's own nested content. */
129
129
  private excludedSubdirNames;
130
130
  private scopeDir;
131
+ /** B2/C-F9 — contain one inbound-gate REJECT, shared by the adopting read (`readScope` sync) and
132
+ * the adoption-restricted committed view (`readScopeCommitted`) so the two doors can never drift:
133
+ * report the finding, capture the SCANNED bytes into control-plane quarantine (never a path
134
+ * re-read — TOCTOU), restore the committed shadow over the suspect where one exists (the write
135
+ * lands by `rename`, which REPLACES a link at the final component instead of traversing it —
136
+ * pinned by test), and escalate any deviation as a report-visible `quarantine_failed`. Returns
137
+ * the shadow text that now backs the entry (undefined ⇔ never committed / shadow-less legacy —
138
+ * each caller decides what leaving the set means on its channel). */
139
+ private containInboundReject;
131
140
  /** B2 inbound gate for content that did NOT come through applyPatches (git pull / hand edits /
132
141
  * a hand-seeded file): secret + cap + structure. Returns the rejection, or undefined (clean). */
133
142
  private inboundGate;
@@ -138,12 +147,49 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
138
147
  * `sync` (read-side callers) adopts a committed file's CURRENT disk rev into the committed-rev
139
148
  * ledger ONLY through the inbound gate (B2: out-of-session edits, e.g. git-pull, are scanned;
140
149
  * a reject is moved to control-plane quarantine and the committed shadow content is restored to
141
- * disk). The applyPatches lookup passes false (the ledger must keep the baseline for CAS). */
150
+ * disk). The applyPatches lookup passes false (the ledger must keep the baseline for CAS).
151
+ *
152
+ * Ledger-write discipline: the sync=true adoption path WRITES `revs.json`, and that write is
153
+ * wholesale (atomic replace) — unserialized against applyPatches' transaction it is a
154
+ * lost update (the later writer drops every row the earlier one added). sync=true is therefore
155
+ * reachable ONLY through {@link readScopeAdopting}: `probeOnly` runs the scan with NO adoption
156
+ * side effects and returns undefined at the FIRST divergent entry (adoption required — the
157
+ * caller re-runs the scan under the txn mutex against a freshly reloaded ledger); a completed
158
+ * probe pass returns the entries (nothing to adopt, no lock ever taken). */
142
159
  private readScope;
160
+ /**
161
+ * The adopting read: {@link readScope} sync=true with its ledger write serialized behind the
162
+ * SAME cross-process txn mutex applyPatches holds (acquisition/steal/timeout semantics shared,
163
+ * never duplicated). Without it, an adoption landing beside a sibling process's commit is a
164
+ * lost update: both read the ledger, both write it wholesale, the later atomic write drops the
165
+ * earlier writer's rows (the entry file stays on disk but the ledger no longer knows it).
166
+ * - PROBE first (no lock): disk agreeing with the ledger is the common case, and that pass writes
167
+ * no LEDGER row — the read stays lock-free and never contends with a writer over the ledger.
168
+ * (Precision, merged-code scan: the non-divergent branch may still BACKFILL a missing shadow
169
+ * file for a committed entry, an unserialized control-plane write with a narrow pre-existing
170
+ * last-writer window against a sibling's commit — see the shadow-backfill site below; that
171
+ * window predates this probe and is the same one the v2 single-lock design closes.)
172
+ * - Divergence ⇒ acquire the mutex, finish any interrupted transaction (the steal-safety
173
+ * contract: a stolen stale lock is safe BECAUSE recovery runs inside the lock), RELOAD the
174
+ * ledger from disk (the probe's cached view is exactly the stale baseline that loses
175
+ * updates), then re-run the scan with adoption and save inside the lock. The save re-verifies
176
+ * lock OWNERSHIP first (a holder stalled past the stale deadline was stolen from — its
177
+ * snapshot is behind the stealer's commit and must abort, exactly like the applyPatches
178
+ * journal write).
179
+ * - A lock that cannot be acquired throws (same loud arm as the write path): an adoption that
180
+ * cannot be serialized must surface, never silently skip the gate or serve stale state as
181
+ * adopted.
182
+ * No self-deadlock: nothing under the lock re-enters acquireTxnLock — the scan's quarantine /
183
+ * shadow / announcement writes use their own per-file sidecar locks (whose holders never take
184
+ * the txn mutex), and applyPatchesLocked only issues sync=false reads. The two UNLOCKED
185
+ * recovery observers (constructor / checkControlPlane) are unaffected: they already stand down
186
+ * while a live holder exists ({@link txnInFlight}), which now covers adoption holds too.
187
+ */
188
+ private readScopeAdopting;
143
189
  listHeaders(scopes: readonly string[]): Promise<MemoryEntryHeader[]>;
144
- private listHeadersWith;
190
+ private listHeadersFrom;
145
191
  getByIds(ids: readonly string[]): Promise<MemoryEntry[]>;
146
- private getByIdsWith;
192
+ private getByIdsFrom;
147
193
  /**
148
194
  * design/178 §3 (ruled 2026-08-08) — a SIDE-EFFECT-FREE projection of this backend for the session's
149
195
  * RETRIEVAL face (`memory_search` / `memory_get`).
@@ -162,11 +208,105 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
162
208
  * WRITES ARE REFUSED, loudly — a view handed to a write path is a defect, not a fallback.
163
209
  */
164
210
  retrievalView(): MemoryBackend;
211
+ /** Dedup keys for divergence findings the restricted view already recorded on THIS instance: one
212
+ * materialize reads a scope several times (listHeaders, then getByIds per scope), and a
213
+ * checkpoint-cadence harvest re-audits the same still-pending state — without the key, one
214
+ * untouched divergence would mint a finding per read. Per-process on purpose (no durable state to
215
+ * lifecycle: a cross-process resume re-discloses once, which is the desired "still pending"
216
+ * notice); a divergence whose bytes CHANGE mints a new key and is reported again. */
217
+ private restrictedFindingKeys;
218
+ private recordRestrictedFinding;
219
+ /** Id-less audit state for an adoption-restricted session's WRITE scope. Armed only by the
220
+ * audited mount, which carries the caller's explicit session verdict together with the session's
221
+ * write scope — never inferred from plane structure (a writeScope-null layering has no write
222
+ * scope and is never given this surface: id-less files there are ordinary working files or
223
+ * external drops). The baseline is the set of id-less rel paths already under the scope at the
224
+ * first audited read after the mount (= materialize time): a pre-existing id-less file cannot be
225
+ * attributed to this session and stays unflagged, while a session under the restriction verdict
226
+ * has no sanctioned write channel into the store — so an id-less file that APPEARS after the
227
+ * baseline arrived out of band by construction and is worth naming. Per-process like the finding
228
+ * keys; when restricted sessions interleave on one instance the later mount re-baselines, which
229
+ * degrades toward silence, never toward a false alarm. */
230
+ private restrictedIdlessScope?;
231
+ private restrictedIdlessBaseline?;
232
+ /**
233
+ * The ADOPTION-RESTRICTED read of one scope: serve the COMMITTED account (ledger + control-plane
234
+ * shadow) and never advance it. This is the tamper boundary for a session with no write intent:
235
+ * the ordinary `readScope(sync=true)` ADOPTS disk divergence as the new committed baseline
236
+ * regardless of session intent, so a byte landed by a side channel (e.g. a shell write under a
237
+ * session whose persistence is declared unavailable, or into a read-only layering) would be
238
+ * committed by the very session that was told it cannot persist — its own checkpoint-resume
239
+ * materialize included. Here instead:
240
+ * - disk rev == committed rev ⇒ served as-is (it IS the committed content);
241
+ * - divergence whose bytes equal the committed SHADOW ⇒ served as-is: the shadow is written at
242
+ * the applyPatches commit point, so matching it is transactional proof that a concurrent
243
+ * writable session committed this state and only this call's ledger snapshot raced it —
244
+ * never treated as tampering;
245
+ * - other divergence of a shadow-backed entry ⇒ the SHADOW (committed content) is served; the
246
+ * divergent disk bytes stay in place UNLESS they fail the inbound gate (the existing
247
+ * quarantine door, unchanged under restriction). Scan-clean divergence is never quarantined,
248
+ * overwritten, or adopted — it stays on disk as deferred external state a later unrestricted
249
+ * session adjudicates normally;
250
+ * - a divergent entry with NO shadow (shadow-less legacy), and a never-committed id-bearing
251
+ * file, have no committed content to serve ⇒ WITHHELD with a loud finding, never served from
252
+ * disk, never adopted.
253
+ * The ledger is reloaded from disk on every call (a stale cache would misread a concurrent commit
254
+ * as divergence) and NEVER written here; the only writes this path may perform are the
255
+ * gate-failing containment above and a shadow backfill for a rev-matching entry (recording
256
+ * committed content — the account itself does not move).
257
+ *
258
+ * `audit` picks the mount: true = the engine's materialize/harvest boundary (findings recorded,
259
+ * gate-failing divergence contained); false = the retrieval-tool face (a PURE committed read —
260
+ * no findings, no containment, no writes: a model lookup must be side-effect-free).
261
+ */
262
+ private readScopeCommitted;
263
+ /** Does `id` have a projection file anywhere in the store OTHER than `excludeAbsPath`? Returns
264
+ * that projection's rel path, or undefined. Early-exits on the first hit; only consulted on the
265
+ * rare divergent-with-shadow path (the scope-binding guard above), never on clean reads. */
266
+ private idProjectionElsewhere;
267
+ /**
268
+ * The ADOPTION-RESTRICTED projection of this backend (see {@link readScopeCommitted}) — what a
269
+ * session with no write intent materializes and retrieves through. The CALLER decides restriction
270
+ * (an explicit verdict: a read-only layering, or persistence declared unavailable) and mounts this
271
+ * view; the backend never guesses a session's intent from its own state.
272
+ * - `audit: true` (the engine's materialize/harvest mount): divergence findings are recorded for
273
+ * the harvest report + announcement queue, and gate-FAILING divergence is contained by the
274
+ * existing quarantine door; `writeScope` (when the session has one) additionally arms the
275
+ * id-less baseline for that scope — see {@link auditRestrictedDivergence};
276
+ * - `audit: false` (the retrieval-tool mount): a pure committed-view read — no findings, no
277
+ * containment, no writes, no baseline.
278
+ * WRITES ARE REFUSED loudly — same posture as {@link retrievalView}.
279
+ */
280
+ restrictedAdoptionView(opts?: {
281
+ audit?: boolean;
282
+ writeScope?: string | null;
283
+ }): MemoryBackend;
284
+ /**
285
+ * The harvest-boundary divergence sweep for an adoption-restricted session (the engine's
286
+ * zero-admission harvest arm calls this BEFORE draining findings): re-run the committed-view audit
287
+ * over the session's scopes — bytes landed AFTER materialize (a mid-session shell write) are
288
+ * detected here, at the very boundary where an unrestricted harvest would have admitted them —
289
+ * then check the committed account for entries MISSING from disk entirely (an unattributed delete
290
+ * is divergence too). Ledger rows carry no scope, so presence is judged across every registered
291
+ * scope, the same resolution `getByIds` uses; a rename or cross-scope move therefore never reads
292
+ * as missing. Non-destructive throughout: nothing is restored, forgotten, or adopted here.
293
+ *
294
+ * `opts.idlessWriteScope` is the CALLER's per-call session verdict (same posture as the mount:
295
+ * the backend never guesses): when the audited session HAS a write scope, id-less files under it
296
+ * that were not in the mount-time baseline are named too — the session has no sanctioned write
297
+ * channel into the store, so a new arrival is out of band by construction. Left out (a
298
+ * writeScope-null layering, or a caller that never mounted the audit), the sweep is exactly the
299
+ * id-bearing audit above; a scope mismatch with the armed mount likewise degrades to silence
300
+ * rather than judging against a foreign baseline.
301
+ */
302
+ auditRestrictedDivergence(scopes: readonly string[], opts?: {
303
+ idlessWriteScope?: string;
304
+ }): void;
165
305
  /** Lexical floor (design/138 §2.1): `1 - Jaccard` distance over name+description+body, ascending. */
166
306
  search(query: string, scopes: readonly string[], opts?: {
167
307
  limit?: number;
168
308
  }): Promise<ScoredMemoryEntry[]>;
169
- private searchWith;
309
+ private searchFrom;
170
310
  /**
171
311
  * H4 — the entry transaction, staged and journaled:
172
312
  * 1. RELOAD the ledger from disk (验收 M2: a concurrently-committed transaction from another
@@ -1,5 +1,5 @@
1
1
  import { lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, openSync, fsyncSync, closeSync } from "node:fs";
2
- import { randomUUID } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import { dirname, join, relative } from "node:path";
4
4
  import { jaccardDistance, termSet } from "../memory-vector.js";
5
5
  import { MAX_MEMORY_BYTES } from "../memory.js";
@@ -229,6 +229,29 @@ export class FileMemoryEngineBackend {
229
229
  scopeDir(scope) {
230
230
  return registerScope(this.directoryRoot, this.controlPlaneRoot, scope);
231
231
  }
232
+ containInboundReject(finding, absPath, text, entryId, committed) {
233
+ this.inboundFindings.push(finding);
234
+ const q = quarantineAndTombstone(absPath, text, join(this.controlPlaneRoot, QUARANTINE_DIR), this.now);
235
+ const shadowText = committed !== undefined ? this.readCommittedShadow(entryId) : undefined;
236
+ let restoredShadow = false;
237
+ if (shadowText !== undefined) {
238
+ try {
239
+ atomicWriteFileSync(absPath, shadowText);
240
+ restoredShadow = true;
241
+ }
242
+ catch {
243
+ }
244
+ }
245
+ if (q.detail !== undefined || (!q.removed && !restoredShadow)) {
246
+ const contained = q.removed || restoredShadow;
247
+ this.inboundFindings.push({
248
+ path: finding.path,
249
+ code: "quarantine_failed",
250
+ reason: `quarantine escalation: ${q.detail ?? "suspect copy not captured"}${contained ? "" : " — rejected content may still be on the model-visible plane"}`,
251
+ });
252
+ }
253
+ return shadowText;
254
+ }
232
255
  inboundGate(relPath, text) {
233
256
  const nameFinding = scanMemoryFileName(relPath);
234
257
  if (nameFinding !== undefined)
@@ -240,7 +263,7 @@ export class FileMemoryEngineBackend {
240
263
  }
241
264
  return undefined;
242
265
  }
243
- readScope(scope, sync = true) {
266
+ readScope(scope, sync, probeOnly, lockToken) {
244
267
  if (!sync && this.batchScan !== undefined) {
245
268
  const memo = this.batchScan.get(scope);
246
269
  if (memo !== undefined)
@@ -270,6 +293,10 @@ export class FileMemoryEngineBackend {
270
293
  if (ledger) {
271
294
  const committed = ledger[entry.id];
272
295
  if (committed !== entry.rev) {
296
+ if (probeOnly)
297
+ return undefined;
298
+ if (lockToken !== undefined)
299
+ this.assertTxnLockOwnership(lockToken);
273
300
  const relPath = relative(this.directoryRoot, f.path);
274
301
  let finding = this.inboundGate(relPath, text);
275
302
  if (finding === undefined && committed !== undefined) {
@@ -288,27 +315,7 @@ export class FileMemoryEngineBackend {
288
315
  }
289
316
  }
290
317
  if (finding) {
291
- this.inboundFindings.push(finding);
292
- const q = quarantineAndTombstone(f.path, text, join(this.controlPlaneRoot, QUARANTINE_DIR), this.now);
293
- const shadowText = committed !== undefined ? this.readCommittedShadow(entry.id) : undefined;
294
- let restoredShadow = false;
295
- if (shadowText !== undefined) {
296
- try {
297
- atomicWriteFileSync(f.path, shadowText);
298
- restoredShadow = true;
299
- }
300
- catch {
301
- }
302
- entry = entryFromFile(shadowText, entry.id, f.slug, scope);
303
- }
304
- if (q.detail !== undefined || (!q.removed && !restoredShadow)) {
305
- const contained = q.removed || restoredShadow;
306
- this.inboundFindings.push({
307
- path: finding.path,
308
- code: "quarantine_failed",
309
- reason: `quarantine escalation: ${q.detail ?? "suspect copy not captured"}${contained ? "" : " — rejected content may still be on the model-visible plane"}`,
310
- });
311
- }
318
+ const shadowText = this.containInboundReject(finding, f.path, text, entry.id, committed);
312
319
  if (shadowText === undefined) {
313
320
  if (committed !== undefined) {
314
321
  delete ledger[entry.id];
@@ -316,6 +323,7 @@ export class FileMemoryEngineBackend {
316
323
  }
317
324
  continue;
318
325
  }
326
+ entry = entryFromFile(shadowText, entry.id, f.slug, scope);
319
327
  }
320
328
  else {
321
329
  if (committed !== undefined)
@@ -339,8 +347,11 @@ export class FileMemoryEngineBackend {
339
347
  }
340
348
  entries.push(entry);
341
349
  }
342
- if (ledgerChanged)
350
+ if (ledgerChanged) {
351
+ if (lockToken !== undefined)
352
+ this.assertTxnLockOwnership(lockToken);
343
353
  this.saveLedger();
354
+ }
344
355
  if (adoptedExternal.length > 0) {
345
356
  try {
346
357
  enqueueMemoryAnnouncement(this.controlPlaneRoot, {
@@ -373,27 +384,46 @@ export class FileMemoryEngineBackend {
373
384
  this.batchScan.set(scope, entries);
374
385
  return entries;
375
386
  }
387
+ async readScopeAdopting(scope, timings) {
388
+ const probed = this.readScope(scope, true, true);
389
+ if (probed !== undefined)
390
+ return probed;
391
+ const lock = await this.acquireTxnLock(timings);
392
+ try {
393
+ this.assertTxnLockOwnership(lock.token);
394
+ this.ledger = undefined;
395
+ this.recoverJournal();
396
+ return this.readScope(scope, true, false, lock.token);
397
+ }
398
+ catch (err) {
399
+ this.ledger = undefined;
400
+ throw err;
401
+ }
402
+ finally {
403
+ lock.release();
404
+ }
405
+ }
376
406
  async listHeaders(scopes) {
377
- return this.listHeadersWith(scopes, true);
407
+ return this.listHeadersFrom(scopes, (s) => this.readScopeAdopting(s));
378
408
  }
379
- listHeadersWith(scopes, adopt) {
409
+ async listHeadersFrom(scopes, read) {
380
410
  const out = [];
381
411
  for (const scope of scopes) {
382
412
  const dir = this.scopeDir(scope);
383
- for (const e of this.readScope(scope, adopt)) {
413
+ for (const e of await read(scope)) {
384
414
  out.push(headerOf(e, join(dir, `${e.slug}.md`)));
385
415
  }
386
416
  }
387
417
  return out;
388
418
  }
389
419
  async getByIds(ids) {
390
- return this.getByIdsWith(ids, true);
420
+ return this.getByIdsFrom(ids, (s) => this.readScopeAdopting(s));
391
421
  }
392
- getByIdsWith(ids, adopt) {
422
+ async getByIdsFrom(ids, read) {
393
423
  const want = new Set(ids);
394
424
  const out = [];
395
425
  for (const scope of Object.keys(registeredScopes(this.controlPlaneRoot))) {
396
- for (const e of this.readScope(scope, adopt)) {
426
+ for (const e of await read(scope)) {
397
427
  if (want.has(e.id))
398
428
  out.push(e);
399
429
  }
@@ -405,18 +435,256 @@ export class FileMemoryEngineBackend {
405
435
  throw new Error(`memory retrieval view is read-only — ${op} must go through the backend itself`);
406
436
  };
407
437
  return {
408
- listHeaders: async (scopes) => this.listHeadersWith(scopes, false),
409
- getByIds: async (ids) => this.getByIdsWith(ids, false),
410
- search: async (query, scopes, opts) => this.searchWith(query, scopes, opts, false),
438
+ listHeaders: async (scopes) => this.listHeadersFrom(scopes, (s) => this.readScope(s, false)),
439
+ getByIds: async (ids) => this.getByIdsFrom(ids, (s) => this.readScope(s, false)),
440
+ search: async (query, scopes, opts) => this.searchFrom(query, scopes, opts, (s) => this.readScope(s, false)),
411
441
  applyPatches: async () => refuse("applyPatches"),
412
442
  getConsolidationCursor: async (scope) => this.getConsolidationCursor(scope),
413
443
  setConsolidationCursor: async () => refuse("setConsolidationCursor"),
414
444
  };
415
445
  }
446
+ restrictedFindingKeys = new Set();
447
+ recordRestrictedFinding(key, finding) {
448
+ if (this.restrictedFindingKeys.has(key))
449
+ return;
450
+ this.restrictedFindingKeys.add(key);
451
+ this.inboundFindings.push(finding);
452
+ }
453
+ restrictedIdlessScope;
454
+ restrictedIdlessBaseline;
455
+ readScopeCommitted(scope, audit) {
456
+ const dir = this.scopeDir(scope);
457
+ const isRoot = dir === this.directoryRoot;
458
+ const files = scanEntryFiles(dir, {
459
+ exclude: isRoot ? this.excludedSubdirNames(scope) : undefined,
460
+ onSkip: audit
461
+ ? (p, kind) => {
462
+ if (kind !== "unreadable")
463
+ return;
464
+ const rel = relative(this.directoryRoot, p);
465
+ this.recordRestrictedFinding(`unreadable|${rel}`, {
466
+ path: rel,
467
+ code: "unreadable",
468
+ reason: "the path could not be read (lstat/readdir failed) — the restricted-session divergence audit is incomplete under it",
469
+ });
470
+ }
471
+ : undefined,
472
+ });
473
+ this.ledger = undefined;
474
+ const ledger = this.loadLedger();
475
+ const idlessSeen = audit && scope === this.restrictedIdlessScope ? new Set() : undefined;
476
+ const entries = [];
477
+ for (const f of files) {
478
+ const rel = relative(this.directoryRoot, f.path);
479
+ let text;
480
+ try {
481
+ text = readFileSync(f.path, "utf8");
482
+ }
483
+ catch {
484
+ if (audit) {
485
+ this.recordRestrictedFinding(`unreadable|${rel}`, {
486
+ path: rel,
487
+ code: "unreadable",
488
+ reason: "memory entry file could not be read — it is missing from this restricted session's view",
489
+ });
490
+ }
491
+ continue;
492
+ }
493
+ const parsed = parseEntryFile(text);
494
+ if (parsed.id === undefined) {
495
+ idlessSeen?.add(rel);
496
+ continue;
497
+ }
498
+ const entry = entryFromFile(text, parsed.id, f.slug, scope);
499
+ const committed = ledger[entry.id];
500
+ if (committed === entry.rev) {
501
+ if (audit && this.readCommittedShadow(entry.id) === undefined) {
502
+ try {
503
+ atomicWriteFileSync(this.shadowPath(entry.id), text);
504
+ }
505
+ catch {
506
+ }
507
+ }
508
+ entries.push(entry);
509
+ continue;
510
+ }
511
+ const shadowText = committed !== undefined ? this.readCommittedShadow(entry.id) : undefined;
512
+ if (shadowText !== undefined) {
513
+ const shadowEntry = entryFromFile(shadowText, entry.id, f.slug, scope);
514
+ if (shadowEntry.rev === entry.rev) {
515
+ entries.push(entry);
516
+ continue;
517
+ }
518
+ const otherProjection = this.idProjectionElsewhere(entry.id, f.path);
519
+ if (otherProjection !== undefined) {
520
+ if (audit) {
521
+ const finding = this.inboundGate(rel, text);
522
+ if (finding !== undefined) {
523
+ this.containInboundReject(finding, f.path, text, entry.id, undefined);
524
+ }
525
+ else {
526
+ this.recordRestrictedFinding(`dupserve|${rel}|${entry.rev}`, {
527
+ path: rel,
528
+ code: "restricted_divergence",
529
+ reason: `this file carries the id of a committed entry that has another projection (${otherProjection}) — the committed copy may only stand in for an entry's unique projection, so this copy is withheld; an unrestricted session's harvest adjudicates it`,
530
+ });
531
+ }
532
+ }
533
+ continue;
534
+ }
535
+ if (audit) {
536
+ const finding = this.inboundGate(rel, text);
537
+ if (finding !== undefined) {
538
+ this.containInboundReject(finding, f.path, text, entry.id, committed);
539
+ }
540
+ else {
541
+ this.recordRestrictedFinding(`divergence|${rel}|${entry.rev}`, {
542
+ path: rel,
543
+ code: "restricted_divergence",
544
+ reason: "on-disk content diverges from the committed entry with no backend transaction backing it; this adoption-restricted session serves the committed content and leaves the disk state pending — an unrestricted session can adopt it normally",
545
+ });
546
+ }
547
+ }
548
+ entries.push(shadowEntry);
549
+ continue;
550
+ }
551
+ if (committed !== undefined) {
552
+ if (audit) {
553
+ this.recordRestrictedFinding(`legacy|${rel}|${entry.rev}`, {
554
+ path: rel,
555
+ code: "restricted_divergence",
556
+ reason: "on-disk content diverges from a committed entry that has no committed shadow copy; this adoption-restricted session withholds the entry (nothing trustworthy to serve) — an unrestricted session can adjudicate it",
557
+ });
558
+ }
559
+ continue;
560
+ }
561
+ if (audit) {
562
+ const finding = this.inboundGate(rel, text);
563
+ if (finding !== undefined) {
564
+ this.containInboundReject(finding, f.path, text, entry.id, undefined);
565
+ }
566
+ else {
567
+ this.recordRestrictedFinding(`uncommitted|${rel}|${entry.rev}`, {
568
+ path: rel,
569
+ code: "restricted_divergence",
570
+ reason: "an id-bearing memory file exists on disk that the committed account has never seen; this adoption-restricted session neither adopts nor serves it — it stays on disk for an unrestricted session to adopt normally",
571
+ });
572
+ }
573
+ }
574
+ }
575
+ if (idlessSeen !== undefined && this.restrictedIdlessBaseline === undefined)
576
+ this.restrictedIdlessBaseline = idlessSeen;
577
+ return entries;
578
+ }
579
+ idProjectionElsewhere(id, excludeAbsPath) {
580
+ for (const scope of Object.keys(registeredScopes(this.controlPlaneRoot))) {
581
+ const dir = this.scopeDir(scope);
582
+ const isRoot = dir === this.directoryRoot;
583
+ for (const f of scanEntryFiles(dir, { exclude: isRoot ? this.excludedSubdirNames(scope) : undefined })) {
584
+ if (f.path === excludeAbsPath)
585
+ continue;
586
+ const text = readSafe(f.path);
587
+ if (text === undefined)
588
+ continue;
589
+ if (parseEntryFile(text).id === id)
590
+ return relative(this.directoryRoot, f.path);
591
+ }
592
+ }
593
+ return undefined;
594
+ }
595
+ restrictedAdoptionView(opts) {
596
+ const audit = opts?.audit === true;
597
+ if (audit) {
598
+ this.restrictedIdlessScope = typeof opts?.writeScope === "string" ? opts.writeScope : undefined;
599
+ this.restrictedIdlessBaseline = undefined;
600
+ }
601
+ const refuse = (op) => {
602
+ throw new Error(`memory adoption-restricted view is read-only — ${op} must go through the backend itself`);
603
+ };
604
+ return {
605
+ listHeaders: async (scopes) => this.listHeadersFrom(scopes, (s) => this.readScopeCommitted(s, audit)),
606
+ getByIds: async (ids) => this.getByIdsFrom(ids, (s) => this.readScopeCommitted(s, audit)),
607
+ search: async (query, scopes, o) => this.searchFrom(query, scopes, o, (s) => this.readScopeCommitted(s, audit)),
608
+ applyPatches: async () => refuse("applyPatches"),
609
+ getConsolidationCursor: async (scope) => this.getConsolidationCursor(scope),
610
+ setConsolidationCursor: async () => refuse("setConsolidationCursor"),
611
+ };
612
+ }
613
+ auditRestrictedDivergence(scopes, opts) {
614
+ for (const scope of scopes)
615
+ this.readScopeCommitted(scope, true);
616
+ const idlessBaseline = opts?.idlessWriteScope !== undefined && opts.idlessWriteScope === this.restrictedIdlessScope ? this.restrictedIdlessBaseline : undefined;
617
+ const idlessScope = idlessBaseline !== undefined ? opts?.idlessWriteScope : undefined;
618
+ const ledger = { ...this.loadLedger() };
619
+ const present = new Map();
620
+ let complete = true;
621
+ for (const scope of Object.keys(registeredScopes(this.controlPlaneRoot))) {
622
+ const dir = this.scopeDir(scope);
623
+ const isRoot = dir === this.directoryRoot;
624
+ const files = scanEntryFiles(dir, {
625
+ exclude: isRoot ? this.excludedSubdirNames(scope) : undefined,
626
+ onSkip: (_p, kind) => {
627
+ if (kind === "unreadable")
628
+ complete = false;
629
+ },
630
+ });
631
+ for (const f of files) {
632
+ const text = readSafe(f.path);
633
+ if (text === undefined) {
634
+ complete = false;
635
+ continue;
636
+ }
637
+ const id = parseEntryFile(text).id;
638
+ if (id !== undefined) {
639
+ present.set(id, [...(present.get(id) ?? []), relative(this.directoryRoot, f.path)]);
640
+ continue;
641
+ }
642
+ if (scope === idlessScope && idlessBaseline !== undefined) {
643
+ const rel = relative(this.directoryRoot, f.path);
644
+ if (!idlessBaseline.has(rel)) {
645
+ this.recordRestrictedFinding(`idless|${rel}|${createHash("sha256").update(text, "utf8").digest("hex").slice(0, 16)}`, {
646
+ path: rel,
647
+ code: "restricted_divergence",
648
+ reason: "an id-less file appeared under the session's write scope after materialize, through none of this session's channels: a concurrent session's in-flight material, or an out-of-band drop — left on disk for an unrestricted harvest to adjudicate",
649
+ });
650
+ }
651
+ }
652
+ }
653
+ }
654
+ for (const [id, paths] of present) {
655
+ if (paths.length < 2 || ledger[id] === undefined)
656
+ continue;
657
+ this.recordRestrictedFinding(`dup|${id}|${paths.sort().join(",")}`, {
658
+ path: paths[0] ?? id,
659
+ code: "restricted_divergence",
660
+ reason: `committed memory entry ${id} appears at ${paths.length} paths (${paths.join(", ")}) — a filesystem copy is not a rename; this adoption-restricted session flags it and an unrestricted session's harvest adjudicates which projection is real`,
661
+ });
662
+ }
663
+ if (!complete) {
664
+ this.recordRestrictedFinding(`missing-audit-incomplete`, {
665
+ path: ".",
666
+ code: "restricted_divergence",
667
+ reason: "the divergence audit could not scan every path (unreadable subtree) — missing-entry detection was skipped this pass rather than judged from an incomplete scan",
668
+ });
669
+ return;
670
+ }
671
+ for (const [id, rev] of Object.entries(ledger)) {
672
+ if (present.has(id))
673
+ continue;
674
+ this.ledger = undefined;
675
+ if (this.loadLedger()[id] === undefined)
676
+ continue;
677
+ this.recordRestrictedFinding(`missing|${id}|${rev}`, {
678
+ path: `id:${id}`,
679
+ code: "restricted_divergence",
680
+ reason: `committed memory entry ${id} is missing from disk with no transaction backing the removal; this adoption-restricted session neither restores nor forgets it — an unrestricted session can restore it from the committed copy`,
681
+ });
682
+ }
683
+ }
416
684
  async search(query, scopes, opts) {
417
- return this.searchWith(query, scopes, opts, true);
685
+ return this.searchFrom(query, scopes, opts, (s) => this.readScopeAdopting(s));
418
686
  }
419
- searchWith(query, scopes, opts, adopt) {
687
+ async searchFrom(query, scopes, opts, read) {
420
688
  const limit = opts?.limit ?? 20;
421
689
  const q = termSet(query);
422
690
  if (q.size === 0)
@@ -424,7 +692,7 @@ export class FileMemoryEngineBackend {
424
692
  const scored = [];
425
693
  for (const scope of scopes) {
426
694
  const dir = this.scopeDir(scope);
427
- for (const e of this.readScope(scope, adopt)) {
695
+ for (const e of await read(scope)) {
428
696
  const haystack = `${e.frontmatter.name ?? e.slug} ${e.frontmatter.description ?? ""} ${e.body}`;
429
697
  const d = jaccardDistance(q, haystack);
430
698
  if (d === null)
@@ -111,6 +111,17 @@ export declare function resolveMemoryEngineRoot(explicit?: string): string;
111
111
  * collision (two distinct scopes → one dir name), so even a hash collision cannot silently merge scopes.
112
112
  */
113
113
  export declare function scopeDirName(scope: string): string;
114
+ /**
115
+ * Whether `dir`'s volume FOLDS letter case — two names differing only in case address ONE
116
+ * directory entry (the macOS/Windows default). Probed on the actual directory, never guessed from
117
+ * the platform: case-sensitive APFS volumes and case-insensitive Linux mounts both exist, and this
118
+ * answer gates a fail-closed isolation refusal, so it must describe the volume the scope dirs
119
+ * really land on — a platform guess would fail OPEN exactly on the mismatched deployments.
120
+ * `undefined` = the volume answered on neither leg (unwritable AND no letter-bearing entry to read
121
+ * against); the caller must treat that as unresolved, never as either determined answer.
122
+ * Exported for the layout pins; not part of the package surface.
123
+ */
124
+ export declare function dirCaseFolds(dir: string): boolean | undefined;
114
125
  /** Which scope owns the memory dir ROOT (undefined = unclaimed). */
115
126
  export declare function rootScopeOf(controlDir: string): string | undefined;
116
127
  /**
@@ -121,8 +132,26 @@ export declare function rootScopeOf(controlDir: string): string | undefined;
121
132
  export declare function claimRootScope(controlDir: string, scope: string): string;
122
133
  /** Register `scope` in the durable registry (idempotent) and return its home dir under `memoryDir`.
123
134
  * H5 fail-closed: a dir-name collision with a DIFFERENT registered scope throws — two scopes must
124
- * never share one directory (silent cross-scope merge). Lock-serialized (独立轨 F5). */
125
- export declare function registerScope(memoryDir: string, controlDir: string, scope: string): string;
135
+ * never share one directory (silent cross-scope merge). The collision test has TWO arms:
136
+ * - exact: two scopes whose labels are the SAME string (hash collision / forged registry);
137
+ * - case-fold (2026-08-12): two labels differing ONLY in letter case are distinct strings to this
138
+ * registry yet ONE physical directory on a case-folding volume — cross-scope bleed the exact arm
139
+ * cannot see. The fold arm compares the FINAL dir labels (ASCII by construction, so toLowerCase
140
+ * is an exact fold) and only fires when the volume actually folds — probed lazily on a fold-equal
141
+ * candidate ({@link dirCaseFolds}), so the no-collision path never pays the probe and a
142
+ * case-SENSITIVE volume keeps hosting case-differing scope pairs as the genuinely distinct
143
+ * directories they are there. An UNPROBEABLE volume (neither probe leg answers) refuses the
144
+ * fold-equal pair too — an unresolved semantics guess would fail open exactly where the guard
145
+ * is needed. `opts.caseFoldingFs` overrides the probe for callers (and pins) that already
146
+ * measured the volume's semantics. The scope→dir MAPPING itself is unchanged on purpose:
147
+ * folding scopeDirName would re-home every existing mixed-case scope's directory.
148
+ * Both arms compare the scope's EFFECTIVE label — the registered one when present (a registered
149
+ * scope's home NEVER moves; entries live there), the formula only for a never-registered scope —
150
+ * so a registry whose stored labels drifted from the current formula is judged by the directories
151
+ * it actually serves, not by labels nobody uses. Lock-serialized (独立轨 F5). */
152
+ export declare function registerScope(memoryDir: string, controlDir: string, scope: string, opts?: {
153
+ caseFoldingFs?: boolean;
154
+ }): string;
126
155
  /** All registered scopes → home dir name (`""` = the root). */
127
156
  export declare function registeredScopes(controlDir: string): Record<string, string>;
128
157
  /** The stable home directory of `scope`'s entry files: the root when it owns the root, else the