@sema-agent/core 5.26.0 → 5.27.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 (34) hide show
  1. package/CHANGELOG.md +49 -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 +39 -4
  8. package/dist/core/hooks.js +15 -12
  9. package/dist/core/memory-engine/engine.d.ts +8 -5
  10. package/dist/core/memory-engine/engine.js +20 -6
  11. package/dist/core/memory-engine/file-backend.d.ts +81 -0
  12. package/dist/core/memory-engine/file-backend.js +250 -24
  13. package/dist/core/memory-engine/types.d.ts +8 -1
  14. package/dist/core/memory-vector.d.ts +6 -1
  15. package/dist/core/memory-vector.js +14 -4
  16. package/dist/core/memory.js +1 -6
  17. package/dist/core/permission-rule-model.d.ts +70 -5
  18. package/dist/core/permission-rule-model.js +58 -0
  19. package/dist/core/runner/prepare-memory.js +14 -9
  20. package/dist/core/runner/prepare-task.d.ts +9 -3
  21. package/dist/core/runner/prepare-task.js +29 -8
  22. package/dist/core/runner/runtask.d.ts +8 -1
  23. package/dist/core/task-registry-agent.d.ts +4 -3
  24. package/dist/core/task-registry.d.ts +6 -3
  25. package/dist/core/tool-policy.d.ts +9 -2
  26. package/dist/core/types.d.ts +35 -7
  27. package/dist/engine/loop/types.d.ts +10 -3
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.js +1 -1
  30. package/dist/orchestration/run-workflow-tool.d.ts +5 -3
  31. package/dist/orchestration/workflow.d.ts +9 -6
  32. package/dist/stores/file/checkpoint-store.d.ts +2 -1
  33. package/dist/stores/file/index.d.ts +1 -1
  34. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,54 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.27.0 — 2026-08-11
4
+
5
+ No API-BREAKING changes (exports grow only). Narrowings are tighten-direction and named below.
6
+
7
+ ### Narrowed (behavior, ruled)
8
+
9
+ - **A declared-unavailable session reads the committed memory view.** Under
10
+ `memoryPersistenceCapable: false`, materialize/search/harvest serve the committed account
11
+ (ledger + shadow): disk divergence with no transaction backing is neither adopted nor served —
12
+ it stays on disk, is loudly reported (`restricted_divergence`, a new `HarvestRejectionCode`
13
+ member — consumers pinning that enum as a closed set must add the row), and a later unrestricted
14
+ session adopts it through the normal gates. Legitimate flows are untouched: user hand-edits and
15
+ git-pull drops keep working (deferred, not destroyed), a concurrent writable session's committed
16
+ changes pass as transaction-backed, and **a writeScope-null plane (org layers default read-only;
17
+ the dual-root non-write plane) keeps its ordinary adopt-on-read semantics** — the restriction is
18
+ the SESSION's declaration, never the plane's structure.
19
+ - **Rule suggestions appear only where a persisted rule could actually work.** Mandated
20
+ (`shellGate:"always"` / tool marks / org / requiresRealApproval), shadowed, hook-originated,
21
+ inherited-unresolved, ancestor-resolved (a live parent approver), and anonymous-task (no
22
+ principal, no local-owner declaration) asks no longer carry `ruleSuggestions` — a card whose rule
23
+ could never silence it stops offering to mint one. Classifier-tier and ordinary policy asks keep
24
+ their suggestions.
25
+ - **Known residual (registered)**: bytes refused by a restricted session remain on disk and are
26
+ adoptable by a later unrestricted session — the alternative (destroying bytes indistinguishable
27
+ from user edits) is ruled out; the boundary is "the restricted session is never polluted and
28
+ tampering is always visible", not "the bytes can never enter".
29
+
30
+ ### Added
31
+
32
+ - **Prefix rule candidates**: the suggestion engine mints a reviewed PREFIX option beside the exact
33
+ one for lexicon-listed command bodies (`git push origin main` → optional `Bash(git push:*)`),
34
+ longest word-boundary hit, no fallback; candidate arrays grow from ≤1 to ≤2 with `exact` fixed at
35
+ index 0 (a documented contract across `AskRequest.ruleSuggestions`, checkpoint park rows, and the
36
+ approval card record). Consumers reading `[0]` unconditionally only miss the new option.
37
+ - **CJK lexical retrieval**: `termSet` now mints CJK character bigrams and unigrams beside
38
+ alphanumeric runs — a pure-Chinese memory entry was previously INVISIBLE to the File backend's
39
+ lexical rung (empty term set); the class is written in explicit escapes (a literal compat
40
+ ideograph NFC-folds to its unified twin, silently widening the range).
41
+ - **`toolResultContentSegment` exported** from the package root (the digest segment the engine's
42
+ own mints carry — consumers derive it instead of inventing a placeholder).
43
+ - `MemoryEngine.materialize` accepts an optional `adoptionRestricted` verdict;
44
+ `MemorySessionHandle.adoptionRestricted` reports it (both additive).
45
+
46
+ ### Fixed
47
+
48
+ - Three loaded-suite timing margins widened without losing discrimination; the defectscan
49
+ guardrail audit skips a concurrently-vanished temp dir instead of throwing; 24 stale doc/JSDoc
50
+ claims restated to actual behavior (bidirectional sweep + rescan trio).
51
+
3
52
  ## 5.26.0 — 2026-08-11
4
53
 
5
54
  No API-BREAKING changes (exports grow only; new members optional/additive). Several
@@ -37,8 +37,11 @@ export interface AgentTranscriptToolOptions {
37
37
  * into a child's tail when the residual isn't enough.
38
38
  *
39
39
  * Safety: reads are bounded to the parent's OWN lineage — the same non-leaking registry access scope as
40
- * SendMessage/TaskOutput (an unknown or out-of-scope id reads identically to "not found"). Only a
41
- * RETAINED child's session survives to be read; a throwaway child returns an honest "not retained".
40
+ * SendMessage/TaskOutput (an unknown or out-of-scope id reads identically to "not found"). Two read
41
+ * paths keep a child's session reachable: the retain ledger (a RETAINED child), and the durable
42
+ * agent-store fallback (`agentStore` wired + accessible row + preserved `sessionId` — readable even
43
+ * with retain off/expired/evicted). Only when BOTH miss does the call return the honest
44
+ * "not retained" / "not found".
42
45
  */
43
46
  export declare function createAgentTranscriptTool(opts: AgentTranscriptToolOptions): import("../internal/harness-types.js").AgentTool<Type.TObject<{
44
47
  id: Type.TString;
@@ -16,7 +16,8 @@ export function createAgentTranscriptTool(opts) {
16
16
  description: `Read the recent tool steps of a background agent you launched (task_id 'a…' from the Agent tool with ` +
17
17
  `run_in_background), so you can see precisely where it got to — useful before SendMessage-ing a follow-up, ` +
18
18
  `or to inspect a stopped agent's tail. Returns the last N steps (tool, target, outcome); it does not re-run ` +
19
- `the agent. Only works for agents this run retained (retainSubagentSessions); otherwise you get an honest error.`,
19
+ `the agent. Works for agents this run retained (retainSubagentSessions) and for agents whose durable ` +
20
+ `record preserved their transcript session; when neither holds you get an honest error.`,
20
21
  parameters: Type.Object({
21
22
  id: Type.String({ description: "The background agent's task_id (a…) returned by the Agent tool." }),
22
23
  lastN: Type.Optional(Type.Integer({ minimum: 1, maximum: AGENT_TRANSCRIPT_MAX_N, description: `How many recent steps to return (default ${AGENT_TRANSCRIPT_DEFAULT_N}).` })),
@@ -19,7 +19,10 @@ export interface SendMessageToolOptions {
19
19
  owner?: string;
20
20
  scope?: string;
21
21
  sessionId?: string;
22
- /** Deployment-level background-notify sink the resumed run's completion rides the existing chain. */
22
+ /** Completion-notify sink for the resumed run. The Runner mount wires the SENDING run's own
23
+ * notification injector here (its runtask-wrapped `injectTaskNotification`), so the completion
24
+ * notice lands in the sender's live injection queue at a turn boundary; a direct mount may wire a
25
+ * deployment-level background-notify sink instead. Absent ⇒ completion is silent. */
23
26
  notify?: (n: TaskNotificationPayload, opts?: {
24
27
  priority?: "now" | "next" | "later";
25
28
  }) => void;
@@ -361,8 +361,11 @@ export interface SubagentSteerHandle {
361
361
  * before any await; rolled back on pre-launch failure) → session exists AND non-empty (r1-m1).
362
362
  * Then: fenced revive prompt (unpredictable marker) →
363
363
  * `runTaskStream` on the FROZEN spawn snapshot + `requireExistingSession` (spec is tighten-only: the caller
364
- * contributes ONLY `content`) → ledger `activeRun` bookkeeping → completion via the deployment-level
365
- * background-notify sink ONLY (no sink ⇒ silent; NEVER the parent-model notification lane — r1-M5).
364
+ * contributes ONLY `content`) → ledger `activeRun` bookkeeping → completion announced through
365
+ * `deps.notify` (no sink ⇒ silent). Which lane
366
+ * that is depends on the mount: the background Agent lane wires the deployment-level background-notify
367
+ * sink, while the SendMessage mount wires the SENDING run's own notification injector — there the
368
+ * completion notice lands in the sender's live injection queue at its next turn boundary.
366
369
  */
367
370
  export declare function createSubagentResume(deps: {
368
371
  ledger: SubagentRetainLedger | undefined;
@@ -574,8 +574,13 @@ export type PendingAction = {
574
574
  /** design/179 §4 (additive) — the persistable allow-rule forms this parked call could be covered by,
575
575
  * minted from the SAME post-hook args the row executes. Advisory display metadata for the approval
576
576
  * inbox's "stop asking me this" option; never read by the resume path, and never a rule by itself
577
- * (minting one goes through the approval-record protocol). Absent when no rule lane is armed or the
578
- * command is one the lane cannot speak for. */
577
+ * (minting one goes through the approval-record protocol). Absent when no rule lane is armed, the
578
+ * command is one the lane cannot speak for, or the parked ask is one a persisted rule could not
579
+ * clear (mandated marks / a real-approval demand / a shadowed matching rule).
580
+ * CONTRACT (same as the synchronous `AskRequest.ruleSuggestions`) — array order is display order,
581
+ * narrowest first: the EXACT form is always index 0, a broader reviewed PREFIX form (at most one)
582
+ * follows; basis ≤ 2, and selection/ticket indices are keyed against this order. A row persisted
583
+ * before the prefix arm existed carries ≤ 1 entry and stays valid under the same reading. */
579
584
  ruleSuggestions?: readonly import("./permission-rule-model.js").RuleSuggestion[];
580
585
  /**
581
586
  * design/80 D-1 §2 (slice 1a.2): the server-minted **opaque** boundInputHash of {@link args} — a
@@ -1,10 +1,10 @@
1
1
  import type { DocumentContent, ImageContent, TextContent } from "../internal/llm.js";
2
2
  import type { ExecutionEnv, FileError, Result, SessionTreeEntry } from "../internal/harness-types.js";
3
- import type { PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
3
+ import type { DecisionReason, PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
4
4
  import { type AskClass } from "./ask-class.js";
5
5
  /**
6
- * In-process hook seam (design/37) — a small, provider-agnostic interception layer modeled on CC's
7
- * hooks but reduced to three process-internal callbacks (no shell/HTTP executors, no settings files):
6
+ * In-process hook seam (design/37) — a provider-agnostic interception layer modeled on CC's hooks,
7
+ * kept process-internal (no shell/HTTP executors, no settings files). The original tool-call trio:
8
8
  *
9
9
  * - {@link Hooks.preToolUse} runs before a tool executes. It may **rewrite** the args (an `allow` with
10
10
  * `updatedInput`), **restrict** the call (`deny`/`ask`), and/or inject `additionalContext`. It is a
@@ -15,6 +15,13 @@ import { type AskClass } from "./ask-class.js";
15
15
  * - {@link Hooks.userPromptSubmit} runs before the objective becomes a user message. It may `block`
16
16
  * submission (the task fails with a model-readable reason) or inject `additionalContext` ahead of it.
17
17
  *
18
+ * The seam has since grown well past the trio — each later member carries its own contract below:
19
+ * {@link Hooks.stop} / {@link Hooks.stopFailure} (run-end pushback / API-error terminal observer),
20
+ * {@link Hooks.postToolUseFailure} and {@link Hooks.postToolBatch} (failure + batch-boundary taps),
21
+ * {@link Hooks.preCompact} / {@link Hooks.postCompact} (compaction gate + observer),
22
+ * {@link Hooks.permissionDenied} (deny observer), and the {@link Hooks.preToolUseObservational}
23
+ * declaration flag.
24
+ *
18
25
  * All feedback the model should see (a block reason, injected context) is wrapped as a
19
26
  * `<system-reminder>` via {@link formatHookFeedback} so the model can read and self-correct.
20
27
  */
@@ -611,7 +618,12 @@ export interface ToolGateInput {
611
618
  realApproval?: import("./checkpoint-store.js").RealApprovalGateBit,
612
619
  /** #144: the matched-but-outranked persisted rule (the surviving ask's `persistedRuleShadowed`)
613
620
  * — threaded so the park mint's risk descriptor carries the disclosure on the durable route. */
614
- shadowedRule?: string) => Promise<ToolGateResult["suspend"] | ParkAttemptFailed | undefined>;
621
+ shadowedRule?: string,
622
+ /** The surviving ask's `decisionReason` — threaded so the park mint's rule-suggestion door can
623
+ * read the ask's origin on the durable route exactly as the synchronous mints read it off the
624
+ * decision (a hook-raised ask is one no persisted rule may clear, so its parked card must not
625
+ * offer to mint one). */
626
+ askDecisionReason?: DecisionReason) => Promise<ToolGateResult["suspend"] | ParkAttemptFailed | undefined>;
615
627
  /**
616
628
  * design/174 — route a policy `ask` on the reserved question tool to this run's CONTENT-ask channel
617
629
  * before it can become a park or a refusal. Called in the `ask` branch with the FINAL post-hook,
@@ -871,6 +883,29 @@ export interface ToolGateInput {
871
883
  * reach the same lane from inside a descendant's fold.
872
884
  */
873
885
  export declare function createPreToolUseConstraintPolicy(preToolUse: NonNullable<Hooks["preToolUse"]>, env?: HookEnvCapabilities, onCrash?: (err: unknown) => void): ToolPolicy;
886
+ /**
887
+ * The mandate provenance of one call, judged from the SAME mark inputs the gate is driven with —
888
+ * the single source for "could a persisted allow rule clear this ask?". Allow rules silence the
889
+ * classifier's questions, never a mandated one, and this predicate is the mandated-family half of
890
+ * that boundary (the real-approval/governance half rides the decision's own `requiresRealApproval`
891
+ * bit, which the org layer stamps):
892
+ * · `egress` — the tool's own external-write mark, judged FIRST: it is the tool's declaration even
893
+ * when the coarse doctrine also installed a shell tier on the same seat;
894
+ * · `shellGated` + tier `"always"` — the operator's per-call confirmation doctrine
895
+ * (`"operator_always"`); the classify doctrine installs `"maybe"`, and THOSE asks stay the rule
896
+ * lane's home turf (`undefined`);
897
+ * · a tool's OWN `"always"`/`"maybe"` irreversibility tier without the doctrine (`"tool_marks"`).
898
+ *
899
+ * Two consumers, one derivation: the gate's silencing arm (a matching rule is disclosed as shadowed
900
+ * instead of clearing the ask) and the runner's suggestion factory (a mandated ask offers no
901
+ * "stop asking me this" option — a rule minted from it would never clear it). A drift between the
902
+ * two would let a card offer a rule the lane then refuses to honor.
903
+ */
904
+ export declare function persistedRuleMandateOf(marks: {
905
+ egress?: boolean;
906
+ shellGated?: boolean;
907
+ irreversibility?: "never" | "maybe" | "always";
908
+ }): "operator_always" | "tool_marks" | undefined;
874
909
  /**
875
910
  * The design/37 **two-phase tool gate** — the single chokepoint that makes the load-bearing invariant
876
911
  * structural ("a hook's `allow` cannot bypass the policy's `deny`/`ask`"):
@@ -161,6 +161,17 @@ function withProbeTimeout(p, ms, signal) {
161
161
  p.then((v) => done(resolve, v), (err) => done(reject, err));
162
162
  });
163
163
  }
164
+ export function persistedRuleMandateOf(marks) {
165
+ return marks.egress === true
166
+ ? "tool_marks"
167
+ : marks.shellGated === true
168
+ ? marks.irreversibility === "always"
169
+ ? "operator_always"
170
+ : undefined
171
+ : marks.irreversibility === "always" || marks.irreversibility === "maybe"
172
+ ? "tool_marks"
173
+ : undefined;
174
+ }
164
175
  export async function runToolGate(input) {
165
176
  const { event, preToolUse, adjudicate, resolveAsk, suspendAsk } = input;
166
177
  const { toolCallId, toolName } = event;
@@ -315,15 +326,7 @@ export async function runToolGate(input) {
315
326
  currentInput = policyRewrite;
316
327
  req.args = policyRewrite;
317
328
  }
318
- const persistedRuleMandate = input.egress === true
319
- ? "tool_marks"
320
- : input.shellGated === true
321
- ? input.irreversibility === "always"
322
- ? "operator_always"
323
- : undefined
324
- : input.irreversibility === "always" || input.irreversibility === "maybe"
325
- ? "tool_marks"
326
- : undefined;
329
+ const persistedRuleMandate = persistedRuleMandateOf(input);
327
330
  if (input.persistedRules &&
328
331
  !orgRealApprovalRequired &&
329
332
  decision.action === "ask" &&
@@ -408,7 +411,7 @@ export async function runToolGate(input) {
408
411
  ? { origin: orgAskOrigin !== undefined ? `org_${orgAskOrigin}` : "policy" }
409
412
  : undefined;
410
413
  if (suspendAsk && decision.action === "ask") {
411
- const suspended = await suspendAsk(req, currentInput, safety, undefined, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined);
414
+ const suspended = await suspendAsk(req, currentInput, safety, undefined, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined, decision.action === "ask" ? decision.decisionReason : undefined);
412
415
  if (suspended) {
413
416
  if ("parkFailed" in suspended)
414
417
  parkFailed = suspended.parkFailed;
@@ -427,7 +430,7 @@ export async function runToolGate(input) {
427
430
  req.args = outcome.presentedInput;
428
431
  }
429
432
  if (suspendAsk && outcome.parkDeclined && parkFailed === undefined) {
430
- const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined);
433
+ const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined, decision.action === "ask" ? decision.decisionReason : undefined);
431
434
  if (suspended) {
432
435
  if ("parkFailed" in suspended)
433
436
  parkFailed = suspended.parkFailed;
@@ -455,7 +458,7 @@ export async function runToolGate(input) {
455
458
  const resolved = await resolveAsk(decision, req);
456
459
  decision = resolved;
457
460
  if (resolved.action === "deny" && resolved.approverUnavailable === true && suspendAsk && parkFailed === undefined) {
458
- const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(askBeforeResolve), askBeforeResolve.action === "ask" ? askBeforeResolve.persistedRuleShadowed : undefined);
461
+ const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(askBeforeResolve), askBeforeResolve.action === "ask" ? askBeforeResolve.persistedRuleShadowed : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.decisionReason : undefined);
459
462
  if (suspended) {
460
463
  if ("parkFailed" in suspended)
461
464
  parkFailed = suspended.parkFailed;
@@ -321,7 +321,9 @@ export declare class MemoryEngine {
321
321
  * eventId anchors on the pollution record (sessionId + marked-at), so however many times this
322
322
  * settlement replays the same pollution event, the generation is allocated once. */
323
323
  private settlePromotions;
324
- materialize(scopes: readonly string[], writeScope: string | null): Promise<MemorySessionHandle>;
324
+ materialize(scopes: readonly string[], writeScope: string | null, opts?: {
325
+ adoptionRestricted?: boolean;
326
+ }): Promise<MemorySessionHandle>;
325
327
  inject(handle: MemorySessionHandle, opts?: {
326
328
  writeToolMounted?: boolean;
327
329
  }): MemoryInjection;
@@ -365,10 +367,11 @@ export declare class MemoryEngine {
365
367
  * @param keepBaseline RB-440 (2026-07-31, hardening probe P3) — relative paths whose baseline must NOT
366
368
  * advance: the files a DEGRADED harvest deferred (`report.degraded.pending`).
367
369
  *
368
- * The deferral contract at :659-665 promises "the REST is deferred to the next harvest (still on disk),
369
- * never dropped", but execution then continued into this scan, which re-baselined EVERY file carrying an
370
- * id the pending ones included writing the CURRENT disk rev into `handle.baseRevs`. The next harvest
371
- * hit the unchanged-baseline fast path at :679 and skipped the file FOREVER: the deferred edit never
370
+ * The deferral contract in the harvest scan (the budget/deadline arm see `harvestDeadlineMs`)
371
+ * promises "the REST is deferred to the next harvest (still on disk), never dropped", but execution
372
+ * then continued into this scan, which re-baselined EVERY file carrying an id the pending ones
373
+ * included — writing the CURRENT disk rev into `handle.baseRevs`. The next harvest hit the harvest
374
+ * scan's unchanged-baseline fast path and skipped the file FOREVER: the deferred edit never
372
375
  * became a patch. Session-NEW files escaped it (no id ⇒ `continue` below), which is exactly why the
373
376
  * existing budget test passed. Measured with a copy-out backend: harvest#1 {update:1} + pending ["b.md"],
374
377
  * harvest#2 {update:0}, and the backend still held v1 — so the next `materialize` wrote the stale text
@@ -323,9 +323,13 @@ export class MemoryEngine {
323
323
  catch {
324
324
  }
325
325
  }
326
- async materialize(scopes, writeScope) {
326
+ async materialize(scopes, writeScope, opts) {
327
327
  ensureDirExists(this.memoryDir);
328
328
  ensureDirExists(this.controlDir);
329
+ const restricted = opts?.adoptionRestricted === true;
330
+ const readBackend = restricted
331
+ ? (this.backend.restrictedAdoptionView?.({ audit: true }) ?? this.backend)
332
+ : this.backend;
329
333
  if (writeScope !== null)
330
334
  claimRootScope(this.controlDir, writeScope);
331
335
  const scopeDirs = new Map();
@@ -336,8 +340,8 @@ export class MemoryEngine {
336
340
  for (const [scope, dir] of scopeDirs)
337
341
  this.chmodScopeTree(dir, 0o755, 0o644, { excludeTopDirs: this.siblingScopeDirNames(dir, scope) });
338
342
  const allScopes = [...scopeDirs.keys()];
339
- const headers = await this.backend.listHeaders(allScopes);
340
- const entries = await this.backend.getByIds(headers.map((h) => h.id));
343
+ const headers = await readBackend.listHeaders(allScopes);
344
+ const entries = await readBackend.getByIds(headers.map((h) => h.id));
341
345
  const byScope = new Map();
342
346
  for (const e of entries) {
343
347
  const arr = byScope.get(e.scope) ?? [];
@@ -357,6 +361,7 @@ export class MemoryEngine {
357
361
  repoIndexTargets: new Map(),
358
362
  indexBaselineLines: 0,
359
363
  indexText: "",
364
+ ...(restricted ? { adoptionRestricted: true } : {}),
360
365
  };
361
366
  if (writeScope !== null)
362
367
  ensureDirExists(handle.writableRoot);
@@ -382,7 +387,8 @@ export class MemoryEngine {
382
387
  const { deleted: _stubDrop, ...stubFm } = entry.frontmatter;
383
388
  const text = stub ? serializeEntryFile({ id: entry.id, frontmatter: stubFm, body: STUB_ARCHIVED_LINE }) : fullText;
384
389
  const target = join(dir, `${entry.slug}.md`);
385
- this.writeIfChanged(target, text);
390
+ if (!(zeroCopy && restricted))
391
+ this.writeIfChanged(target, text);
386
392
  const onDiskRev = stub ? revOfText(text, entry.id) : entry.rev;
387
393
  const path = canonicalize(target);
388
394
  handle.materialized.push({
@@ -415,7 +421,7 @@ export class MemoryEngine {
415
421
  this.discloseAnnounceFailure("index-gate enqueue", err);
416
422
  }
417
423
  }
418
- const indexText = this.rebuildIndex(handle, headers, { write: writeScope !== null, ignoreOnDisk: indexGate !== undefined });
424
+ const indexText = this.rebuildIndex(handle, headers, { write: writeScope !== null && !restricted, ignoreOnDisk: indexGate !== undefined || restricted });
419
425
  handle.indexBaselineLines = countIndexLines(indexText);
420
426
  handle.indexText = indexText;
421
427
  for (const [scope, dir] of scopeDirs) {
@@ -433,7 +439,7 @@ export class MemoryEngine {
433
439
  const instruction = writeChannel ? buildMemoryInstruction(handle.writableRoot) : "";
434
440
  const readOnlyNotice = handle.writeScope === null ? MEMORY_READONLY_NOTICE : undefined;
435
441
  const indexPath = join(handle.writableRoot, MEMORY_INDEX_FILENAME);
436
- const onDisk = handle.indexOnDiskUntrusted === true ? undefined : readSafe(indexPath);
442
+ const onDisk = handle.indexOnDiskUntrusted === true || handle.adoptionRestricted === true ? undefined : readSafe(indexPath);
437
443
  const indexText = onDisk !== undefined && onDisk.trim() !== "" ? onDisk : handle.indexText;
438
444
  const truncated = truncateIndex(indexText);
439
445
  const index = composeMemoryBlock(truncated, handle.writeScope ?? handle.scopes[0] ?? "memory");
@@ -529,6 +535,14 @@ export class MemoryEngine {
529
535
  };
530
536
  const writeScope = handle.writeScope;
531
537
  if (writeScope === null || opts?.admitNothing !== undefined) {
538
+ try {
539
+ this.backend.auditRestrictedDivergence?.([
540
+ ...new Set([...handle.scopes, ...(writeScope !== null ? [writeScope] : [])]),
541
+ ]);
542
+ }
543
+ catch (err) {
544
+ report.warnings.push(`restricted-session divergence audit failed: ${err instanceof Error ? err.message : String(err)}`);
545
+ }
532
546
  const roFindings = this.backend.drainInboundFindings?.();
533
547
  if (roFindings !== undefined && roFindings.length > 0)
534
548
  report.inboundFindings = roFindings;
@@ -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;
@@ -142,8 +151,10 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
142
151
  private readScope;
143
152
  listHeaders(scopes: readonly string[]): Promise<MemoryEntryHeader[]>;
144
153
  private listHeadersWith;
154
+ private listHeadersFrom;
145
155
  getByIds(ids: readonly string[]): Promise<MemoryEntry[]>;
146
156
  private getByIdsWith;
157
+ private getByIdsFrom;
147
158
  /**
148
159
  * design/178 §3 (ruled 2026-08-08) — a SIDE-EFFECT-FREE projection of this backend for the session's
149
160
  * RETRIEVAL face (`memory_search` / `memory_get`).
@@ -162,11 +173,81 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
162
173
  * WRITES ARE REFUSED, loudly — a view handed to a write path is a defect, not a fallback.
163
174
  */
164
175
  retrievalView(): MemoryBackend;
176
+ /** Dedup keys for divergence findings the restricted view already recorded on THIS instance: one
177
+ * materialize reads a scope several times (listHeaders, then getByIds per scope), and a
178
+ * checkpoint-cadence harvest re-audits the same still-pending state — without the key, one
179
+ * untouched divergence would mint a finding per read. Per-process on purpose (no durable state to
180
+ * lifecycle: a cross-process resume re-discloses once, which is the desired "still pending"
181
+ * notice); a divergence whose bytes CHANGE mints a new key and is reported again. */
182
+ private restrictedFindingKeys;
183
+ private recordRestrictedFinding;
184
+ /**
185
+ * The ADOPTION-RESTRICTED read of one scope: serve the COMMITTED account (ledger + control-plane
186
+ * shadow) and never advance it. This is the tamper boundary for a session with no write intent:
187
+ * the ordinary `readScope(sync=true)` ADOPTS disk divergence as the new committed baseline
188
+ * regardless of session intent, so a byte landed by a side channel (e.g. a shell write under a
189
+ * session whose persistence is declared unavailable, or into a read-only layering) would be
190
+ * committed by the very session that was told it cannot persist — its own checkpoint-resume
191
+ * materialize included. Here instead:
192
+ * - disk rev == committed rev ⇒ served as-is (it IS the committed content);
193
+ * - divergence whose bytes equal the committed SHADOW ⇒ served as-is: the shadow is written at
194
+ * the applyPatches commit point, so matching it is transactional proof that a concurrent
195
+ * writable session committed this state and only this call's ledger snapshot raced it —
196
+ * never treated as tampering;
197
+ * - other divergence of a shadow-backed entry ⇒ the SHADOW (committed content) is served; the
198
+ * divergent disk bytes stay in place UNLESS they fail the inbound gate (the existing
199
+ * quarantine door, unchanged under restriction). Scan-clean divergence is never quarantined,
200
+ * overwritten, or adopted — it stays on disk as deferred external state a later unrestricted
201
+ * session adjudicates normally;
202
+ * - a divergent entry with NO shadow (shadow-less legacy), and a never-committed id-bearing
203
+ * file, have no committed content to serve ⇒ WITHHELD with a loud finding, never served from
204
+ * disk, never adopted.
205
+ * The ledger is reloaded from disk on every call (a stale cache would misread a concurrent commit
206
+ * as divergence) and NEVER written here; the only writes this path may perform are the
207
+ * gate-failing containment above and a shadow backfill for a rev-matching entry (recording
208
+ * committed content — the account itself does not move).
209
+ *
210
+ * `audit` picks the mount: true = the engine's materialize/harvest boundary (findings recorded,
211
+ * gate-failing divergence contained); false = the retrieval-tool face (a PURE committed read —
212
+ * no findings, no containment, no writes: a model lookup must be side-effect-free).
213
+ */
214
+ private readScopeCommitted;
215
+ /** Does `id` have a projection file anywhere in the store OTHER than `excludeAbsPath`? Returns
216
+ * that projection's rel path, or undefined. Early-exits on the first hit; only consulted on the
217
+ * rare divergent-with-shadow path (the scope-binding guard above), never on clean reads. */
218
+ private idProjectionElsewhere;
219
+ /**
220
+ * The ADOPTION-RESTRICTED projection of this backend (see {@link readScopeCommitted}) — what a
221
+ * session with no write intent materializes and retrieves through. The CALLER decides restriction
222
+ * (an explicit verdict: a read-only layering, or persistence declared unavailable) and mounts this
223
+ * view; the backend never guesses a session's intent from its own state.
224
+ * - `audit: true` (the engine's materialize/harvest mount): divergence findings are recorded for
225
+ * the harvest report + announcement queue, and gate-FAILING divergence is contained by the
226
+ * existing quarantine door;
227
+ * - `audit: false` (the retrieval-tool mount): a pure committed-view read — no findings, no
228
+ * containment, no writes.
229
+ * WRITES ARE REFUSED loudly — same posture as {@link retrievalView}.
230
+ */
231
+ restrictedAdoptionView(opts?: {
232
+ audit?: boolean;
233
+ }): MemoryBackend;
234
+ /**
235
+ * The harvest-boundary divergence sweep for an adoption-restricted session (the engine's
236
+ * zero-admission harvest arm calls this BEFORE draining findings): re-run the committed-view audit
237
+ * over the session's scopes — bytes landed AFTER materialize (a mid-session shell write) are
238
+ * detected here, at the very boundary where an unrestricted harvest would have admitted them —
239
+ * then check the committed account for entries MISSING from disk entirely (an unattributed delete
240
+ * is divergence too). Ledger rows carry no scope, so presence is judged across every registered
241
+ * scope, the same resolution `getByIds` uses; a rename or cross-scope move therefore never reads
242
+ * as missing. Non-destructive throughout: nothing is restored, forgotten, or adopted here.
243
+ */
244
+ auditRestrictedDivergence(scopes: readonly string[]): void;
165
245
  /** Lexical floor (design/138 §2.1): `1 - Jaccard` distance over name+description+body, ascending. */
166
246
  search(query: string, scopes: readonly string[], opts?: {
167
247
  limit?: number;
168
248
  }): Promise<ScoredMemoryEntry[]>;
169
249
  private searchWith;
250
+ private searchFrom;
170
251
  /**
171
252
  * H4 — the entry transaction, staged and journaled:
172
253
  * 1. RELOAD the ledger from disk (验收 M2: a concurrently-committed transaction from another