@sema-agent/core 5.27.0 → 5.29.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 (99) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/dist/agents/cascade.d.ts +1 -1
  3. package/dist/agents/observer.d.ts +3 -3
  4. package/dist/agents/peer-admission.d.ts +1 -1
  5. package/dist/agents/teacher.d.ts +1 -1
  6. package/dist/agents/verify.d.ts +6 -6
  7. package/dist/bench/metrics.d.ts +1 -1
  8. package/dist/brain/retry.d.ts +1 -1
  9. package/dist/core/arg-summary.d.ts +1 -1
  10. package/dist/core/ask-class.d.ts +2 -2
  11. package/dist/core/ask-question.d.ts +1 -1
  12. package/dist/core/ask-question.js +14 -1
  13. package/dist/core/background-shell.d.ts +5 -5
  14. package/dist/core/checkpoint-store.d.ts +14 -14
  15. package/dist/core/file-snapshot-store.d.ts +1 -1
  16. package/dist/core/hooks.d.ts +25 -3
  17. package/dist/core/hooks.js +22 -3
  18. package/dist/core/human-input-projection.d.ts +2 -2
  19. package/dist/core/memory-admission.d.ts +2 -2
  20. package/dist/core/memory-engine/engine.d.ts +1 -1
  21. package/dist/core/memory-engine/engine.js +2 -4
  22. package/dist/core/memory-engine/file-backend.d.ts +68 -9
  23. package/dist/core/memory-engine/file-backend.js +69 -27
  24. package/dist/core/memory-engine/header-hints.d.ts +1 -1
  25. package/dist/core/memory-engine/layout.d.ts +32 -3
  26. package/dist/core/memory-engine/layout.js +132 -8
  27. package/dist/core/memory-engine/types.d.ts +6 -5
  28. package/dist/core/memory-recall.d.ts +1 -1
  29. package/dist/core/memory.d.ts +2 -2
  30. package/dist/core/oracle-isolation.d.ts +2 -2
  31. package/dist/core/permission-rule-consent.d.ts +83 -9
  32. package/dist/core/permission-rule-consent.js +92 -1
  33. package/dist/core/permission-rule-model.d.ts +18 -2
  34. package/dist/core/permission-rule-model.js +21 -0
  35. package/dist/core/permission-rule-org.d.ts +23 -4
  36. package/dist/core/permission-rule-org.js +67 -20
  37. package/dist/core/permission-rule-store.d.ts +1 -1
  38. package/dist/core/permission-rule-store.js +2 -2
  39. package/dist/core/permission-rule-sync.d.ts +15 -1
  40. package/dist/core/permission-rule-sync.js +89 -47
  41. package/dist/core/runner/active-skill-scope.d.ts +1 -1
  42. package/dist/core/runner/memory-consolidation.d.ts +1 -1
  43. package/dist/core/runner/prepare-task.d.ts +8 -3
  44. package/dist/core/runner/prepare-task.js +22 -7
  45. package/dist/core/runner/runtask.d.ts +1 -1
  46. package/dist/core/runner/runtask.js +13 -6
  47. package/dist/core/runner/session-rule-policy.d.ts +2 -2
  48. package/dist/core/runner/teardown-bounded.d.ts +1 -1
  49. package/dist/core/runner/tool-disclosure.d.ts +1 -1
  50. package/dist/core/scheduler.d.ts +4 -4
  51. package/dist/core/session-policy-store.d.ts +3 -3
  52. package/dist/core/shared-memory/normalize.d.ts +1 -1
  53. package/dist/core/skill-tool-specifier.d.ts +1 -1
  54. package/dist/core/task-notification.d.ts +2 -2
  55. package/dist/core/task-registry-agent.d.ts +18 -9
  56. package/dist/core/task-registry-agent.js +51 -21
  57. package/dist/core/task-registry-monitor.js +1 -1
  58. package/dist/core/task-registry-shared.d.ts +13 -4
  59. package/dist/core/tool-errors.d.ts +2 -2
  60. package/dist/core/tool-policy.d.ts +78 -12
  61. package/dist/core/tool-policy.js +74 -7
  62. package/dist/core/tool-result-store.d.ts +109 -8
  63. package/dist/core/tool-result-store.js +95 -15
  64. package/dist/core/trace.d.ts +1 -1
  65. package/dist/core/types.d.ts +92 -22
  66. package/dist/core/types.js +30 -1
  67. package/dist/core/untrusted-text.d.ts +6 -0
  68. package/dist/core/untrusted-text.js +1 -0
  69. package/dist/engine/compaction/compaction.d.ts +1 -1
  70. package/dist/engine/harness/messages.d.ts +1 -1
  71. package/dist/engine/harness/types.d.ts +2 -2
  72. package/dist/engine/llm/types.d.ts +1 -1
  73. package/dist/engine/loop/types.d.ts +2 -2
  74. package/dist/engine/session/import-validate.d.ts +1 -1
  75. package/dist/engine/session/log-digest.d.ts +1 -1
  76. package/dist/fixtures/index.d.ts +18 -2
  77. package/dist/fixtures/index.js +11 -0
  78. package/dist/index.d.ts +2 -2
  79. package/dist/orchestration/goal.d.ts +1 -1
  80. package/dist/orchestration/workflow-types.d.ts +5 -4
  81. package/dist/orchestration/workflow.js +11 -3
  82. package/dist/prompt-assembly/artifact.d.ts +1 -1
  83. package/dist/prompts/default.d.ts +1 -1
  84. package/dist/prompts/default.js +1 -1
  85. package/dist/scenarios/scenario-registry.d.ts +3 -3
  86. package/dist/stores/file/tool-result-store.d.ts +43 -3
  87. package/dist/stores/file/tool-result-store.js +107 -19
  88. package/dist/tools/fs/bash-readonly-classifier.d.ts +2 -2
  89. package/dist/tools/fs/fs-bash.d.ts +8 -1
  90. package/dist/tools/fs/fs-search-tools.d.ts +1 -1
  91. package/dist/tools/fs/fs-shared.d.ts +6 -1
  92. package/dist/tools/fs/fs-shared.js +11 -7
  93. package/dist/tools/fs/index.d.ts +6 -0
  94. package/dist/tools/fs/index.js +2 -0
  95. package/dist/tools/fs/safety.d.ts +1 -1
  96. package/dist/tools/scheduler-tools.d.ts +1 -1
  97. package/dist/tools/task-list.d.ts +1 -1
  98. package/dist/tools/web.d.ts +3 -3
  99. package/package.json +1 -1
@@ -37,7 +37,7 @@ export declare const MEMORY_RECALL_DISCIPLINE = "Before answering questions abou
37
37
  * CC-verbatim `# Memory` instruction stays byte-identical to its sha256-pinned capture. Appended
38
38
  * only when a WRITE channel exists (prepare-memory gates it) — the lines it teaches are frontmatter
39
39
  * the MODEL writes; the engine has no write arm for them (writing usage evidence for the model
40
- * would forge the very account the three-tier discipline forbids forging).
40
+ * would self-declare the very account the three-tier discipline forbids forging).
41
41
  */
42
42
  export declare const MEMORY_PREFERENCE_DISCIPLINE = "When the user confirms a stored preference or fact still holds, refresh that entry's `last-confirmed: <YYYY-MM-DD>` frontmatter line (add it when absent). When you save a preference, add an `applies-when: <context>` frontmatter line naming when it applies. Both are plain frontmatter lines \u2014 write them yourself; nothing fills them in for you.";
43
43
  /**
@@ -328,7 +328,7 @@ export class MemoryEngine {
328
328
  ensureDirExists(this.controlDir);
329
329
  const restricted = opts?.adoptionRestricted === true;
330
330
  const readBackend = restricted
331
- ? (this.backend.restrictedAdoptionView?.({ audit: true }) ?? this.backend)
331
+ ? (this.backend.restrictedAdoptionView?.({ audit: true, writeScope }) ?? this.backend)
332
332
  : this.backend;
333
333
  if (writeScope !== null)
334
334
  claimRootScope(this.controlDir, writeScope);
@@ -536,9 +536,7 @@ export class MemoryEngine {
536
536
  const writeScope = handle.writeScope;
537
537
  if (writeScope === null || opts?.admitNothing !== undefined) {
538
538
  try {
539
- this.backend.auditRestrictedDivergence?.([
540
- ...new Set([...handle.scopes, ...(writeScope !== null ? [writeScope] : [])]),
541
- ]);
539
+ this.backend.auditRestrictedDivergence?.([...new Set([...handle.scopes, ...(writeScope !== null ? [writeScope] : [])])], handle.adoptionRestricted === true && writeScope !== null ? { idlessWriteScope: writeScope } : undefined);
542
540
  }
543
541
  catch (err) {
544
542
  report.warnings.push(`restricted-session divergence audit failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -147,13 +147,48 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
147
147
  * `sync` (read-side callers) adopts a committed file's CURRENT disk rev into the committed-rev
148
148
  * ledger ONLY through the inbound gate (B2: out-of-session edits, e.g. git-pull, are scanned;
149
149
  * a reject is moved to control-plane quarantine and the committed shadow content is restored to
150
- * 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). */
151
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;
152
189
  listHeaders(scopes: readonly string[]): Promise<MemoryEntryHeader[]>;
153
- private listHeadersWith;
154
190
  private listHeadersFrom;
155
191
  getByIds(ids: readonly string[]): Promise<MemoryEntry[]>;
156
- private getByIdsWith;
157
192
  private getByIdsFrom;
158
193
  /**
159
194
  * design/178 §3 (ruled 2026-08-08) — a SIDE-EFFECT-FREE projection of this backend for the session's
@@ -181,6 +216,19 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
181
216
  * notice); a divergence whose bytes CHANGE mints a new key and is reported again. */
182
217
  private restrictedFindingKeys;
183
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?;
184
232
  /**
185
233
  * The ADOPTION-RESTRICTED read of one scope: serve the COMMITTED account (ledger + control-plane
186
234
  * shadow) and never advance it. This is the tamper boundary for a session with no write intent:
@@ -223,13 +271,15 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
223
271
  * view; the backend never guesses a session's intent from its own state.
224
272
  * - `audit: true` (the engine's materialize/harvest mount): divergence findings are recorded for
225
273
  * the harvest report + announcement queue, and gate-FAILING divergence is contained by the
226
- * existing quarantine door;
274
+ * existing quarantine door; `writeScope` (when the session has one) additionally arms the
275
+ * id-less baseline for that scope — see {@link auditRestrictedDivergence};
227
276
  * - `audit: false` (the retrieval-tool mount): a pure committed-view read — no findings, no
228
- * containment, no writes.
277
+ * containment, no writes, no baseline.
229
278
  * WRITES ARE REFUSED loudly — same posture as {@link retrievalView}.
230
279
  */
231
280
  restrictedAdoptionView(opts?: {
232
281
  audit?: boolean;
282
+ writeScope?: string | null;
233
283
  }): MemoryBackend;
234
284
  /**
235
285
  * The harvest-boundary divergence sweep for an adoption-restricted session (the engine's
@@ -240,13 +290,22 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
240
290
  * is divergence too). Ledger rows carry no scope, so presence is judged across every registered
241
291
  * scope, the same resolution `getByIds` uses; a rename or cross-scope move therefore never reads
242
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.
243
301
  */
244
- auditRestrictedDivergence(scopes: readonly string[]): void;
302
+ auditRestrictedDivergence(scopes: readonly string[], opts?: {
303
+ idlessWriteScope?: string;
304
+ }): void;
245
305
  /** Lexical floor (design/138 §2.1): `1 - Jaccard` distance over name+description+body, ascending. */
246
306
  search(query: string, scopes: readonly string[], opts?: {
247
307
  limit?: number;
248
308
  }): Promise<ScoredMemoryEntry[]>;
249
- private searchWith;
250
309
  private searchFrom;
251
310
  /**
252
311
  * H4 — the entry transaction, staged and journaled:
@@ -264,7 +323,7 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
264
323
  applyPatches(patches: readonly NotePatch[]): Promise<PatchReport>;
265
324
  private txnLockDir;
266
325
  /**
267
- * 对抗复审 L1/L2/L3 — the mkdir mutex, hardened (same shape as layout.ts' acquireSidecarLock, but
326
+ * 证伪式复审 L1/L2/L3 — the mkdir mutex, hardened (same shape as layout.ts' acquireSidecarLock, but
268
327
  * fail-closed timings and an async wait):
269
328
  * - L2: every acquisition writes an OWNER TOKEN (`<lock>/owner`, crypto-random — never a
270
329
  * timestamp) immediately after mkdir. Release only removes the lock while the token is still
@@ -279,7 +338,7 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
279
338
  * crashed with `remaining stale > waitMs` no longer strands every waiter in an all-throw window.
280
339
  */
281
340
  private acquireTxnLock;
282
- /** 对抗复审 L2 — 提交点归属复验: called immediately before the JOURNAL write (the persistent commit
341
+ /** 证伪式复审 L2 — 提交点归属复验: called immediately before the JOURNAL write (the persistent commit
283
342
  * point). A holder that stalled past the stale deadline (slow disk / GC pause / long txn) and was
284
343
  * stolen from must ABORT — its staged tmps carry no committed state and the stealer's journal
285
344
  * recovery is now authoritative; committing anyway would overwrite the stealer's transaction. */
@@ -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";
@@ -263,7 +263,7 @@ export class FileMemoryEngineBackend {
263
263
  }
264
264
  return undefined;
265
265
  }
266
- readScope(scope, sync = true) {
266
+ readScope(scope, sync, probeOnly, lockToken) {
267
267
  if (!sync && this.batchScan !== undefined) {
268
268
  const memo = this.batchScan.get(scope);
269
269
  if (memo !== undefined)
@@ -293,6 +293,10 @@ export class FileMemoryEngineBackend {
293
293
  if (ledger) {
294
294
  const committed = ledger[entry.id];
295
295
  if (committed !== entry.rev) {
296
+ if (probeOnly)
297
+ return undefined;
298
+ if (lockToken !== undefined)
299
+ this.assertTxnLockOwnership(lockToken);
296
300
  const relPath = relative(this.directoryRoot, f.path);
297
301
  let finding = this.inboundGate(relPath, text);
298
302
  if (finding === undefined && committed !== undefined) {
@@ -343,8 +347,11 @@ export class FileMemoryEngineBackend {
343
347
  }
344
348
  entries.push(entry);
345
349
  }
346
- if (ledgerChanged)
350
+ if (ledgerChanged) {
351
+ if (lockToken !== undefined)
352
+ this.assertTxnLockOwnership(lockToken);
347
353
  this.saveLedger();
354
+ }
348
355
  if (adoptedExternal.length > 0) {
349
356
  try {
350
357
  enqueueMemoryAnnouncement(this.controlPlaneRoot, {
@@ -377,33 +384,46 @@ export class FileMemoryEngineBackend {
377
384
  this.batchScan.set(scope, entries);
378
385
  return entries;
379
386
  }
380
- async listHeaders(scopes) {
381
- return this.listHeadersWith(scopes, true);
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
+ }
382
405
  }
383
- listHeadersWith(scopes, adopt) {
384
- return this.listHeadersFrom(scopes, (s) => this.readScope(s, adopt));
406
+ async listHeaders(scopes) {
407
+ return this.listHeadersFrom(scopes, (s) => this.readScopeAdopting(s));
385
408
  }
386
- listHeadersFrom(scopes, read) {
409
+ async listHeadersFrom(scopes, read) {
387
410
  const out = [];
388
411
  for (const scope of scopes) {
389
412
  const dir = this.scopeDir(scope);
390
- for (const e of read(scope)) {
413
+ for (const e of await read(scope)) {
391
414
  out.push(headerOf(e, join(dir, `${e.slug}.md`)));
392
415
  }
393
416
  }
394
417
  return out;
395
418
  }
396
419
  async getByIds(ids) {
397
- return this.getByIdsWith(ids, true);
398
- }
399
- getByIdsWith(ids, adopt) {
400
- return this.getByIdsFrom(ids, (s) => this.readScope(s, adopt));
420
+ return this.getByIdsFrom(ids, (s) => this.readScopeAdopting(s));
401
421
  }
402
- getByIdsFrom(ids, read) {
422
+ async getByIdsFrom(ids, read) {
403
423
  const want = new Set(ids);
404
424
  const out = [];
405
425
  for (const scope of Object.keys(registeredScopes(this.controlPlaneRoot))) {
406
- for (const e of read(scope)) {
426
+ for (const e of await read(scope)) {
407
427
  if (want.has(e.id))
408
428
  out.push(e);
409
429
  }
@@ -415,9 +435,9 @@ export class FileMemoryEngineBackend {
415
435
  throw new Error(`memory retrieval view is read-only — ${op} must go through the backend itself`);
416
436
  };
417
437
  return {
418
- listHeaders: async (scopes) => this.listHeadersWith(scopes, false),
419
- getByIds: async (ids) => this.getByIdsWith(ids, false),
420
- 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)),
421
441
  applyPatches: async () => refuse("applyPatches"),
422
442
  getConsolidationCursor: async (scope) => this.getConsolidationCursor(scope),
423
443
  setConsolidationCursor: async () => refuse("setConsolidationCursor"),
@@ -430,6 +450,8 @@ export class FileMemoryEngineBackend {
430
450
  this.restrictedFindingKeys.add(key);
431
451
  this.inboundFindings.push(finding);
432
452
  }
453
+ restrictedIdlessScope;
454
+ restrictedIdlessBaseline;
433
455
  readScopeCommitted(scope, audit) {
434
456
  const dir = this.scopeDir(scope);
435
457
  const isRoot = dir === this.directoryRoot;
@@ -450,6 +472,7 @@ export class FileMemoryEngineBackend {
450
472
  });
451
473
  this.ledger = undefined;
452
474
  const ledger = this.loadLedger();
475
+ const idlessSeen = audit && scope === this.restrictedIdlessScope ? new Set() : undefined;
453
476
  const entries = [];
454
477
  for (const f of files) {
455
478
  const rel = relative(this.directoryRoot, f.path);
@@ -468,8 +491,10 @@ export class FileMemoryEngineBackend {
468
491
  continue;
469
492
  }
470
493
  const parsed = parseEntryFile(text);
471
- if (parsed.id === undefined)
494
+ if (parsed.id === undefined) {
495
+ idlessSeen?.add(rel);
472
496
  continue;
497
+ }
473
498
  const entry = entryFromFile(text, parsed.id, f.slug, scope);
474
499
  const committed = ledger[entry.id];
475
500
  if (committed === entry.rev) {
@@ -547,6 +572,8 @@ export class FileMemoryEngineBackend {
547
572
  }
548
573
  }
549
574
  }
575
+ if (idlessSeen !== undefined && this.restrictedIdlessBaseline === undefined)
576
+ this.restrictedIdlessBaseline = idlessSeen;
550
577
  return entries;
551
578
  }
552
579
  idProjectionElsewhere(id, excludeAbsPath) {
@@ -567,6 +594,10 @@ export class FileMemoryEngineBackend {
567
594
  }
568
595
  restrictedAdoptionView(opts) {
569
596
  const audit = opts?.audit === true;
597
+ if (audit) {
598
+ this.restrictedIdlessScope = typeof opts?.writeScope === "string" ? opts.writeScope : undefined;
599
+ this.restrictedIdlessBaseline = undefined;
600
+ }
570
601
  const refuse = (op) => {
571
602
  throw new Error(`memory adoption-restricted view is read-only — ${op} must go through the backend itself`);
572
603
  };
@@ -579,9 +610,11 @@ export class FileMemoryEngineBackend {
579
610
  setConsolidationCursor: async () => refuse("setConsolidationCursor"),
580
611
  };
581
612
  }
582
- auditRestrictedDivergence(scopes) {
613
+ auditRestrictedDivergence(scopes, opts) {
583
614
  for (const scope of scopes)
584
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;
585
618
  const ledger = { ...this.loadLedger() };
586
619
  const present = new Map();
587
620
  let complete = true;
@@ -602,8 +635,20 @@ export class FileMemoryEngineBackend {
602
635
  continue;
603
636
  }
604
637
  const id = parseEntryFile(text).id;
605
- if (id !== undefined)
638
+ if (id !== undefined) {
606
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
+ }
607
652
  }
608
653
  }
609
654
  for (const [id, paths] of present) {
@@ -637,12 +682,9 @@ export class FileMemoryEngineBackend {
637
682
  }
638
683
  }
639
684
  async search(query, scopes, opts) {
640
- return this.searchWith(query, scopes, opts, true);
641
- }
642
- searchWith(query, scopes, opts, adopt) {
643
- return this.searchFrom(query, scopes, opts, (s) => this.readScope(s, adopt));
685
+ return this.searchFrom(query, scopes, opts, (s) => this.readScopeAdopting(s));
644
686
  }
645
- searchFrom(query, scopes, opts, read) {
687
+ async searchFrom(query, scopes, opts, read) {
646
688
  const limit = opts?.limit ?? 20;
647
689
  const q = termSet(query);
648
690
  if (q.size === 0)
@@ -650,7 +692,7 @@ export class FileMemoryEngineBackend {
650
692
  const scored = [];
651
693
  for (const scope of scopes) {
652
694
  const dir = this.scopeDir(scope);
653
- for (const e of read(scope)) {
695
+ for (const e of await read(scope)) {
654
696
  const haystack = `${e.frontmatter.name ?? e.slug} ${e.frontmatter.description ?? ""} ${e.body}`;
655
697
  const d = jaccardDistance(q, haystack);
656
698
  if (d === null)
@@ -11,7 +11,7 @@
11
11
  * This accessor is the ONLY consumer-side reading: pure, lenient, and closed over bad values —
12
12
  * a malformed line reads as FIELD ABSENT, never an error and never a partial adoption. Nothing in
13
13
  * the engine writes these lines; the model does (the preference-discipline paragraph teaches it),
14
- * because an engine-written confirmation would forge the usage evidence it is supposed to record.
14
+ * because an engine-written confirmation would self-declare the usage evidence it is supposed to record.
15
15
  */
16
16
  /** The parsed v2 hints. Every field is absent unless a well-formed line was found. */
17
17
  export interface V2HeaderHints {
@@ -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 / caller-declared 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
@@ -444,7 +473,7 @@ export declare function appendChallengeEvents(controlDir: string, events: Readon
444
473
  *
445
474
  * `eventCount` rides along for the same reason it does on {@link appendChallengeEvents}: a resolve
446
475
  * APPENDS an event too, so it crosses the size bound exactly as a challenge does (independent
447
- * adversarial review, verified — hanging the disclosure off the challenge writer alone leaves a ledger
476
+ * falsification review, verified — hanging the disclosure off the challenge writer alone leaves a ledger
448
477
  * that crossed the bound on a resolve silent until the next challenge, which may never come).
449
478
  * `undefined` when nothing was appended. */
450
479
  export declare function resolveChallengeEvent(controlDir: string, entryId: string, generation: number, reason: string, now: () => number, requestId?: string): {
@@ -1,5 +1,5 @@
1
- import { closeSync, constants as fsConstants, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
2
- const { O_WRONLY, O_CREAT, O_TRUNC, O_NOFOLLOW } = fsConstants;
1
+ import { closeSync, constants as fsConstants, copyFileSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
2
+ const { O_WRONLY, O_CREAT, O_TRUNC, O_NOFOLLOW, O_EXCL } = fsConstants;
3
3
  import { homedir } from "node:os";
4
4
  import { createHash } from "node:crypto";
5
5
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
@@ -184,6 +184,119 @@ export function scopeDirName(scope) {
184
184
  const hash = createHash("sha256").update(scope, "utf8").digest("hex").slice(0, 6);
185
185
  return `${cleaned}-${hash}`;
186
186
  }
187
+ const caseFoldByDirIdentity = new Map();
188
+ let caseFoldProbeSeq = 0;
189
+ function lstatOrAbsent(p) {
190
+ try {
191
+ return lstatSync(p);
192
+ }
193
+ catch (err) {
194
+ if (err.code === "ENOENT")
195
+ return undefined;
196
+ throw err;
197
+ }
198
+ }
199
+ function probeDirCaseFolds(dir) {
200
+ const name = `.casefold-probe-${process.pid}-${caseFoldProbeSeq++}`;
201
+ const writeLeg = (() => {
202
+ let fd;
203
+ try {
204
+ fd = openSync(join(dir, name), O_WRONLY | O_CREAT | O_EXCL, 0o600);
205
+ }
206
+ catch {
207
+ return undefined;
208
+ }
209
+ let identity;
210
+ try {
211
+ const st = fstatSync(fd);
212
+ identity = { dev: st.dev, ino: st.ino };
213
+ }
214
+ catch {
215
+ }
216
+ finally {
217
+ closeSync(fd);
218
+ }
219
+ try {
220
+ let swapped;
221
+ let orig;
222
+ try {
223
+ swapped = lstatOrAbsent(join(dir, name.toUpperCase()));
224
+ orig = swapped === undefined ? lstatOrAbsent(join(dir, name)) : undefined;
225
+ }
226
+ catch {
227
+ return undefined;
228
+ }
229
+ if (swapped !== undefined) {
230
+ return identity === undefined || (swapped.dev === identity.dev && swapped.ino === identity.ino) ? true : undefined;
231
+ }
232
+ if (orig === undefined || identity === undefined)
233
+ return undefined;
234
+ return orig.dev === identity.dev && orig.ino === identity.ino ? false : undefined;
235
+ }
236
+ finally {
237
+ try {
238
+ rmSync(join(dir, name), { force: true });
239
+ }
240
+ catch {
241
+ }
242
+ }
243
+ })();
244
+ if (writeLeg !== undefined)
245
+ return writeLeg;
246
+ try {
247
+ for (const entry of readdirSync(dir)) {
248
+ if (!/[A-Za-z]/.test(entry))
249
+ continue;
250
+ const swapped = entry.toLowerCase() !== entry ? entry.toLowerCase() : entry.toUpperCase();
251
+ try {
252
+ const orig = lstatOrAbsent(join(dir, entry));
253
+ if (orig === undefined)
254
+ continue;
255
+ const other = lstatOrAbsent(join(dir, swapped));
256
+ const again = lstatOrAbsent(join(dir, entry));
257
+ if (again === undefined || again.dev !== orig.dev || again.ino !== orig.ino)
258
+ continue;
259
+ if (other === undefined)
260
+ return false;
261
+ return other.dev === orig.dev && other.ino === orig.ino;
262
+ }
263
+ catch {
264
+ continue;
265
+ }
266
+ }
267
+ }
268
+ catch {
269
+ }
270
+ return undefined;
271
+ }
272
+ export function dirCaseFolds(dir) {
273
+ let key;
274
+ try {
275
+ ensureDirExists(dir);
276
+ const s = statSync(dir);
277
+ key = `${s.dev}:${s.ino}`;
278
+ }
279
+ catch {
280
+ }
281
+ if (key !== undefined) {
282
+ const cached = caseFoldByDirIdentity.get(key);
283
+ if (cached !== undefined)
284
+ return cached;
285
+ }
286
+ const folds = probeDirCaseFolds(dir);
287
+ if (folds === undefined)
288
+ return undefined;
289
+ try {
290
+ const after = statSync(dir);
291
+ if (key === undefined || key !== `${after.dev}:${after.ino}`)
292
+ return undefined;
293
+ }
294
+ catch {
295
+ return undefined;
296
+ }
297
+ caseFoldByDirIdentity.set(key, folds);
298
+ return folds;
299
+ }
187
300
  function readScopesRecord(controlDir) {
188
301
  const path = join(controlDir, SCOPES_FILE);
189
302
  let raw;
@@ -217,6 +330,11 @@ function readScopesRecord(controlDir) {
217
330
  for (const [k, v] of Object.entries(rec.scopes)) {
218
331
  if (typeof v !== "string")
219
332
  throw new ControlPlaneCorruptError(`scope registry entry ${JSON.stringify(k)} is not a string: ${path}`);
333
+ if ((v === "") !== (k === rec.rootScope)) {
334
+ throw new ControlPlaneCorruptError(v === ""
335
+ ? `scope registry maps ${JSON.stringify(k)} to the root home but rootScope is ${rec.rootScope === undefined ? "unclaimed" : JSON.stringify(rec.rootScope)}: ${path}`
336
+ : `scope registry maps the root scope ${JSON.stringify(k)} to subdir ${JSON.stringify(v)} instead of the root home: ${path}`);
337
+ }
220
338
  }
221
339
  scopes = rec.scopes;
222
340
  }
@@ -255,20 +373,26 @@ export function claimRootScope(controlDir, scope) {
255
373
  return { next: { rootScope: scope, scopes: { ...rec.scopes, [scope]: "" } }, result: scope };
256
374
  });
257
375
  }
258
- export function registerScope(memoryDir, controlDir, scope) {
376
+ export function registerScope(memoryDir, controlDir, scope, opts) {
259
377
  return lockedScopesUpdate(controlDir, (rec) => {
260
- const dirName = rec.rootScope === scope ? "" : scopeDirName(scope);
378
+ const dirName = rec.rootScope === scope ? "" : (rec.scopes?.[scope] ?? scopeDirName(scope));
261
379
  for (const [other, otherDir] of Object.entries(rec.scopes ?? {})) {
262
- if (other !== scope && otherDir !== "" && otherDir === dirName) {
380
+ if (other === scope || otherDir === "")
381
+ continue;
382
+ if (otherDir === dirName) {
263
383
  throw new ControlPlaneCorruptError(`scope directory collision: ${JSON.stringify(scope)} and ${JSON.stringify(other)} both map to ${JSON.stringify(dirName)} — refusing (fail-closed)`);
264
384
  }
385
+ if (dirName !== "" && otherDir.toLowerCase() === dirName.toLowerCase()) {
386
+ const folds = opts?.caseFoldingFs ?? dirCaseFolds(memoryDir);
387
+ if (folds !== false) {
388
+ throw new ControlPlaneCorruptError(`scope directory collision on a case-folding filesystem: ${JSON.stringify(scope)} → ${JSON.stringify(dirName)} and ${JSON.stringify(other)} → ${JSON.stringify(otherDir)} are one physical directory under ${memoryDir}` +
389
+ `${folds === undefined ? " (volume case semantics could not be probed — refusing the fold-equal pair rather than risking a silent cross-scope merge)" : ""} — refusing (fail-closed)`);
390
+ }
391
+ }
265
392
  }
266
393
  if (rec.scopes?.[scope] === undefined) {
267
394
  return { next: { ...rec, scopes: { ...rec.scopes, [scope]: dirName } }, result: dirName === "" ? memoryDir : join(memoryDir, dirName) };
268
395
  }
269
- if (rec.scopes[scope] !== dirName) {
270
- return { result: rec.scopes[scope] === "" ? memoryDir : join(memoryDir, rec.scopes[scope]) };
271
- }
272
396
  return { result: dirName === "" ? memoryDir : join(memoryDir, dirName) };
273
397
  });
274
398
  }
@@ -224,11 +224,12 @@ export interface MemorySessionHandle {
224
224
  * leave the injection path reading them anyway. Absent ⇒ the normal "live file wins" behavior. */
225
225
  indexOnDiskUntrusted?: boolean;
226
226
  /** True ⇔ this session materialized through the ADOPTION-RESTRICTED (committed-view) read face:
227
- * either the plane is a read-only layering (`writeScope === null`) or the caller declared the
228
- * session unable to persist (an explicit verdict never inferred down here). Restricted sessions
229
- * read committed state only (ledger + control-plane shadow); disk divergence with no transaction
230
- * backing is neither adopted into the committed account nor served, and `inject` reads the
231
- * materialize-time index text instead of the live on-disk file. */
227
+ * the caller declared the session unable to persist (an explicit session-level verdict never
228
+ * inferred down here, and never derived from the plane's shape: a read-only layering
229
+ * (`writeScope === null`) keeps its ordinary adopt-on-read semantics and does NOT set this).
230
+ * Restricted sessions read committed state only (ledger + control-plane shadow); disk divergence
231
+ * with no transaction backing is neither adopted into the committed account nor served, and
232
+ * `inject` reads the materialize-time index text instead of the live on-disk file. */
232
233
  adoptionRestricted?: boolean;
233
234
  }
234
235
  /** Stable rejection codes a harvest gate can produce (model-visible gate events — 镜头 I). */
@@ -162,7 +162,7 @@ export declare function composeSelectiveBody(manifestText: string, selected: Mem
162
162
  /** A `(scope, id)` composite key for the multi-scope durable de-dup (design/84 Seam A decision 5). Note ids
163
163
  * are only unique WITHIN a scope, so the cross-scope `priorSurfacedKeys`/`surfacedKeys` set MUST be keyed on
164
164
  * both. Core owns the FORMAT so a caller can't drift the wire format (decision 7) — the encoding is OPAQUE
165
- * and length-prefixes the scope so a scope/id containing the separator cannot forge a collision.
165
+ * and length-prefixes the scope so a scope/id containing the separator cannot self-declare a collision.
166
166
  *
167
167
  * ONE-WAY by design (codex MINOR (d)): the key is only ever PRODUCED here (`surfacedKeys`) and compared by
168
168
  * STRING EQUALITY against the caller's persisted set ({@link LayeredRecallOptions.priorSurfacedKeys}, applied