@sema-agent/core 5.25.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 (62) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/dist/agents/agent-definition.js +5 -0
  3. package/dist/agents/agent-transcript-tool.d.ts +5 -2
  4. package/dist/agents/agent-transcript-tool.js +2 -1
  5. package/dist/agents/send-message-tool.d.ts +4 -1
  6. package/dist/agents/send-message-tool.js +1 -0
  7. package/dist/agents/subagent.d.ts +6 -2
  8. package/dist/agents/subagent.js +5 -0
  9. package/dist/core/checkpoint-store.d.ts +7 -2
  10. package/dist/core/hooks.d.ts +39 -4
  11. package/dist/core/hooks.js +18 -14
  12. package/dist/core/memory-engine/dual-root.js +3 -1
  13. package/dist/core/memory-engine/engine.d.ts +53 -6
  14. package/dist/core/memory-engine/engine.js +43 -11
  15. package/dist/core/memory-engine/file-backend.d.ts +81 -0
  16. package/dist/core/memory-engine/file-backend.js +250 -24
  17. package/dist/core/memory-engine/index.d.ts +1 -1
  18. package/dist/core/memory-engine/index.js +1 -1
  19. package/dist/core/memory-engine/types.d.ts +8 -1
  20. package/dist/core/memory-vector.d.ts +6 -1
  21. package/dist/core/memory-vector.js +14 -4
  22. package/dist/core/memory.js +1 -6
  23. package/dist/core/permission-rule-consent.js +8 -1
  24. package/dist/core/permission-rule-model.d.ts +70 -5
  25. package/dist/core/permission-rule-model.js +58 -0
  26. package/dist/core/runner/compaction-call-options.d.ts +4 -4
  27. package/dist/core/runner/compaction-call-options.js +3 -4
  28. package/dist/core/runner/prepare-memory.d.ts +34 -15
  29. package/dist/core/runner/prepare-memory.js +99 -26
  30. package/dist/core/runner/prepare-task.d.ts +9 -3
  31. package/dist/core/runner/prepare-task.js +60 -15
  32. package/dist/core/runner/runtask.d.ts +8 -1
  33. package/dist/core/store-contracts/tool-result-store-contract.d.ts +6 -0
  34. package/dist/core/store-contracts/tool-result-store-contract.js +24 -0
  35. package/dist/core/task-registry-agent.d.ts +4 -3
  36. package/dist/core/task-registry-agent.js +3 -3
  37. package/dist/core/task-registry-monitor.js +6 -5
  38. package/dist/core/task-registry.d.ts +6 -3
  39. package/dist/core/tool-policy.d.ts +9 -2
  40. package/dist/core/tool-result-budget.d.ts +1 -1
  41. package/dist/core/tool-result-budget.js +3 -3
  42. package/dist/core/tool-result-store.d.ts +164 -9
  43. package/dist/core/tool-result-store.js +82 -23
  44. package/dist/core/types.d.ts +103 -7
  45. package/dist/core/untrusted-text.d.ts +6 -2
  46. package/dist/core/untrusted-text.js +1 -1
  47. package/dist/engine/loop/types.d.ts +10 -3
  48. package/dist/engine/session/import-validate.js +2 -1
  49. package/dist/index.d.ts +3 -3
  50. package/dist/index.js +3 -3
  51. package/dist/orchestration/run-workflow-tool.d.ts +5 -3
  52. package/dist/orchestration/workflow.d.ts +9 -6
  53. package/dist/orchestration/workflow.js +2 -0
  54. package/dist/prompts/default.d.ts +11 -0
  55. package/dist/prompts/default.js +3 -0
  56. package/dist/stores/file/checkpoint-store.d.ts +2 -1
  57. package/dist/stores/file/fs-atomic.d.ts +1 -1
  58. package/dist/stores/file/index.d.ts +1 -1
  59. package/dist/stores/file/tool-result-store.d.ts +45 -9
  60. package/dist/stores/file/tool-result-store.js +76 -9
  61. package/dist/tools/fs/fs-shared.js +5 -4
  62. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,120 @@
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
+
52
+ ## 5.26.0 — 2026-08-11
53
+
54
+ No API-BREAKING changes (exports grow only; new members optional/additive). Several
55
+ behavior-surface narrowings — every one tighten-direction — called out below.
56
+
57
+ ### Narrowed (behavior, ruled)
58
+
59
+ - **A remote execution env no longer teaches the memory write path.** With `executionEnv` remote
60
+ and a memory store mounted, the `# Memory` write instruction is withheld (the sandboxed hands
61
+ cannot reach the host store; teaching the path walked the model into receipting saves that never
62
+ land). A deployment persisting through its own closure restores it by declaring
63
+ `TaskSpec.memoryPersistenceCapable: true`. **Consumer note**: probes pinning "remote run carries
64
+ the write instruction" go red; re-pin on the declaration.
65
+ - **`memoryPersistenceCapable: false` is enforced, not just disclosed.** Over a writable scope the
66
+ session mounts the read-only notice AND the write gate refuses file-tool writes into the memory
67
+ root (`read_only_layering`, loud), and the terminal/checkpoint harvest admits nothing — through
68
+ the engine's zero-admission arm, so the report half still runs: materialize-time inbound findings
69
+ and quarantine escalations are drained into a real `HarvestReport` (with a warning naming the
70
+ declaration) instead of a fabricated empty one.
71
+ - **The read-only notice retraction is DECLARED-only.** Only an explicit
72
+ `memoryPersistenceCapable: true` retracts the engine's writeScope-null notice; a write-capable
73
+ roster alone (inferred capability) no longer strips it — the gate refuses those very writes, so
74
+ inference proved nothing about the store.
75
+ - **Dual-root overlap is a prepare-time configuration error.** Two memory planes whose data roots
76
+ or control dirs overlap (either direction, either tier) are refused loudly at prepare instead of
77
+ running two engines over shared physical state.
78
+ - **A shell-doctrine mark is positive-only and monotonic.** A negative `irreversibility` tier is
79
+ not a provenance mark; `shellGate:"classify"` never downgrades an explicitly marked `"always"`.
80
+ Deployments with marked tools will see asks that classify used to swallow.
81
+ - **Tool-result refs are injective and conflict-typed (#119).** The mint hashes an injective
82
+ representation (four `~`-separated segments incl. a content digest); writing a different payload
83
+ under an existing ref is a typed `ref_conflict` error, never a silent overwrite; a damaged owner
84
+ record refuses whole.
85
+
86
+ ### Added
87
+
88
+ - **`TaskSpec.memoryPersistenceCapable`** (tri-state, additive): `true` = the deployment vouches
89
+ for a persistence channel the engine cannot see; `false` = mandatory floor (disclosure + write
90
+ gate + zero-admission harvest); absent = inferred from the final roster. Crosses the delegation
91
+ boundary tighten-only (a parent's `false` binds the subtree; a child definition cannot loosen
92
+ it). Non-boolean values are refused loudly at both doors (`config.memory_persistence_invalid` /
93
+ `config.agent.invalid`).
94
+ - **`MEMORY_READONLY_NOTICE` / `NO_PERSISTENT_MEMORY_NOTICE` exported**, and
95
+ **`MemoryInjection.readOnlyNotice`** (additive member): a session with no write channel is told
96
+ so instead of silently receipting saves; the standalone notice serves rosters the engine never
97
+ sees.
98
+ - **`MemoryEngine.harvest` accepts `admitNothing`** (additive option): a real harvest that commits
99
+ nothing while still draining and announcing inbound findings.
100
+
101
+ ### Fixed
102
+
103
+ - Delegation seats forward the capability floor (subagent arbitration, retained-resume folding,
104
+ workflow agent-type folding fill absence only).
105
+ - The offloaded-detail notice allowance derives from the ref-mint ceiling (a literal sized against
106
+ the retired two-segment shape under-bounded the four-segment notice).
107
+ - Ten stale-or-loose doc/comment spots from the merged-code rescan (classify doctrine conditionals
108
+ stated in public docs; JSDoc reattachments; TiDB sizing note states the four-segment mint).
109
+
110
+ ### Known residual (registered, not a regression)
111
+
112
+ - The zero-copy File backend's read-side inbound sync adopts disk divergence independent of session
113
+ intent: bytes a shell lands under the memory root during a read-only or declared-false session
114
+ can be adopted at a later materialize (checkpoint-resume included). Registered with three
115
+ candidate fixes pending a direction ruling; the disclosure, gate, and harvest boundaries above
116
+ all hold — this is the remaining channel, stated here so the boundary's edges are explicit.
117
+
3
118
  ## 5.25.0 — 2026-08-10
4
119
 
5
120
  No API-BREAKING changes (exports grow only; every new member is optional/additive). One
@@ -4,5 +4,10 @@ export function defineAgent(def) {
4
4
  e.code = "config.agent.invalid";
5
5
  throw e;
6
6
  }
7
+ if (def.memoryPersistenceCapable !== undefined && typeof def.memoryPersistenceCapable !== "boolean") {
8
+ const e = new Error(`defineAgent("${def.name}"): memoryPersistenceCapable must be a boolean when present — got ${JSON.stringify(def.memoryPersistenceCapable)} (a string "false" would silently read as capable).`);
9
+ e.code = "config.agent.invalid";
10
+ throw e;
11
+ }
7
12
  return Object.freeze({ ...def });
8
13
  }
@@ -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;
@@ -682,6 +682,7 @@ export function createSendMessageTool(opts) {
682
682
  ...(ctx.handsReadOnly === true ? { handsReadOnly: true } : {}),
683
683
  ...(ctx.interactiveTools === false ? { interactiveTools: false } : {}),
684
684
  ...(ctx.oneShot === true ? { oneShot: true } : {}),
685
+ ...(ctx.memoryPersistenceCapable === false ? { memoryPersistenceCapable: false } : {}),
685
686
  },
686
687
  ...(ctx.autoModeReview !== undefined ? { currentAutoModeReview: ctx.autoModeReview } : {}),
687
688
  });
@@ -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;
@@ -400,6 +403,7 @@ export declare function createSubagentResume(deps: {
400
403
  handsReadOnly?: true;
401
404
  interactiveTools?: false;
402
405
  oneShot?: true;
406
+ memoryPersistenceCapable?: false;
403
407
  };
404
408
  /** The RESUMING caller's own handback-review seat (its trusted `ToolExecuteContext.autoModeReview`),
405
409
  * same turn-bound reasoning as `currentOnQuestion` above: a resumed cycle is a completion like any
@@ -522,6 +522,9 @@ export function createSubagentResume(deps) {
522
522
  ...(entry.specSnapshot.handsReadOnly === true || deps.currentClamps?.handsReadOnly === true ? { handsReadOnly: true } : {}),
523
523
  ...(entry.specSnapshot.interactiveTools === false || deps.currentClamps?.interactiveTools === false ? { interactiveTools: false } : {}),
524
524
  ...(entry.specSnapshot.oneShot === true || deps.currentClamps?.oneShot === true ? { oneShot: true } : {}),
525
+ ...(entry.specSnapshot.memoryPersistenceCapable === false || deps.currentClamps?.memoryPersistenceCapable === false
526
+ ? { memoryPersistenceCapable: false }
527
+ : {}),
525
528
  signal: abort.signal,
526
529
  };
527
530
  if (deps.registry !== undefined && deps.taskId !== undefined && deps.taskAccess !== undefined) {
@@ -1668,6 +1671,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1668
1671
  };
1669
1672
  const childThinking = def?.thinking ?? ctx.thinkingLevel;
1670
1673
  const provenanceAgentName = agentName ?? def?.name;
1674
+ const childMemoryPersistenceCapable = ctx.memoryPersistenceCapable === false ? false : (def?.memoryPersistenceCapable ?? ctx.memoryPersistenceCapable);
1671
1675
  const childOnAsk = ctx.onAsk !== undefined
1672
1676
  ? withDelegationProvenance(ctx.onAsk, {
1673
1677
  parentToolCallId: ctx.toolCallId,
@@ -1689,6 +1693,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1689
1693
  ...(ctx.onQuestion !== undefined ? { onQuestion: ctx.onQuestion } : {}),
1690
1694
  ...(ctx.interactiveTools === false ? { interactiveTools: false } : {}),
1691
1695
  ...(ctx.handsReadOnly === true ? { handsReadOnly: true } : {}),
1696
+ ...(childMemoryPersistenceCapable !== undefined ? { memoryPersistenceCapable: childMemoryPersistenceCapable } : {}),
1692
1697
  ...(ctx.oneShot === true ? { oneShot: true } : {}),
1693
1698
  ...(ctx.clientContext !== undefined ? { clientContext: ctx.clientContext } : {}),
1694
1699
  ...(ctx.excludeTools !== undefined ? { excludeTools: [...ctx.excludeTools] } : {}),
@@ -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" &&
@@ -342,11 +345,12 @@ export async function runToolGate(input) {
342
345
  await notifier.notifyAsync(() => input.persistedRules?.onResolved?.({ toolName: req.toolName, toolCallId, rule: hit }), "toolGate.persistedRuleResolved");
343
346
  }
344
347
  else if (hit !== undefined) {
348
+ const shownRule = inlineUntrusted(hit, 200);
345
349
  const mandateNoun = persistedRuleMandate === "operator_always" ? "this deployment mandates per-call confirmation for shell commands (shellGate: always)" : "this tool carries egress/irreversibility marks (a mandated confirmation a rule cannot clear)";
346
350
  decision = {
347
351
  ...decision,
348
- persistedRuleShadowed: hit,
349
- message: `${decision.message !== undefined ? `${decision.message} ` : ""}(a persisted allow rule (${hit}) matches this call but does not clear the ask — ${mandateNoun})`,
352
+ persistedRuleShadowed: shownRule,
353
+ message: `${decision.message !== undefined ? `${decision.message} ` : ""}(a persisted allow rule (${shownRule}) matches this call but does not clear the ask — ${mandateNoun})`,
350
354
  };
351
355
  }
352
356
  }
@@ -407,7 +411,7 @@ export async function runToolGate(input) {
407
411
  ? { origin: orgAskOrigin !== undefined ? `org_${orgAskOrigin}` : "policy" }
408
412
  : undefined;
409
413
  if (suspendAsk && decision.action === "ask") {
410
- 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);
411
415
  if (suspended) {
412
416
  if ("parkFailed" in suspended)
413
417
  parkFailed = suspended.parkFailed;
@@ -426,7 +430,7 @@ export async function runToolGate(input) {
426
430
  req.args = outcome.presentedInput;
427
431
  }
428
432
  if (suspendAsk && outcome.parkDeclined && parkFailed === undefined) {
429
- 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);
430
434
  if (suspended) {
431
435
  if ("parkFailed" in suspended)
432
436
  parkFailed = suspended.parkFailed;
@@ -454,7 +458,7 @@ export async function runToolGate(input) {
454
458
  const resolved = await resolveAsk(decision, req);
455
459
  decision = resolved;
456
460
  if (resolved.action === "deny" && resolved.approverUnavailable === true && suspendAsk && parkFailed === undefined) {
457
- 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);
458
462
  if (suspended) {
459
463
  if ("parkFailed" in suspended)
460
464
  parkFailed = suspended.parkFailed;
@@ -31,13 +31,15 @@ export function mergeInjections(project, personal) {
31
31
  if (!personal)
32
32
  return project;
33
33
  const instruction = project.instruction || personal.instruction;
34
+ const readOnlyNotice = instruction === "" && project.readOnlyNotice !== undefined && personal.readOnlyNotice !== undefined ? project.readOnlyNotice : undefined;
34
35
  const indexParts = [project.index, personal.index].filter((s) => Boolean(s && s.trim()));
35
36
  const announcements = [...(project.announcements ?? []), ...(personal.announcements ?? [])];
36
37
  const announceParts = [project.announceBlock, personal.announceBlock].filter((s) => Boolean(s && s.trim()));
37
- const blockParts = [instruction, ...indexParts, ...announceParts];
38
+ const blockParts = [instruction || readOnlyNotice, ...indexParts, ...announceParts];
38
39
  const indexSeed = project.indexSeed ?? personal.indexSeed;
39
40
  return {
40
41
  instruction,
42
+ ...(readOnlyNotice !== undefined ? { readOnlyNotice } : {}),
41
43
  ...(indexParts.length > 0 ? { index: indexParts.join("\n\n") } : {}),
42
44
  ...(announcements.length > 0 ? { announcements } : {}),
43
45
  ...(announceParts.length > 0 ? { announceBlock: announceParts.join("\n\n") } : {}),
@@ -40,6 +40,37 @@ export declare const MEMORY_RECALL_DISCIPLINE = "Before answering questions abou
40
40
  * would forge the very account the three-tier discipline forbids forging).
41
41
  */
42
42
  export declare const MEMORY_PREFERENCE_DISCIPLINE = "When the user confirms a stored preference or fact still holds, refresh that entry's `last-confirmed: <YYYY-MM-DD>` frontmatter line (add it when absent). When you save a preference, add an `applies-when: <context>` frontmatter line naming when it applies. Both are plain frontmatter lines \u2014 write them yourself; nothing fills them in for you.";
43
+ /**
44
+ * Corrections for announcement segments that carry store-mutation guidance ("record a fresh entry …
45
+ * tombstone the old one", minted in an earlier writable session) a session cannot act on. The queue
46
+ * items are opaque strings (rewriting them would be text surgery over minted-at-enqueue wording),
47
+ * so the correction is a trailing coda, not a rewrite — in two scopes, because the two mounting
48
+ * seats speak about different things (an unqualified "the memory store is not writable" beside
49
+ * another, WRITABLE plane's instruction would negate that plane's guidance):
50
+ * - PLANE scope, attached by `inject()` right after a read-only layering's own announcement block:
51
+ * explicitly local to the immediately preceding notices, so a mixed dual-root merge keeps the
52
+ * writable plane's instruction and announcements fully actionable.
53
+ * - SESSION scope, attached by the runner at the block tail when the whole session cannot persist:
54
+ * there is no writable instruction left standing there (it is replaced or absent), so the global
55
+ * wording is accurate. Name-free by construction (#181 class).
56
+ */
57
+ export declare const MEMORY_ANNOUNCEMENT_READONLY_PLANE_CODA = "The notices immediately above concern a READ-ONLY memory store: any guidance in them to record, update, or tombstone an entry cannot be applied to that store this session \u2014 surface it to the user instead of claiming it done.";
58
+ export declare const MEMORY_ANNOUNCEMENT_READONLY_CODA = "The memory store itself is not writable this session, so any guidance above to record, update, or tombstone a memory entry cannot be applied here \u2014 surface it to the user instead of claiming it done.";
59
+ /**
60
+ * The read-only counterpart of the `# Memory` write instruction. A run with memory mounted but no
61
+ * way to write it used to get an EMPTY instruction — correct in what it doesn't teach, but silent
62
+ * about the state itself, and a model asked "remember X" under that silence answers with a success
63
+ * receipt for a save that never happens (the confabulated-receipt shape). This section states the
64
+ * state instead. Mounted only where "you cannot save" is provably TRUE: by `inject()` for a
65
+ * read-only layering (writeScope null ⇒ chmod'd tree), and by the runner for handsReadOnly (the
66
+ * shell rides the read-only band), for a declared-unavailable session (`memoryPersistenceCapable:
67
+ * false` — where the runner's write gate also refuses the file channel, keeping the engine-refusal
68
+ * sentence true), and for a write-less roster the persistence inference cannot vouch for. A merely
69
+ * Write-less roster does NOT qualify — other tools can still write the root. Name-free by
70
+ * construction (#181 class — it names no tool), and NOT part of the CC-verbatim capture: CC has no
71
+ * read-only memory layering, so there is nothing to capture; the section is sema-authored.
72
+ */
73
+ export declare const MEMORY_READONLY_NOTICE = "# Memory\n\nYou have READ-ONLY access to persistent memory in this session: stored notes are available below, but this session has no memory write channel \u2014 the engine will not accept writes into the memory store. If the user asks you to remember something for later, say plainly that you cannot save it in this session \u2014 never claim to have noted or remembered it.";
43
74
  /** CC index-injection parameters: MEMORY.md's first 200 lines / 25KB enter the prompt. */
44
75
  export declare const MEMORY_INDEX_MAX_LINES = 200;
45
76
  export declare const MEMORY_INDEX_MAX_BYTES: number;
@@ -105,6 +136,16 @@ export interface MemoryInjection {
105
136
  /** CC `# Memory` section (system-authority instruction — §0.3 逐字 surface). Empty for a read-only
106
137
  * layering (no write channel to instruct). */
107
138
  instruction: string;
139
+ /** Present exactly when this is a read-only LAYERING (writeScope null — the tree is chmod'd
140
+ * read-only, so no tool writes into it whatever the roster): the {@link MEMORY_READONLY_NOTICE}
141
+ * section stating that memory cannot be saved to, so the model declines "remember X" instead of
142
+ * issuing a confabulated success receipt. NOT set for `writeToolMounted:false` over a writable
143
+ * scope — there only the instruction-named tool is absent, other roster tools can still write the
144
+ * root, and the claim would be false (the runner mounts the notice for its own provably
145
+ * write-less shape, handsReadOnly). Kept as its OWN member (not folded into `instruction`) so the
146
+ * dual-root merge's "the write plane's instruction wins" falsy-OR keeps working: a read-only
147
+ * plane must never outrank a write plane's instruction. */
148
+ readOnlyNotice?: string;
108
149
  /** The derived MEMORY.md index, truncated (200 lines / 25KB) and FENCED untrusted. Undefined when empty. */
109
150
  index?: string;
110
151
  /** design/138 S2-B (时机①) — the announcements DRAINED by this inject (queued by the previous
@@ -280,7 +321,9 @@ export declare class MemoryEngine {
280
321
  * eventId anchors on the pollution record (sessionId + marked-at), so however many times this
281
322
  * settlement replays the same pollution event, the generation is allocated once. */
282
323
  private settlePromotions;
283
- materialize(scopes: readonly string[], writeScope: string | null): Promise<MemorySessionHandle>;
324
+ materialize(scopes: readonly string[], writeScope: string | null, opts?: {
325
+ adoptionRestricted?: boolean;
326
+ }): Promise<MemorySessionHandle>;
284
327
  inject(handle: MemorySessionHandle, opts?: {
285
328
  writeToolMounted?: boolean;
286
329
  }): MemoryInjection;
@@ -301,7 +344,7 @@ export declare class MemoryEngine {
301
344
  ok: true;
302
345
  } | {
303
346
  ok: false;
304
- code: ScanFinding["code"];
347
+ code: ScanFinding["code"] | "read_only_layering";
305
348
  reason: string;
306
349
  muted: boolean;
307
350
  };
@@ -315,16 +358,20 @@ export declare class MemoryEngine {
315
358
  reason: string;
316
359
  };
317
360
  sessionId?: string;
361
+ admitNothing?: {
362
+ reason: string;
363
+ };
318
364
  }): Promise<HarvestReport>;
319
365
  private harvestCore;
320
366
  /**
321
367
  * @param keepBaseline RB-440 (2026-07-31, hardening probe P3) — relative paths whose baseline must NOT
322
368
  * advance: the files a DEGRADED harvest deferred (`report.degraded.pending`).
323
369
  *
324
- * The deferral contract at :659-665 promises "the REST is deferred to the next harvest (still on disk),
325
- * never dropped", but execution then continued into this scan, which re-baselined EVERY file carrying an
326
- * id the pending ones included writing the CURRENT disk rev into `handle.baseRevs`. The next harvest
327
- * 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
328
375
  * became a patch. Session-NEW files escaped it (no id ⇒ `continue` below), which is exactly why the
329
376
  * existing budget test passed. Measured with a copy-out backend: harvest#1 {update:1} + pending ["b.md"],
330
377
  * harvest#2 {update:0}, and the backend still held v1 — so the next `materialize` wrote the stale text