@sema-agent/core 5.26.0 → 5.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/dist/agents/agent-transcript-tool.d.ts +5 -2
  3. package/dist/agents/agent-transcript-tool.js +2 -1
  4. package/dist/agents/send-message-tool.d.ts +4 -1
  5. package/dist/agents/subagent.d.ts +5 -2
  6. package/dist/core/checkpoint-store.d.ts +7 -2
  7. package/dist/core/hooks.d.ts +61 -4
  8. package/dist/core/hooks.js +37 -15
  9. package/dist/core/memory-engine/engine.d.ts +8 -5
  10. package/dist/core/memory-engine/engine.js +18 -6
  11. package/dist/core/memory-engine/file-backend.d.ts +144 -4
  12. package/dist/core/memory-engine/file-backend.js +304 -36
  13. package/dist/core/memory-engine/layout.d.ts +31 -2
  14. package/dist/core/memory-engine/layout.js +132 -8
  15. package/dist/core/memory-engine/types.d.ts +9 -1
  16. package/dist/core/memory-vector.d.ts +6 -1
  17. package/dist/core/memory-vector.js +14 -4
  18. package/dist/core/memory.js +1 -6
  19. package/dist/core/permission-rule-consent.d.ts +82 -8
  20. package/dist/core/permission-rule-consent.js +92 -1
  21. package/dist/core/permission-rule-model.d.ts +87 -6
  22. package/dist/core/permission-rule-model.js +79 -0
  23. package/dist/core/permission-rule-org.d.ts +22 -3
  24. package/dist/core/permission-rule-org.js +67 -20
  25. package/dist/core/permission-rule-store.js +2 -2
  26. package/dist/core/permission-rule-sync.d.ts +15 -1
  27. package/dist/core/permission-rule-sync.js +89 -47
  28. package/dist/core/runner/prepare-memory.js +14 -9
  29. package/dist/core/runner/prepare-task.d.ts +9 -3
  30. package/dist/core/runner/prepare-task.js +37 -11
  31. package/dist/core/runner/runtask.d.ts +8 -1
  32. package/dist/core/runner/runtask.js +8 -1
  33. package/dist/core/task-registry-agent.d.ts +13 -3
  34. package/dist/core/task-registry-agent.js +51 -21
  35. package/dist/core/task-registry-monitor.js +1 -1
  36. package/dist/core/task-registry-shared.d.ts +9 -0
  37. package/dist/core/task-registry.d.ts +6 -3
  38. package/dist/core/tool-policy.d.ts +44 -4
  39. package/dist/core/tool-policy.js +37 -3
  40. package/dist/core/tool-result-store.d.ts +108 -7
  41. package/dist/core/tool-result-store.js +95 -15
  42. package/dist/core/types.d.ts +115 -17
  43. package/dist/core/types.js +30 -1
  44. package/dist/engine/loop/types.d.ts +10 -3
  45. package/dist/index.d.ts +2 -2
  46. package/dist/index.js +1 -1
  47. package/dist/orchestration/run-workflow-tool.d.ts +5 -3
  48. package/dist/orchestration/workflow.d.ts +9 -6
  49. package/dist/stores/file/checkpoint-store.d.ts +2 -1
  50. package/dist/stores/file/index.d.ts +1 -1
  51. package/dist/stores/file/tool-result-store.d.ts +41 -1
  52. package/dist/stores/file/tool-result-store.js +107 -19
  53. package/dist/tools/fs/fs-bash.d.ts +7 -0
  54. package/dist/tools/fs/fs-shared.d.ts +5 -0
  55. package/dist/tools/fs/fs-shared.js +11 -7
  56. package/dist/tools/fs/index.d.ts +6 -0
  57. package/dist/tools/fs/index.js +2 -0
  58. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,118 @@
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
+
67
+ ## 5.27.0 — 2026-08-11
68
+
69
+ No API-BREAKING changes (exports grow only). Narrowings are tighten-direction and named below.
70
+
71
+ ### Narrowed (behavior, ruled)
72
+
73
+ - **A declared-unavailable session reads the committed memory view.** Under
74
+ `memoryPersistenceCapable: false`, materialize/search/harvest serve the committed account
75
+ (ledger + shadow): disk divergence with no transaction backing is neither adopted nor served —
76
+ it stays on disk, is loudly reported (`restricted_divergence`, a new `HarvestRejectionCode`
77
+ member — consumers pinning that enum as a closed set must add the row), and a later unrestricted
78
+ session adopts it through the normal gates. Legitimate flows are untouched: user hand-edits and
79
+ git-pull drops keep working (deferred, not destroyed), a concurrent writable session's committed
80
+ changes pass as transaction-backed, and **a writeScope-null plane (org layers default read-only;
81
+ the dual-root non-write plane) keeps its ordinary adopt-on-read semantics** — the restriction is
82
+ the SESSION's declaration, never the plane's structure.
83
+ - **Rule suggestions appear only where a persisted rule could actually work.** Mandated
84
+ (`shellGate:"always"` / tool marks / org / requiresRealApproval), shadowed, hook-originated,
85
+ inherited-unresolved, ancestor-resolved (a live parent approver), and anonymous-task (no
86
+ principal, no local-owner declaration) asks no longer carry `ruleSuggestions` — a card whose rule
87
+ could never silence it stops offering to mint one. Classifier-tier and ordinary policy asks keep
88
+ their suggestions.
89
+ - **Known residual (registered)**: bytes refused by a restricted session remain on disk and are
90
+ adoptable by a later unrestricted session — the alternative (destroying bytes indistinguishable
91
+ from user edits) is ruled out; the boundary is "the restricted session is never polluted and
92
+ tampering is always visible", not "the bytes can never enter".
93
+
94
+ ### Added
95
+
96
+ - **Prefix rule candidates**: the suggestion engine mints a reviewed PREFIX option beside the exact
97
+ one for lexicon-listed command bodies (`git push origin main` → optional `Bash(git push:*)`),
98
+ longest word-boundary hit, no fallback; candidate arrays grow from ≤1 to ≤2 with `exact` fixed at
99
+ index 0 (a documented contract across `AskRequest.ruleSuggestions`, checkpoint park rows, and the
100
+ approval card record). Consumers reading `[0]` unconditionally only miss the new option.
101
+ - **CJK lexical retrieval**: `termSet` now mints CJK character bigrams and unigrams beside
102
+ alphanumeric runs — a pure-Chinese memory entry was previously INVISIBLE to the File backend's
103
+ lexical rung (empty term set); the class is written in explicit escapes (a literal compat
104
+ ideograph NFC-folds to its unified twin, silently widening the range).
105
+ - **`toolResultContentSegment` exported** from the package root (the digest segment the engine's
106
+ own mints carry — consumers derive it instead of inventing a placeholder).
107
+ - `MemoryEngine.materialize` accepts an optional `adoptionRestricted` verdict;
108
+ `MemorySessionHandle.adoptionRestricted` reports it (both additive).
109
+
110
+ ### Fixed
111
+
112
+ - Three loaded-suite timing margins widened without losing discrimination; the defectscan
113
+ guardrail audit skips a concurrently-vanished temp dir instead of throwing; 24 stale doc/JSDoc
114
+ claims restated to actual behavior (bidirectional sweep + rescan trio).
115
+
3
116
  ## 5.26.0 — 2026-08-11
4
117
 
5
118
  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
  */
@@ -410,6 +417,28 @@ export interface HookToolOutput {
410
417
  /**
411
418
  * A PreToolUse hook result: a {@link PermissionResult} (so a hook can `deny`/`ask`, or `allow` with an
412
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.
413
442
  */
414
443
  export type PreToolUseResult = PermissionResult & {
415
444
  additionalContext?: string;
@@ -611,7 +640,12 @@ export interface ToolGateInput {
611
640
  realApproval?: import("./checkpoint-store.js").RealApprovalGateBit,
612
641
  /** #144: the matched-but-outranked persisted rule (the surviving ask's `persistedRuleShadowed`)
613
642
  * — threaded so the park mint's risk descriptor carries the disclosure on the durable route. */
614
- shadowedRule?: string) => Promise<ToolGateResult["suspend"] | ParkAttemptFailed | undefined>;
643
+ shadowedRule?: string,
644
+ /** The surviving ask's `decisionReason` — threaded so the park mint's rule-suggestion door can
645
+ * read the ask's origin on the durable route exactly as the synchronous mints read it off the
646
+ * decision (a hook-raised ask is one no persisted rule may clear, so its parked card must not
647
+ * offer to mint one). */
648
+ askDecisionReason?: DecisionReason) => Promise<ToolGateResult["suspend"] | ParkAttemptFailed | undefined>;
615
649
  /**
616
650
  * design/174 — route a policy `ask` on the reserved question tool to this run's CONTENT-ask channel
617
651
  * before it can become a park or a refusal. Called in the `ask` branch with the FINAL post-hook,
@@ -871,6 +905,29 @@ export interface ToolGateInput {
871
905
  * reach the same lane from inside a descendant's fold.
872
906
  */
873
907
  export declare function createPreToolUseConstraintPolicy(preToolUse: NonNullable<Hooks["preToolUse"]>, env?: HookEnvCapabilities, onCrash?: (err: unknown) => void): ToolPolicy;
908
+ /**
909
+ * The mandate provenance of one call, judged from the SAME mark inputs the gate is driven with —
910
+ * the single source for "could a persisted allow rule clear this ask?". Allow rules silence the
911
+ * classifier's questions, never a mandated one, and this predicate is the mandated-family half of
912
+ * that boundary (the real-approval/governance half rides the decision's own `requiresRealApproval`
913
+ * bit, which the org layer stamps):
914
+ * · `egress` — the tool's own external-write mark, judged FIRST: it is the tool's declaration even
915
+ * when the coarse doctrine also installed a shell tier on the same seat;
916
+ * · `shellGated` + tier `"always"` — the operator's per-call confirmation doctrine
917
+ * (`"operator_always"`); the classify doctrine installs `"maybe"`, and THOSE asks stay the rule
918
+ * lane's home turf (`undefined`);
919
+ * · a tool's OWN `"always"`/`"maybe"` irreversibility tier without the doctrine (`"tool_marks"`).
920
+ *
921
+ * Two consumers, one derivation: the gate's silencing arm (a matching rule is disclosed as shadowed
922
+ * instead of clearing the ask) and the runner's suggestion factory (a mandated ask offers no
923
+ * "stop asking me this" option — a rule minted from it would never clear it). A drift between the
924
+ * two would let a card offer a rule the lane then refuses to honor.
925
+ */
926
+ export declare function persistedRuleMandateOf(marks: {
927
+ egress?: boolean;
928
+ shellGated?: boolean;
929
+ irreversibility?: "never" | "maybe" | "always";
930
+ }): "operator_always" | "tool_marks" | undefined;
874
931
  /**
875
932
  * The design/37 **two-phase tool gate** — the single chokepoint that makes the load-bearing invariant
876
933
  * structural ("a hook's `allow` cannot bypass the policy's `deny`/`ask`"):
@@ -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
  }
@@ -161,6 +162,17 @@ function withProbeTimeout(p, ms, signal) {
161
162
  p.then((v) => done(resolve, v), (err) => done(reject, err));
162
163
  });
163
164
  }
165
+ export function persistedRuleMandateOf(marks) {
166
+ return marks.egress === true
167
+ ? "tool_marks"
168
+ : marks.shellGated === true
169
+ ? marks.irreversibility === "always"
170
+ ? "operator_always"
171
+ : undefined
172
+ : marks.irreversibility === "always" || marks.irreversibility === "maybe"
173
+ ? "tool_marks"
174
+ : undefined;
175
+ }
164
176
  export async function runToolGate(input) {
165
177
  const { event, preToolUse, adjudicate, resolveAsk, suspendAsk } = input;
166
178
  const { toolCallId, toolName } = event;
@@ -209,13 +221,31 @@ export async function runToolGate(input) {
209
221
  if ((decision.action === "allow" || decision.action === "ask") && decision.updatedInput !== undefined) {
210
222
  policyRewrite = decision.updatedInput;
211
223
  }
212
- else if (hookAsk?.action === "ask" && hookAsk.updatedInput !== undefined) {
224
+ else if (hookAsk?.updatedInput !== undefined) {
213
225
  policyRewrite = hookAsk.updatedInput;
214
226
  }
215
227
  if (decision.action === "allow" && hookAsk) {
216
- 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
+ };
217
235
  denySource = "hook";
218
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
+ }
219
249
  if (input.egress && decision.action === "allow") {
220
250
  decision = {
221
251
  action: "ask",
@@ -315,15 +345,7 @@ export async function runToolGate(input) {
315
345
  currentInput = policyRewrite;
316
346
  req.args = policyRewrite;
317
347
  }
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;
348
+ const persistedRuleMandate = persistedRuleMandateOf(input);
327
349
  if (input.persistedRules &&
328
350
  !orgRealApprovalRequired &&
329
351
  decision.action === "ask" &&
@@ -408,7 +430,7 @@ export async function runToolGate(input) {
408
430
  ? { origin: orgAskOrigin !== undefined ? `org_${orgAskOrigin}` : "policy" }
409
431
  : undefined;
410
432
  if (suspendAsk && decision.action === "ask") {
411
- const suspended = await suspendAsk(req, currentInput, safety, undefined, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined);
433
+ const suspended = await suspendAsk(req, currentInput, safety, undefined, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined, decision.action === "ask" ? decision.decisionReason : undefined);
412
434
  if (suspended) {
413
435
  if ("parkFailed" in suspended)
414
436
  parkFailed = suspended.parkFailed;
@@ -427,7 +449,7 @@ export async function runToolGate(input) {
427
449
  req.args = outcome.presentedInput;
428
450
  }
429
451
  if (suspendAsk && outcome.parkDeclined && parkFailed === undefined) {
430
- const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined);
452
+ const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined, decision.action === "ask" ? decision.decisionReason : undefined);
431
453
  if (suspended) {
432
454
  if ("parkFailed" in suspended)
433
455
  parkFailed = suspended.parkFailed;
@@ -455,7 +477,7 @@ export async function runToolGate(input) {
455
477
  const resolved = await resolveAsk(decision, req);
456
478
  decision = resolved;
457
479
  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);
480
+ const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(askBeforeResolve), askBeforeResolve.action === "ask" ? askBeforeResolve.persistedRuleShadowed : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.decisionReason : undefined);
459
481
  if (suspended) {
460
482
  if ("parkFailed" in suspended)
461
483
  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, writeScope }) ?? 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,12 @@ 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?.([...new Set([...handle.scopes, ...(writeScope !== null ? [writeScope] : [])])], handle.adoptionRestricted === true && writeScope !== null ? { idlessWriteScope: writeScope } : undefined);
540
+ }
541
+ catch (err) {
542
+ report.warnings.push(`restricted-session divergence audit failed: ${err instanceof Error ? err.message : String(err)}`);
543
+ }
532
544
  const roFindings = this.backend.drainInboundFindings?.();
533
545
  if (roFindings !== undefined && roFindings.length > 0)
534
546
  report.inboundFindings = roFindings;