@arnilo/prism 0.8.0 → 0.10.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 (141) hide show
  1. package/CHANGELOG.md +62 -1
  2. package/README.md +13 -12
  3. package/dist/agent-approval.d.ts +17 -2
  4. package/dist/agent-approval.js +15 -6
  5. package/dist/agent-event-source.d.ts +9 -1
  6. package/dist/agent-event-source.js +10 -3
  7. package/dist/agent-loops.js +7 -4
  8. package/dist/agent-run-lifecycle.d.ts +15 -1
  9. package/dist/agent-run-lifecycle.js +82 -11
  10. package/dist/agent-run-state.d.ts +47 -6
  11. package/dist/agent-run-state.js +154 -6
  12. package/dist/agent-session/event-subscriber.d.ts +2 -0
  13. package/dist/agent-session/event-subscriber.js +3 -0
  14. package/dist/agent-session/helpers.js +14 -0
  15. package/dist/agent-session/session/assemble.js +281 -32
  16. package/dist/agent-session/session/persist.d.ts +11 -0
  17. package/dist/agent-session/session/persist.js +48 -16
  18. package/dist/agent-session/session/provider-round.d.ts +14 -4
  19. package/dist/agent-session/session/provider-round.js +226 -19
  20. package/dist/agent-session/session/tool-round.d.ts +2 -2
  21. package/dist/agent-session/session/tool-round.js +78 -6
  22. package/dist/agent-session/session/types.d.ts +44 -3
  23. package/dist/agent-session/session.d.ts +100 -5
  24. package/dist/agent-session/session.js +224 -13
  25. package/dist/attention-compiler.d.ts +51 -2
  26. package/dist/attention-compiler.js +282 -21
  27. package/dist/cache-helpers.d.ts +4 -2
  28. package/dist/cache-helpers.js +8 -6
  29. package/dist/checkpoint-restore.d.ts +45 -0
  30. package/dist/checkpoint-restore.js +54 -0
  31. package/dist/context-budget.d.ts +13 -1
  32. package/dist/context-budget.js +57 -4
  33. package/dist/contracts-core/agent.d.ts +52 -1
  34. package/dist/contracts-core/attention.d.ts +95 -0
  35. package/dist/contracts-core/content.d.ts +10 -0
  36. package/dist/contracts-core/extensions.d.ts +3 -0
  37. package/dist/contracts-core/guardrail-packs.d.ts +46 -0
  38. package/dist/contracts-core/guardrail-packs.js +2 -0
  39. package/dist/contracts-core/loop.d.ts +36 -0
  40. package/dist/contracts-core/provider.d.ts +30 -0
  41. package/dist/contracts-core/run-limits.d.ts +29 -1
  42. package/dist/contracts-core/session.d.ts +23 -5
  43. package/dist/contracts-core/session.js +21 -2
  44. package/dist/contracts-core/usage.d.ts +40 -0
  45. package/dist/contracts-core/usage.js +8 -0
  46. package/dist/contracts-core.d.ts +2 -0
  47. package/dist/contracts-core.js +2 -0
  48. package/dist/contracts-protocol.d.ts +81 -5
  49. package/dist/contracts-run-state.d.ts +91 -2
  50. package/dist/contributions.d.ts +2 -1
  51. package/dist/contributions.js +1 -0
  52. package/dist/extensions.d.ts +15 -1
  53. package/dist/extensions.js +68 -0
  54. package/dist/guardrail-packs/coding-standard.d.ts +3 -0
  55. package/dist/guardrail-packs/coding-standard.js +63 -0
  56. package/dist/guardrail-packs/destructive-commands.d.ts +3 -0
  57. package/dist/guardrail-packs/destructive-commands.js +46 -0
  58. package/dist/guardrail-packs/errors.d.ts +7 -0
  59. package/dist/guardrail-packs/errors.js +9 -0
  60. package/dist/guardrail-packs/index.d.ts +4 -0
  61. package/dist/guardrail-packs/index.js +15 -0
  62. package/dist/guardrail-packs/secrets-hygiene.d.ts +3 -0
  63. package/dist/guardrail-packs/secrets-hygiene.js +23 -0
  64. package/dist/guardrail-packs/types.d.ts +26 -0
  65. package/dist/guardrail-packs/types.js +2 -0
  66. package/dist/guardrail-packs/validation-respect.d.ts +3 -0
  67. package/dist/guardrail-packs/validation-respect.js +69 -0
  68. package/dist/guardrails.d.ts +61 -1
  69. package/dist/guardrails.js +377 -0
  70. package/dist/index.d.ts +16 -11
  71. package/dist/index.js +10 -7
  72. package/dist/input.d.ts +8 -1
  73. package/dist/input.js +68 -6
  74. package/dist/middleware.d.ts +37 -2
  75. package/dist/middleware.js +41 -0
  76. package/dist/node/session-store-jsonl.js +18 -3
  77. package/dist/observability.js +6 -0
  78. package/dist/provider-events.d.ts +8 -2
  79. package/dist/provider-events.js +60 -2
  80. package/dist/providers/openai-compatible.js +6 -3
  81. package/dist/run-bundle.d.ts +6 -1
  82. package/dist/run-bundle.js +5 -1
  83. package/dist/run-limits.d.ts +11 -1
  84. package/dist/run-limits.js +59 -0
  85. package/dist/session-stores.d.ts +12 -1
  86. package/dist/session-stores.js +21 -4
  87. package/dist/testing/agent-event-source-conformance.js +41 -2
  88. package/dist/testing/prefix-stability-conformance.d.ts +59 -0
  89. package/dist/testing/prefix-stability-conformance.js +172 -0
  90. package/dist/testing/session-store-conformance.d.ts +3 -2
  91. package/dist/testing/session-store-conformance.js +48 -0
  92. package/dist/tools.d.ts +5 -0
  93. package/dist/tools.js +21 -6
  94. package/dist/usage-estimation.d.ts +29 -0
  95. package/dist/usage-estimation.js +79 -0
  96. package/docs/agent-events.md +75 -4
  97. package/docs/agent-session-runtime.md +10 -6
  98. package/docs/attention-compiler.md +89 -8
  99. package/docs/caveman.md +1 -1
  100. package/docs/coding-agent-tools.md +1 -1
  101. package/docs/compaction-and-retry.md +1 -1
  102. package/docs/compaction-llm.md +2 -0
  103. package/docs/compaction-observational-memory.md +54 -7
  104. package/docs/durable-runs.md +46 -3
  105. package/docs/embeddings.md +9 -0
  106. package/docs/evaluations.md +5 -0
  107. package/docs/execution-timeline.md +79 -1
  108. package/docs/extensions.md +20 -3
  109. package/docs/guardrails.md +50 -4
  110. package/docs/hooks.md +282 -0
  111. package/docs/index.md +37 -15
  112. package/docs/input-and-prompt-assembly.md +4 -4
  113. package/docs/instruction-injection.md +1 -0
  114. package/docs/knowledge-sync.md +4 -0
  115. package/docs/live-testing.md +3 -1
  116. package/docs/memory-fabric.md +28 -0
  117. package/docs/middleware-hooks.md +90 -4
  118. package/docs/migrate-to-0.9.md +210 -0
  119. package/docs/migration.md +26 -0
  120. package/docs/multi-agent-patterns.md +25 -2
  121. package/docs/node-jsonl-session-store.md +7 -1
  122. package/docs/observability.md +7 -3
  123. package/docs/options-index.md +4 -1
  124. package/docs/policy-and-audit.md +26 -1
  125. package/docs/prefix-stability-conformance.md +143 -0
  126. package/docs/provider-caching.md +4 -4
  127. package/docs/provider-conformance.md +16 -0
  128. package/docs/provider-packages.md +20 -20
  129. package/docs/public-contracts.md +3 -2
  130. package/docs/rag.md +188 -3
  131. package/docs/release-and-install.md +45 -40
  132. package/docs/runs-and-usage.md +56 -10
  133. package/docs/scoped-agent-memory.md +270 -0
  134. package/docs/scoped-memory.md +138 -0
  135. package/docs/session-store-conformance.md +1 -2
  136. package/docs/session-stores.md +17 -17
  137. package/docs/supervisors.md +32 -12
  138. package/docs/tools.md +18 -1
  139. package/docs/wiki.md +4 -2
  140. package/docs/workflows.md +5 -0
  141. package/package.json +8 -2
@@ -87,6 +87,8 @@ Key exports:
87
87
  | `createFoldedMemoryDetails()` | Create JSON details for compaction `data.memory`. |
88
88
  | `renderObservationalMemory()` | Render reflections and observations into a prepared memory summary. |
89
89
  | `recallObservationalMemory()` | Recover source evidence for a known observation/reflection id from supplied current-branch entries. `invalidatedIds` withholds content (`reason: "revoked"`) without injecting derived text. |
90
+ | `listInvalidatedIds()` (`@arnilo/prism-memory`) | Read the ids one exact scope currently withholds (`corrected` stays) and pass them as `invalidatedIds`, so blocks that rest on a source revoked mid-turn go stale on the next build. Empty for stores without lineage invalidation. |
91
+ | `createObservationalMemoryDropHandler()` | The OM leg of `createDeletionPropagator`: folds the session ledger once and appends one `om.observations.dropped` entry naming every active observation whose id or `sourceEntryIds` intersect the tombstone set (`coversUpToId` omitted — a tombstone set is not a coverage position). Register with `{ session, appendEntry }`; pair it with `listInvalidatedIds()` for the read path. |
90
92
  | `recallObservationalMemoryBranchPage()` | Page eligible user/assistant/tool messages around a cursor entry id (`forward`/`backward`, optional `detail: summary|full`). |
91
93
  | `createMemoryId()` / `isMemoryId()` | Create/check 12-character ids. |
92
94
  | `resolveObservationalMemorySettings()` | Merge `observational-memory` settings with defaults and overrides. |
@@ -94,22 +96,67 @@ Key exports:
94
96
  | `createObservationalMemoryRuntime()` | Low-level explicit flush for advanced hosts or tests. |
95
97
  | `createObservationalMemoryCompactionStrategy()` | Render existing folded memory as a standard Prism compaction summary with `data.memory`. |
96
98
  | `createObservationalMemoryExtension()` | Inert extension helper that registers the strategy contribution unless disabled. |
97
- | `createRecallMemoryTool()` | Optional `recall` tool factory: exact id lookup or current-branch message paging via host-supplied entries. |
99
+ | `createRecallMemoryTool()` | Optional `recall` tool factory: exact id lookup (optionally merged with granted shared scopes) or current-branch message paging via host-supplied entries. |
98
100
  | `createMemoryStatusCommand()` / `createMemoryViewCommand()` | Optional `om:status` and `om:view` command factories. |
99
101
  | `createObservationalMemoryCommands()` | Convenience factory returning status and view commands. |
100
102
 
101
103
  Pure utilities create no events, workers, tools, commands, credentials, or provider requests. `createObservationalMemoryExtension()` and import alone start nothing. `createObservationalMemory().attach()` runs workers only after proxied `run`/`prompt`/`stream`/`compact` complete (or after `wrapResumeRun` / `wrapResumeStream`). `createObservationalMemoryRuntime().flush()` remains for manual/advanced use. Attached `contextProvider` renders two blocks each turn: `observational-memory` (active reflections/observations aligned to the recent-message boundary) and `recent-messages` (last `keepRecentEntries` message entries in branch order, optionally trimmed by `recentMessageMaxTokens` using `estimateEntryTokens`; oldest dropped first). Compaction uses the same `keepRecentEntries` setting. Observer input includes only eligible `message` entries (`user`, `assistant`, `tool`); memory/compaction/bookkeeping entries advance `coversUpToId` scan coverage without entering the observer prompt. Successful observer/reflector runs append coverage markers even when they record zero facts. Reflection uses only active observations recorded after the last `om.reflections.recorded` entry unless `flush({ fullReflectionRebuild: true })`. Attached `flush()` skips with `run_active` while a proxied run is in flight. The compaction strategy is O(n) over supplied entries and makes no provider call.
102
104
 
105
+ ### Revocation wiring (plan 102 Tasks 2/8)
106
+
107
+ A revoked source must stop feeding memory on both sides of the write. The two halves share one tombstone set:
108
+
109
+ ```ts
110
+ import { listInvalidatedIds } from "@arnilo/prism-memory";
111
+ import { buildObservationalMemoryContextBlocks, createObservationalMemoryDropHandler } from "@arnilo/prism-memory/compaction/observational-memory";
112
+
113
+ // Write path: register the OM leg on the host's deletion propagator (see docs/rag.md for the full wiring).
114
+ const handlers = [createObservationalMemoryDropHandler({ session, appendEntry })];
115
+
116
+ // Read path: withhold at build time for a projection whose ledger has no drop entry yet.
117
+ const blocked = await listInvalidatedIds(store, scope); // one scope read; `corrected` ids stay
118
+ const blocks = buildObservationalMemoryContextBlocks(entries, { invalidatedIds: blocked });
119
+ ```
120
+
121
+ - Both paths render the same memory for the same tombstones, so an id is withheld whether or not the physical drop ran — the drop entry answers "what did the revocation retire" for audit, `invalidatedIds` answers "what must not be injected right now".
122
+ - The drop entry carries observation ids only (no observation text), and the fold treats it as a drop rather than progress (`coversUpToId` omitted).
123
+
103
124
  ### Work-scope index (opt-in)
104
125
 
105
- `WorkScope` is a host-named, append-only index over one observational-memory ledger. Without `om.scope.*` entries, the map has only its implicit `session` root, context renders the existing active pool, and the dropper keeps its existing behavior.
126
+ `WorkScope` is a host-named, append-only index over one observational-memory ledger. Without `om.scope.*` entries, the map has only its implicit `session` root, context renders the existing active pool, and the dropper keeps its existing behavior. Shared work scopes extend that index across sessions under explicit grants — see "Shared work scopes (opt-in)" below.
106
127
 
107
- Use `createWorkScopeController({ session, appendEntry, secrets? })` to `open`, `close`, `enter`, `leave`, `bind`, or `unbind` scopes. Scope ids are host-defined (`[A-Za-z0-9._:/-]{1,128}`, no `..`); there are caps of 256 scopes, depth/stack 8, 4,096 binds per scope, and 512 characters for labels or kinds. Invalid ids, missing/closed parents, duplicate scopes, unknown record ids, and ownership mismatch fail closed. Labels and kinds receive the same secret redaction as observational-memory text.
128
+ Use `createWorkScopeController({ session, appendEntry, secrets? })` to `open`, `close`, `enter`, `leave`, `bind`, `unbind`, `grant`, or `revoke` scopes. Scope ids are host-defined (`[A-Za-z0-9._:/-]{1,128}`, no `..`); there are caps of 256 scopes, depth/stack 8, 4,096 binds and 1,024 principals per scope, and 512 characters for labels or kinds. Invalid ids, missing/closed parents, duplicate scopes, unknown record ids, reserved/closed grant targets, and ownership mismatch fail closed. Labels and kinds receive the same secret redaction as observational-memory text.
108
129
 
109
130
  `projectWorkMemory(ledger, map, { from, include, closed?, kinds? })` returns a filtered observation/reflection view plus outline. `include` is `self`, `self+ancestors`, `self+descendants`, or `lineage`; `closed: "hide"` is the default, except closed ancestors of `from` remain available. Default attached context uses the current leaf with `self+ancestors`, rendering Scope Outline, Reflections, then Observations. The compaction summary — the layer the next run's pack starts from — renders the same projection, so the full ledger never rides into the prefix; the folded payload keeps every observation, so entering another scope can still surface what that summary hid. `recallObservationalMemory()` still reads the complete current branch by exact id.
110
131
 
111
132
  After a flush records new observations or reflections, it binds those ids once to the current leaf scope only. A host promotes relevant memory explicitly by binding it to an ancestor; a reflection whose bind sits on a **closed** scope can also graduate into durable semantic memory through the fabric's `remember({ kind: "fact" | "procedure", reflectionId })`. While any host scope exists, the runtime skips the observation dropper; the folded-payload byte cap remains a storage safety cap, not working-set garbage collection. `withWorkScope(controller, spec, fn)` opens `spec` if needed, enters it, runs `fn`, and leaves in `finally`; it never closes a scope. This index does not provide resource-scoped observational memory or budget-based dropping as a working-set mechanism.
112
133
 
134
+ ### Shared work scopes (opt-in)
135
+
136
+ A shared work scope lets several sessions contribute to and read one scope under explicit owner grants. Declare it per participant in `attach()`; see `examples/shared-work-scope.ts` for a runnable grant → contribute → recall → revoke demo:
137
+
138
+ ```ts
139
+ const attached = om.attach(session, {
140
+ appendEntry: (entry, options) => store.append(entry, options),
141
+ sharedScopes: { "build-42": { ownerSessionId: "session-...", entries: (sessionId) => store.list(sessionId) } },
142
+ onScopeAccess: (event) => audit.info("om.scope.access", event),
143
+ });
144
+ ```
145
+
146
+ Requirements, all fail-closed:
147
+
148
+ - The participant opens the scope in its own branch (`open`/`enter`) and binds its own observations/reflections to it. Only ids bound to that exact scope id are shared; memory bound to an ancestor, descendant, or other scope stays private.
149
+ - The owner branch carries `om.scope.granted` / `om.scope.revoked` records (`controller.grant(scopeId, principalIds)` / `revoke`) and is the only grant authority; grants in any other branch are inert. A grant is symmetric read+write — use separate scopes for asymmetric visibility.
150
+ - The host `entries(sessionId)` callback is the store/tenant boundary: the package checks grants, it cannot verify another branch's tenant. Keep the callback inside one `OwnershipScope`.
151
+ - Absent, unknown, revoked, unreachable, or not-opened-locally scope state denies the read and reports `onScopeAccess({ granted: false, reason })`. `onScopeAccess` fires for every decision, granted or denied.
152
+ - Revocation lands on the next read: each resolve re-reads the owner branch and re-folds the grant map. The local folded payload never contains foreign observations, so revocation also holds across local compaction.
153
+
154
+ `resolveSharedScopes({ scopes, principalId, map, onAccess? })` reads the owner branch for grants, then the owner and every granted branch, folds each branch separately, and unions the id-keyed results (`mergeObservationalMemoryLedgers`). Raw entry lists are never concatenated across branches — coverage cursors and projection boundaries are positional per branch. Bound memory is merged into the context blocks, `recallObservationalMemory`, the `recall` tool (exact-id only; branch paging stays current-branch), and `om:view`; `om:status` counts stay session-local.
155
+
156
+ Rendering still follows the work-scope projection: the reader needs the shared scope in its current leaf lineage (`enter`, or a host that keeps it entered) for the context block to include it. Recall by exact id does not depend on the leaf. The compaction strategy and its folded payload stay local, so a shared observation re-enters context from the provider rather than from the summary.
157
+
158
+ Cost: one branch read and fold per participating branch per context resolve (and per `recall` call that resolves shared scopes) — not per observation. Cache per flush only if profiling demands it.
159
+
113
160
  ### Compact-when override
114
161
 
115
162
  `createObservationalMemory()` accepts a compact-when gate beside the settings: `trigger` (the same union `CompactionOptions.trigger` uses) or the `shouldCompact(context)` shorthand. When either is set it **replaces** `context.compactAfterTokens`; omitted, the token gate is unchanged.
@@ -210,7 +257,7 @@ Worker `provider.generate` calls use a **derived** correlation id `om:{session.i
210
257
 
211
258
  The runtime requires host-supplied `session`, an `appendEntry` callback bound to that session's owning store/branch, and at least one worker provider (`observation.provider` / `reflection.provider` / `dropper.provider`). Model selection uses [use-case model selection](use-case-model-selection.md): pass per-worker `model` (or settings `observation.model` / `reflection.model` / `dropper.model`) to override, and `sessionModel: agent.config.model` so workers fall back to the session model when no worker model is configured. `requireExplicitModel: true` restores the historical `missing_model` skip when no explicit worker model is set. It no longer accepts a separate `store` option because mismatched session/store pairs can append memory entries outside the active branch. After each memory append, the runtime checks the appended entry is visible at the session leaf and fails closed/restores the previous checkout if the callback points elsewhere. Optional credential resolution is explicit; missing requested credentials skip worker execution. Default credential requests use the **resolved** model's provider id.
212
259
 
213
- `createObservationalMemoryCompactionStrategy()` keeps recent message entries like the default compaction strategy, renders existing observations/reflections as the summary, and returns a standard Prism compaction entry. Its `data` includes `throughEntryId`, `keepEntryIds`, `strategy`, `trigger`, and `memory: { type: "om.folded", version: 1, fullFold, observations, reflections, droppedObservationIds }`. When active observations exceed `context.observationsPoolMaxTokens`, it performs a full fold and synchronously trims lowest-relevance observations until the folded payload fits hard byte/token caps (or throws a typed error).
260
+ `createObservationalMemoryCompactionStrategy()` keeps recent message entries like the default compaction strategy, renders existing observations/reflections as the summary, and returns a standard Prism compaction entry. Its `data` includes `throughEntryId`, `keepEntryIds`, `strategy`, `trigger`, and `memory: { type: "om.folded", version: 1, fullFold, observations, reflections, droppedObservationIds }`. When active observations exceed `context.observationsPoolMaxTokens`, it performs a full fold and synchronously trims lowest-relevance observations until the folded payload fits hard byte/token caps (or throws a typed error). Because the runtime routes both compaction paths through the pre-strategy `compaction_request` middleware hook, a host can also rewrite the entries this strategy folds without forking it — see [Middleware hooks](middleware-hooks.md).
214
261
 
215
262
  `createRecallMemoryTool()` accepts either `{ id }` for exact memory recall or `{ cursor, limit?, direction?, detail? }` for current-branch raw-message paging (default limit 20, hard cap 100). Reflection recall resolves supporting observations from the full ledger and reports `droppedSupportingObservationIds` / `missingSupportingObservationIds`; dropped supports still return available raw sources. Invalid ids, ambiguous requests, wrong `sessionId`, missing cursors, non-message cursors, and oversized pages fail closed. It does not search by topic.
216
263
 
@@ -220,7 +267,7 @@ The runtime requires host-supplied `session`, an `appendEntry` callback bound to
220
267
 
221
268
  ## Cross-session / delegation-tree recall (opt-in pattern)
222
269
 
223
- Default is per-session: `attach()` + `appendEntry` bind one store/branch, and `recallObservationalMemory(entries, id)` / `createRecallMemoryTool({ getEntries })` see only the entries the host passes for that session. Supervisor children therefore produce observations the parent cannot recall. That is acceptable for v1 — the parent transcript already contains `delegate()` results, so parent OM covers milestones. There is no package primitive for a shared workspace scope (a namespaced multi-tenant store key is out of scope).
270
+ Default is per-session: `attach()` + `appendEntry` bind one store/branch, and `recallObservationalMemory(entries, id)` / `createRecallMemoryTool({ getEntries })` see only the entries the host passes for that session. Supervisor children therefore produce observations the parent cannot recall. That is acceptable for v1 — the parent transcript already contains `delegate()` results, so parent OM covers milestones. When the host can read the participating branches, use a shared work scope instead (above); the funnel below remains the option when it cannot (a namespaced multi-tenant store key is still out of scope).
224
271
 
225
272
  Hosts that need parent recall of child *source* work compose it themselves: wrap the shared `SessionStore.append` so eligible child messages (`isEligibleObservationSourceEntry`) are copied onto a workspace (or parent) session with a **new entry id** and that session's `sessionId`/`parentId`. Parent OM then observes those copies and mints **new** observation ids. Child OM, if attached, stays on the child session with its own ids.
226
273
 
@@ -254,7 +301,7 @@ Wire the wrapped store into both the parent session and each supervisor child fa
254
301
 
255
302
  Rules that keep exact-id recall unambiguous:
256
303
 
257
- - Recall always takes **one** branch (`session.entries()` / `getEntries(sessionId)`). Never concatenate parent + child lists into one `recallObservationalMemory()` call.
304
+ - Recall always takes **one** branch (`session.entries()` / `getEntries(sessionId)`). Never concatenate parent + child lists into one `recallObservationalMemory()` call. Shared work scopes are the supported exception: they union per-branch folded ledgers (id-keyed), never raw entry lists.
258
305
  - Copies mint a new `entry.id`. `createMemorySessionStore` rejects duplicate ids globally; JSONL/DB adapters do too.
259
306
  - Do **not** rewrite the child's OM `appendEntry` onto the workspace session. After each memory append the runtime checks the entry is visible at the **child** leaf and fails closed on a session/store mismatch. Funnel messages; let parent OM observe them.
260
307
  - Do **not** copy `om.*` custom entries across. Their `sourceEntryIds` point at the origin session and would dangle on the workspace branch.
@@ -262,7 +309,7 @@ Rules that keep exact-id recall unambiguous:
262
309
 
263
310
  Cost: the workspace branch grows with every funneled child message; parent `compactAfterTokens` / observation-pool caps still apply but fire sooner. Keep the per-session default unless parent recall of child sources is required.
264
311
 
265
- Ownership: funnel only within the `OwnershipScope` already on the parent agent/store. Child factories receive that ownership from the supervisor; do not share a store across tenants or identities. Observations never leave the store the host scoped.
312
+ Ownership: funnel only within the `OwnershipScope` already on the parent agent/store. Child factories receive that ownership from the supervisor; do not share a store across tenants or identities. Observations never leave the store the host scoped — the same rule applies to shared work-scope grants.
266
313
 
267
314
  ## Security and performance notes
268
315
 
@@ -12,7 +12,7 @@ This is crash recovery for the in-run state, not an orchestrator. The host workf
12
12
  - The host wants a bounded, explicit recovery point rather than "restart the whole run".
13
13
  - An external orchestrator needs to resume a single run without replaying its tools.
14
14
 
15
- For approval suspension and batch decisions, see [Agent/session runtime § Durable interruption](agent-session-runtime.md#durable-interruption); `every-turn` is additive to that machinery and uses the same store, redaction, bounds, fingerprint, and CAS.
15
+ For approval suspension and batch decisions — including approve-with-edits revalidation against the session's restored pack rules — see [Agent/session runtime § Durable interruption](agent-session-runtime.md#durable-interruption); `every-turn` is additive to that machinery and uses the same store, redaction, bounds, fingerprint, and CAS.
16
16
 
17
17
  ## Inputs / request
18
18
 
@@ -23,19 +23,61 @@ For approval suspension and batch decisions, see [Agent/session runtime § Durab
23
23
  | `checkpointPolicy` | `"decision"` (default) persists only on suspension/terminal status. `"every-turn"` adds one running-state checkpoint per provider turn. |
24
24
  | `checkpoints` | The host's `CheckpointStore`; the same store serves suspension, crash recovery, and status. |
25
25
  | `definitionRevision` | Host-authored revision participating in the fingerprint; a change without a revision bump refuses resume. |
26
- | `persistSessionState` | Also carries loaded-skill names and the attention sticky frontier into each turn checkpoint. |
26
+ | `persistSessionState` | Also carries loaded-skill names, the attention sticky frontier, and (plan 104 Task 2/3) the session's guardrail pack refs (or an inline pack's pattern rules) plus each pack's own state snapshot into each turn checkpoint. |
27
27
  | `includeSkillBodies` | Alongside `persistSessionState`, carries exact skill instructions. |
28
28
  | `maxStateBytes` | Save-side byte ceiling (default 256 KB, hard 1 MB). Applies to every turn checkpoint identically. |
29
+ | `checkpointMetadata` | Sidecar map (`Record<string, string>`, ≤ 4 KB, redacted) written with every checkpoint record — never inside the state value, so it costs no `maxStateBytes` budget. A function is resolved at each write, so a host closure can pin state that moves mid-run (git commit, document version). |
30
+
31
+ ```ts
32
+ let head = "commit-1";
33
+ await session.run("investigate", {
34
+ runState: {
35
+ checkpoints,
36
+ definitionRevision: "2026-09-19.1",
37
+ checkpointMetadata: () => ({ gitCommit: head, docVersion: "v12" }),
38
+ },
39
+ });
40
+ head = "commit-2"; // the next checkpoint records the new commit
41
+ ```
42
+
43
+ `AgentRunLifecycle.status()` and `loadAgentRunState()` return the record's `metadata`; `resume` accepts `checkpointMetadata` to annotate the claim write, and without it the recorded map is preserved byte-for-byte across the claim and every later write. Legacy records without metadata read as `undefined` — an oversize or non-string map reads as absent rather than failing the resume.
44
+
45
+ ### Restore hooks (all-or-nothing)
46
+
47
+ `resume` also accepts `restoreHooks`: host code that puts each external layer recorded in `checkpointMetadata` back where the checkpoint says it was. Hooks run sequentially before the claim write, each receiving the checkpoint context (`runId`, `version`, `status`, the redacted `metadata` map, and the raw `checkpoint` record) plus an `AbortSignal` that fires on host abort or the per-hook timeout.
48
+
49
+ ```ts
50
+ await lifecycle.resume(ref, { decision: "approve", expectedVersion }, {
51
+ restoreHooks: [
52
+ async function restoreGit(cp) {
53
+ await git.reset(cp.metadata?.gitCommit);
54
+ },
55
+ async function restoreDocs(cp) {
56
+ await docs.restoreVersion(cp.metadata?.docVersion);
57
+ },
58
+ ],
59
+ restoreHookTimeoutMs: 10_000, // default, per hook
60
+ });
61
+ ```
62
+
63
+ All-or-nothing:
64
+
65
+ - The first hook that throws or overruns `restoreHookTimeoutMs` (default 10 s, `DEFAULT_CHECKPOINT_RESTORE_TIMEOUT_MS`) aborts the resume with `CheckpointRestoreError` — `code: "ERR_PRISM_CHECKPOINT_RESTORE"`, `hook` naming the layer, `cause` the original error. Later hooks do not run.
66
+ - The claim write and the conversation replay happen only after every hook succeeds, so a failed restore leaves the checkpoint byte-for-byte as it was — still resumable — instead of claiming a half-restored world. The server maps the failure to `409`/`ERR_PRISM_CHECKPOINT_RESTORE`.
67
+ - Hooks run on claiming resumes only; `deny` and resuspend paths never call them.
68
+ - The claim's `agent_resumed` event carries the audit: `restore: { hooks: [{ hook, durationMs }], durationMs }`.
69
+ - Register once on the lifecycle (`createAgentRunLifecycle({ restoreHooks })`) or per resume; lifecycle-registered hooks run first. No hooks ⇒ no call, no overhead, no `restore` field.
29
70
 
30
71
  Resume uses `resumeAgentRun` / `resumeAgentRunStream` with `{ expectedVersion, decision: "continue" }`. The checkpoint records its own cadence, so a continued run keeps writing turn checkpoints without the host repeating `checkpointPolicy`.
31
72
 
32
73
  ## Outputs / response / events
33
74
 
34
- Each turn checkpoint is a normal durable state (schema v1) carrying status `running`, the current `leafId`, run counters and wall deadline, loop-local state when the loop declares `snapshot`/`restore`, the run's `toolNames` grant, and — with `persistSessionState` — the loaded-skill catalog plus sticky attention frontier. Hard gates are unchanged: CAS `expectedVersion`, ownership/fencing, redaction at the checkpoint boundary, `maxStateBytes`, and the agent fingerprint (`agentFingerprint`) over id, revision, model, instructions, system prompt, skills, tools, guardrails, and loop revision.
75
+ Each turn checkpoint is a normal durable state (schema v1) carrying status `running`, the current `leafId`, run counters and wall deadline, loop-local state when the loop declares `snapshot`/`restore`, the run's `toolNames` grant, and — with `persistSessionState` — the loaded-skill catalog, sticky attention frontier, and the guardrail pack block (`sessionState.guardrailPacks`: `{ packs: [{ id, version, options?, rules? }], state?: { <packId>: <pack state> } }`, ≤ 8 packs and ≤ 64 rules each, ≤ 8 KiB per pack row/rule/options/state, ids ≤ 96 chars, redacted like all state). Measured cost: a 656-byte `persistSessionState` checkpoint grows by 185 bytes for all four built-in packs' rows (≈ 46 bytes/pack); a `validation-respect` row with non-default options and live `validationFailed` state adds ≈ 169 bytes in total. Hard gates are unchanged: CAS `expectedVersion`, ownership/fencing, redaction at the checkpoint boundary, `maxStateBytes`, and the agent fingerprint (`agentFingerprint`) over id, revision, model, instructions, system prompt, skills, tools, guardrails, and loop revision.
35
76
 
36
77
  A crash leaves the last checkpoint at status `running`. `decision: "continue"` accepts exactly that: a running checkpoint with no interruption and no unresolved pending decisions. Everything else fails closed with `AgentRunStateError` and zero checkpoint writes:
37
78
 
38
79
  - `expectedVersion` mismatch, ownership/fencing mismatch, revision or fingerprint mismatch (`Stale or non-running agent run resume`, `Agent revision or fingerprint mismatch on resume`).
80
+ - `sessionState.guardrailPacks` that cannot be replayed: an unknown pack id, an inline rule carrying a `deny` predicate or `RegExp` pattern, a row `version` that no longer matches the installed pack definition, a pack whose state has no codec, more than 8 rows (or 64 rules in one), or malformed/oversized pack state. The resume refuses with `AgentRunStateError` and dispatches nothing — a suspended run's enforcement never silently downgrades. A checkpoint written before plan 104 (no key) resumes with no packs and no error.
39
81
  - Status `suspended` — approvals, elicitations, and input guardrails still require `approve`/`deny` or a `RunDecision` batch; `continue` never bypasses a gate.
40
82
  - Any interruption, pending decision, or ready-to-dispatch pending call recorded in the state.
41
83
 
@@ -82,6 +124,7 @@ The complete network-free demo — one tool execution across the crash, resumed
82
124
  ## Security and performance notes
83
125
 
84
126
  - `"continue"` is a host-API action only. Prism's AG-UI interrupt resolution accepts `approve`/`deny` only, channel adapters resume with `deny`, and there is no server route that forwards an untrusted `continue`; adding one would create an approval-bypass path.
127
+ - Restore hooks are trusted host code running outside the sandbox: they see the checkpoint's (already redacted) sidecar map and are bounded only by their timeout. Because they run before the claim write, a timeout cannot leave a claimed checkpoint pointing at un-restored external state.
85
128
  - Every gate that protects a suspension protects a continue resume: exact ownership, fencing token, fingerprint, revision, CAS version, and the absence of unresolved work. A running checkpoint is a recovery point, never an authorization.
86
129
  - Cost is one bounded checkpoint write per provider turn (same redaction and `maxStateBytes` ceiling as suspension writes). A 40-turn investigation under `"every-turn"` therefore writes 40 checkpoint rows plus the terminal save, while the default `"decision"` policy writes at most one row per approval or suspension. Each row carries the run frontier, counters, run limits, and loop snapshot — not the message history, which stays in the session store and is pointed at by `leafId` — so the store grows with turns, not with turns × transcript; a state that would exceed `maxStateBytes` (default 256 KiB, `DEFAULT_MAX_AGENT_RUN_STATE_BYTES`) fails closed rather than truncating. Pick `"every-turn"` when a worker restart must cost at most one turn of thinking, and leave the default for runs with many cheap turns.
87
130
  - Checkpoints never contain provider objects, callbacks, signals, credentials, or raw secrets; the payload is bounded and redacted like any other durable state.
@@ -87,6 +87,15 @@ await runEmbeddingsConformance({
87
87
  bridge structurally (`createAlibabaEmbedder` remains assignable to `Embedder`
88
88
  without importing it). The contract is a superset: it adds usage and per-item
89
89
  error mapping.
90
+ - The same host-seam posture covers local inference in retrieval: the RAG local
91
+ reranker (`resolveReranker({ kind: "local" })` / `createLocalReranker`) runs a
92
+ cross-encoder in the host process through a `LocalRerankRuntime`, so no
93
+ inference dependency name enters any manifest — see
94
+ [RAG local reranker](rag.md#local-reranker). A host that runs both an embedder
95
+ and the reranker through the same runtime should point them at one weight cache:
96
+ one `cacheDir` per host (e.g. `~/.cache/prism/models`), one subdirectory per
97
+ model id, so each model is downloaded once and shared by every process on that
98
+ host; a cache miss downloads into that directory and later runs stay on disk.
90
99
  - Adapters never auto-chunk: a batch over the provider cap rejects with
91
100
  `batch_too_large`, so `embedBatched`-style callers own batching and preserve
92
101
  per-item error attribution.
@@ -31,6 +31,7 @@ Use this package when a host needs offline quality checks or sampled live scorin
31
31
  | `createSchemaScorer` | Validates final result or named step output against JSON schema |
32
32
  | `createErrorClassScorer` | Fails closed if denied error codes or blocked executions appear on timeline |
33
33
  | `createApprovalBeforeEffectScorer` | Verifies explicit approval occurred on timeline prior to sensitive tool effect |
34
+ | `createDeterministicTurnScorer` | Requires host-answered (no-model) turns with intact provenance: `minTurns` deterministic steps, optionally from one `middleware`, and no provider request inside those turns |
34
35
  | `createCitationIntegrityScorer` | Invariant 0 on missing source, hash/span mismatch, or revoked ACL. Reads `environment.citations[]`. Ignores semantic `support`. |
35
36
  | `runComparison` | immutable dataset, 2–8 named candidates by default, pairwise scorers |
36
37
  | `assertEvaluationThreshold` / `serializeEvaluationReport` | mean/failure/per-scorer gates, hard invariant enforcement, and bounded redacted JSON |
@@ -267,6 +268,10 @@ The spawn pack (`@arnilo/prism-core/governance/evals` `spawn-pack.test.ts`) grad
267
268
 
268
269
  Negative controls wire deliberately vulnerable host compositions — uncatalogued spawn, skipped reservation, model-supplied scope escalation, leaky child tool list, non-aborting cancel, ungated ship — and assert the matching grader reports `0` naming the violation.
269
270
 
271
+ ## Guardrail-pack trajectory scenarios (plan 092)
272
+
273
+ `guardrail-pack-scenarios.test.ts` gives every built-in [guardrail pack](guardrails.md#guardrail-packs) a violating and a compliant trajectory, graded by `createGuardrailPackScorer()` on the projected timeline: a denying guardrail step scores `0` and names `metadata.guardrail` (`pack:<pack>/<rule>`), a compliant trajectory scores `1` with no pack denial, and a `forbidTools` call that executed fails as an enforcement escape instead of passing vacuously. Each scenario runs `runScenario({ agent, turns, sessionConfig: { guardrailPacks: [...] }, timeline: "metadata" })` against a scripted mock provider; a pack-absent control re-runs the destructive script with no packs to prove the blocked calls were blocked by the pack.
274
+
270
275
  ## PostgreSQL enterprise state (0.0.23)
271
276
 
272
277
  `createPostgresEnterpriseState({ pool, schema }).evaluations` implements this package's existing `EvaluationStore`. The host creates an `EvaluationRecord` from verified ownership before append; every PostgreSQL query requires tenant scope, uses exact normalized account/user matching, and returns owner-bound opaque cursor pages. It is durable across reopen and supports the existing id/scorer/session/run/trace/dataset/item/experiment/status filters.
@@ -68,6 +68,8 @@ interface ExecutionTimeline {
68
68
  readonly sessionId?: string;
69
69
  readonly workflowId?: string;
70
70
  readonly workflowRevision?: string;
71
+ /** Workflow checkpoint sidecar metadata (`WorkflowCheckpointValue.metadata`); present only when projected with a checkpoint. */
72
+ readonly workflowMetadata?: Readonly<Record<string, unknown>>;
71
73
  readonly traceId?: string;
72
74
  readonly status: string;
73
75
  readonly stopReason?: AgentFinishReason;
@@ -77,7 +79,10 @@ interface ExecutionTimeline {
77
79
  readonly input?: unknown;
78
80
  readonly result?: unknown;
79
81
  readonly usage?: Usage;
82
+ readonly cacheHitRate?: number;
80
83
  readonly steps: readonly ExecutionStep[];
84
+ readonly turns?: readonly TimelineTurn[];
85
+ readonly exhaustion?: TimelineExhaustion;
81
86
  readonly redacted: boolean;
82
87
  readonly content: TimelineContentPolicy;
83
88
  }
@@ -104,10 +109,55 @@ interface ExecutionStep {
104
109
  }
105
110
  ```
106
111
 
107
- Step kinds: `"run"`, `"turn"`, `"provider"`, `"tool"`, `"guardrail"`, `"delegation"`, `"compaction"`, `"attention"`, `"retry"`, `"hitl"`, `"artifact"`, `"workflow_node"`, `"loop_iteration"`, `"nested_workflow"`.
112
+ Step kinds: `"run"`, `"turn"`, `"deterministic"`, `"provider"`, `"tool"`, `"guardrail"`, `"delegation"`, `"compaction"`, `"attention"`, `"retry"`, `"hitl"`, `"artifact"`, `"workflow_node"`, `"loop_iteration"`, `"nested_workflow"`.
113
+
114
+ ### `TimelineTurn` and `TimelineExhaustion`
115
+
116
+ ```ts
117
+ interface TimelineTurn {
118
+ readonly turn: number;
119
+ readonly status: ExecutionStepStatus;
120
+ readonly startedAt: string;
121
+ readonly finishedAt?: string;
122
+ readonly durationMs?: number;
123
+ readonly providerAttempts: number;
124
+ readonly cacheHitRate?: number;
125
+ readonly budgets?: TurnBudgets;
126
+ readonly stopReason?: ProviderStopReason;
127
+ }
128
+
129
+ interface TimelineExhaustion {
130
+ readonly limit: RunLimitName;
131
+ readonly maximum?: number;
132
+ readonly observed?: number;
133
+ readonly currency?: string;
134
+ readonly consumed?: BudgetConsumedCounters;
135
+ readonly closestOtherAxes: readonly BudgetAxisUsage[];
136
+ readonly recentToolCalls: readonly ToolCallSummary[];
137
+ }
138
+ ```
139
+
140
+ `turns` is the per-turn trace, derived in one pass over folded provider steps: turn number, status,
141
+ timing, attempts (retries included), input-token-weighted `cacheHitRate`, the last provider
142
+ attempt's recorded `budgets` (with its `inputTokensSource` provenance label), and its stop reason.
143
+ Cache rate is absent when cache usage is unknown;
144
+ `budgets` is copied verbatim from `provider_turn_finished.metadata.budgets` and is absent on legacy
145
+ events. `ExecutionTimeline.cacheHitRate` is the same input-token-weighted calculation across all
146
+ provider attempts. The stop reason also rides the `provider` step's metadata (`metadata.stopReason`),
147
+ so a flat renderer can badge attempts without walking `turns`. `turns` is absent on timelines with
148
+ no turn steps (workflow timelines).
149
+
150
+ `exhaustion` is the terminal limit attribution, present only when the run died on a run limit. It
151
+ joins `run_limit_exceeded` (`limit`, `maximum`, `observed`, `currency`) with `budget_exhausted`
152
+ (`consumed`, `closestOtherAxes`, `recentToolCalls`); a trace that recorded only the breach carries the
153
+ first group and empty axes. Argument hashes only — `recentToolCalls` never contains raw arguments.
108
154
 
109
155
  `attention_compiled` folds into a one-step `"attention"` entry (status `succeeded`) whose metadata carries the measured counts (`used`, `usedAfter`, `inputCap`, `triggerRatio`, `droppedThinkingTurns`, `stubbedToolResults`, `stubbedBytes`, `truncated`); under-ratio turns emit no event, so they add no step.
110
156
 
157
+ `deterministic_turn` folds into a `"deterministic"` step whose `name` is the answering middleware id and whose metadata carries `{ turn, middleware }`. A deterministic turn has no provider step, no `usage`, and no `stopReason`, so a host-answered turn can never be read as model output; its `turns` entry carries `providerAttempts: 0`, and `summarizeTimeline()`/`summarizeSession()` split the turn count into `turns: { model, deterministic }`. The same provenance is copied onto the assistant message as `message.metadata.deterministic = { middleware }`, so the persisted transcript alone proves the turn had no model behind it.
158
+
159
+ `guardrail_decision` folds into a `"guardrail"` step whose `name` is the stage (`input`/`output`/`tool_input`/`tool_output`) and whose metadata carries `action`, the rule identity `metadata.guardrail` (compiled packs name it `pack:<pack>/<rule>`, other guardrails their configured name), and `toolName`/`toolCallId` when the decision is tool-scoped. A denying action (`deny`, `block`, `tripwire`) sets status `denied`; `interrupt` (a pack `ask` rule in a durable run) sets status `succeeded` and leaves the run `suspended` awaiting a decision, while the same rule in a run that cannot suspend reports `block` and sets `denied`; the free-text guardrail reason stays on the event, not the step.
160
+
111
161
  Step statuses: `"running"`, `"succeeded"`, `"failed"`, `"blocked"`, `"skipped"`, `"suspended"`, `"denied"`, `"aborted"`.
112
162
 
113
163
  Tree structure: steps are a flat ordered array. Tree via `parentId` (run → turn → provider/tool). Scorers iterate the flat array; UIs that need nesting walk `parentId`.
@@ -138,6 +188,7 @@ const timeline = projectTraceTimeline(trace, {
138
188
  redactor: createSecretRedactor(secrets),
139
189
  });
140
190
  // timeline.steps.map(s => [s.order, s.kind, s.name, s.status])
191
+ // timeline.turns.map(t => [t.turn, t.cacheHitRate, t.budgets, t.stopReason])
141
192
  ```
142
193
 
143
194
  ### Workflow fold with checkpoint outputs
@@ -158,6 +209,33 @@ See runnable host demo in `examples/execution-timeline.ts` for offline workflow
158
209
 
159
210
  Run-level `stopReason` mirrors `agent_finished.finishReason` when the loop stopped on a ceiling or a host turn policy (`"host_policy"`); `status` reads `finished:<stopReason>` for those runs and `succeeded` for a natural end. `stopDetail` carries the host's `turnPolicy.stop` reason, bounded to 256 bytes and redacted at the runtime boundary. See [Runs and usage ledger § Clean stops and stop reasons](runs-and-usage.md#clean-stops-and-stop-reasons).
160
211
 
212
+ Per-turn stop reasons are a separate, closed taxonomy (`ProviderStopReason`: `end_turn`, `tool_calls`,
213
+ `max_output_tokens`, `content_filter`, `abort`, `provider_error`, `unknown`) because they answer a
214
+ different question — why the *provider* returned, not why the loop ended. Each `provider_turn_finished`
215
+ badges its turn (`timeline.turns[i].stopReason`) and its provider step (`metadata.stopReason`). See
216
+ [Agent events](agent-events.md) § Provider turn events.
217
+
218
+ A run that died on a run limit packs its attribution into the timeline and the summary line:
219
+
220
+ ```ts
221
+ import { projectTraceTimeline, summarizeTimeline } from "@arnilo/prism-core/governance/observability";
222
+
223
+ const timeline = projectTraceTimeline(trace);
224
+ // timeline.turns.map(t => [t.turn, t.stopReason]);
225
+ // [[1, "tool_calls"], [2, "end_turn"]]
226
+ // timeline.exhaustion;
227
+ // { limit: "maxTurns", maximum: 12, observed: 13,
228
+ // consumed: { turns: 13, inputTokens: 41_200, providerAttempts: 13, requestBytes: 1_048_576 },
229
+ // closestOtherAxes: [{ axis: "maxToolCalls", usedRatio: 0.625 }],
230
+ // recentToolCalls: [{ id: "tc_91", name: "searchCodebase", argHash: "sha256:9f.." }] }
231
+
232
+ summarizeTimeline(timeline).exhaustion;
233
+ // "maxTurns exhausted (13/12); closest: maxToolCalls 0.625"
234
+ ```
235
+
236
+ `summarizeTimeline().exhaustion` is one renderable dashboard line; runs that ended any other way omit
237
+ it, and `summarizeSession()` keeps each run's line in its `runs` array.
238
+
161
239
  ## Bounds
162
240
 
163
241
  | Dimension | Default | Hard cap |
@@ -8,6 +8,7 @@ APIs:
8
8
 
9
9
  - `createExtensionKernel()` / `ExtensionKernel`
10
10
  - `createExtensionEventBus()` / `ExtensionEventBus`
11
+ - `forwardAgentEvents()` / `AgentEventBridgeOptions`
11
12
  - `ExtensionAPI`, `ExtensionEvent`, and `extension_error` events
12
13
  - Shared `MiddlewareRegistry` access and `api.use()` registration
13
14
 
@@ -42,7 +43,8 @@ createExtensionEventBus(options?: { errorPolicy?: "event" | "throw"; secrets?: r
42
43
  - `kernel.events.on(type, handler)` registers ordered event handlers and returns an unsubscribe function.
43
44
  - `kernel.events.emit(event)` calls matching handlers in registration order.
44
45
  - `kernel.middleware.run(hook, value)` runs matching middleware in registration order.
45
- - `activateKernel(kernel)` copies the `createAgent()` array slots into one config: `{ tools, skills, instructionInjectors, context, commands, middleware }`. Contributions stay inert until the host passes them into runtime config; single-slot builders, `compaction`/`retry`, provider/model selection, and skill activation remain host-owned decisions.
46
+ - `forwardAgentEvents(source, events, options?)` is host-invoked wiring for a live `AgentEvent` stream: it maps `agent_started` → `before_agent_start`, `turn_started`/`turn_finished` → `turn`, `tool_execution_started` → `tool_call`, and `tool_execution_finished` → `tool_result`, carrying the original event as read-only `payload`. Other events are ignored. Handlers run in event order and never in the run's path, so a slow or throwing listener cannot stall or fail the observed run; the returned function stops forwarding and releases the source iterator. Bridge failures go to `options.onError` (or become `extension_error` under the bus's own policy) — never to the run.
47
+ - `activateKernel(kernel)` copies the `createAgent()` array slots into one config: `{ tools, skills, instructionInjectors, context, stopHooks, commands, middleware }`. Contributions stay inert until the host passes them into runtime config; single-slot builders, `compaction`/`retry`, provider/model selection, and skill activation remain host-owned decisions.
46
48
  - With default `errorPolicy: "event"`, setup/listener/middleware errors become `extension_error` events with redacted `ErrorInfo`.
47
49
  - With `errorPolicy: "throw"`, setup/listener/middleware errors reject/throw.
48
50
 
@@ -58,7 +60,7 @@ createExtensionEventBus(options?: { errorPolicy?: "event" | "throw"; secrets?: r
58
60
  ## Implementation example
59
61
 
60
62
  ```ts
61
- import { activateKernel, createAgent, createExtensionKernel, type Extension } from "@arnilo/prism";
63
+ import { activateKernel, createAgent, createExtensionKernel, forwardAgentEvents, type Extension } from "@arnilo/prism";
62
64
 
63
65
  const extension: Extension = {
64
66
  name: "demo-extension",
@@ -76,9 +78,11 @@ const extension: Extension = {
76
78
  api.registerAgent({ name: "demo", create: () => createAgent({ model, provider }) });
77
79
  api.registerCompactionStrategy({ name: "compact", compact: () => ({ summary: "summary" }) });
78
80
  api.registerRetryPolicy({ name: "retry", decide: () => ({ retry: false }) });
79
- api.on("session_start", (event) => {
81
+ api.registerStopHook({ name: "checklist", decide: (ctx) => (ctx.stopHookActive ? { action: "stop" } : { action: "continue", reason: "Verify the checklist." }) });
82
+ api.on("demo:ready", (event) => {
80
83
  console.log(event.type);
81
84
  });
85
+ api.use("session_start", (payload) => payload);
82
86
  api.use("provider_request", (request) => request);
83
87
  api.use("compaction", (payload) => payload);
84
88
  api.use("retry", (payload) => payload);
@@ -105,9 +109,16 @@ const agent = createAgent({
105
109
  tools: activated.tools,
106
110
  skills: activated.skills,
107
111
  instructionInjectors: activated.instructionInjectors,
112
+ stopHooks: activated.stopHooks,
108
113
  context: activated.context,
109
114
  middleware: activated.middleware,
110
115
  });
116
+
117
+ // Forward live AgentEvents onto the bus; stop() ends forwarding and releases the subscription.
118
+ const session = agent.createSession();
119
+ const stop = forwardAgentEvents(session.subscribe(), kernel.events, { onError: (error) => console.warn(error) });
120
+ // const run = await session.run("Hi");
121
+ // stop();
111
122
  ```
112
123
 
113
124
  ## Extension and configuration notes
@@ -120,6 +131,9 @@ const agent = createAgent({
120
131
  - `api.registerInputBuilder()`, `api.registerPromptBuilder()`, and `api.registerContextProvider()` contribute inert builders/providers; they do not replace defaults or run until the host passes selected entries to Phase 5 helpers.
121
132
  - `api.registerSkill()` contributes an inert `Skill` to `registries.skills`; it does not disclose instructions, activate referenced tools, or grant permissions until the host selects it.
122
133
  - `api.registerInstructionInjector()` (Phase 30) contributes an inert `InstructionInjector` to `registries.instructionInjectors`; it grants no tools, skills, or permissions and is only applied when the host selects it via `AgentConfig.instructionInjectors`/`RunOptions.instructionInjectors`. See [Instruction injection](instruction-injection.md).
134
+ - `api.registerStopHook()` contributes an inert run-end `StopHook` to `registries.stopHooks`; `activateKernel()` copies it into `stopHooks` for `createAgent({ stopHooks })`, and `LoadedExtension.dispose()` unwinds it. Hooks decide at a natural loop end only — see [Hooks](hooks.md).
135
+ - `forwardAgentEvents()` is host-invoked wiring, not a runtime default, and it observes only: the bus never transforms what the run sees. Prefer `session.subscribe()` directly when the host wants the raw stream; use the bridge when extension packages already listen on the bus.
136
+ - Session lifecycle middleware (`session_start`/`session_shutdown`) is dispatched by the agent/session runtime when the host passes its registry to `AgentConfig.middleware` — see [Middleware hooks](middleware-hooks.md).
123
137
  - `api.registerProviderPackage()`, `api.registerAuthMethod()`, `api.registerProviderRequestPolicy()`, and `api.registerSystemPromptContribution()` contribute inert provider-package data; they do not load packages, resolve credentials, mutate provider payloads, or change prompts until selected by a host/runtime helper that documents that behavior.
124
138
  - `api.registerAgent()` contributes an inert `AgentDefinition`; its `create()` can call `createAgent()`, but the runtime is not started until host code resolves the definition and creates/runs a session.
125
139
  - The kernel registers middleware only into the explicit registry returned by `createMiddlewareRegistry()` or provided by the host.
@@ -144,7 +158,10 @@ const agent = createAgent({
144
158
  - [Contribution registries](contribution-registries.md): registry bundle populated by `ExtensionAPI`.
145
159
  - [Contribution discovery (workspace)](contribution-discovery.md): filesystem-driven complement to extension registration — opt-in scan without `import()` or activation.
146
160
  - [Tools](tools.md): host activation, filtering, and dispatch for contributed tool definitions.
161
+ - [Middleware hooks](middleware-hooks.md): hook names, payloads, and the dispatched `session_start`/`session_shutdown` call sites.
162
+ - [Agent events](agent-events.md): the `AgentEvent` union the bridge forwards.
147
163
  - [Instruction injection](instruction-injection.md): package injectors that layer instructions and context blocks for `first_turn`/`every_turn`/`on_input` without granting tools.
164
+ - [Hooks](hooks.md): the hook model and event map, plus run-end stop hooks contributed through `ExtensionAPI.registerStopHook()`.
148
165
  - [Input and prompt assembly](input-and-prompt-assembly.md): host selection for contributed input/prompt builders.
149
166
  - [System prompts](system-prompts.md): host selection for contributed system prompt layers.
150
167
  - [Context and skills](context-and-skills.md): host selection and tool checks for contributed context providers and skills.
@@ -6,7 +6,7 @@ Guardrails are typed, fail-closed checks at input, completed provider output, to
6
6
 
7
7
  ## When to use it
8
8
 
9
- Use guardrails to block unsafe prompts, model responses, tool arguments, or tool results before their next boundary. Use a redactor for known secrets. Do not treat guardrails as a sandbox, secret detector, permission policy, or validation replacement.
9
+ Use guardrails to block unsafe prompts, model responses, tool arguments, or tool results before their next boundary. Use a redactor for known secrets. Do not treat guardrails as a sandbox, secret detector, permission policy, or validation replacement. For how guardrails combine with middleware, injectors, and stop hooks — and where each Claude Code / Codex hook event lands — see [Hooks](hooks.md).
10
10
 
11
11
  ## Inputs / request
12
12
 
@@ -26,6 +26,8 @@ const guardrails: Guardrails = { input: [pii], maxConcurrency: 1 };
26
26
 
27
27
  Set `AgentConfig.guardrails` for every session run or `RunOptions.guardrails` to append checks for one run. `DispatchToolCallOptions.guardrails`, workflow `RunWorkflowOptions.guardrails`, and MCP server `CreatePrismMcpServerOptions.guardrails` apply tool stages to direct calls. A stage has `Guardrail<"input" | "output" | "tool_input" | "tool_output">`, a name, optional revision, and `evaluate(context)` result.
28
28
 
29
+ `AgentSessionConfig.guardrailPacks` compiles declarative, restrictive-only rule sets onto the tool stages once per session (see [Guardrail packs](#guardrail-packs)). Session packs merge after `AgentConfig.guardrails` and before `RunOptions.guardrails`. Hosts that dispatch tools directly can compile the same config with `compileGuardrailPacks(refs)` and pass the result as `DispatchToolCallOptions.guardrails`.
30
+
29
31
  Decisions are `allow`, `block`, `tripwire`, or `interrupt`. Evaluation defaults to declaration-order sequential. `maxConcurrency` may be 1–16; records are emitted in declaration order. Thrown or malformed decisions become a fail-closed tripwire. A throwing guardrail produces a `guardrail_failed` record whose `metadata.error` carries the underlying error message — redacted and bounded to 4 KiB — so failures stay diagnosable without leaking internals. Decision reasons are capped at 4 KiB and metadata at 16 KiB after JSON normalization and optional redaction.
30
32
 
31
33
  ## Outputs / response / events
@@ -38,7 +40,7 @@ Action outcome by stage:
38
40
  | --- | --- | --- | --- |
39
41
  | `input` | run rejected (`GuardrailError`); steered message: dropped + `steer_rejected`, run continues | run rejected; steered message: dropped + `steer_rejected`, run continues | fresh durable run: suspends for approval; otherwise fails closed |
40
42
  | `output` | run rejected | run rejected | fails closed (`ERR_PRISM_GUARDRAIL_INTERRUPT_UNAVAILABLE`) |
41
- | `tool_input` | blocked `ToolResult`, run continues | run rejected | fails closed |
43
+ | `tool_input` | blocked `ToolResult`, run continues | run rejected | fails closed for a hand-written guardrail; a compiled pack `ask` rule gates the call instead (see [Guardrail packs § `ask`](#asking-for-approval-ask-rules)); approve-with-edits decisions are revalidated at decision time, so an edit into a pack-violating state is refused with `ERR_PRISM_DECISION_INVALID` before the decision is recorded |
42
44
  | `tool_output` | blocked `ToolResult`, run continues | run rejected | fails closed |
43
45
 
44
46
  The `GuardrailError` message names the stage so unsupported `interrupt` placements are diagnosable without reading core source.
@@ -70,6 +72,50 @@ const agent = createAgent({ model, provider, guardrails: { input: [pii], output:
70
72
  await agent.createSession().run("Draft reply", { guardrails: { toolInput: [commandGuard] } });
71
73
  ```
72
74
 
75
+ ## Guardrail packs
76
+
77
+ A pack is configuration, not code: rules compile once per session onto the existing `tool_input` / `tool_output` seams. Packs can only deny, tripwire, or ask for approval — they never grant permissions, widen arguments, or add a stage. A `deny` rule that matches produces the standard refusal-shaped `ToolResult`; `tripwire` additionally rejects the enclosing run.
78
+
79
+ A blocked call tells the model which rule refused it, so it stops retrying the same call: `ToolResult.error.message` is `Blocked by guardrail rule <identity>` — with `: <reason>` appended when the pack configured one, where `<identity>` is `pack:<pack>/<rule>` — bounded to 200 bytes and redacted like every other guardrail record. Tool arguments never appear in the text. A guardrail the host wrote by hand keeps the neutral `Tool call blocked by guardrail` / `Tool result blocked by guardrail` line: a pack identity is taken from the compiled rule's metadata, never synthesized from a name. Compiler-synthesized default reasons are omitted rather than echoed twice, and a reason that would push the line past the cap is truncated, so the identity always survives. The same text lands on the `tool_execution_blocked` event's `error.message` while the event's `reason` stays the machine code (`guardrail_blocked`).
80
+
81
+ ```ts
82
+ const session = agent.createSession({
83
+ guardrailPacks: ["secrets-hygiene"],
84
+ // or, with options / inline rules:
85
+ guardrailPacks: [
86
+ { id: "coding-standard", options: { cwd: "/repo", roots: ["/repo"] } },
87
+ {
88
+ id: "my-pack",
89
+ version: 1,
90
+ rules: [{ id: "no-etc", tool: "write", pattern: "^/etc/", reason: "system path" }],
91
+ },
92
+ ],
93
+ });
94
+ ```
95
+
96
+ Built-in pack ids are public surface and versioned:
97
+
98
+ | Pack | Rules | Notes |
99
+ | --- | --- | --- |
100
+ | `coding-standard` | `no-unrelated-file-edits`, `no-test-rewrites` | Applies to `write`/`edit`/`delete`/`move`. `options.roots` defaults to `[process.cwd()]`; `options.cwd` is the resolution base. Containment is lexical — symlinks are not resolved, so an `ExecutionPolicy` remains the hard boundary. |
101
+ | `destructive-commands` | `no-recursive-force-delete`, `no-long-flag-force-delete`, `no-force-push` (includes `--force-with-lease`), `no-destructive-sql`, `no-device-overwrite` | Matched against the `shell` tool's `command` argument. |
102
+ | `validation-respect` | `no-mutation-after-failed-validation` | Observes `options.validationTools` (default `test`, `run_tests`, `validate`, `validation`, `lint`, `typecheck`, `check`). A result carrying an error or a non-zero `exitCode` marks validation failed; the next successful validation clears it. Opt `shell` in explicitly when validations run through the shell tool. |
103
+ | `secrets-hygiene` | `no-secret-material-in-arguments` | Scans argument strings (bounded depth and count) for credential shapes: `sk-`, `gh[pousr]_`, `AKIA…`, PEM private-key headers, JWTs, `xox[baprs]-`. Prism redaction replaces exact known values only, so these patterns ship with the pack. |
104
+
105
+ Inline rule shape: exactly one of `pattern` (string or `RegExp`, compiled once) or `deny(args, context)` (typed predicate, host-trusted like all host code); optional `tool` (name or names; omitted matches every tool), `argPath` (dot path or paths such as `command` or `["from", "to"]`; omitted scans every argument string), `action` (`deny` default, `tripwire`, or `ask`), and `reason`. Predicates receive `{ toolName, toolCallId, sessionId, runId, metadata, state }`, where `state` is pack-local and read-only.
106
+
107
+ ### Asking for approval (`ask` rules)
108
+
109
+ `action: "ask"` gates exactly the calls the rule matches, without the all-tools `interruptBeforeTool` gate. It requires `pattern` (an opaque `deny` predicate cannot raise an approval — it stays available for silent denials) and behaves by run shape:
110
+
111
+ - **Durable run** (`runState` set): the call suspends before dispatch, in a `tool_approval` interruption whose reason names `pack:<pack>/<rule>` and whose pending decision also carries `guardrail` and `guardrailRule: { pack, rule }`. `allow_once` dispatches once; `allow_for_run` sticks to the same decision scope (tool, argument hash, effect kind, identity); `reject_once`/`reject_for_run` continue the run with a refusal-shaped `ToolResult` and never execute the tool. The gate costs one rule evaluation per tool call at charge time, and the decision reaches the timeline as a `guardrail` step with `action: "interrupt"`.
112
+ - **Non-durable run** (no `runState`): nothing can resume a suspension, so the rule is an ordinary `block` — the run continues, the call never executes, and `ToolResult.error.message` names the rule (`Blocked by guardrail rule pack:<pack>/<rule>` plus the pack's `reason` when it configured one, bounded to 200 bytes).
113
+ An approval never widens a pack: after `allow_*` the call still runs the ordinary `tool_input` stage, so a `deny` rule that matches the same call blocks it. An approve-with-edits decision is revalidated against the packs the resumed session restored — the `deny`/`tripwire` rules plus the `ask` rules compiled as blocks — before the decision is recorded, so an edited argument set that still trips a rule is refused at decision time with `ERR_PRISM_DECISION_INVALID` naming the rule (bounded and redacted, never echoing the arguments). The rules come from the session's checkpoint-restored packs and are passed in explicitly, never merged into `agent.config.guardrails`, so no other session of that agent inherits them.
114
+
115
+ Every evaluated rule emits a `guardrail_decision` event; the denying record's `guardrail` is `pack:<pack>/<rule>` and its `metadata` is `{ pack, rule, version }` — never tool arguments. `describeGuardrailPacks(refs)` returns the same identity rows (`pack:<pack>/<rule>`, stage, `pack@version`) that `snapshotRunBundle()` reports for the session config. Malformed config (unknown id, duplicate pack or rule id, both `pattern` and `deny`, invalid regex, `ask` with a `deny` predicate, an unknown `action` value) throws `GuardrailPackError` at session creation instead of silently dropping a rule. Under `persistSessionState` (plan 104 Task 2/3), the checkpoint also carries each pack's row — a registered pack by `{ id, version, options? }`, an inline pack by its `pattern` rules (a `deny` predicate or `RegExp` pattern cannot round-trip and refuses the save) — plus its own state-codec output (≤ 8 packs, ≤ 8 KiB per pack, ids ≤ 96 chars); a resume recompiles the rows and refuses with `AgentRunStateError` on an unknown id, a version mismatch against the installed pack, or malformed/oversized state — a suspension never silently downgrades enforcement. `session.guardrailPackRefs` exposes the refs a session (including a resumed one) actually enforces.
116
+
117
+ On the observability timeline each guardrail step carries that identity in `metadata.guardrail` (with `status: "denied"` when it denied — the free-text reason stays off the step to keep metadata low-cardinality), so evals can grade enforcement without reading tool arguments: `createGuardrailPackScorer()` from `@arnilo/prism-core/governance/evals` scores a denied `pack:` rule as a failed trajectory and names it. The built-in packs are covered by violating/compliant scenario pairs in `packages/prism-core/src/governance/evals/__tests__/guardrail-pack-scenarios.test.ts` (see [Evaluations](evaluations.md#guardrail-pack-trajectory-scenarios-plan-092)).
118
+
73
119
  ## Claim grounding
74
120
 
75
121
  `createClaimGroundingGuardrail(options: ClaimGroundingGuardrailOptions)` is a deterministic output guardrail for quantitative claims. It scans assistant text once, then attributes each number to a completed host tool result from **this run** or to a host-governed figure. It never calls a model, store, or network service.
@@ -105,13 +151,13 @@ With `onViolation: "block"`, the standard `GuardrailError` has `reason: "claim_u
105
151
 
106
152
  ## Extension and configuration notes
107
153
 
108
- Guardrails are callbacks supplied by the host. Prism does not discover, load, retry, or persist callback code. `createSecureAgent()` keeps configured guardrails and only appends run-level checks; it never lets a run remove secure defaults. Custom loops receive guarded `LoopContext.generate()` and `LoopContext.dispatchToolCall()`; host code that directly calls a provider or `ToolDefinition.execute()` is outside the runtime boundary.
154
+ Guardrails are callbacks supplied by the host. Prism does not discover, load, retry, or persist callback code. `createSecureAgent()` keeps configured guardrails and only appends run-level checks; it never lets a run remove secure defaults. Custom loops receive guarded `LoopContext.generate()` and `LoopContext.dispatchToolCall()`; host code that directly calls a provider or `ToolDefinition.execute()` is outside the runtime boundary. Guardrail packs follow the same rule: they are host-supplied config, compiled in memory per session, never discovered from disk. Their compiled identity and pack-owned state persist only inside an opt-in durable checkpoint (`persistSessionState`, see above) and nowhere else.
109
155
 
110
156
  ## Security and performance notes
111
157
 
112
158
  Optional `@arnilo/prism-core/governance/policy` can record guardrail outcomes via `recordGuardrailDecision` (evidence refs only; see [Policy and audit](policy-and-audit.md)).
113
159
 
114
- Output buffering prevents blocked provider content from reaching subscribers, session entries, ledgers, parsers, delegation, or tools. Tool-output checks receive raw results but Prism discards blocked raw output before event, ledger, transcript, or MCP exposure. Redaction replaces exact known values only; it is not general secret detection. Parallel checks receive an abort signal, but callback code must honor it to stop in-flight work. Browser snapshots and page text from the `browser` subpath are untrusted external content: never allow them to modify tools, permissions, credentials, or policy. Browser mutations still require host `ExecutionPolicy`/approval; prompt-injection text in a page cannot grant upload/download release.
160
+ Output buffering prevents blocked provider content from reaching subscribers, session entries, ledgers, parsers, delegation, or tools. Tool-output checks receive raw results but Prism discards blocked raw output before event, ledger, transcript, or MCP exposure. Redaction replaces exact known values only; it is not general secret detection. Guardrail-pack patterns compile once at session creation and argument scans are bounded (depth 8, 64 strings, 16 KiB per string), so rule cost stays off the provider path. Parallel checks receive an abort signal, but callback code must honor it to stop in-flight work. Browser snapshots and page text from the `browser` subpath are untrusted external content: never allow them to modify tools, permissions, credentials, or policy. Browser mutations still require host `ExecutionPolicy`/approval; prompt-injection text in a page cannot grant upload/download release.
115
161
 
116
162
  ## Related APIs
117
163