@sema-agent/core 7.4.0 → 7.5.1

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 (115) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/dist/agents/cascade.d.ts +2 -1
  3. package/dist/agents/peer-notices.d.ts +11 -1
  4. package/dist/agents/peer-session-drain.js +2 -0
  5. package/dist/agents/verify.d.ts +2 -1
  6. package/dist/core/ask-origin.d.ts +130 -0
  7. package/dist/core/ask-origin.js +35 -0
  8. package/dist/core/auto-mode-arming.d.ts +40 -1
  9. package/dist/core/auto-mode-arming.js +51 -3
  10. package/dist/core/auto-mode.d.ts +87 -10
  11. package/dist/core/auto-mode.js +34 -6
  12. package/dist/core/checkpoint-store.d.ts +41 -6
  13. package/dist/core/checkpoint-store.js +8 -0
  14. package/dist/core/hooks.d.ts +63 -19
  15. package/dist/core/hooks.js +37 -16
  16. package/dist/core/mcp.d.ts +47 -55
  17. package/dist/core/mcp.js +89 -31
  18. package/dist/core/park-selfcheck.js +3 -0
  19. package/dist/core/permission-rule-consent.d.ts +2 -11
  20. package/dist/core/permission-rule-consent.js +13 -62
  21. package/dist/core/permission-rule-org.d.ts +70 -54
  22. package/dist/core/permission-rule-org.js +47 -61
  23. package/dist/core/permission-rule-provider.d.ts +162 -0
  24. package/dist/core/permission-rule-provider.js +148 -0
  25. package/dist/core/permission-rule-session.d.ts +23 -19
  26. package/dist/core/permission-rule-session.js +5 -5
  27. package/dist/core/permission-rule-store.d.ts +46 -21
  28. package/dist/core/permission-rule-store.js +13 -6
  29. package/dist/core/permission-rule-sync.d.ts +2 -1
  30. package/dist/core/permission-rule-sync.js +11 -0
  31. package/dist/core/runner/assemble-result.d.ts +3 -2
  32. package/dist/core/runner/checkpoint-scope.d.ts +32 -0
  33. package/dist/core/runner/checkpoint-scope.js +4 -0
  34. package/dist/core/runner/contracts.d.ts +1878 -0
  35. package/dist/core/runner/contracts.js +1 -0
  36. package/dist/core/runner/denial-limit-arms.d.ts +57 -31
  37. package/dist/core/runner/denial-limit-arms.js +42 -17
  38. package/dist/core/runner/derived-route-fallback.d.ts +34 -0
  39. package/dist/core/runner/derived-route-fallback.js +16 -0
  40. package/dist/core/runner/prepare-acquire-reconcile.d.ts +1 -1
  41. package/dist/core/runner/prepare-announce-once.d.ts +83 -0
  42. package/dist/core/runner/prepare-announce-once.js +105 -0
  43. package/dist/core/runner/prepare-caps-and-workflow.d.ts +170 -0
  44. package/dist/core/runner/prepare-caps-and-workflow.js +255 -0
  45. package/dist/core/runner/prepare-config-doors.d.ts +2 -10
  46. package/dist/core/runner/prepare-defer-classify.d.ts +86 -0
  47. package/dist/core/runner/prepare-defer-classify.js +107 -0
  48. package/dist/core/runner/prepare-delegation-surface.d.ts +104 -0
  49. package/dist/core/runner/prepare-delegation-surface.js +144 -0
  50. package/dist/core/runner/prepare-execution-env.d.ts +54 -0
  51. package/dist/core/runner/prepare-execution-env.js +86 -0
  52. package/dist/core/runner/prepare-file-history.d.ts +95 -0
  53. package/dist/core/runner/prepare-file-history.js +383 -0
  54. package/dist/core/runner/prepare-hands-readface.d.ts +6 -8
  55. package/dist/core/runner/prepare-hands-readface.js +3 -3
  56. package/dist/core/runner/prepare-inherited-gate.d.ts +268 -0
  57. package/dist/core/runner/prepare-inherited-gate.js +266 -0
  58. package/dist/core/runner/prepare-listings.d.ts +77 -0
  59. package/dist/core/runner/prepare-listings.js +76 -0
  60. package/dist/core/runner/prepare-lsp.d.ts +55 -0
  61. package/dist/core/runner/prepare-lsp.js +27 -0
  62. package/dist/core/runner/prepare-memory.d.ts +1 -1
  63. package/dist/core/runner/prepare-offload-wrappers.d.ts +62 -0
  64. package/dist/core/runner/prepare-offload-wrappers.js +45 -0
  65. package/dist/core/runner/prepare-permission-rules.d.ts +132 -0
  66. package/dist/core/runner/prepare-permission-rules.js +140 -0
  67. package/dist/core/runner/prepare-project-context.d.ts +131 -0
  68. package/dist/core/runner/prepare-project-context.js +150 -0
  69. package/dist/core/runner/prepare-prompt-inputs.d.ts +138 -0
  70. package/dist/core/runner/prepare-prompt-inputs.js +141 -0
  71. package/dist/core/runner/prepare-protocol-tools.d.ts +91 -0
  72. package/dist/core/runner/prepare-protocol-tools.js +182 -0
  73. package/dist/core/runner/prepare-question-face.d.ts +119 -0
  74. package/dist/core/runner/prepare-question-face.js +83 -0
  75. package/dist/core/runner/prepare-run-refs.d.ts +89 -0
  76. package/dist/core/runner/prepare-run-refs.js +39 -0
  77. package/dist/core/runner/prepare-safety-scan.d.ts +3 -2
  78. package/dist/core/runner/prepare-task.d.ts +11 -1815
  79. package/dist/core/runner/prepare-task.js +138 -2542
  80. package/dist/core/runner/prepare-tool-disclosure-mount.d.ts +111 -0
  81. package/dist/core/runner/prepare-tool-disclosure-mount.js +219 -0
  82. package/dist/core/runner/prepare-wiring-manifest.d.ts +184 -0
  83. package/dist/core/runner/prepare-wiring-manifest.js +240 -0
  84. package/dist/core/runner/prepare-workspace-restore.d.ts +1 -27
  85. package/dist/core/runner/prepare-workspace-restore.js +1 -22
  86. package/dist/core/runner/rollback-stack.d.ts +32 -0
  87. package/dist/core/runner/rollback-stack.js +30 -0
  88. package/dist/core/runner/runtask.d.ts +11 -2
  89. package/dist/core/runner/runtask.js +27 -9
  90. package/dist/core/runner/workspace-path.d.ts +33 -0
  91. package/dist/core/runner/workspace-path.js +22 -0
  92. package/dist/core/sensitive-path-policy.d.ts +16 -0
  93. package/dist/core/sensitive-path-policy.js +1 -1
  94. package/dist/core/tool-policy.d.ts +57 -9
  95. package/dist/core/tool-policy.js +11 -0
  96. package/dist/core/types.d.ts +63 -51
  97. package/dist/core/wiring-manifest.d.ts +40 -3
  98. package/dist/core/wiring-manifest.js +4 -3
  99. package/dist/core/write-protect.d.ts +13 -2
  100. package/dist/core/write-protect.js +58 -29
  101. package/dist/engine/harness/types.d.ts +38 -16
  102. package/dist/engine/harness/types.js +25 -1
  103. package/dist/engine/session/session.d.ts +3 -11
  104. package/dist/index.d.ts +10 -6
  105. package/dist/index.js +9 -5
  106. package/dist/internal/harness.d.ts +1 -0
  107. package/dist/stores/file/adoption/adopt.d.ts +1 -1
  108. package/dist/stores/file/adoption/marker.d.ts +1 -1
  109. package/dist/stores/file/permission-rule-adopt.js +4 -3
  110. package/dist/stores/file/permission-rule-store.d.ts +65 -25
  111. package/dist/stores/file/permission-rule-store.js +215 -37
  112. package/dist/stores/file/task-list-store.d.ts +1 -1
  113. package/dist/tools/fs/read-face.d.ts +1 -1
  114. package/package.json +8 -2
  115. package/test/export-surface.snapshot.json +76 -28
package/CHANGELOG.md CHANGED
@@ -1,5 +1,74 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.5.1 — 2026-09-06
4
+
5
+ Maintenance release: the design/390 runner decomposition (slices S0–S2), the post-7.5.0 gate repairs, and one summary hardening (#566). **No BREAKING change**: the export surface is byte-identical to 7.5.0 (export snapshot unchanged); the only observable delta is the label text of a wedged dispose on the prepare-throw path (below).
6
+
7
+ ### Changed — design/390 S0–S2: `prepareTask` decomposed into phase modules (14 229 → 7 723 lines), one contract each
8
+ - 24 phase modules under `src/core/runner/prepare-*.ts`, each `prepareX(input: Input): Result` with every Input seat classified (owned / borrowed-readonly / borrowed-mutable + writer table / ownership-ref) and the driver reduced to one destructuring call per phase. Extracted verbatim: offload wrappers, execution-env (`OwnedEnvSeat`), file-history, run-refs, protocol-tools (MCP/A2A), question-face, LSP, project-context, prompt-inputs, defer-classify, listings, inherited-gate, caps-and-workflow, delegation-surface, tool-disclosure-mount, wiring-manifest; helpers `contracts.ts`, `workspace-path.ts`, `derived-route-fallback.ts`, `checkpoint-scope.ts`, `denial-limit-arms.ts`.
9
+ - **RollbackStack** (`rollback-stack.ts`): every resource acquired during prepare registers its disposer the moment it is acquired (env, MCP clients, A2A peers); a prepare throw unwinds them in reverse order, each bounded and reported, replacing the driver's explicit `if (handle) dispose` catch legs. The A2A registration keys on the acquisition decision read ONCE (a caller replacing `spec.a2a` during materialization can no longer unregister an acquired handle).
10
+ - **Disclosed delta**: a dispose that hangs past its budget on the prepare-throw path now reports leg `"<disposer> (rollback leg N of M, prepare-throw)"` (e.g. `mcpDispose (rollback leg 2 of 3, prepare-throw)`) instead of `"mcp.dispose (prepare-throw leg)"` / `"a2a.dispose (prepare-throw leg)"` / `"ownedEnv.destroy (prepare-throw leg)"`. Error class and budget unchanged; no test or consumer referenced the old text.
11
+ - Contract sentence `@contract prepare.deps-read-stable`: the host keeps `deps` / `internals` / `executionEnv` read-stable for the duration of `prepareTask`; an async phase returning a microtask later than the inlined code is within contract.
12
+ - Roster mutation owner table: which phase pushes/splices which tool under which condition is written once on `Prepared.tools` (contracts.ts), not scattered in prose.
13
+ - `isLiveApproverSeat` consumers (3) now read the one predicate in `tool-policy.ts`.
14
+
15
+ ### Fixed
16
+ - #566 `CheckpointSummary.denialLimitFallback` is produced by `readDenialLimitFallback` (the same reader the park row and `AskRequest` use): exactly the four typed members when the row's value is well-formed, no key otherwise — a hand-edited or partial value is dropped, never echoed.
17
+ - Wire-change generator: inherited keys are keys the type HAS — `readTreeModel` folds an interface's `extends` / intersection bases, so moving members onto a base interface no longer reports a false "lost keys" BREAKING item (the two false items in `wire-changes/core@7.5.0.json` are left as published and noted there).
18
+ - `gate:message-branching` (the mcp.ts `transport.kind` fold's five sites are the ONE reader, exemption count corrected) and `gate:domain-lexicon` (UNC anchor test written with `isUncPath`) — two CI-only gates that had been red since the A-089 merge without the local chain running them.
19
+ - Two load-sensitive pins (#583) measure by best-of-three / a generous spawn timeout instead of a single wall-clock reading.
20
+
21
+ ### Tooling / gates
22
+ - `node scripts/gate-all.mjs [--with-dist] [--rc]` runs the whole `gate:*` roster in one command; a `gate:*` script without a roster seat fails `test/gate-all-roster.test.ts` (the release chain's L3 line now points here).
23
+ - New gates: **G1** module line-count ratchet (`test/module-size-ratchet.test.ts`, banked per extraction), **G2** dependency-direction gate (`scripts/verify-layering.mjs` + `docs/LAYERING.json`, four layers: contracts ← prepare-path machinery ← phases ← orchestrator ← runtask; exemptions 13 → 3), **G5** phase-contract gate (`scripts/verify-phase-api.mjs`: every `prepare-*.ts` exports one Input/Result pair, every Input field classified; `KNOWN_INPUT_FIELDS` anti-blindness floor 371), `gate:agent-returns` (every archived car receipt's unresolved line carries a ticket / DEBTS id / DV / slice home).
24
+ - Release criteria: `retiredIn` annotation lets a historic criteria file name a face that a later version removed without rewriting history (`criteria-lint` accepts an absent face carrying the annotation).
25
+ - 16 runtime exports added in 7.5.0 registered in the consumer smoke probe; `test/prepare-lsp-ignore-filter.test.ts` pins the LSP tool's git-ignore wiring (a receiving-side mutation had left every LSP test green).
26
+
27
+ ## 7.5.0 — 2026-09-05
28
+
29
+ **BREAKING release** (the 7.4.1 bug batch merged into it — one publish, one pickup). 13 exports removed, 5 public types lose keys, one store contract replaced whole; every item is named below with its replacement. Nothing here is a wire FRAME change: `TaskEvent`, `PendingAction`, `CheckpointSummary`, `WiringManifest` only GAIN keys.
30
+
31
+ ### BREAKING — design/389 one permission-rule store, partitioned by source (the three stores become one)
32
+ - `RunnerDeps.permissionRuleStore` is now the ONE seam: a `PermissionRuleStoreProvider` built by `createPermissionRuleStoreProvider({ durable, org?, session? })`. Its `partitions { durable, org, session }` is a mandatory DECLARATION (a manifest reads it, nothing probes for it); `forPrincipal(p).effective({ sessionId? })` is the single read, answering every partition with each row's `source ∈ org|user|project|session` and `status ∈ live|shadowed-by-org` plus the org resolution beside it.
33
+ - **Removed** (13): `RunnerDeps.permissionRuleOrg` and `RunnerDeps.sessionPermissionRules` (both folded into the provider's `org` / `session` partitions); `RuleConsentDeps.sessionRules`; `PermissionRuleStore.list()` (→ `effective()`); `OrgRuleOverlay` / `createOrgRuleOverlay` / `OrgOverlayResolution` / `OrgOverlayStatus` / `orgRuleStatePersistenceOf` (→ `OrgRulePartitionConfig` / `OrgRuleResolution` / `OrgRuleStatus`, `createPermissionRuleStoreProvider({ org })`); `SessionRuleOverlay` / `SessionRuleOverlayAdd` / `SessionRuleOverlayApplyResult` / `InMemorySessionRuleOverlay` (→ `SessionRulePartition` / `SessionRuleAdd` / `SessionRuleApplyResult` / `InMemorySessionRulePartition`); `FilePermissionRuleStoreProvider` / `InMemoryPermissionRuleStore` / `WritablePermissionRuleStore` / `EMPTY_RULE_STORE` (→ `FileDurableRulePartitionProvider` / `InMemoryDurableRulePartition` / `WritableDurableRulePartition` / `EMPTY_DURABLE_RULE_PARTITION`; the durable backend a provider is built over is a `DurableRulePartitionProvider`).
34
+ - **Reshaped, same name**: `PermissionRuleStoreProvider` (tier advanced → stable; `+partitions`); `PermissionRuleStore` (`-list`, `+effective`); `EffectivePermissionRule` now `extends PersistedAllowRule` and adds `source`/`status` (its `rule`/`scope` keys are INHERITED, not lost — a syntactic key diff reports them removed).
35
+ - **File backend schema 3**: bucket files drop the `org` block (→ `org.json`, `ORG_STATE_FILE`); a one-time migration runs at construction under the writer lock and publishes `.rule-schema-3` when complete. A legacy (schema 1/2) bucket after the cutover is REFUSED WHOLE by the reader (`list()` rejects; `effective()` reports the durable partition `unreadable`) — previously a bad bucket read as empty. A live writer (an older engine) or an unwritable directory DEFERS the migration (disclosed via `onError`, no marker, construction succeeds as a read face); the next construction that can take the lock performs it.
36
+ - Org: `resolve()` is bounded whole by `ORG_ADJUDICATION_TIMEOUT_MS` (15 s) — a persistence that never answers reports `unavailable` instead of hanging the gate; an equal-revision snapshot with the same content keeps the fresher `fetchedAtMs`; a lower/conflicting revision among several legacy buckets is a typed `OrgRevisionRefusal` (disclosed, the highest wins).
37
+ - Session-scope grants route through the same writer (`redemption-add` with a `session` scope lands in the session partition; the durable `expectedRev` is not compared there). Known coupling (#579, design candidate): a session grant is refused while the durable partition is unreadable, because the dot is minted by the durable replica.
38
+
39
+ ### BREAKING — #559 ② the auto-deny window is minted at the ROUTE, by one writer (car D + car H)
40
+ - `PermissionResult`'s ask arm `denialLimitFallback` is now an `UnarmedDenialLimitFallback` (counts only: `consecutive`, `total`, `limit`; `autoDenyAfterMs?: never`). A policy can no longer declare a window — `{ …, autoDenyAfterMs: 120000 }` on a decision is a compile error in every shape (fresh literal, predeclared const, `as const`, async `check`). The window is written by exactly two route stations: `createAutoModeDenialTracker().armTimedWindow(fallback)` (the hand-out to a LIVE function approver, the only writer of a non-zero window) and `unarmedWindow(fallback)` (every route that cannot count down: durable park, headless, blanket `onAsk` — the one place a `0` is spelled).
41
+ - `DenialLimitFallback` (what `AskRequest`, the durable park row, `CheckpointSummary` and `tool_end` carry) keeps its FOUR members; it now `extends DenialLimitCounts` (a syntactic key diff reports `consecutive`/`limit`/`total` removed — they are inherited; server's structural conformance door still holds both ways). Readers that only render take `DenialLimitFallbackFace = Unarmed | Armed` (`denialLimitSentence`, `denialLimitFallbackMessage`).
42
+ - **Tripwire (loud, not silent)**: a DECISION whose `denialLimitFallback` carries `autoDenyAfterMs` (a structural widening through `DenialLimitCounts`, a cast, a JS host) is refused at the policy fold — deny with the retired member named — instead of having its number silently replaced by the route station. The route stations construct the armed member from the three counts by name (a prototype-backed member keeps its counts; a foreign key does not ride onto the request).
43
+ - **Behaviour**: the durable park row's `autoDenyAfterMs` is 0 (a park is never timed — the timed card is not burned; the streak's next live hand-out arms it); the TOTAL-bound card is never timed; headless arms nothing (the fail-closed deny stands); a tracker `reset` starts a new minted generation (an old member cannot burn the new streak's card). `AskRequest.origin` (see #564) rides the wire; `suspendAsk` takes one structural seat `AskCarry { signal?, denialLimitFallback?, origin? }` instead of growing positional seats.
44
+ - `AutoModeArmingRecipe` / `AutoModeArmingFace` gain `denialLimit` (#556: the persisted arming recipe carries the bounds; a rebuilt tracker runs the configured limit, not the default).
45
+
46
+ ### Changed (narrowing, named) — A-089 / #562 remote MCP error text is redacted where it is minted
47
+ - Every remote-authored MCP error text that becomes an engine-thrown `Error` message, an operator warning `cause`, a first-turn attachment, a continuation-page record, a tool-result fence or a manifest `error` is passed through `redactSecrets` at ONE mint (`remoteErrorText`); a server-signalled `isError` body joins the same rule. Successful tool CONTENT is not touched (tool-output egress is a separate matter). Consumers that pinned raw upstream error strings will see redacted markers.
48
+ - `WiringManifest.mcp[]` / `WiringFacts.mcp` (#562): per-server connection-state read face `{ name, status, errorCode?, … }` with the closed set `MCP_FAILURE_CODES` / `mcpFailureCodeOf` (`McpServerStatus.errorCode`, `McpServerSpec.source`). Not part of the fingerprint.
49
+
50
+ ### Changed (narrowing, named) — #555 / #549 the `.sema` rows and ONE data root
51
+ - `WRITE_PROTECTED_DEFAULT_TABLE` gains `.sema.json` (basename) and `.sema` (segment) — the brand twins of `.claude.json` / `.claude`; a Write/Edit onto `~/.sema/settings.json` is demoted to `ask` (headless: deny). The data-root passthrough (`dataRootSpellings`, one resolver for both judges) keeps the engine's own library writable, and the engine's mount now hands the judge `RunnerDeps.memoryEngineDir` — the same seat the memory library is materialized under — so a deployment that sites its data root under its config root by that seat alone (env unset) is not demoted (A-090.1). Bare deployments (no `memoryEngineDir`, no `$AGENT_DATA_DIR`) still resolve `~/.ai-agent`. UNC `..` cannot hop shares (#555 r2).
52
+
53
+ ### Changed — #564 / #570 the ask's ORIGIN is a closed set and one predicate
54
+ - `ASK_ORIGINS` (8 words: `policy | hook | ask_rule | org_rule | org_unavailable | content_question | denial_limit_fallback | unresolvable`), `askOriginOf`, `classifierMayAnswer` (the table: only `policy` is classifier-answerable); the auto-mode classifier's eligibility gate is this one predicate instead of an eight-conjunct exclusion chain (the inherited lane reads the same table). `PermissionResult.origin` / `AskRequest.origin` / `PendingAction.tool_approval.origin` / `CheckpointSummary.origin` (all additive).
55
+
56
+ ### Fixed
57
+ - MCP dial reads `transport.kind` ONCE per server and threads it to the connect, the idle-timeout bucket and the failure classifier (#562 had added a second reader before the dial; a mutating `kind` getter could connect over one transport and be judged under another).
58
+ - #569 a mailbox `ack` that faults keeps its watermark, so the next boundary retries the SAME prefix; a dropped watermark re-judged the whole leased batch and fired every per-record side effect twice inside one run (the admission gate's seq+body replay arm ADMITS a re-presentation; only the model injection was masked by the injection queue's dedup key). Pin D4c watches a side effect with no downstream dedup.
59
+ - #560 MCP ghost replies: prepare-time one-shot announcements (failed MCP servers, memory-assembly advisories, agent/skills listings) are per-SESSION facts on the `announcedListings` ledger, re-issued only on delta (CC 2.1.250 form); a halt/stop-after-turn boundary does not collect the attachment bundle.
60
+ - #557 `tool_end.autoDenied` + `settledBy:"timeout"` + `resolution:"window_expired"` on the frame; the durable park row and `CheckpointSummary` carry `denialLimitFallback` and `requiresRealApproval` (`PendingAction.tool_approval.*`, additive).
61
+ - #536 `PEER_DROP_REASONS.queue-full` STAYS (CC 2.1.250 mints it on the recipient's ingress; this engine judges the same fact at the send station with tool error `queue_full` — divergence DV-536-1 registered; the receipt word is kept for renderer alignment).
62
+ - A-089/A-090 JSDoc and INTEGRATION-CORE coordinates brought to the verified truth (the store-contract table, `tool_end.autoDenied`'s counterpart, `mcpFailureCodeOf`'s two message-derived words, read-face's org-governance seat).
63
+
64
+ ### Tooling
65
+ - `docs/release-criteria` observations accept `retiredIn: "<version>"`: a historic document keeps citing the face its release shipped, and the linter requires that face to be ABSENT (an annotation on a face that still exists reds). 7.0.0.json carries seven (the design/389 removals).
66
+ - File rule store: the three on-disk shapes are read as `unknown` and admitted by type guards (no boundary cast); one `jsonForm` round-trip helper.
67
+ - `scripts/wire-changes.mjs` (`npm run gate:wire`): the machine-readable wire delta of a release (exports / closed sets / type keys / narrowing notes / criteria coverage / consumer hints) → `wire-changes/core@<ver>.json`; `ship-post-lint` grows a WIRE leg; `scripts/capability-rows.mjs` prints CAPABILITY-CONSUMPTION rows. `scripts/repro/B-002.mjs`, `B-014..B-017` dist-level repros + `gate:repro`. `prepareTask`'s #548 inherited arms live in `runner/denial-limit-arms.ts`.
68
+
69
+ ### Export surface
70
+ - +37 / −13 (named above), 1 tier change; `test/export-surface.snapshot.json` 2048 → 2072.
71
+
3
72
  ## 7.4.0 — 2026-09-04
4
73
 
5
74
  Bug-fix release. No exports removed, no wire key removed; every narrowing is named below.
@@ -157,7 +157,8 @@ export interface CascadeRunResult extends TaskResult {
157
157
  export declare function runCascade(runner: Runner, spec: TaskSpec, config: CascadeConfig,
158
158
  /**
159
159
  * The TRUSTED, run-scoped internals channel — the same object `Runner.runTask` takes as its second
160
- * argument — forwarded UNCHANGED to EVERY rung.
160
+ * argument — forwarded to EVERY rung under the composition forward contract (identity, with the one
161
+ * placement-axis exception stated where the shape is named in `src/index.ts`).
161
162
  *
162
163
  * WHY THE SEAT EXISTS: `runCascade` returns a `TaskResult` and DRAINS each rung's event stream
163
164
  * internally, so the host never holds a `TaskStream` for any rung. Without this parameter a
@@ -46,7 +46,17 @@ export declare const PEER_HELD_REVIEW_CAUSES: readonly CrossSessionHoldCause[];
46
46
  export declare const PEER_DELIVERY_RECEIPT_STATES: readonly ["held", "denied", "expired", "delivered", "refused", "dropped"];
47
47
  export type PeerDeliveryReceiptState = (typeof PEER_DELIVERY_RECEIPT_STATES)[number];
48
48
  export declare function isPeerDeliveryReceiptState(v: unknown): v is PeerDeliveryReceiptState;
49
- /** The drop reasons a `dropped` receipt may carry (CC `S$e` vocabulary — the recipient's ingress guard). */
49
+ /**
50
+ * The drop reasons a `dropped` receipt may carry (CC vocabulary, kept verbatim — the recipient's ingress guard).
51
+ *
52
+ * `queue-full` is a member of the set but is NOT minted by this engine today: CC judges "recipient's queue
53
+ * is at its limit" on the recipient's ingress and reports it back as a dropped receipt, whereas here the
54
+ * SendMessage tool reads the recipient box's observable count at the send station and refuses synchronously
55
+ * (tool error code `queue_full`, nothing queued). The word stays so that renderers and ledgers keyed on this
56
+ * set remain aligned with CC and an ingress-side mint (two senders racing past the count) would need no
57
+ * vocabulary change. The approval-hold buffer overflowing is a different arm: the oldest held message is
58
+ * evicted with an `expired` receipt (see peer-held-queue).
59
+ */
50
60
  export declare const PEER_DROP_REASONS: readonly ["rate-limited", "duplicate", "hop-loop", "hop-runaway", "queue-full"];
51
61
  export type PeerDropReason = (typeof PEER_DROP_REASONS)[number];
52
62
  /** CC `g$e` — the short label of a receipt state (UI face). */
@@ -159,6 +159,8 @@ export function createPeerSessionDrain(opts) {
159
159
  await mailbox.ack(scope, boxHandle, drainOwner, upTo);
160
160
  }
161
161
  catch (e) {
162
+ if (settledUpTo === undefined || settledUpTo < upTo)
163
+ settledUpTo = upTo;
162
164
  opts.onError?.(e, { phase: "degraded", sessionId, classification: "peer-box-ack" });
163
165
  }
164
166
  };
@@ -245,7 +245,8 @@ export interface VerificationResult extends TaskResult {
245
245
  export declare function verifyCompleted(runner: Runner, result: TaskResult, specBase: ResumeTaskConfig, objective: string, config: VerifyConfig,
246
246
  /**
247
247
  * The TRUSTED, run-scoped internals channel — the same object `Runner.runTask` takes as its second
248
- * argument — forwarded UNCHANGED to EVERY leg this gate drives: the verifier run of each round and
248
+ * argument — forwarded to EVERY leg this gate drives under the composition forward contract (identity,
249
+ * with the one placement-axis exception stated where the shape is named in `src/index.ts`): the verifier run of each round and
249
250
  * each fix continuation (and, from {@link runWithVerification} / {@link resumeWithVerification} /
250
251
  * {@link runDeveloperTask}, the implementation leg as well). This doc is the contract for the whole
251
252
  * family; the sibling entries carry the same parameter and point here.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The ORIGIN of a surviving `ask` — WHICH AUTHORITY raised it, or took it over — as one word from a
3
+ * closed set, and the per-lane tables that say what an automatic decider may do with an ask of that
4
+ * origin.
5
+ *
6
+ * Why an axis of its own. The tool gate has several automatic ask-resolution lanes (the auto-mode
7
+ * classifier today; the persisted-rule lane and the sandbox-admission leg are the same shape), and each
8
+ * one's eligibility used to be written as a growing conjunction of NEGATIVES — "not an org ask, not a
9
+ * hook ask, not a matched ask rule, not the classifier's own fallback, not the question tool, not a
10
+ * marked call…" — one more conjunct per kind of ask the engine learned to mint. Every conjunct was
11
+ * right; the FORM was the debt: a new kind of ask had to be remembered at every lane, and forgetting
12
+ * one was a silent loosening (the new ask fell into whichever lane nobody had excluded it from).
13
+ *
14
+ * The replacement: the gate derives the origin ONCE from the facts its layers established
15
+ * ({@link askOriginOf}), stamps it on the surviving ask, and each lane asks one question — "may this
16
+ * lane answer an ask of this origin?" — against a table that MUST name every member of the set
17
+ * ({@link AssertAllKeysHandled}). Adding a kind of ask is then: one member here, one arm in the
18
+ * derivation, one row per table — and the compiler refuses the build until the row exists, so the
19
+ * default for an unconsidered origin is a red build, never a silent allow.
20
+ *
21
+ * What is and is not an origin. An origin is WHO raised the question. Two facts that ride beside it
22
+ * are deliberately not origins: `requiresRealApproval` says what may CLEAR the question (a governance
23
+ * bit any origin can carry — a safety policy's own ask carries it with origin `policy`), and the
24
+ * gate's `autoMode` seat says whether a classifier is WIRED at all (a property of the session, not of
25
+ * the ask). Both stay where they are and are consumed beside the origin, not folded into it.
26
+ */
27
+ import type { PermissionResult } from "./tool-policy.js";
28
+ /**
29
+ * The closed set, in AUTHORITY order — the order {@link askOriginOf} resolves a fold in when several
30
+ * facts hold on one call (a hook ask folded over a policy ask, an org stamp over either). The order
31
+ * decides only the WORD; every lane table below is total over the set, so it can never turn an ask
32
+ * one member would have excluded into one another member admits (the invariant is pinned).
33
+ *
34
+ * - `content_question` — the reserved question tool: a content-ROUTING signal, not a permission
35
+ * question; it leaves the gate through the content channel, never through a permission decider.
36
+ * - `unresolvable` — the call is MARKED by an ancestor (an inherited approver-unavailable float, or
37
+ * a durable mandate floated down because this task can park it): it must reach the park with no
38
+ * synchronous decision-maker in between.
39
+ * - `org_unavailable` — the deployment is org-governed and could not read its snapshot: the fail-closed
40
+ * tighten, cleared by a person only ("does the org forbid this?" is unanswered, and no automatic
41
+ * lane may answer it).
42
+ * - `org_rule` — an organization policy rule asked (or stamped an existing ask): "no configuration
43
+ * clears this, a person nods".
44
+ * - `hook` — a deployment's PreToolUse screening face raised or co-raised the ask; the word is
45
+ * engine-stamped at the fold and is the deployment's own per-call adjudication.
46
+ * - `ask_rule` — an EXPLICIT `ask` permission rule matched: a person's standing "ask me each time",
47
+ * an instruction rather than hesitation.
48
+ * - `denial_limit_fallback` — the auto-mode classifier reached its denial bound and handed the call
49
+ * to a person; the classifier stands BEFORE this ask, never after it.
50
+ * - `policy` — everything else: a deployment `ToolPolicy` ask, a mode-default ask, or one of the
51
+ * engine's own post-fold tightens (egress / irreversibility / peer referral / write protection).
52
+ * This is the hesitation family — the one an armed classifier exists to resolve.
53
+ */
54
+ export declare const ASK_ORIGINS: readonly ["content_question", "unresolvable", "org_unavailable", "org_rule", "hook", "ask_rule", "denial_limit_fallback", "policy"];
55
+ export type AskOrigin = (typeof ASK_ORIGINS)[number];
56
+ /** Whether a value is a member of the closed origin set. Exported because the word crosses process
57
+ * boundaries: a host validating one it received must not hand-roll the set. */
58
+ export declare function isAskOrigin(v: unknown): v is AskOrigin;
59
+ /**
60
+ * A compile-time fence: `T` must be `never`. Instantiate it with `Exclude<<closed set>, keyof <table>>`
61
+ * beside a table that is meant to be TOTAL over the set — while every member has a row the argument is
62
+ * `never` and the build is green; add a member without its row and the argument is that member's
63
+ * literal, which does not satisfy the constraint, so the build is red AT THE FENCE with the missing
64
+ * member spelled out in the error. The table's own `satisfies Partial<Record<…>>` clause guards the
65
+ * other direction (a row for a word that is not in the set).
66
+ */
67
+ export type AssertAllKeysHandled<T extends never> = T;
68
+ /** The facts about one call that only the gate holds — the derivation's inputs beyond the decision
69
+ * itself. Every member is a plain boolean/word read ONCE by the gate, so the derivation is a pure
70
+ * function that a test can drive arm by arm. */
71
+ export interface AskOriginFacts {
72
+ /** The call is on the reserved question tool. */
73
+ readonly contentQuestion: boolean;
74
+ /** The call is marked unresolvable by an ancestor (see the gate's `isMarkedUnresolvable` seat). */
75
+ readonly markedUnresolvable: boolean;
76
+ /** Which org state, if any, spoke on this call — the gate's own record of its org layer, never a
77
+ * read-back off the decision (a policy can put `decisionReason: "org_rule"` on its own ask; the
78
+ * gate's cell cannot be written by a policy). */
79
+ readonly org: "rule" | "unavailable" | undefined;
80
+ }
81
+ /**
82
+ * The ONE derivation of an ask's origin, from the surviving decision plus the gate's own facts. The
83
+ * arms are in {@link ASK_ORIGINS} order (strongest authority first); the members it reads off the
84
+ * decision are the three the engine already treats as tighten-only provenance (`decisionReason:
85
+ * "hook"` is engine-stamped at both fold arms; `matchedAskRule` and `denialLimitFallback` are stamped
86
+ * by their mint sites and, when self-declared by a policy, can only opt that policy's own ask OUT of
87
+ * automatic resolution — the safe direction). Nothing here reads a member that could loosen.
88
+ */
89
+ export declare function askOriginOf(decision: Extract<PermissionResult, {
90
+ action: "ask";
91
+ }>, facts: AskOriginFacts): AskOrigin;
92
+ /**
93
+ * The auto-mode CLASSIFIER's table: may a live classifier verdict resolve an ask of this origin?
94
+ *
95
+ * The classifier is the deployment's delegate for CLASSIFY-doctrine hesitation — the `policy` family,
96
+ * and ONLY it. Every other origin is a question the classifier is structurally the wrong party to
97
+ * answer, each for its own reason (kept beside its row so the reason travels with the fact):
98
+ * - `content_question`: a permission decider's verdict is neither an answer nor a park — letting it
99
+ * speak would skip the durable mandate, the park and the content route in one step.
100
+ * - `unresolvable`: a marked call must reach the park with no synchronous decision-maker between.
101
+ * - `org_unavailable` / `org_rule`: judgment may clear a `requiresRealApproval` bit a POLICY minted,
102
+ * but not the org's — while adjudication is unavailable the org's answer is unknown, and an org
103
+ * ask carries the org's own "a person nods" semantic.
104
+ * - `hook`: the deployment's own per-call adjudication; the classifier is its delegate for
105
+ * hesitation, not an override for its screening face.
106
+ * - `ask_rule`: a person's standing "ask me each time" — the classifier answering it would decide
107
+ * whether the person is asked at all, which is the rule's whole content.
108
+ * - `denial_limit_fallback`: the classifier already spoke (its bound is what minted this ask); an
109
+ * allow here would un-say the fallback, a block would count it twice.
110
+ * - `policy`: YES — including the engine's own safety tightens and a safety policy's
111
+ * `requiresRealApproval` ask (a live verdict is judgment, and arming auto mode is the deployment's
112
+ * explicit choice to let the classifier be the explicit ask-resolution for those classes).
113
+ * Exclusion is skip-not-deny: the ask flows the original chain (park / approver / headless deny) —
114
+ * narrower autonomy, never a lost ask.
115
+ */
116
+ declare const CLASSIFIER_MAY_ANSWER: {
117
+ readonly content_question: false;
118
+ readonly unresolvable: false;
119
+ readonly org_unavailable: false;
120
+ readonly org_rule: false;
121
+ readonly hook: false;
122
+ readonly ask_rule: false;
123
+ readonly denial_limit_fallback: false;
124
+ readonly policy: true;
125
+ };
126
+ /** The fence over the classifier table: `never` while every origin has a row (pinned by a type test). */
127
+ export type ClassifierTableCoversEveryOrigin = AssertAllKeysHandled<Exclude<AskOrigin, keyof typeof CLASSIFIER_MAY_ANSWER>>;
128
+ /** Whether the auto-mode classifier may resolve an ask of this origin (see the table's contract). */
129
+ export declare function classifierMayAnswer(origin: AskOrigin): boolean;
130
+ export {};
@@ -0,0 +1,35 @@
1
+ export const ASK_ORIGINS = ["content_question", "unresolvable", "org_unavailable", "org_rule", "hook", "ask_rule", "denial_limit_fallback", "policy"];
2
+ const ASK_ORIGIN_SET = new Set(ASK_ORIGINS);
3
+ export function isAskOrigin(v) {
4
+ return ASK_ORIGIN_SET.has(v);
5
+ }
6
+ export function askOriginOf(decision, facts) {
7
+ if (facts.contentQuestion)
8
+ return "content_question";
9
+ if (facts.markedUnresolvable)
10
+ return "unresolvable";
11
+ if (facts.org === "unavailable")
12
+ return "org_unavailable";
13
+ if (facts.org === "rule")
14
+ return "org_rule";
15
+ if (decision.decisionReason === "hook")
16
+ return "hook";
17
+ if (decision.matchedAskRule !== undefined)
18
+ return "ask_rule";
19
+ if (decision.denialLimitFallback !== undefined)
20
+ return "denial_limit_fallback";
21
+ return "policy";
22
+ }
23
+ const CLASSIFIER_MAY_ANSWER = {
24
+ content_question: false,
25
+ unresolvable: false,
26
+ org_unavailable: false,
27
+ org_rule: false,
28
+ hook: false,
29
+ ask_rule: false,
30
+ denial_limit_fallback: false,
31
+ policy: true,
32
+ };
33
+ export function classifierMayAnswer(origin) {
34
+ return CLASSIFIER_MAY_ANSWER[origin];
35
+ }
@@ -1,4 +1,5 @@
1
1
  import type { AutoModeRules, AutoModeWindowOptions } from "./auto-mode-prompt.js";
2
+ import type { AutoModeDenialLimitOptions } from "./auto-mode.js";
2
3
  /** The recipe format's version. A reader that does not know a version REFUSES it (never guesses): the
3
4
  * recipe names the criteria a classifier enforces, and a partially-understood criteria set is the one
4
5
  * thing a permission gate may not improvise. */
@@ -29,6 +30,24 @@ export interface AutoModeArmingRecipe {
29
30
  timeoutMs?: number;
30
31
  /** Consecutive-failure threshold opening the one-way breaker (floored, as the decider itself floors it). */
31
32
  failureThreshold?: number;
33
+ /**
34
+ * #556 — the classifier DENIAL-LIMIT bounds (`RunnerDeps.autoMode.denialLimit`, CC 2.1.250 `FO`/`AKe`):
35
+ * how many blocks the ancestor's classifier was allowed before a person had to look, and the window the
36
+ * fallback ask ran under. A KNOB triple, not prompt body — every member is orderable, so the fold takes
37
+ * the strictest of the two sides rather than refusing on a difference.
38
+ *
39
+ * It belongs on the recipe because the tracker CANNOT travel (it is live state, like the breaker) while
40
+ * its BOUNDS are exactly the kind of criteria the recipe exists to carry: without them a cross-process
41
+ * redemption rebuilt the ancestor's classifier and counted its blocks against the REDEEMING
42
+ * deployment's own bounds, which may be looser — the ancestor's "three strikes and a person looks"
43
+ * silently became the redeemer's five. (A `requiresRealApproval` fallback ask still has a person as its
44
+ * only exit either way, which is why this is a widening of the BUDGET and not of the gate.)
45
+ *
46
+ * Members are canonicalized under the SAME screen `createAutoModeDenialTracker` applies to the live
47
+ * face — a recorded value that tracker would refuse makes the whole recipe malformed here rather than
48
+ * being floored into one it accepts, because the rebuild hands these very numbers back to it.
49
+ */
50
+ denialLimit?: AutoModeDenialLimitOptions;
32
51
  /**
33
52
  * The cross-session lane's classifier rule was spliced into the assembled prompt (the lane was
34
53
  * mounted on the arming leg). Part of the PROMPT BODY: the rebuild re-splices the same engine
@@ -64,11 +83,29 @@ export interface AutoModeArmingFace {
64
83
  window?: AutoModeWindowOptions;
65
84
  timeoutMs?: number;
66
85
  failureThreshold?: number;
86
+ denialLimit?: AutoModeDenialLimitOptions;
67
87
  settingsEpoch?: string;
68
88
  /** `true` when the cross-session lane's classifier rule is spliced into this deployment's classifier
69
89
  * prompt (the Runner sets it from its own lane mount; a redeeming host declares it from its). */
70
90
  crossSessionMessagesRule?: boolean;
71
91
  }
92
+ /**
93
+ * #556 — the ONE tighten rule for the denial-limit bounds, shared by {@link foldAutoModeArming} and by
94
+ * the runner's rebuilt-tracker mint (a second computation of "the strictest of these two" is how the
95
+ * recipe's account and the tracker's actual bounds would come to disagree). Both sides' EFFECTIVE values
96
+ * are materialized first (an omitted bound is the default the tracker applies, never "unbounded" — the
97
+ * same correction the knob fold below already carries), and the result is explicit for the same reason.
98
+ *
99
+ * · `maxConsecutive` / `maxTotal` — the MINIMUM. A lower bound falls back to a person SOONER, which is
100
+ * the fail-closed direction on both: the classifier gets less rope, never more.
101
+ * · `autoDenyAfterMs` — the SHORTER WINDOW wins, and `0` is not a short window: it is the special value
102
+ * for "no window at all" (the fallback ask blocks on the approver, which is the shape a total-bound
103
+ * card and every park already run under, #559). Read numerically, `0` would read as the strictest
104
+ * value and would silently DISARM an ancestor's window; read as "no window", a declared absence of one
105
+ * never removes the other side's. Both surviving directions are deny-ward — an auto-deny can only ever
106
+ * refuse — so neither can widen the gate.
107
+ */
108
+ export declare function tightenDenialLimit(a: AutoModeDenialLimitOptions | undefined, b: AutoModeDenialLimitOptions | undefined): Required<AutoModeDenialLimitOptions>;
72
109
  /**
73
110
  * Canonicalize + VALIDATE an arming recipe: the plain-data form that persists, or `undefined` when the
74
111
  * input is not one (unknown version, a non-string in a rule list, a NaN knob, a non-object).
@@ -140,7 +177,9 @@ export type AutoModeArmingFold = {
140
177
  * · bodies EQUAL ⇒ rebuild, with `timeoutMs`/`failureThreshold` taking the MINIMUM of the two. Both
141
178
  * directions of that minimum are fail-closed: a shorter timeout turns a slow classifier into
142
179
  * `unavailable` (the original chain, i.e. a human), and a lower threshold opens the one-way breaker
143
- * sooner (the session falls back to non-auto). Neither can widen a verdict.
180
+ * sooner (the session falls back to non-auto). Neither can widen a verdict. The denial-limit triple
181
+ * (#556) folds the same way through {@link tightenDenialLimit} — fewer blocks before a person looks,
182
+ * and no window ever lengthened or removed.
144
183
  * · bodies DIFFER ⇒ refuse. `settings_moved` normally; `epoch_inconsistent` when the two sides
145
184
  * nevertheless declare the same epoch — that is a deployment telling us its settings did not move
146
185
  * while its own data says otherwise, and picking either side would be a guess about which one is the
@@ -1,4 +1,4 @@
1
- import { AUTO_MODE_DEFAULT_FAILURE_THRESHOLD, AUTO_MODE_DEFAULT_TIMEOUT_MS, AUTO_MODE_DEFAULT_WINDOW_MAX_CHARS, AUTO_MODE_DEFAULT_WINDOW_MAX_ENTRIES, AUTO_MODE_DEFAULTS_SENTINEL, } from "./auto-mode-defaults.js";
1
+ import { AUTO_MODE_DEFAULT_FAILURE_THRESHOLD, AUTO_MODE_DEFAULT_TIMEOUT_MS, AUTO_MODE_DEFAULT_WINDOW_MAX_CHARS, AUTO_MODE_DEFAULT_WINDOW_MAX_ENTRIES, AUTO_MODE_DEFAULTS_SENTINEL, AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS, AUTO_MODE_DENIAL_LIMIT_DEFAULTS, } from "./auto-mode-defaults.js";
2
2
  export const AUTO_MODE_ARMING_RECIPE_VERSION = 1;
3
3
  const isPlainRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
4
4
  function canonicalStrings(v) {
@@ -22,6 +22,44 @@ function canonicalRuleSection(v) {
22
22
  return undefined;
23
23
  return deduped.length === 1 && deduped[0] === AUTO_MODE_DEFAULTS_SENTINEL ? undefined : deduped;
24
24
  }
25
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
26
+ function canonicalDenialBound(v, min, max) {
27
+ if (v === undefined)
28
+ return undefined;
29
+ if (typeof v !== "number" || !Number.isInteger(v) || v < min || v > max)
30
+ return null;
31
+ return v;
32
+ }
33
+ const NO_BOUND_CEILING = Number.POSITIVE_INFINITY;
34
+ function canonicalDenialLimit(v) {
35
+ if (v === undefined)
36
+ return undefined;
37
+ if (!isPlainRecord(v))
38
+ return null;
39
+ const maxConsecutive = canonicalDenialBound(v.maxConsecutive, 1, NO_BOUND_CEILING);
40
+ const maxTotal = canonicalDenialBound(v.maxTotal, 1, NO_BOUND_CEILING);
41
+ const autoDenyAfterMs = canonicalDenialBound(v.autoDenyAfterMs, 0, MAX_TIMER_DELAY_MS);
42
+ if (maxConsecutive === null || maxTotal === null || autoDenyAfterMs === null)
43
+ return null;
44
+ const merged = {
45
+ ...(maxConsecutive !== undefined ? { maxConsecutive } : {}),
46
+ ...(maxTotal !== undefined ? { maxTotal } : {}),
47
+ ...(autoDenyAfterMs !== undefined ? { autoDenyAfterMs } : {}),
48
+ };
49
+ return Object.keys(merged).length > 0 ? merged : undefined;
50
+ }
51
+ export function tightenDenialLimit(a, b) {
52
+ const window = (v) => {
53
+ const eff = v ?? AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS;
54
+ return eff === 0 ? Number.POSITIVE_INFINITY : eff;
55
+ };
56
+ const shortest = Math.min(window(a?.autoDenyAfterMs), window(b?.autoDenyAfterMs));
57
+ return {
58
+ maxConsecutive: Math.min(a?.maxConsecutive ?? AUTO_MODE_DENIAL_LIMIT_DEFAULTS.maxConsecutive, b?.maxConsecutive ?? AUTO_MODE_DENIAL_LIMIT_DEFAULTS.maxConsecutive),
59
+ maxTotal: Math.min(a?.maxTotal ?? AUTO_MODE_DENIAL_LIMIT_DEFAULTS.maxTotal, b?.maxTotal ?? AUTO_MODE_DENIAL_LIMIT_DEFAULTS.maxTotal),
60
+ autoDenyAfterMs: Number.isFinite(shortest) ? shortest : 0,
61
+ };
62
+ }
25
63
  function canonicalNumber(v, min, floor) {
26
64
  if (v === undefined)
27
65
  return undefined;
@@ -39,7 +77,8 @@ export function sanitizeAutoModeArmingRecipe(value) {
39
77
  const sessionContext = canonicalStrings(value.sessionContext);
40
78
  const timeoutMs = canonicalNumber(value.timeoutMs, 1, false);
41
79
  const failureThreshold = canonicalNumber(value.failureThreshold, 1, true);
42
- if (settingsDenyRules === null || sessionContext === null || timeoutMs === null || failureThreshold === null)
80
+ const denialLimit = canonicalDenialLimit(value.denialLimit);
81
+ if (settingsDenyRules === null || sessionContext === null || timeoutMs === null || failureThreshold === null || denialLimit === null)
43
82
  return undefined;
44
83
  let rules;
45
84
  if (value.rules !== undefined) {
@@ -94,6 +133,7 @@ export function sanitizeAutoModeArmingRecipe(value) {
94
133
  ...(window !== undefined ? { window } : {}),
95
134
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
96
135
  ...(failureThreshold !== undefined ? { failureThreshold } : {}),
136
+ ...(denialLimit !== undefined ? { denialLimit } : {}),
97
137
  ...(settingsEpoch !== undefined && settingsEpoch !== "" ? { settingsEpoch } : {}),
98
138
  };
99
139
  }
@@ -107,6 +147,7 @@ export function autoModeArmingRecipeOf(face, bind) {
107
147
  ...(face.window !== undefined ? { window: face.window } : {}),
108
148
  ...(face.timeoutMs !== undefined ? { timeoutMs: face.timeoutMs } : {}),
109
149
  ...(face.failureThreshold !== undefined ? { failureThreshold: face.failureThreshold } : {}),
150
+ ...(face.denialLimit !== undefined ? { denialLimit: face.denialLimit } : {}),
110
151
  ...(face.settingsEpoch !== undefined ? { settingsEpoch: face.settingsEpoch } : {}),
111
152
  ...(face.crossSessionMessagesRule !== undefined ? { crossSessionMessagesRule: face.crossSessionMessagesRule } : {}),
112
153
  });
@@ -173,6 +214,8 @@ export function foldAutoModeArming(recorded, current) {
173
214
  const minKnob = (a, b, fallback) => Math.min(a ?? fallback, b ?? fallback);
174
215
  const timeoutMs = minKnob(rec.timeoutMs, cur.timeoutMs, AUTO_MODE_DEFAULT_TIMEOUT_MS);
175
216
  const failureThreshold = minKnob(rec.failureThreshold, cur.failureThreshold, AUTO_MODE_DEFAULT_FAILURE_THRESHOLD);
217
+ const denialLimit = tightenDenialLimit(rec.denialLimit, cur.denialLimit);
218
+ const recordedDenial = tightenDenialLimit(rec.denialLimit, rec.denialLimit);
176
219
  return {
177
220
  ok: true,
178
221
  recorded: rec,
@@ -180,8 +223,13 @@ export function foldAutoModeArming(recorded, current) {
180
223
  ...rec,
181
224
  timeoutMs,
182
225
  failureThreshold,
226
+ denialLimit,
183
227
  ...(cur.settingsEpoch !== undefined ? { settingsEpoch: cur.settingsEpoch } : {}),
184
228
  },
185
- tightened: timeoutMs < (rec.timeoutMs ?? AUTO_MODE_DEFAULT_TIMEOUT_MS) || failureThreshold < (rec.failureThreshold ?? AUTO_MODE_DEFAULT_FAILURE_THRESHOLD),
229
+ tightened: timeoutMs < (rec.timeoutMs ?? AUTO_MODE_DEFAULT_TIMEOUT_MS) ||
230
+ failureThreshold < (rec.failureThreshold ?? AUTO_MODE_DEFAULT_FAILURE_THRESHOLD) ||
231
+ denialLimit.maxConsecutive < recordedDenial.maxConsecutive ||
232
+ denialLimit.maxTotal < recordedDenial.maxTotal ||
233
+ denialLimit.autoDenyAfterMs !== recordedDenial.autoDenyAfterMs,
186
234
  };
187
235
  }
@@ -113,38 +113,115 @@ export interface AutoModeDenialLimitOptions {
113
113
  * delays and fires at once — the fail-closed direction, and invisible). */
114
114
  autoDenyAfterMs?: number;
115
115
  }
116
- /** The additive member a denial-limit fallback ask carries (`PermissionResult` ask arm, `AskRequest`):
117
- * the counts that tripped the bound and the auto-deny window this particular ask runs under. */
118
- export interface DenialLimitFallback {
116
+ /** What BOTH shapes below say, and the whole of what a reader that only renders the card needs: the
117
+ * counts that tripped the bound and which bound it was. Declared once so the two faces cannot drift.
118
+ * A reader that only renders takes {@link DenialLimitFallbackFace} rather than this, so that an ARMED
119
+ * object LITERAL is accepted too (a parameter of this type would meet the excess-property check). */
120
+ export interface DenialLimitCounts {
119
121
  /** Consecutive blocks INCLUDING the one that tripped the bound. */
120
122
  readonly consecutive: number;
121
123
  /** Total blocks this run INCLUDING the one that tripped the bound (read before the total-bound reset). */
122
124
  readonly total: number;
123
125
  /** Which bound tripped. */
124
126
  readonly limit: "consecutive" | "total";
125
- /** The auto-deny window for THIS ask, ms; `0` = none (disarmed by configuration, or the timed card was
126
- * already shown for this streak). The ask resolver reads this member to arm its deadline. */
127
+ }
128
+ /**
129
+ * The DECISION-layer member of a denial-limit fallback ask (`PermissionResult` ask arm): the counts
130
+ * that tripped the bound, and NOTHING about a window. A window is a fact of the ask's ROUTE — a live
131
+ * function approver can count down, a durable park row and a headless deny cannot — and the route is
132
+ * chosen downstream of the count, so the decision that carries these counts is by construction not in
133
+ * a position to state one. The type says so: there is no window member to write here, and the only
134
+ * ways to reach a {@link DenialLimitFallback} (the member an {@link
135
+ * import("./tool-policy.js").AskRequest} carries) are the two route stations,
136
+ * {@link AutoModeDenialTracker.armTimedWindow} and {@link unarmedWindow}. A host that builds its own
137
+ * gate adapter therefore cannot forward a decision's member straight into an approval request: the
138
+ * compiler stops it and names the station it skipped.
139
+ */
140
+ export interface UnarmedDenialLimitFallback extends DenialLimitCounts {
141
+ /** FORBIDDEN on a decision, and stated rather than merely omitted. Leaving the member out would only
142
+ * stop a FRESH object literal (TypeScript's excess-property check); a policy that writes its window
143
+ * through a `const`, an `as const`, or an annotated return type would still compile — and would then
144
+ * have its number silently replaced at whichever route station the ask reached, turning a bounded
145
+ * wait into an unbounded one with no diagnostic. `never` makes the old shape a compile error at the
146
+ * policy boundary instead, so the retirement of the self-declared window is a migration a host is
147
+ * TOLD about rather than one it discovers when nobody is auto-denied. (A host that casts past the
148
+ * type still meets the route stations' runtime belt: they write the route's number, never the
149
+ * decision's.) */
150
+ readonly autoDenyAfterMs?: never;
151
+ }
152
+ /** The REQUEST-layer member (`AskRequest`, a durable park row, a checkpoint summary's echo): the same
153
+ * counts with the route's window already decided. Reachable only through the two route stations —
154
+ * {@link AutoModeDenialTracker.armTimedWindow} for a hand-out to a live function approver, and
155
+ * {@link unarmedWindow} for every route that cannot count down (a durable park, a headless deny, a
156
+ * non-function `onAsk` seat, a fallback no tracker of this run minted). Neither station accepts an
157
+ * ALREADY-armed member: a window is decided once, at the route, and re-routing an armed one would be
158
+ * a second decision with the first one silently discarded. */
159
+ export interface DenialLimitFallback extends DenialLimitCounts {
160
+ /** The auto-deny window for THIS ask, ms; `0` = none. Non-zero only where a live function approver
161
+ * is about to be called and the streak's timed card is still due — {@link
162
+ * AutoModeDenialTracker.armTimedWindow} is its one writer. The ask resolver reads this member to
163
+ * arm its deadline. */
127
164
  readonly autoDenyAfterMs: number;
128
165
  }
166
+ /** Either face of the member — what every READ-ONLY consumer accepts. A reader that renders the card
167
+ * has no interest in the route, so it must take both, and as a UNION rather than as the shared base:
168
+ * the base alone would reject a caller's four-member object literal on the excess-property check,
169
+ * which is a break with nothing behind it (the routing invariant constrains where a window is WRITTEN,
170
+ * never who may read one). */
171
+ export type DenialLimitFallbackFace = UnarmedDenialLimitFallback | DenialLimitFallback;
172
+ /**
173
+ * A stored or foreign value read back AS a fallback member, or `undefined` when it is not one: exactly
174
+ * the four members, each of the right kind (finite non-negative counts, the closed `limit` word, a
175
+ * finite non-negative window), nothing else carried. A deployment-written row (a checkpoint store is a
176
+ * host's) can hold any shape; the faces that echo it (the inbox summary) read through here, so a bad
177
+ * shape reads as ABSENT rather than reaching a consumer as a malformed card. Not a parser of intent —
178
+ * `"3"` is not 3.
179
+ */
180
+ export declare function readDenialLimitFallback(v: unknown): DenialLimitFallback | undefined;
181
+ /** The window-less route's member, spelled in ONE place: every station that hands a fallback somewhere
182
+ * nothing can count down (a durable park row, a headless deny, a string `onAsk` seat, a member this
183
+ * run's tracker did not mint) passes through here rather than writing a `0` of its own. */
184
+ export declare function unarmedWindow(fallback: UnarmedDenialLimitFallback): DenialLimitFallback;
129
185
  export type DenialLimitVerdict = {
130
186
  limitReached: false;
131
187
  } | {
132
188
  limitReached: true;
133
- fallback: DenialLimitFallback;
189
+ fallback: UnarmedDenialLimitFallback;
134
190
  };
135
191
  /** The per-run denial tracker. Lives beside {@link AutoModeDecider} (same owner, same lifetime). */
136
192
  export interface AutoModeDenialTracker {
137
193
  /** A classifier `block`: count first, judge second (CC `eme` → `tme`). Returns whether THIS block is
138
- * the one that falls back to a person, with the fallback's own snapshot. */
194
+ * the one that falls back to a person, with the fallback's counts. There is no window here — the
195
+ * route is not known yet, and the minted member has no member to hold one; see {@link armTimedWindow}. */
139
196
  recordBlock(): DenialLimitVerdict;
197
+ /** The ONE writer of the timed card (CC `u8t`: the timed shape exists only when a dialog is present,
198
+ * the CONSECUTIVE bound tripped, and no timed card was shown this streak). Called by the station
199
+ * that is about to hand `fallback` to a live function approver; returns the member that ask carries:
200
+ * the configured window when the card is due, else the same counts with `0` ({@link unarmedWindow}).
201
+ * Sets the once-per-streak mark and `windowArmedAt` exactly when it hands out a non-zero window. A
202
+ * fallback this tracker did not mint (a deeper ancestor's, or a policy's self-declared member) comes
203
+ * back with no window — the tracker arms only the streak it counted, and consults each minted member
204
+ * ONCE (a second call with the same member arms nothing), so the hand-out is a recorded fact, not a
205
+ * convention. */
206
+ armTimedWindow(fallback: UnarmedDenialLimitFallback): DenialLimitFallback;
140
207
  /** A classifier `allow`, or a person's allow of a fallback ask (CC `yR`): consecutive → 0 and the
141
208
  * timed-card mark cleared. A rule/fast-path allow must NOT call this. */
142
209
  recordAllow(): void;
143
- /** The current counts (diagnostics / pins). */
210
+ /** The current counts (diagnostics / pins). `windowArmedAt` = the epoch-ms instant
211
+ * {@link armTimedWindow} handed the CURRENT streak's fallback a non-zero window to a live approver
212
+ * (the instant CC's `timedFallbackShown` mark was set); present ⇔ that mark stands, absent otherwise
213
+ * — a fallback parked or denied headless WITHOUT reaching a function approver never arms it, a
214
+ * total-bound card is never timed, a disarmed window (`autoDenyAfterMs: 0`) arms nothing, and every
215
+ * reset (`recordAllow`, the total-bound budget reset) clears it. Present ⇒ the ask was handed to a
216
+ * function approver carrying a window; the resolver arms its deadline from it unless it exits before
217
+ * the wait (an already-aborted signal, unclonable args, or an approver that answers "unavailable" and
218
+ * sends the ask to the park) — the card counts as shown in those cases too, which errs toward a
219
+ * person being asked without a countdown, never toward an auto-deny nobody was shown. */
144
220
  snapshot(): {
145
221
  consecutive: number;
146
222
  total: number;
147
223
  timedFallbackShown: boolean;
224
+ windowArmedAt?: number;
148
225
  };
149
226
  }
150
227
  /** Build the per-run tracker. Bad knob values throw (the deployment face is read at prepare; a refusal
@@ -154,6 +231,6 @@ export declare function createAutoModeDenialTracker(opts?: AutoModeDenialLimitOp
154
231
  * and the inherited-lane arms): `Classifier denial limit exceeded, falling back to prompting: <limit
155
232
  * sentence>` + a blank line + `Latest blocked action: <the classifier's own reason, or the tool name>`.
156
233
  * The reason is the classifier MODEL's text — the caller neutralizes it before it gets here. */
157
- export declare function denialLimitFallbackMessage(fallback: DenialLimitFallback, latestBlockedAction: string): string;
234
+ export declare function denialLimitFallbackMessage(fallback: DenialLimitFallbackFace, latestBlockedAction: string): string;
158
235
  /** The limit sentence alone (CC's two forms) — shared by the fallback ask and the headless terminal. */
159
- export declare function denialLimitSentence(fallback: DenialLimitFallback): string;
236
+ export declare function denialLimitSentence(fallback: DenialLimitFallbackFace): string;