@sema-agent/core 5.27.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 (39) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/core/hooks.d.ts +22 -0
  3. package/dist/core/hooks.js +22 -3
  4. package/dist/core/memory-engine/engine.js +2 -4
  5. package/dist/core/memory-engine/file-backend.d.ts +66 -7
  6. package/dist/core/memory-engine/file-backend.js +69 -27
  7. package/dist/core/memory-engine/layout.d.ts +31 -2
  8. package/dist/core/memory-engine/layout.js +132 -8
  9. package/dist/core/memory-engine/types.d.ts +6 -5
  10. package/dist/core/permission-rule-consent.d.ts +82 -8
  11. package/dist/core/permission-rule-consent.js +92 -1
  12. package/dist/core/permission-rule-model.d.ts +17 -1
  13. package/dist/core/permission-rule-model.js +21 -0
  14. package/dist/core/permission-rule-org.d.ts +22 -3
  15. package/dist/core/permission-rule-org.js +67 -20
  16. package/dist/core/permission-rule-store.js +2 -2
  17. package/dist/core/permission-rule-sync.d.ts +15 -1
  18. package/dist/core/permission-rule-sync.js +89 -47
  19. package/dist/core/runner/prepare-task.js +8 -3
  20. package/dist/core/runner/runtask.js +8 -1
  21. package/dist/core/task-registry-agent.d.ts +9 -0
  22. package/dist/core/task-registry-agent.js +51 -21
  23. package/dist/core/task-registry-monitor.js +1 -1
  24. package/dist/core/task-registry-shared.d.ts +9 -0
  25. package/dist/core/tool-policy.d.ts +35 -2
  26. package/dist/core/tool-policy.js +37 -3
  27. package/dist/core/tool-result-store.d.ts +108 -7
  28. package/dist/core/tool-result-store.js +95 -15
  29. package/dist/core/types.d.ts +80 -10
  30. package/dist/core/types.js +30 -1
  31. package/dist/index.d.ts +2 -2
  32. package/dist/stores/file/tool-result-store.d.ts +41 -1
  33. package/dist/stores/file/tool-result-store.js +107 -19
  34. package/dist/tools/fs/fs-bash.d.ts +7 -0
  35. package/dist/tools/fs/fs-shared.d.ts +5 -0
  36. package/dist/tools/fs/fs-shared.js +11 -7
  37. package/dist/tools/fs/index.d.ts +6 -0
  38. package/dist/tools/fs/index.js +2 -0
  39. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,69 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.28.0 — 2026-08-12
4
+
5
+ No API-BREAKING changes (exports grow only). Narrowings are tighten-direction and named below.
6
+
7
+ ### Narrowed (behavior, ruled)
8
+
9
+ - **A hook-promoted ask's provenance is stamped, never read back.** Every consumption point keying
10
+ on `decisionReason === "hook"` now reads an engine-stamped value: the fold reconstructs the
11
+ promoted ask from an explicit member whitelist and stamps `"hook"` unconditionally, so a hook
12
+ self-declaring any other word (or extra members like `persistedRuleShadowed`) cannot change how
13
+ the persisted-rule lane, the rule-suggestion factory, or the inherited-frame reuse treat the ask.
14
+ - **A `decisionReason` outside the closed set is refused at the consumption screen.** A
15
+ deployment-authored `ToolPolicy.check` returning an unrecognized word (or routing the field
16
+ through a getter/prototype) is denied fail-closed with the closed set named; plain decisions pass
17
+ by reference. The HOOK seam is exempt by design (the field is a documented non-input there — see
18
+ the stamp above).
19
+ - **The served org snapshot meets the same validator as a fetched one.** A persisted last-known-good
20
+ snapshot carrying a rule the current validator refuses now fails closed during a provider outage
21
+ instead of serving partial policy.
22
+ - **Legacy rule text the current validator refuses no longer stalls sync.** A shape-whole tombstone
23
+ whose TEXT is refused gets the new closed-set reason `invalid_rule_text` — disclosed, quarantined,
24
+ and no longer withholding the whole inbound-adds round (safe: tombstones match byte-identical
25
+ text only, and an add with that text is refused by the same predicate).
26
+ - **Case-colliding scope directories are refused loudly on case-insensitive filesystems.** Two
27
+ scopes differing only by case no longer silently share one physical directory (macOS/Windows);
28
+ collision detection compares folded identity (dev:ino-keyed), with lazy real probing instead of
29
+ platform guessing.
30
+ - **Spill provenance is session-only, and a failing spill degrades instead of poisoning.** The agent
31
+ spill write points stamp `{sessionId}` (matching the offload/budget/compaction form); a failed or
32
+ conflicting spill put defers to a sibling-proven ref or reports honestly — a legacy-shape row can
33
+ no longer make an agent result permanently unreadable (guarded put + generation guard + coherent
34
+ poll snapshot).
35
+
36
+ ### Added
37
+
38
+ - `onNotice` structured notice seat (first two families: bash timeout, MATERIALIZE) — absent keeps
39
+ the `console.warn` text verbatim; a present non-function seat is announced once and falls back
40
+ loudly instead of silencing both channels.
41
+ - `tool_end.errorCode` now reads both discriminator spellings — `details.code` first (string), then
42
+ `details.errorKind` — so loop-thrown error frames are classifiable; `code` wins when both exist.
43
+ - `ToolResultStore.deleteBySession?` (optional member; File + in-memory implementations, not on the
44
+ scoped wrapper, not in the published contract kit) — returns `{ deleted, unattributable }`;
45
+ selection keys on recorded provenance, unowned rows are counted and never deleted. A put refused
46
+ over an orphan sidecar un-publishes its own content (pre-call state restored), and a damaged
47
+ re-published survivor is counted unattributable.
48
+ - Adopt-on-read ledger writes are serialized behind the existing cross-process txn mutex (lock-free
49
+ probe fast path; divergence acquires the mutex, reloads the ledger from disk, and re-verifies
50
+ lock ownership at recovery, side-effect and commit points) — cross-process lost-update closed.
51
+ - `runTaskStream` refuses an out-of-shape `resume` synchronously (a resume without `outcome` used to
52
+ hang `stream.result()` forever).
53
+ - `docs/INTEGRATION-CORE.md` — the core→embedder contract document (event-frame per-key obligations,
54
+ code-vocabulary pointers, TaskResult matrix, resume/decide shapes, store optional-member table);
55
+ maintained per L3 release.
56
+ - Rule-suggestion admission honesty (#174 adjacency): the suggestion factory keys on the stamped
57
+ provenance.
58
+
59
+ ### Fixed
60
+
61
+ - Control-character gate: five bench probe arms now genuinely reach their claimed product paths;
62
+ the published B2 index-truncation curve was corrected to `[0, 0, 0.005, 0.602]` (the old counting
63
+ masked a real N200 loss).
64
+ - `TaskResult.retryAfterMs` and `tool_end.errorCode` JSDoc corrected to match code (two accepted
65
+ codes; the errorKind fallback).
66
+
3
67
  ## 5.27.0 — 2026-08-11
4
68
 
5
69
  No API-BREAKING changes (exports grow only). Narrowings are tighten-direction and named below.
@@ -417,6 +417,28 @@ export interface HookToolOutput {
417
417
  /**
418
418
  * A PreToolUse hook result: a {@link PermissionResult} (so a hook can `deny`/`ask`, or `allow` with an
419
419
  * `updatedInput` rewrite) plus optional `additionalContext` injected into the eventual tool result.
420
+ *
421
+ * **The provenance members are not hook-settable inputs** (#174). `decisionReason` is the ENGINE's account
422
+ * of which layer produced a verdict, and `persistedRuleShadowed` is the gate's own #144 disclosure that a
423
+ * stored rule matched without clearing the ask. The gate stamps both itself — a `decisionReason` on a hook
424
+ * result is DISCARDED (a hook ask always reads `"hook"` downstream), and a `persistedRuleShadowed` is
425
+ * dropped rather than rendered on an approval card as a rule no lane matched.
426
+ *
427
+ * Which of a hook's OTHER members reach the surviving decision depends on which fold arm ran, so read
428
+ * the arm, not a single list: an ask promoted from a policy `allow` is rebuilt from the hook's `message`,
429
+ * `requiresRealApproval` and `updatedInput`, while a hook ask folded BESIDE a policy ask contributes its
430
+ * message and its mandate to the policy's decision and leaves the policy's own rewrite in place (the
431
+ * hook's rewrite still reaches execution — through the gate's captured rewrite, not through the decision
432
+ * object). Both arms are at the `hookAsk` fold in {@link runToolGate}.
433
+ *
434
+ * `additionalContext` is on neither list and never was consumed as a decision member: it rides its own
435
+ * channel — collected into the gate's `preToolContext` as soon as the hook answers, whichever verdict it
436
+ * returned, and drained onto the tool result by the runner. DELIVERY is narrower than collection: the
437
+ * runner stashes the context only for a call that will EXECUTE, because a blocked or suspended call never
438
+ * reaches the result step that drains it and carries its own model-facing reason instead. The context
439
+ * formerly APPEARED on the promoted ask object as an undeclared property, because that fold spread the
440
+ * whole hook result; the concurrent arm never carried it, so the two arms disagreed on a member neither
441
+ * of them reads. Nothing in the engine reads it off a decision.
420
442
  */
421
443
  export type PreToolUseResult = PermissionResult & {
422
444
  additionalContext?: string;
@@ -98,7 +98,7 @@ function preToolUseCrashReason(subject, err) {
98
98
  function screenPreToolUseResult(r) {
99
99
  if (r === undefined)
100
100
  return undefined;
101
- return refuseOutOfContractDecision(r);
101
+ return refuseOutOfContractDecision(r, { reasonIsNonInput: true });
102
102
  }
103
103
  export function createPreToolUseConstraintPolicy(preToolUse, env, onCrash) {
104
104
  return brandPolicyAskClass({
@@ -125,6 +125,7 @@ export function createPreToolUseConstraintPolicy(preToolUse, env, onCrash) {
125
125
  action: "ask",
126
126
  message: decisionText(r) ?? `approval required for "${req.toolName}" (inherited PreToolUse hook)`,
127
127
  decisionReason: "hook",
128
+ ...(r.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
128
129
  ...(r.updatedInput !== undefined ? { updatedInput: r.updatedInput } : {}),
129
130
  };
130
131
  }
@@ -220,13 +221,31 @@ export async function runToolGate(input) {
220
221
  if ((decision.action === "allow" || decision.action === "ask") && decision.updatedInput !== undefined) {
221
222
  policyRewrite = decision.updatedInput;
222
223
  }
223
- else if (hookAsk?.action === "ask" && hookAsk.updatedInput !== undefined) {
224
+ else if (hookAsk?.updatedInput !== undefined) {
224
225
  policyRewrite = hookAsk.updatedInput;
225
226
  }
226
227
  if (decision.action === "allow" && hookAsk) {
227
- decision = { ...hookAsk, decisionReason: hookAsk.decisionReason ?? "hook" };
228
+ decision = {
229
+ action: "ask",
230
+ ...(hookAsk.message !== undefined ? { message: hookAsk.message } : {}),
231
+ decisionReason: "hook",
232
+ ...(hookAsk.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
233
+ ...(hookAsk.updatedInput !== undefined ? { updatedInput: hookAsk.updatedInput } : {}),
234
+ };
228
235
  denySource = "hook";
229
236
  }
237
+ else if (decision.action === "ask" && hookAsk) {
238
+ const hookMessage = hookAsk.message;
239
+ const foldedMessage = hookMessage !== undefined && hookMessage !== decision.message
240
+ ? `${decision.message !== undefined ? `${decision.message} ` : ""}(a PreToolUse hook also asks: ${hookMessage})`
241
+ : decision.message;
242
+ decision = {
243
+ ...decision,
244
+ decisionReason: "hook",
245
+ ...(foldedMessage !== undefined ? { message: foldedMessage } : {}),
246
+ ...(hookAsk.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
247
+ };
248
+ }
230
249
  if (input.egress && decision.action === "allow") {
231
250
  decision = {
232
251
  action: "ask",
@@ -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:
@@ -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)
@@ -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