@sema-agent/core 5.24.0 → 5.26.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 (54) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/dist/agents/agent-definition.js +5 -0
  3. package/dist/agents/send-message-tool.js +1 -0
  4. package/dist/agents/subagent.d.ts +1 -0
  5. package/dist/agents/subagent.js +5 -0
  6. package/dist/core/checkpoint-store.d.ts +47 -8
  7. package/dist/core/checkpoint-store.js +1 -0
  8. package/dist/core/hooks.d.ts +12 -5
  9. package/dist/core/hooks.js +22 -4
  10. package/dist/core/memory-engine/dual-root.js +3 -1
  11. package/dist/core/memory-engine/engine.d.ts +45 -1
  12. package/dist/core/memory-engine/engine.js +40 -7
  13. package/dist/core/memory-engine/index.d.ts +1 -1
  14. package/dist/core/memory-engine/index.js +1 -1
  15. package/dist/core/permission-rule-consent.js +8 -1
  16. package/dist/core/permission-rule-org.d.ts +9 -0
  17. package/dist/core/permission-rule-org.js +12 -5
  18. package/dist/core/runner/compaction-call-options.d.ts +4 -4
  19. package/dist/core/runner/compaction-call-options.js +3 -4
  20. package/dist/core/runner/prepare-memory.d.ts +34 -15
  21. package/dist/core/runner/prepare-memory.js +85 -17
  22. package/dist/core/runner/prepare-task.d.ts +2 -0
  23. package/dist/core/runner/prepare-task.js +63 -11
  24. package/dist/core/runner/runtask.js +25 -8
  25. package/dist/core/store-contracts/tool-result-store-contract.d.ts +6 -0
  26. package/dist/core/store-contracts/tool-result-store-contract.js +24 -0
  27. package/dist/core/task-registry-agent.js +3 -3
  28. package/dist/core/task-registry-monitor.js +6 -5
  29. package/dist/core/tool-policy.d.ts +11 -0
  30. package/dist/core/tool-result-budget.d.ts +1 -1
  31. package/dist/core/tool-result-budget.js +3 -3
  32. package/dist/core/tool-result-store.d.ts +164 -9
  33. package/dist/core/tool-result-store.js +82 -23
  34. package/dist/core/types.d.ts +68 -0
  35. package/dist/core/untrusted-text.d.ts +6 -2
  36. package/dist/core/untrusted-text.js +1 -1
  37. package/dist/engine/session/import-validate.js +2 -1
  38. package/dist/index.d.ts +4 -4
  39. package/dist/index.js +4 -4
  40. package/dist/orchestration/workflow.js +2 -0
  41. package/dist/prompts/default.d.ts +11 -0
  42. package/dist/prompts/default.js +3 -0
  43. package/dist/stores/file/adoption/adopt.d.ts +23 -3
  44. package/dist/stores/file/adoption/adopt.js +1 -0
  45. package/dist/stores/file/adoption/marker.d.ts +26 -11
  46. package/dist/stores/file/fs-atomic.d.ts +1 -1
  47. package/dist/stores/file/permission-rule-store.d.ts +15 -1
  48. package/dist/stores/file/permission-rule-store.js +4 -1
  49. package/dist/stores/file/task-list-store.d.ts +15 -1
  50. package/dist/stores/file/task-list-store.js +2 -2
  51. package/dist/stores/file/tool-result-store.d.ts +45 -9
  52. package/dist/stores/file/tool-result-store.js +76 -9
  53. package/dist/tools/fs/fs-shared.js +26 -9
  54. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,141 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.26.0 — 2026-08-11
4
+
5
+ No API-BREAKING changes (exports grow only; new members optional/additive). Several
6
+ behavior-surface narrowings — every one tighten-direction — called out below.
7
+
8
+ ### Narrowed (behavior, ruled)
9
+
10
+ - **A remote execution env no longer teaches the memory write path.** With `executionEnv` remote
11
+ and a memory store mounted, the `# Memory` write instruction is withheld (the sandboxed hands
12
+ cannot reach the host store; teaching the path walked the model into receipting saves that never
13
+ land). A deployment persisting through its own closure restores it by declaring
14
+ `TaskSpec.memoryPersistenceCapable: true`. **Consumer note**: probes pinning "remote run carries
15
+ the write instruction" go red; re-pin on the declaration.
16
+ - **`memoryPersistenceCapable: false` is enforced, not just disclosed.** Over a writable scope the
17
+ session mounts the read-only notice AND the write gate refuses file-tool writes into the memory
18
+ root (`read_only_layering`, loud), and the terminal/checkpoint harvest admits nothing — through
19
+ the engine's zero-admission arm, so the report half still runs: materialize-time inbound findings
20
+ and quarantine escalations are drained into a real `HarvestReport` (with a warning naming the
21
+ declaration) instead of a fabricated empty one.
22
+ - **The read-only notice retraction is DECLARED-only.** Only an explicit
23
+ `memoryPersistenceCapable: true` retracts the engine's writeScope-null notice; a write-capable
24
+ roster alone (inferred capability) no longer strips it — the gate refuses those very writes, so
25
+ inference proved nothing about the store.
26
+ - **Dual-root overlap is a prepare-time configuration error.** Two memory planes whose data roots
27
+ or control dirs overlap (either direction, either tier) are refused loudly at prepare instead of
28
+ running two engines over shared physical state.
29
+ - **A shell-doctrine mark is positive-only and monotonic.** A negative `irreversibility` tier is
30
+ not a provenance mark; `shellGate:"classify"` never downgrades an explicitly marked `"always"`.
31
+ Deployments with marked tools will see asks that classify used to swallow.
32
+ - **Tool-result refs are injective and conflict-typed (#119).** The mint hashes an injective
33
+ representation (four `~`-separated segments incl. a content digest); writing a different payload
34
+ under an existing ref is a typed `ref_conflict` error, never a silent overwrite; a damaged owner
35
+ record refuses whole.
36
+
37
+ ### Added
38
+
39
+ - **`TaskSpec.memoryPersistenceCapable`** (tri-state, additive): `true` = the deployment vouches
40
+ for a persistence channel the engine cannot see; `false` = mandatory floor (disclosure + write
41
+ gate + zero-admission harvest); absent = inferred from the final roster. Crosses the delegation
42
+ boundary tighten-only (a parent's `false` binds the subtree; a child definition cannot loosen
43
+ it). Non-boolean values are refused loudly at both doors (`config.memory_persistence_invalid` /
44
+ `config.agent.invalid`).
45
+ - **`MEMORY_READONLY_NOTICE` / `NO_PERSISTENT_MEMORY_NOTICE` exported**, and
46
+ **`MemoryInjection.readOnlyNotice`** (additive member): a session with no write channel is told
47
+ so instead of silently receipting saves; the standalone notice serves rosters the engine never
48
+ sees.
49
+ - **`MemoryEngine.harvest` accepts `admitNothing`** (additive option): a real harvest that commits
50
+ nothing while still draining and announcing inbound findings.
51
+
52
+ ### Fixed
53
+
54
+ - Delegation seats forward the capability floor (subagent arbitration, retained-resume folding,
55
+ workflow agent-type folding fill absence only).
56
+ - The offloaded-detail notice allowance derives from the ref-mint ceiling (a literal sized against
57
+ the retired two-segment shape under-bounded the four-segment notice).
58
+ - Ten stale-or-loose doc/comment spots from the merged-code rescan (classify doctrine conditionals
59
+ stated in public docs; JSDoc reattachments; TiDB sizing note states the four-segment mint).
60
+
61
+ ### Known residual (registered, not a regression)
62
+
63
+ - The zero-copy File backend's read-side inbound sync adopts disk divergence independent of session
64
+ intent: bytes a shell lands under the memory root during a read-only or declared-false session
65
+ can be adopted at a later materialize (checkpoint-resume included). Registered with three
66
+ candidate fixes pending a direction ruling; the disclosure, gate, and harvest boundaries above
67
+ all hold — this is the remaining channel, stated here so the boundary's edges are explicit.
68
+
69
+ ## 5.25.0 — 2026-08-10
70
+
71
+ No API-BREAKING changes (exports grow only; every new member is optional/additive). One
72
+ behavior-surface narrowing, called out below.
73
+
74
+ ### Narrowed (behavior, ruled)
75
+
76
+ - **The persisted-rule lane gains its mandate boundary (#144, dual-source measured).** *Allow rules
77
+ silence the classifier's questions, never a mandated one.* A persisted allow rule used to
78
+ short-circuit every surviving non-governance ask — including an operator's `shellGate:"always"`
79
+ (per-call confirmation mandated by deployment config) and a tool's own egress/irreversibility
80
+ marks (the non-budgetable family). The boundary is a single-source provenance predicate: the
81
+ classify-doctrine bash ask (coarse tier `"maybe"`) stays the rule lane's home turf (the
82
+ don't-ask-again main case is deliberately preserved); doctrine-installed `"always"` and
83
+ tool-declared marks are not clearable by rule. When a rule MATCHES but cannot clear, the surviving
84
+ ask discloses it on both channels: a message note naming the rule and the mandate, and the new
85
+ additive `PermissionResult.ask.persistedRuleShadowed` member (the matched rule text) — a consumer
86
+ renders "your rule is alive, just outranked". **Consumer note**: deployments under
87
+ `shellGate:"always"` or with marked tools will see asks their users' rules used to clear.
88
+
89
+ ### Added
90
+
91
+ - **`CheckpointError.detail.reason`** (closed set, additive; downstream-requested): discriminates
92
+ the pre-CAS refusal arms one code used to cover — `version_newer` / `env_factory_missing` /
93
+ `governed_unwired` on `unsupported_version` (each retryable on a differently-capable worker), and
94
+ `real_approval_damaged` / `real_approval_forged` / `constraint_chain_missing` on the
95
+ `invalid_outcome` row-integrity arms (terminal for the row's bytes). A deployment retry policy can
96
+ now tell "a capable worker can redeem this" from "no worker ever will".
97
+ - **`REAL_APPROVAL_CHECKPOINT_VERSION` exported** from the package root (joins the other five ladder
98
+ constants).
99
+ - **The I6 adoption boot gate reaches two more store faces** via an optional `dataRoot` anchor on
100
+ `FilePermissionRuleStoreProvider` and `createFileTaskListStore` (additive; the adoption arc's own
101
+ nested rule leg keeps its exemption, pinned). The freeze header states its real coverage — two
102
+ doors and the seams between them — instead of over-claiming.
103
+
104
+ ### Fixed
105
+
106
+ - **The disclosure channel is real end-to-end (pre-release rescan on this very batch).**
107
+ `persistedRuleShadowed` was write-only as first landed: `AskRequest` gains the member (all four
108
+ mint sites thread it), and the durable park mint carries it as `RiskDescriptor.shadowedRule`
109
+ (`inlineUntrusted`-capped) — the mandated population's normal route now discloses like the
110
+ synchronous one. The mandate predicate judges egress FIRST (the tool's own declaration is not
111
+ shadowed by the coarse doctrine sharing the seat). The secret-scrub quarantine capture stops
112
+ treating a name collision as a receipt (family suffix form; the polluted-index warning claims a
113
+ capture only when its write landed). The last silent timeout discard (a legal cap below the
114
+ resolved default) is announced; an empty env string is a written silence exception (the unset
115
+ idiom). The materialize-strategy announcements gain a per-value process ledger and a reset seam.
116
+ - **The loud-bad-value law lands on its two founding cases (#123, ruled).** `BASH_*_TIMEOUT_MS`
117
+ discards stop exempting the garbage/0/negative classes (every discarded value names the knob, what
118
+ arrived — env legs show the original string — and the value in force);
119
+ `SEMA_TOOL_MATERIALIZE_STRATEGY`'s refusal now matches the seat the bad value occupies: shadowed
120
+ by an explicit legal spec ⇒ loud discard (the documented "spec wins" precedence finally holds),
121
+ would-be-in-force ⇒ the closed-set refusal stands, dormant (no deferred tools) ⇒ announced once
122
+ per process instead of lying in wait.
123
+ - **A v7+ checkpoint declaring parent constraints must carry both the frozen chain and its digest**
124
+ (they are one write with the version stamp) — a row carrying neither is refused pre-CAS as damaged
125
+ instead of silently falling back to the count-only contract. Rows v6 and below keep the historic
126
+ contract.
127
+ - **The org resume belt's refusal carries the overlay's disclosure lines** (the resume path has no
128
+ `onUnavailable` seat) and tells a cancelled wait apart from unreadable governance. The verdict is
129
+ unchanged; the account is not.
130
+ - The sandbox-admission registry/decision domain split is documented and pinned (the two
131
+ un-instrumented fold families are safe for structural reasons — a family that gains an ask arm
132
+ reds instead of arriving unrecorded); frozen projections' fold position is disclosed; the adoption
133
+ report declares the memory-engine tree `action:"none"` explicitly; two engine quarantine captures
134
+ go `wx`-exclusive (no symlink at the final name, no overwrite of earlier evidence under a coarse
135
+ clock); the abort-races-timeout test pin gets real load headroom; the v8 stamp note carries its
136
+ erratum (v8 rows appear on ANY durable deployment via the always-mounted integrity policies — what
137
+ stays true: rows without the bit keep their historic stamps).
138
+
3
139
  ## 5.24.0 — 2026-08-10
4
140
 
5
141
  No API-BREAKING changes (exports grow only; `suspendAsk` gains an optional fifth parameter;
@@ -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
  }
@@ -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
  });
@@ -400,6 +400,7 @@ export declare function createSubagentResume(deps: {
400
400
  handsReadOnly?: true;
401
401
  interactiveTools?: false;
402
402
  oneShot?: true;
403
+ memoryPersistenceCapable?: false;
403
404
  };
404
405
  /** The RESUMING caller's own handback-review seat (its trusted `ToolExecuteContext.autoModeReview`),
405
406
  * 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] } : {}),
@@ -135,6 +135,11 @@ export interface RiskDescriptor {
135
135
  * attempted — over-reaching a shell parse risks a wrong/forgeable path). Each path `inlineUntrusted`-capped.
136
136
  * Omitted when none derivable. */
137
137
  touchedPaths?: string[];
138
+ /** #144 (additive): a persisted allow rule MATCHED this call but could not clear the mandated ask —
139
+ * the matched rule text (`inlineUntrusted`-capped), threaded to the mint so the durable-park route
140
+ * carries the same disclosure the synchronous ask does. The inbox renders "their rule is alive,
141
+ * just outranked" instead of the person concluding their rule silently broke. */
142
+ shadowedRule?: string;
138
143
  }
139
144
  /**
140
145
  * design/80 §D-E: the DETERMINISTIC severity tier (1..5) for an escalation checkpoint, a PURE function of the
@@ -266,6 +271,8 @@ export declare function buildRiskDescriptor(input: {
266
271
  shellGated?: boolean;
267
272
  /** The resolved doctrine to persist when `shellGated` (see {@link RiskDescriptor.shellGateDoctrine}). */
268
273
  shellGateDoctrine?: "classify" | "always";
274
+ /** #144: the matched-but-outranked persisted rule to persist (see {@link RiskDescriptor.shadowedRule}). */
275
+ shadowedRule?: string;
269
276
  }): RiskDescriptor;
270
277
  /**
271
278
  * #130/#131/#120 (2026-08-10) — the durable record of an ask's `requiresRealApproval` bit, which used
@@ -805,8 +812,11 @@ export interface CheckpointState {
805
812
  * (extra deny-narrowing layers + the pre-CAS edit re-adjudication) rather than trusting a
806
813
  * re-supplied policy's execution half for the frozen deny data — a re-supplied closure that
807
814
  * matches the digest but behaves differently cannot un-deny what the suspend froze. Present iff
808
- * `requiresParentConstraint` on new mints; absent on older checkpoints (⇒ no frozen deny data —
809
- * the count-only contract governs, and an EDIT resume is refused for want of a validatable chain). */
815
+ * `requiresParentConstraint` on new mints; absent on PRE-F-012 checkpoints (⇒ no frozen deny data —
816
+ * the count-only contract governs, and an EDIT resume is refused for want of a validatable chain).
817
+ * "Older" is version-decidable, not guessed: chain, digest and the {@link F012_CHECKPOINT_VERSION}
818
+ * stamp are one write, so on a v7+ row the pair is REQUIRED — its absence there is a damaged row and
819
+ * the resume ladder refuses it pre-CAS rather than falling back to the count-only contract. */
810
820
  constraintChain?: ReadonlyArray<import("./tool-policy.js").ConstraintChainEntry>;
811
821
  /**
812
822
  * F-012 L1 — the content digest over `constraintChain` ({@link import("./tool-policy.js").constraintChainDigest}):
@@ -814,7 +824,8 @@ export interface CheckpointState {
814
824
  * content. The resume pre-CAS ladder recomputes the digest over the RE-SUPPLIED chain's
815
825
  * projections and rejects a mismatch (`resume.parent_constraint_mismatch`) — replacing the
816
826
  * count-only shape check with a content contract (a same-length chain of different frozen deny
817
- * data no longer passes). Present iff `constraintChain` is. */
827
+ * data no longer passes). Present iff `constraintChain` is — and on a v7+ row both are required
828
+ * (see {@link constraintChain}: the pair and the version stamp are minted together). */
818
829
  constraintDigest?: string;
819
830
  };
820
831
  /**
@@ -940,7 +951,11 @@ export declare const F012_CHECKPOINT_VERSION = 7;
940
951
  * either redeem a governed row with no org wiring at all (#120's exact hole, replayed through version
941
952
  * skew) or burn the approval on the unavailable belt the bit exists to soften. Stamping v8 forces it
942
953
  * to reject PRE-CAS (`unsupported_version`, stays `pending`, retried on an enforcing worker). A gate
943
- * with NO `realApproval` keeps the historic stamps ungoverned deployments see zero version movement.
954
+ * with NO `realApproval` keeps the historic stamps. NOTE (erratum 2026-08-10, downstream-measured):
955
+ * "zero version movement for ungoverned deployments" is TOO WIDE — the two always-mounted integrity
956
+ * policies (transcript-integrity, unverifiable-delete) mint `requiresRealApproval` asks, so a park on
957
+ * one of THOSE stamps v8 with `origin:"policy"` on ANY durable deployment, org-governed or not. What
958
+ * stays true: rows whose gate carries no realApproval bit keep their historic stamps everywhere.
944
959
  */
945
960
  export declare const REAL_APPROVAL_CHECKPOINT_VERSION = 8;
946
961
  /** The highest {@link Checkpoint.version} `runner.resume` will act on; a higher one is rejected pre-CAS with
@@ -1463,8 +1478,8 @@ export declare class CheckpointError extends Error {
1463
1478
  * computable from a self-reported closure, so the edited resume is refused typed pre-CAS (the
1464
1479
  * checkpoint stays `pending`, still decidable); an UNEDITED approval is untouched. */
1465
1480
  | "resume.constraint_unprojectable";
1466
- /** Structured discrimination a caller can branch on where the code alone is ambiguous. Today one
1467
- * member: `field` names WHICH part of the decision payload was rejected on a
1481
+ /** Structured discrimination a caller can branch on where the code alone is ambiguous. Two
1482
+ * members. `field` names WHICH part of the decision payload was rejected on a
1468
1483
  * `checkpoint.invalid_outcome` — `"boundCallId"` (the action you decided on has been replaced:
1469
1484
  * re-fetch the pending list), `"boundInputHash"` (the input you reviewed has changed under the same
1470
1485
  * action: re-review), or `"answer"` (the content-ask answer is missing, or was attached to a
@@ -1476,6 +1491,18 @@ export declare class CheckpointError extends Error {
1476
1491
  * discriminant. */
1477
1492
  readonly detail?: {
1478
1493
  field?: "boundCallId" | "boundInputHash" | "answer" | "settledBy";
1494
+ /** WHICH pre-CAS refusal arm fired, where one `code` covers several (requested 2026-08-10: a
1495
+ * deployment retry policy needs to tell "a newer worker can redeem this row" from "this row is
1496
+ * damaged/forged and no worker ever will" — blanket-retriable and gate-shape heuristics were
1497
+ * both refuted downstream, so the throw site carries the fact it always knew). Closed set;
1498
+ * additive and optional like `field` — `code` remains the only REQUIRED discriminant.
1499
+ * · `unsupported_version` arms: `"version_newer"` (retryable on a newer worker),
1500
+ * `"env_factory_missing"` (retryable on a factory-wired worker), `"governed_unwired"`
1501
+ * (retryable on an org-wired worker).
1502
+ * · `invalid_outcome` pre-CAS row-integrity arms: `"real_approval_damaged"`,
1503
+ * `"real_approval_forged"`, `"constraint_chain_missing"` (all terminal for the row's
1504
+ * current bytes — no worker version redeems a damaged row). */
1505
+ reason?: "version_newer" | "env_factory_missing" | "governed_unwired" | "real_approval_damaged" | "real_approval_forged" | "constraint_chain_missing";
1479
1506
  } | undefined;
1480
1507
  constructor(code: "checkpoint.already_exists" | "checkpoint.already_resolved" | "checkpoint.not_found"
1481
1508
  /** `runner.resume` was handed an {@link ResumeOutcome} whose `gate` arm does not match the
@@ -1572,8 +1599,8 @@ export declare class CheckpointError extends Error {
1572
1599
  * computable from a self-reported closure, so the edited resume is refused typed pre-CAS (the
1573
1600
  * checkpoint stays `pending`, still decidable); an UNEDITED approval is untouched. */
1574
1601
  | "resume.constraint_unprojectable", message: string,
1575
- /** Structured discrimination a caller can branch on where the code alone is ambiguous. Today one
1576
- * member: `field` names WHICH part of the decision payload was rejected on a
1602
+ /** Structured discrimination a caller can branch on where the code alone is ambiguous. Two
1603
+ * members. `field` names WHICH part of the decision payload was rejected on a
1577
1604
  * `checkpoint.invalid_outcome` — `"boundCallId"` (the action you decided on has been replaced:
1578
1605
  * re-fetch the pending list), `"boundInputHash"` (the input you reviewed has changed under the same
1579
1606
  * action: re-review), or `"answer"` (the content-ask answer is missing, or was attached to a
@@ -1585,6 +1612,18 @@ export declare class CheckpointError extends Error {
1585
1612
  * discriminant. */
1586
1613
  detail?: {
1587
1614
  field?: "boundCallId" | "boundInputHash" | "answer" | "settledBy";
1615
+ /** WHICH pre-CAS refusal arm fired, where one `code` covers several (requested 2026-08-10: a
1616
+ * deployment retry policy needs to tell "a newer worker can redeem this row" from "this row is
1617
+ * damaged/forged and no worker ever will" — blanket-retriable and gate-shape heuristics were
1618
+ * both refuted downstream, so the throw site carries the fact it always knew). Closed set;
1619
+ * additive and optional like `field` — `code` remains the only REQUIRED discriminant.
1620
+ * · `unsupported_version` arms: `"version_newer"` (retryable on a newer worker),
1621
+ * `"env_factory_missing"` (retryable on a factory-wired worker), `"governed_unwired"`
1622
+ * (retryable on an org-wired worker).
1623
+ * · `invalid_outcome` pre-CAS row-integrity arms: `"real_approval_damaged"`,
1624
+ * `"real_approval_forged"`, `"constraint_chain_missing"` (all terminal for the row's
1625
+ * current bytes — no worker version redeems a damaged row). */
1626
+ reason?: "version_newer" | "env_factory_missing" | "governed_unwired" | "real_approval_damaged" | "real_approval_forged" | "constraint_chain_missing";
1588
1627
  } | undefined);
1589
1628
  }
1590
1629
  /**
@@ -120,6 +120,7 @@ export function buildRiskDescriptor(input) {
120
120
  axes,
121
121
  toolName,
122
122
  ...(input.shellGated && input.shellGateDoctrine !== undefined ? { shellGateDoctrine: input.shellGateDoctrine } : {}),
123
+ ...(input.shadowedRule !== undefined ? { shadowedRule: inlineUntrusted(input.shadowedRule, 200) } : {}),
123
124
  ...(summary !== undefined ? { summary } : {}),
124
125
  ...(touchedPaths !== undefined ? { touchedPaths } : {}),
125
126
  };
@@ -608,7 +608,10 @@ export interface ToolGateInput {
608
608
  * auto-approve what only judgment may clear). `origin` records whether the bit came from an org
609
609
  * ASK rule, from the org-unavailable tighten (whose resume semantics differ — see
610
610
  * {@link import("./checkpoint-store.js").RealApprovalGateBit}), or from a policy/hook. */
611
- realApproval?: import("./checkpoint-store.js").RealApprovalGateBit) => Promise<ToolGateResult["suspend"] | ParkAttemptFailed | undefined>;
611
+ realApproval?: import("./checkpoint-store.js").RealApprovalGateBit,
612
+ /** #144: the matched-but-outranked persisted rule (the surviving ask's `persistedRuleShadowed`)
613
+ * — threaded so the park mint's risk descriptor carries the disclosure on the durable route. */
614
+ shadowedRule?: string) => Promise<ToolGateResult["suspend"] | ParkAttemptFailed | undefined>;
612
615
  /**
613
616
  * design/174 — route a policy `ask` on the reserved question tool to this run's CONTENT-ask channel
614
617
  * before it can become a park or a refusal. Called in the `ask` branch with the FINAL post-hook,
@@ -728,10 +731,14 @@ export interface ToolGateInput {
728
731
  * · the reserved question tool and a call MARKED unresolvable are excluded for the same reason the
729
732
  * classifier excludes them: both contracts require that no synchronous decision-maker stands between
730
733
  * the ask and the park / content route, and this lane is one.
731
- * What it DOES consume is the egress and irreversibility/shellGate tightens and unmarked bare asks —
732
- * deliberately, because a deployment forcing shell classification is exactly where the feature is for.
733
- * Consuming such an ask also skips the park it would have minted; that IS what a standing approval
734
- * means, and the first bullet is what keeps an integrity ask out of that set.
734
+ * · #144 (ruled): a MANDATED ask is never consumed an operator's shellGate:"always" tier and a
735
+ * tool's own egress/irreversibility marks are structural requirements, not classifier hesitation
736
+ * ("allow rules silence the classifier's questions, never a mandated one"). When a rule matches
737
+ * but cannot clear, the surviving ask discloses it (message + `persistedRuleShadowed`).
738
+ * What it DOES consume is the classify-DOCTRINE shell ask (the coarse tier "maybe" — the
739
+ * don't-ask-again main case this feature exists for) and unmarked bare asks. Consuming such an ask
740
+ * also skips the park it would have minted; that IS what a standing approval means, and the
741
+ * bullets above are what keep integrity/hook/mandated asks out of that set.
735
742
  */
736
743
  persistedRules?: {
737
744
  /** The canonical text of the rule that admits this call, or `undefined`. Must not throw: a store that
@@ -315,6 +315,15 @@ export async function runToolGate(input) {
315
315
  currentInput = policyRewrite;
316
316
  req.args = policyRewrite;
317
317
  }
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;
318
327
  if (input.persistedRules &&
319
328
  !orgRealApprovalRequired &&
320
329
  decision.action === "ask" &&
@@ -323,7 +332,7 @@ export async function runToolGate(input) {
323
332
  req.toolName !== ASK_USER_QUESTION_TOOL_NAME &&
324
333
  input.isMarkedUnresolvable?.(input.event.toolCallId) !== true) {
325
334
  const hit = await input.persistedRules.admits(req).catch(() => undefined);
326
- if (hit !== undefined) {
335
+ if (hit !== undefined && persistedRuleMandate === undefined) {
327
336
  decision = {
328
337
  action: "allow",
329
338
  message: `a persisted allow rule (${hit}) covers this call`,
@@ -332,6 +341,15 @@ export async function runToolGate(input) {
332
341
  };
333
342
  await notifier.notifyAsync(() => input.persistedRules?.onResolved?.({ toolName: req.toolName, toolCallId, rule: hit }), "toolGate.persistedRuleResolved");
334
343
  }
344
+ else if (hit !== undefined) {
345
+ const shownRule = inlineUntrusted(hit, 200);
346
+ 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)";
347
+ decision = {
348
+ ...decision,
349
+ persistedRuleShadowed: shownRule,
350
+ message: `${decision.message !== undefined ? `${decision.message} ` : ""}(a persisted allow rule (${shownRule}) matches this call but does not clear the ask — ${mandateNoun})`,
351
+ };
352
+ }
335
353
  }
336
354
  if (input.autoMode &&
337
355
  !orgRealApprovalRequired &&
@@ -390,7 +408,7 @@ export async function runToolGate(input) {
390
408
  ? { origin: orgAskOrigin !== undefined ? `org_${orgAskOrigin}` : "policy" }
391
409
  : undefined;
392
410
  if (suspendAsk && decision.action === "ask") {
393
- const suspended = await suspendAsk(req, currentInput, safety, undefined, realApprovalOf(decision));
411
+ const suspended = await suspendAsk(req, currentInput, safety, undefined, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined);
394
412
  if (suspended) {
395
413
  if ("parkFailed" in suspended)
396
414
  parkFailed = suspended.parkFailed;
@@ -409,7 +427,7 @@ export async function runToolGate(input) {
409
427
  req.args = outcome.presentedInput;
410
428
  }
411
429
  if (suspendAsk && outcome.parkDeclined && parkFailed === undefined) {
412
- const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(decision));
430
+ const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined);
413
431
  if (suspended) {
414
432
  if ("parkFailed" in suspended)
415
433
  parkFailed = suspended.parkFailed;
@@ -437,7 +455,7 @@ export async function runToolGate(input) {
437
455
  const resolved = await resolveAsk(decision, req);
438
456
  decision = resolved;
439
457
  if (resolved.action === "deny" && resolved.approverUnavailable === true && suspendAsk && parkFailed === undefined) {
440
- const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(askBeforeResolve));
458
+ const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(askBeforeResolve), askBeforeResolve.action === "ask" ? askBeforeResolve.persistedRuleShadowed : undefined);
441
459
  if (suspended) {
442
460
  if ("parkFailed" in suspended)
443
461
  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
@@ -301,7 +342,7 @@ export declare class MemoryEngine {
301
342
  ok: true;
302
343
  } | {
303
344
  ok: false;
304
- code: ScanFinding["code"];
345
+ code: ScanFinding["code"] | "read_only_layering";
305
346
  reason: string;
306
347
  muted: boolean;
307
348
  };
@@ -315,6 +356,9 @@ export declare class MemoryEngine {
315
356
  reason: string;
316
357
  };
317
358
  sessionId?: string;
359
+ admitNothing?: {
360
+ reason: string;
361
+ };
318
362
  }): Promise<HarvestReport>;
319
363
  private harvestCore;
320
364
  /**