@sema-agent/core 7.3.0 → 7.4.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 (72) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/agents/peer-admission.d.ts +18 -3
  3. package/dist/agents/peer-admission.js +79 -4
  4. package/dist/agents/peer-held-queue.d.ts +101 -0
  5. package/dist/agents/peer-held-queue.js +229 -0
  6. package/dist/agents/peer-idle.d.ts +109 -0
  7. package/dist/agents/peer-idle.js +240 -0
  8. package/dist/agents/peer-notice-route.d.ts +33 -0
  9. package/dist/agents/peer-notice-route.js +46 -0
  10. package/dist/agents/peer-notices.d.ts +103 -0
  11. package/dist/agents/peer-notices.js +206 -0
  12. package/dist/agents/peer-session-drain.d.ts +39 -4
  13. package/dist/agents/peer-session-drain.js +248 -42
  14. package/dist/agents/send-message-tool.d.ts +8 -1
  15. package/dist/agents/send-message-tool.js +96 -30
  16. package/dist/agents/subagent.js +1 -0
  17. package/dist/brain/status-sink.d.ts +10 -0
  18. package/dist/brain/status-sink.js +13 -4
  19. package/dist/brain/stream-engine.d.ts +11 -0
  20. package/dist/brain/stream-engine.js +39 -3
  21. package/dist/core/arg-summary.d.ts +13 -3
  22. package/dist/core/arg-summary.js +138 -7
  23. package/dist/core/auto-mode-defaults.d.ts +11 -0
  24. package/dist/core/auto-mode-defaults.js +2 -0
  25. package/dist/core/auto-mode.d.ts +59 -0
  26. package/dist/core/auto-mode.js +57 -1
  27. package/dist/core/checkpoint-store.js +2 -2
  28. package/dist/core/governance-codes.d.ts +1 -1
  29. package/dist/core/governance-codes.js +8 -0
  30. package/dist/core/hooks.d.ts +30 -0
  31. package/dist/core/hooks.js +43 -8
  32. package/dist/core/mailbox-store.d.ts +33 -1
  33. package/dist/core/mailbox-store.js +42 -2
  34. package/dist/core/runner/assemble-result.d.ts +5 -0
  35. package/dist/core/runner/assemble-result.js +1 -1
  36. package/dist/core/runner/denial-limit-arms.d.ts +149 -0
  37. package/dist/core/runner/denial-limit-arms.js +91 -0
  38. package/dist/core/runner/edited-files-ledger.d.ts +33 -0
  39. package/dist/core/runner/edited-files-ledger.js +14 -0
  40. package/dist/core/runner/prepare-hands-readface.d.ts +5 -0
  41. package/dist/core/runner/prepare-hands-readface.js +1 -0
  42. package/dist/core/runner/prepare-task.d.ts +62 -1
  43. package/dist/core/runner/prepare-task.js +135 -89
  44. package/dist/core/runner/runtask.js +12 -0
  45. package/dist/core/sensitive-path-policy.d.ts +27 -6
  46. package/dist/core/sensitive-path-policy.js +57 -2
  47. package/dist/core/task-notification.d.ts +24 -2
  48. package/dist/core/task-notification.js +6 -1
  49. package/dist/core/tool-policy.d.ts +55 -4
  50. package/dist/core/tool-policy.js +28 -5
  51. package/dist/core/tools.js +1 -0
  52. package/dist/core/types.d.ts +251 -15
  53. package/dist/core/wiring-manifest.d.ts +41 -5
  54. package/dist/core/wiring-manifest.js +8 -0
  55. package/dist/engine/harness/agent-harness.d.ts +1 -0
  56. package/dist/engine/harness/agent-harness.js +3 -0
  57. package/dist/engine/harness/types.d.ts +3 -0
  58. package/dist/engine/loop/agent-loop.d.ts +7 -0
  59. package/dist/engine/loop/agent-loop.js +79 -0
  60. package/dist/engine/loop/types.d.ts +42 -0
  61. package/dist/index.d.ts +12 -6
  62. package/dist/index.js +10 -4
  63. package/dist/internal/harness-types.d.ts +1 -1
  64. package/dist/orchestration/workflow.js +7 -3
  65. package/dist/tools/fs/fs-write.d.ts +4 -4
  66. package/dist/tools/fs/fs-write.js +99 -14
  67. package/dist/tools/fs/index.d.ts +7 -1
  68. package/dist/tools/fs/index.js +1 -1
  69. package/dist/tools/fs/safety.d.ts +29 -8
  70. package/dist/tools/fs/safety.js +11 -1
  71. package/package.json +1 -1
  72. package/test/export-surface.snapshot.json +181 -1
@@ -98,3 +98,62 @@ export interface AutoModeDecider {
98
98
  * One instance per run/session — the breaker state is the session's "退回非 auto" latch.
99
99
  */
100
100
  export declare function createAutoModeDecider(opts: AutoModeDeciderOptions): AutoModeDecider;
101
+ /** The deployment's bounds for the denial limit (`RunnerDeps.autoMode.denialLimit`). Every member
102
+ * optional; an omitted member takes its CC default. A present member with a bad value is REFUSED
103
+ * loudly at construction (never clamped, never read as the default). */
104
+ export interface AutoModeDenialLimitOptions {
105
+ /** Consecutive classifier blocks (no classifier/human allow between them) that fall back to a person.
106
+ * Default 3. A positive integer. */
107
+ maxConsecutive?: number;
108
+ /** Total classifier blocks per run that fall back to a person and reset the budget. Default 20. A
109
+ * positive integer. */
110
+ maxTotal?: number;
111
+ /** The fallback ask's auto-deny window, ms. Default 120_000; `0` = no window (the ask waits on the
112
+ * approver alone). A non-negative integer no greater than 2147483647 (a host timer truncates larger
113
+ * delays and fires at once — the fail-closed direction, and invisible). */
114
+ autoDenyAfterMs?: number;
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 {
119
+ /** Consecutive blocks INCLUDING the one that tripped the bound. */
120
+ readonly consecutive: number;
121
+ /** Total blocks this run INCLUDING the one that tripped the bound (read before the total-bound reset). */
122
+ readonly total: number;
123
+ /** Which bound tripped. */
124
+ 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
+ readonly autoDenyAfterMs: number;
128
+ }
129
+ export type DenialLimitVerdict = {
130
+ limitReached: false;
131
+ } | {
132
+ limitReached: true;
133
+ fallback: DenialLimitFallback;
134
+ };
135
+ /** The per-run denial tracker. Lives beside {@link AutoModeDecider} (same owner, same lifetime). */
136
+ export interface AutoModeDenialTracker {
137
+ /** 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. */
139
+ recordBlock(): DenialLimitVerdict;
140
+ /** A classifier `allow`, or a person's allow of a fallback ask (CC `yR`): consecutive → 0 and the
141
+ * timed-card mark cleared. A rule/fast-path allow must NOT call this. */
142
+ recordAllow(): void;
143
+ /** The current counts (diagnostics / pins). */
144
+ snapshot(): {
145
+ consecutive: number;
146
+ total: number;
147
+ timedFallbackShown: boolean;
148
+ };
149
+ }
150
+ /** Build the per-run tracker. Bad knob values throw (the deployment face is read at prepare; a refusal
151
+ * there is the loud exit the knob doctrine requires). */
152
+ export declare function createAutoModeDenialTracker(opts?: AutoModeDenialLimitOptions): AutoModeDenialTracker;
153
+ /** The fallback ask's text — CC's sentence family, single-sourced for every mint site (the main gate
154
+ * and the inherited-lane arms): `Classifier denial limit exceeded, falling back to prompting: <limit
155
+ * sentence>` + a blank line + `Latest blocked action: <the classifier's own reason, or the tool name>`.
156
+ * 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;
158
+ /** 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;
@@ -1,4 +1,4 @@
1
- import { AUTO_MODE_DEFAULT_FAILURE_THRESHOLD, AUTO_MODE_DEFAULT_TIMEOUT_MS } from "./auto-mode-defaults.js";
1
+ import { AUTO_MODE_DEFAULT_FAILURE_THRESHOLD, AUTO_MODE_DEFAULT_TIMEOUT_MS, AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS, AUTO_MODE_DENIAL_LIMIT_DEFAULTS } from "./auto-mode-defaults.js";
2
2
  export function parseAutoModeResponse(text) {
3
3
  const t = text
4
4
  .replace(/<thinking>[\s\S]*?<\/thinking>/g, "")
@@ -99,3 +99,59 @@ class AutoModeTimeout extends Error {
99
99
  super("auto-mode classify timeout");
100
100
  }
101
101
  }
102
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
103
+ export function createAutoModeDenialTracker(opts = {}) {
104
+ const bound = (name, v, dflt) => {
105
+ if (v === undefined)
106
+ return dflt;
107
+ if (!Number.isInteger(v) || v < 1) {
108
+ throw new Error(`RunnerDeps.autoMode.denialLimit.${name} must be a positive integer (got ${JSON.stringify(v)}) — omit it for the default ${dflt}`);
109
+ }
110
+ return v;
111
+ };
112
+ const maxConsecutive = bound("maxConsecutive", opts.maxConsecutive, AUTO_MODE_DENIAL_LIMIT_DEFAULTS.maxConsecutive);
113
+ const maxTotal = bound("maxTotal", opts.maxTotal, AUTO_MODE_DENIAL_LIMIT_DEFAULTS.maxTotal);
114
+ let autoDenyAfterMs = AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS;
115
+ if (opts.autoDenyAfterMs !== undefined) {
116
+ if (!Number.isInteger(opts.autoDenyAfterMs) || opts.autoDenyAfterMs < 0 || opts.autoDenyAfterMs > MAX_TIMER_DELAY_MS) {
117
+ throw new Error(`RunnerDeps.autoMode.denialLimit.autoDenyAfterMs must be an integer between 0 and ${MAX_TIMER_DELAY_MS} (got ${JSON.stringify(opts.autoDenyAfterMs)}) — 0 disarms the window; omit it for the default ${AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS}`);
118
+ }
119
+ autoDenyAfterMs = opts.autoDenyAfterMs;
120
+ }
121
+ let consecutive = 0;
122
+ let total = 0;
123
+ let timedFallbackShown = false;
124
+ return {
125
+ recordBlock() {
126
+ consecutive += 1;
127
+ total += 1;
128
+ if (consecutive < maxConsecutive && total < maxTotal)
129
+ return { limitReached: false };
130
+ const totalTripped = total >= maxTotal;
131
+ const windowMs = timedFallbackShown || totalTripped ? 0 : autoDenyAfterMs;
132
+ const fallback = { consecutive, total, limit: totalTripped ? "total" : "consecutive", autoDenyAfterMs: windowMs };
133
+ if (totalTripped) {
134
+ consecutive = 0;
135
+ total = 0;
136
+ timedFallbackShown = false;
137
+ }
138
+ else if (windowMs > 0) {
139
+ timedFallbackShown = true;
140
+ }
141
+ return { limitReached: true, fallback };
142
+ },
143
+ recordAllow() {
144
+ consecutive = 0;
145
+ timedFallbackShown = false;
146
+ },
147
+ snapshot: () => ({ consecutive, total, timedFallbackShown }),
148
+ };
149
+ }
150
+ export function denialLimitFallbackMessage(fallback, latestBlockedAction) {
151
+ return `Classifier denial limit exceeded, falling back to prompting: ${denialLimitSentence(fallback)}\n\nLatest blocked action: ${latestBlockedAction}`;
152
+ }
153
+ export function denialLimitSentence(fallback) {
154
+ return fallback.limit === "total"
155
+ ? `${fallback.total} actions were blocked this session. Please review the transcript before continuing.`
156
+ : `${fallback.consecutive} consecutive actions were blocked. Please review the transcript before continuing.`;
157
+ }
@@ -125,7 +125,7 @@ export function buildRiskDescriptor(input) {
125
125
  if (shell || toolName === "Bash") {
126
126
  const cmd = isPlainRecord(args) ? safeDataValue(args, "command") : undefined;
127
127
  if (typeof cmd === "string" && cmd.length > 0)
128
- summary = renderUntrustedCommandText(redactSecrets(stripFormatCharacters(cmd)), SUMMARY_CMD_MAX);
128
+ summary = renderUntrustedCommandText(stripFormatCharacters(redactSecrets(cmd)), SUMMARY_CMD_MAX);
129
129
  const bg = isPlainRecord(args) ? safeDataValue(args, "run_in_background") : undefined;
130
130
  if (bg === true)
131
131
  summary = `[background persistent process — no per-step recheck] ${summary ?? ""}`.trimEnd();
@@ -140,7 +140,7 @@ export function buildRiskDescriptor(input) {
140
140
  if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
141
141
  const encDigest = (s) => s.replace(/%/g, "%25").replace(/=/g, "%3D").replace(/ /g, "%20");
142
142
  const k = encDigest(renderUntrustedCommandText(key, 40));
143
- const val = encDigest(renderUntrustedCommandText(redactSecrets(stripFormatCharacters(String(v))), SUMMARY_VALUE_MAX));
143
+ const val = encDigest(renderUntrustedCommandText(stripFormatCharacters(redactSecrets(String(v))), SUMMARY_VALUE_MAX));
144
144
  parts.push(`${k}=${val}`);
145
145
  }
146
146
  }
@@ -103,7 +103,7 @@ export type NoticeAudience = "user" | "operator";
103
103
  * src/ for notice mint shapes and names any code that is minted but unregistered, or registered but
104
104
  * no longer minted.
105
105
  */
106
- export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.durable_gate_unavailable", "config.peer_lane_unmounted", "peer.inbound_disposition", "delegation.transcript_integrity", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
106
+ export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "delegation.transcript_integrity", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
107
107
  /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
108
108
  * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
109
109
  * sink is a supported shape, and narrowing that field would break it. */
@@ -102,8 +102,12 @@ export const ENGINE_NOTICE_CODES = [
102
102
  "config.tool_model_gate_unknown_class",
103
103
  "config.tool_model_gate_env_invalid",
104
104
  "config.durable_gate_unavailable",
105
+ "config.peer_admission_out_of_range",
105
106
  "config.peer_lane_unmounted",
106
107
  "peer.inbound_disposition",
108
+ "peer.held_settled",
109
+ "peer.idle_subscription",
110
+ "classifier.denial_limit",
107
111
  "delegation.transcript_integrity",
108
112
  "mcp.revocation_probe_failed",
109
113
  "workflow.governance_key_stripped",
@@ -142,6 +146,7 @@ const NOTICE_AUDIENCE_TABLE = {
142
146
  "memory.hold_disposed": "user",
143
147
  "task.user_steer_undrained": "user",
144
148
  "task.user_followup_undrained": "user",
149
+ "classifier.denial_limit": "user",
145
150
  "task.turn_interrupted": "user",
146
151
  "steering.parked_input_blocked": "user",
147
152
  "task.halt_unconsumed": "user",
@@ -159,7 +164,10 @@ const NOTICE_AUDIENCE_TABLE = {
159
164
  "config.tool_model_gate_unknown_class": "operator",
160
165
  "config.tool_model_gate_env_invalid": "operator",
161
166
  "config.peer_lane_unmounted": "operator",
167
+ "config.peer_admission_out_of_range": "operator",
162
168
  "peer.inbound_disposition": "user",
169
+ "peer.held_settled": "user",
170
+ "peer.idle_subscription": "user",
163
171
  "delegation.transcript_integrity": "operator",
164
172
  "mcp.revocation_probe_failed": "operator",
165
173
  "workflow.governance_key_stripped": "operator",
@@ -2,6 +2,7 @@ import type { ActorAssertion, DocumentContent, ImageContent, TextContent } from
2
2
  import type { ExecutionEnv, FileError, Result, SessionTreeEntry } from "../internal/harness-types.js";
3
3
  import type { DecisionReason, PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
4
4
  import { type AskClass } from "./ask-class.js";
5
+ import { type AutoModeDenialTracker } from "./auto-mode.js";
5
6
  import type { WiringLegKind } from "./wiring-manifest.js";
6
7
  /**
7
8
  * In-process hook seam (design/37) — a provider-agnostic interception layer modeled on CC's hooks,
@@ -429,6 +430,10 @@ export interface PermissionDeniedPayload {
429
430
  * screen; a policy's direct deny, a hook deny, and the crash/plan-mode/compliance emissions carry
430
431
  * none. See {@link import("./tool-policy.js").AskDenyResolution}. */
431
432
  resolution?: import("./tool-policy.js").AskDenyResolution;
433
+ /** #548 — the deny is the classifier denial-limit fallback's AUTO-DENY (core's own window elapsed;
434
+ * see {@link import("./tool-policy.js").ResolvedAsk.autoDenied}). Carried verbatim from the resolver;
435
+ * absent on every other deny. */
436
+ autoDenied?: true;
432
437
  /** {@link HookSeatSignal} — this invocation's own abort signal. On an OBSERVATION seat the deny has
433
438
  * already happened and nothing this callback does can change it, so the signal says exactly one
434
439
  * thing: stop reading, nobody is waiting for your answer any more. */
@@ -1458,7 +1463,32 @@ export interface ToolGateInput {
1458
1463
  */
1459
1464
  autoMode?: {
1460
1465
  decider: import("./auto-mode.js").AutoModeDecider;
1466
+ /**
1467
+ * #548 — the per-run DENIAL-LIMIT tracker beside the decider (CC 2.1.250 `FO`/`Wie`; same owner,
1468
+ * same lifetime). Absent ⇒ the pre-#548 chain byte for byte: every block is a deny, without bound.
1469
+ * Present ⇒ a block first counts, then judges; the block that reaches a bound becomes the fallback
1470
+ * ask (`requiresRealApproval: true` + `denialLimitFallback`), and a classifier allow or a person's
1471
+ * allow of that ask resets the consecutive count. The Runner builds it from
1472
+ * `RunnerDeps.autoMode.denialLimit`; a host driving the gate directly may hand its own.
1473
+ */
1474
+ denialTracking?: AutoModeDenialTracker;
1461
1475
  };
1476
+ /**
1477
+ * #548 — the HEADLESS arm of the denial-limit fallback: the fallback ask was resolved by a deny no
1478
+ * person made (no approver wired; a blanket `"allow"` refused as no approver; an approver that
1479
+ * reported unavailable with no park to take it). The fallback had nowhere to go, so the caller — which
1480
+ * owns the run — stops it (`TaskResult.errorCode = "classifier.denial_limit"`, CC's "too many
1481
+ * classifier denials in headless mode" abort). The deny itself still stands; this gate only names the
1482
+ * fact. A TOP-LEVEL seat, deliberately NOT under `autoMode`: the fallback can arrive from an ANCESTOR's
1483
+ * frozen tracker through the fold (a delegated child with no armed classifier of its own), and that
1484
+ * child's run is the one that has to stop. Observe-only for the gate: a throwing seat never alters
1485
+ * the deny.
1486
+ */
1487
+ onHeadlessDenialLimit?: (info: {
1488
+ toolName: string;
1489
+ toolCallId: string;
1490
+ fallback: import("./auto-mode.js").DenialLimitFallback;
1491
+ }) => void;
1462
1492
  /**
1463
1493
  * design/153 §2/§7.4 (件4 复审, MED): true means this call's ask is MARKED — an inherited ancestor
1464
1494
  * constraint already determined "no synchronous layer may resolve this ask" (the ancestor's frozen
@@ -1,5 +1,6 @@
1
- import { coreMintedResolutionOf, decisionText, describeThrown, isAskDenyResolution, refuseOutOfContractDecision } from "./tool-policy.js";
1
+ import { coreMintedAutoDeniedOf, coreMintedResolutionOf, decisionText, describeThrown, isAskDenyResolution, refuseOutOfContractDecision } from "./tool-policy.js";
2
2
  import { brandPolicyAskClass } from "./ask-class.js";
3
+ import { denialLimitFallbackMessage } from "./auto-mode.js";
3
4
  import { inlineUntrusted } from "./untrusted-text.js";
4
5
  import { mintSystemReminder } from "./reminder-mint.js";
5
6
  import { PROBE_REASON_MAX, normalizeProbeCause } from "./checkpoint-store.js";
@@ -443,6 +444,7 @@ export async function runToolGate(input) {
443
444
  let hookAsk;
444
445
  let parkFailed;
445
446
  let askDenyResolution;
447
+ let askAutoDenied = false;
446
448
  const notifier = createSafeNotifier(input.onNotifyError !== undefined ? { onError: input.onNotifyError } : undefined);
447
449
  const hookSeatMs = resolveHookTimeoutMs(input.hookTimeoutMs, (err) => traceHookCrash(input, err, notifier));
448
450
  const seatBound = (abortEnds) => ({
@@ -804,12 +806,14 @@ export async function runToolGate(input) {
804
806
  decision.action === "ask" &&
805
807
  decision.decisionReason !== "hook" &&
806
808
  decision.matchedAskRule === undefined &&
809
+ decision.denialLimitFallback === undefined &&
807
810
  req.toolName !== ASK_USER_QUESTION_TOOL_NAME &&
808
811
  input.isMarkedUnresolvable?.(input.event.toolCallId) !== true) {
809
812
  const verdict = await input.autoMode.decider
810
813
  .decide({ req, askMessage: decisionText(decision) }, input.abortSignal)
811
814
  .catch(() => ({ kind: "unavailable", cause: "error" }));
812
815
  if (verdict.kind === "allow") {
816
+ input.autoMode.denialTracking?.recordAllow();
813
817
  decision = {
814
818
  action: "allow",
815
819
  message: "auto-mode classifier allowed this call",
@@ -820,12 +824,25 @@ export async function runToolGate(input) {
820
824
  else if (verdict.kind === "block") {
821
825
  const reason = verdict.reason ? inlineUntrusted(verdict.reason) : "";
822
826
  const category = verdict.category ? inlineUntrusted(verdict.category) : "";
823
- decision = {
824
- action: "deny",
825
- message: `auto-mode classifier blocked this call${reason ? `: ${reason}` : category ? `: [${category}]` : ""}`,
826
- decisionReason: "classifier",
827
- };
828
- denySource = "classifier";
827
+ const tracked = input.autoMode.denialTracking?.recordBlock();
828
+ if (tracked?.limitReached === true) {
829
+ decision = {
830
+ ...decision,
831
+ message: denialLimitFallbackMessage(tracked.fallback, reason || category || toolName),
832
+ decisionReason: "classifier",
833
+ requiresRealApproval: true,
834
+ denialLimitFallback: tracked.fallback,
835
+ };
836
+ denySource = "classifier";
837
+ }
838
+ else {
839
+ decision = {
840
+ action: "deny",
841
+ message: `auto-mode classifier blocked this call${reason ? `: ${reason}` : category ? `: [${category}]` : ""}`,
842
+ decisionReason: "classifier",
843
+ };
844
+ denySource = "classifier";
845
+ }
829
846
  }
830
847
  }
831
848
  if (input.sandboxAdmission !== undefined &&
@@ -910,6 +927,8 @@ export async function runToolGate(input) {
910
927
  resolvedApprover = resolved.approver;
911
928
  if (resolved.action === "deny" && isAskDenyResolution(resolved.resolution))
912
929
  askDenyResolution = resolved.resolution;
930
+ if (resolved.action === "deny" && resolved.autoDenied === true)
931
+ askAutoDenied = true;
913
932
  decision = resolved;
914
933
  if (resolved.action === "deny" && resolved.approverUnavailable === true && suspendAsk && parkFailed === undefined) {
915
934
  const parkArgs = [req, currentInput, safety, true, realApprovalOf(askBeforeResolve), askBeforeResolve.action === "ask" ? askBeforeResolve.persistedRuleShadowed : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.decisionReason : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.probeReason : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.probeCause : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.segmentCoverage : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.matchedAskRule : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.probeMandated : undefined];
@@ -921,6 +940,21 @@ export async function runToolGate(input) {
921
940
  return { suspend: suspended, preToolContext };
922
941
  }
923
942
  }
943
+ if (askBeforeResolve.action === "ask" && askBeforeResolve.denialLimitFallback !== undefined) {
944
+ if (decision.action === "allow") {
945
+ input.autoMode?.denialTracking?.recordAllow();
946
+ }
947
+ else if (askDenyResolution === "no_approver" ||
948
+ askDenyResolution === "blanket_allow_refused" ||
949
+ askDenyResolution === "approver_unavailable" ||
950
+ resolved.approverUnavailable === true) {
951
+ try {
952
+ input.onHeadlessDenialLimit?.({ toolName, toolCallId, fallback: askBeforeResolve.denialLimitFallback });
953
+ }
954
+ catch {
955
+ }
956
+ }
957
+ }
924
958
  if (decision.action === "allow" && decision.updatedInput !== undefined) {
925
959
  let editArgs = decision.updatedInput;
926
960
  let editDenied;
@@ -1062,7 +1096,8 @@ export async function runToolGate(input) {
1062
1096
  currentInput = decision.updatedInput;
1063
1097
  }
1064
1098
  const denyResolution = askDenyResolution ?? coreMintedResolutionOf(decision, { toolCallId, toolName });
1065
- await notifyPermissionDeniedSeat({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource, ...(denyResolution !== undefined ? { resolution: denyResolution } : {}), ...(input.identity !== undefined ? { identity: input.identity } : {}) });
1099
+ const denyAutoDenied = askAutoDenied || coreMintedAutoDeniedOf(decision, { toolCallId, toolName });
1100
+ await notifyPermissionDeniedSeat({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource, ...(denyResolution !== undefined ? { resolution: denyResolution } : {}), ...(denyAutoDenied ? { autoDenied: true } : {}), ...(input.identity !== undefined ? { identity: input.identity } : {}) });
1066
1101
  const denySettledBy = decision.settledBy;
1067
1102
  const denyApprover = denySettledBy !== undefined ? resolvedApprover : undefined;
1068
1103
  return {
@@ -35,11 +35,43 @@ export interface MailboxPeerMeta {
35
35
  fromScope?: string;
36
36
  /** Cross-principal delivery only — the delivery gate's receipt id (audit back-reference). */
37
37
  gateReceiptId?: string;
38
+ /** Notice-kind records only (design/385 §4.4 / §5.2): the typed notice the requester's drain renders
39
+ * — a delivery receipt's state (`kind: "delivery_notice"`) or an idle notice's kind
40
+ * (`kind: "idle_notice"`). The record's `content` is a human-readable mirror; THIS is the authority. */
41
+ notice?: MailboxPeerNoticeMeta;
42
+ }
43
+ /** design/385 — the typed body of a notice-kind record. `state` is drawn from the receipt states on a
44
+ * `delivery_notice` (`held|denied|expired|delivered|refused|dropped`) and from the idle kinds on an
45
+ * `idle_notice` (`idle|exited|unavailable|expired`); the validator admits the union, the drain checks
46
+ * the pairing (a state that does not fit its kind is settled `notice_unrouted`, never rendered). */
47
+ export interface MailboxPeerNoticeMeta {
48
+ state: string;
49
+ /** delivery_notice: the box seq of the SENDER'S message this receipt is about. */
50
+ refSeq?: number;
51
+ /** delivery_notice: the recipient's display label (as its directory row spells it). */
52
+ recipient?: string;
53
+ /** delivery_notice `dropped`: the ingress guard's reason. */
54
+ dropReason?: string;
55
+ /** idle_notice `idle`/`exited`: when the target finished its turn (epoch ms). */
56
+ finishedAt?: number;
57
+ /** idle_notice `idle`: the target's one-line detail (already bounded by the producer). */
58
+ detail?: string;
59
+ /** idle_notice `expired` (the requester answering ITSELF): the label the ask carried for the target
60
+ * that never answered — the record's author is the requester, so the target must be named here. */
61
+ target?: string;
38
62
  }
39
63
  export declare const MAILBOX_PEER_FROM_MODES: readonly ["bypass", "prompting"];
40
64
  export type MailboxPeerFromMode = (typeof MAILBOX_PEER_FROM_MODES)[number];
41
- export declare const MAILBOX_PEER_RECORD_KINDS: readonly ["peer_message", "idle_notice", "delivery_notice"];
65
+ /** design/385: `peer_message` runs the parity judgment; `idle_subscription` registers the sender as an
66
+ * idle subscriber of the box owner (§5.2, the store form of CC's `notify_when_idle` frame);
67
+ * `idle_notice` / `delivery_notice` land on the notice face (rendered, never enveloped as a peer's words). */
68
+ export declare const MAILBOX_PEER_RECORD_KINDS: readonly ["peer_message", "idle_notice", "delivery_notice", "idle_subscription"];
42
69
  export type MailboxPeerRecordKind = (typeof MAILBOX_PEER_RECORD_KINDS)[number];
70
+ /** The `notice.state` vocabulary the validator admits (the union of both notice families; the drain
71
+ * checks the kind↔state pairing). Spelled here, beside the record kinds, so the store contract is
72
+ * self-contained — the lane's own closed sets re-derive from these. */
73
+ export declare const MAILBOX_PEER_NOTICE_STATES: readonly ["held", "denied", "expired", "delivered", "refused", "dropped", "idle", "exited", "unavailable"];
74
+ export type MailboxPeerNoticeState = (typeof MAILBOX_PEER_NOTICE_STATES)[number];
43
75
  /** The `append` refusal code for a malformed `peerMeta` (design/385): a garbage record is refused up
44
76
  * front with this code, never silently stripped or stored — a drain that reads a half-typed record
45
77
  * would judge on fabricated inputs. */
@@ -1,6 +1,7 @@
1
1
  import { assertRetentionPolicy } from "./retention-policy.js";
2
2
  export const MAILBOX_PEER_FROM_MODES = ["bypass", "prompting"];
3
- export const MAILBOX_PEER_RECORD_KINDS = ["peer_message", "idle_notice", "delivery_notice"];
3
+ export const MAILBOX_PEER_RECORD_KINDS = ["peer_message", "idle_notice", "delivery_notice", "idle_subscription"];
4
+ export const MAILBOX_PEER_NOTICE_STATES = ["held", "denied", "expired", "delivered", "refused", "dropped", "idle", "exited", "unavailable"];
4
5
  export const MAILBOX_INVALID_PEER_META_CODE = "mailbox.invalid_peer_meta";
5
6
  export const MAILBOX_CROSS_PROCESS_UNSAFE_CODE = "mailbox.cross_process_unsafe";
6
7
  const PEER_META_STRING_KEYS = ["fromSession", "senderKey", "fromScope", "gateReceiptId"];
@@ -38,14 +39,53 @@ export function readMailboxPeerMeta(raw) {
38
39
  return refuse(`${k} must be a non-empty string`);
39
40
  out[k] = v;
40
41
  }
42
+ else if (k === "notice") {
43
+ out.notice = readMailboxPeerNoticeMeta(v, refuse);
44
+ }
41
45
  else {
42
46
  return refuse(`carries an unknown key ${JSON.stringify(k)}`);
43
47
  }
44
48
  }
45
49
  return out;
46
50
  }
51
+ function readMailboxPeerNoticeMeta(raw, refuse) {
52
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
53
+ return refuse("notice must be a plain object");
54
+ const proto = Object.getPrototypeOf(raw);
55
+ if (proto !== Object.prototype && proto !== null)
56
+ return refuse("notice must be a plain object (a class instance, Date, Map or similar is not)");
57
+ const out = {};
58
+ for (const key of Reflect.ownKeys(raw)) {
59
+ if (typeof key !== "string")
60
+ return refuse("notice carries a symbol-keyed member");
61
+ const v = raw[key];
62
+ if (v === undefined)
63
+ continue;
64
+ if (key === "state") {
65
+ if (!MAILBOX_PEER_NOTICE_STATES.includes(v))
66
+ return refuse(`notice.state must be one of ${MAILBOX_PEER_NOTICE_STATES.join("|")}`);
67
+ out.state = v;
68
+ }
69
+ else if (key === "recipient" || key === "dropReason" || key === "detail" || key === "target") {
70
+ if (typeof v !== "string" || v === "")
71
+ return refuse(`notice.${key} must be a non-empty string`);
72
+ out[key] = v;
73
+ }
74
+ else if (key === "refSeq" || key === "finishedAt") {
75
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0)
76
+ return refuse(`notice.${key} must be a non-negative finite number`);
77
+ out[key] = v;
78
+ }
79
+ else {
80
+ return refuse(`notice carries an unknown key ${JSON.stringify(key)}`);
81
+ }
82
+ }
83
+ if (out.state === undefined)
84
+ return refuse("notice.state is required");
85
+ return out;
86
+ }
47
87
  export function cloneMailboxPeerMeta(meta) {
48
- return meta === undefined ? undefined : { ...meta };
88
+ return meta === undefined ? undefined : { ...meta, ...(meta.notice !== undefined ? { notice: { ...meta.notice } } : {}) };
49
89
  }
50
90
  export const MAILBOX_TOMBSTONED_RECIPIENT_CODE = "mailbox.recipient_tombstoned";
51
91
  export class MailboxStoreError extends Error {
@@ -117,6 +117,11 @@ export interface ResultFlags {
117
117
  * Pure pass-through — assembly neither adds nor filters (a rewind that FAILED never reaches here; it
118
118
  * throws at prepare and lands in the `threw` slot as a terminal errorCode). */
119
119
  rewindNotes?: TaskResult["rewindNotes"];
120
+ /** The run's OWN edited-file ledger, echoed on `TaskResult.editedFiles`. Pure pass-through on
121
+ * EVERY terminal — what this run's hands changed is a fact about the leg that ran, whatever
122
+ * terminal it reached (a failed/aborted run that edited files is exactly the case a "restore the
123
+ * code this message changed" affordance is for). Undefined ⇒ the key is omitted. */
124
+ editedFiles?: TaskResult["editedFiles"];
120
125
  /** The run's final turn was halted by a person's BARE rejection of a tool call (the parent-thread
121
126
  * control-flow boundary) — echoed on `TaskResult.haltedOnUserRejection`. Pure pass-through on
122
127
  * every terminal: the fact is about the leg that ran, whatever terminal it reached (on the normal
@@ -181,5 +181,5 @@ export function assembleResult(spec, sessionId, final, stats, flags) {
181
181
  if (flags.unpricedSpend)
182
182
  delete publicStats.costMicroUsd;
183
183
  const stampHaltedByUser = flags.userHalted === true && status !== "suspended" && status !== "needs_review";
184
- return { taskId, ...(flags.runId !== undefined ? { runId: flags.runId } : {}), sessionId, status, ...(flags.model !== undefined ? { model: flags.model } : {}), result: result.trim(), salvagedOutput, blockedReason, errorMessage, errorCode, ...(apiFailure !== undefined ? { apiFailure } : {}), ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), checkpointToken, ...(checkpointId !== undefined ? { checkpointId } : {}), checkpointGate, ...(workspaceRestoreMode !== undefined ? { workspaceRestoreMode } : {}), ...(flags.rewindNotes !== undefined && flags.rewindNotes.length > 0 ? { rewindNotes: flags.rewindNotes } : {}), ...(flags.haltedOnUserRejection === true ? { haltedOnUserRejection: true } : {}), ...(stampHaltedByUser ? { haltedByUser: true } : {}), ...(flags.remoteEnvFailures !== undefined && flags.remoteEnvFailures.length > 0 ? { remoteEnvFailures: [...flags.remoteEnvFailures] } : {}), ...(flags.strandedHumanAnswers !== undefined && flags.strandedHumanAnswers.length > 0 ? { strandedHumanAnswers: flags.strandedHumanAnswers } : {}), ...(flags.effectiveReadFace !== undefined ? { effectiveReadFace: flags.effectiveReadFace } : {}), ...(flags.effectiveReadDenyPatterns !== undefined && flags.effectiveReadDenyPatterns.length > 0 ? { effectiveReadDenyPatterns: flags.effectiveReadDenyPatterns } : {}), ...(flags.effectiveMemoryScopes !== undefined ? { effectiveMemoryScopes: flags.effectiveMemoryScopes } : {}), ...(flags.effectiveReasoning !== undefined ? { effectiveReasoning: flags.effectiveReasoning } : {}), stats: publicStats };
184
+ return { taskId, ...(flags.runId !== undefined ? { runId: flags.runId } : {}), sessionId, status, ...(flags.model !== undefined ? { model: flags.model } : {}), result: result.trim(), salvagedOutput, blockedReason, errorMessage, errorCode, ...(apiFailure !== undefined ? { apiFailure } : {}), ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), checkpointToken, ...(checkpointId !== undefined ? { checkpointId } : {}), checkpointGate, ...(workspaceRestoreMode !== undefined ? { workspaceRestoreMode } : {}), ...(flags.rewindNotes !== undefined && flags.rewindNotes.length > 0 ? { rewindNotes: flags.rewindNotes } : {}), ...(flags.editedFiles !== undefined && flags.editedFiles.length > 0 ? { editedFiles: flags.editedFiles } : {}), ...(flags.haltedOnUserRejection === true ? { haltedOnUserRejection: true } : {}), ...(stampHaltedByUser ? { haltedByUser: true } : {}), ...(flags.remoteEnvFailures !== undefined && flags.remoteEnvFailures.length > 0 ? { remoteEnvFailures: [...flags.remoteEnvFailures] } : {}), ...(flags.strandedHumanAnswers !== undefined && flags.strandedHumanAnswers.length > 0 ? { strandedHumanAnswers: flags.strandedHumanAnswers } : {}), ...(flags.effectiveReadFace !== undefined ? { effectiveReadFace: flags.effectiveReadFace } : {}), ...(flags.effectiveReadDenyPatterns !== undefined && flags.effectiveReadDenyPatterns.length > 0 ? { effectiveReadDenyPatterns: flags.effectiveReadDenyPatterns } : {}), ...(flags.effectiveMemoryScopes !== undefined ? { effectiveMemoryScopes: flags.effectiveMemoryScopes } : {}), ...(flags.effectiveReasoning !== undefined ? { effectiveReasoning: flags.effectiveReasoning } : {}), stats: publicStats };
185
185
  }
@@ -0,0 +1,149 @@
1
+ import { type AutoModeDecider, type AutoModeDenialLimitOptions, type AutoModeDenialTracker, type DenialLimitFallback } from "../auto-mode.js";
2
+ import type { PermissionResult, ResolvedAsk, ToolCallRequest } from "../tool-policy.js";
3
+ import { type EngineNotice } from "../types.js";
4
+ import type { Prepared } from "./prepare-task.js";
5
+ /**
6
+ * #548 — the classifier DENIAL-LIMIT arms of the tool gate's inherited (delegation) lane, extracted
7
+ * from `prepareTask` as a phase module (design/238 D-7: extract, don't accrete). The gate's OWN
8
+ * classifier block site lives in tool-policy; these are the pieces the runner threads around it:
9
+ * · the tracker attached to a re-supplied chain entry ({@link attachRebuiltDenialTrackers});
10
+ * · the run's ONE typed stop seat + the closure that fills it ({@link createDenialLimitStop});
11
+ * · the frozen classifier's judgment with the count-then-fallback step, shared by the two inherited
12
+ * wrapper arms and the approved-edit re-check ({@link judgeInheritedClassifier});
13
+ * · the post-resolution settlement of a fallback ask — a person's allow resets the streak, a deny
14
+ * nobody made stops the run ({@link settleDenialLimitFallback}).
15
+ * Each helper is pure over what prepare hands it; the fold/ask seams themselves stay in the runner.
16
+ */
17
+ /** An ancestor chain entry's classifier half as the arms read it: the decider and, beside it, the
18
+ * per-run denial tracker (absent on an entry that counts nothing). */
19
+ export interface InheritedAutoMode {
20
+ decider: AutoModeDecider;
21
+ denialTracking?: AutoModeDenialTracker;
22
+ }
23
+ /**
24
+ * A re-supplied chain entry (a cross-process redemption rebuilt its ancestor's classifier from the
25
+ * recorded recipe — `rebuildAutoModeDecider` hands back a decider and its arming, nothing live)
26
+ * carries no denial tracker, and an entry with a decider but no tracker would count nothing: the
27
+ * rebuilt classifier could block without bound on the redeemed leg, the exact gap the limit closes.
28
+ * Attach a FRESH tracker under this deployment's own bounds (a fresh count is the documented
29
+ * cross-process semantics — the breaker restarts the same way). Entries that already carry one (the
30
+ * in-process chain from a live ancestor) are kept BY IDENTITY; only a tracker-less armed entry is
31
+ * re-wrapped (its `policy`, the identity the fold and the pass-through compare, is untouched).
32
+ * ONE tracker per DECIDER identity, not per entry: a parent attaches its one decider to both its
33
+ * hook entry and its caller-policy entry, and a count split across two trackers could alternate
34
+ * forever without either reaching the bound. The tracker is minted lazily, so a bad bound refuses
35
+ * only a prepare that actually has such an entry (the armed leg's own mint screens the knob first).
36
+ */
37
+ export declare function attachRebuiltDenialTrackers<T extends {
38
+ autoMode?: InheritedAutoMode;
39
+ }>(entries: ReadonlyArray<T> | undefined, denialLimit: AutoModeDenialLimitOptions | undefined): T[] | undefined;
40
+ /** The one closure that stops a run for a headless denial-limit fallback (see {@link createDenialLimitStop}). */
41
+ export type StopForDenialLimit = (info: {
42
+ toolName: string;
43
+ toolCallId: string;
44
+ fallback: DenialLimitFallback;
45
+ }) => void;
46
+ /**
47
+ * The gate's typed stop seat (`Prepared.gateStopRef`) and the one closure that fills it — the headless
48
+ * arm of the denial-limit fallback: no approver to fall back to ⇒ stop the run with
49
+ * `classifier.denial_limit` (CC throws "Agent aborted: too many classifier denials in headless
50
+ * mode"). Once per run (the first headless fallback owns the terminal); the deny that triggered it
51
+ * stands as that call's result. Minted once so every mint site — the main gate's seat and the
52
+ * inherited-lane arms — stops the SAME run through the SAME seat. `abort` is the run's own abort
53
+ * (controller + harness), handed in because the harness is built after the seat is.
54
+ */
55
+ export declare function createDenialLimitStop(opts: {
56
+ sessionId: string;
57
+ runId: string;
58
+ onNotice: ((notice: EngineNotice) => void) | undefined;
59
+ abort: () => void;
60
+ }): {
61
+ gateStopRef: Prepared["gateStopRef"];
62
+ stopForDenialLimit: StopForDenialLimit;
63
+ };
64
+ /** The ask arm of a permission decision — what the inherited lane arbitrates. */
65
+ export type InheritedAsk = Extract<PermissionResult, {
66
+ action: "ask";
67
+ }>;
68
+ /**
69
+ * The delegation half of the two classifier exclusions the LIVE gate enforces: an ask raised by a hook
70
+ * (`decisionReason:"hook"`, engine-stamped, never self-supplied) and an ask minted by an explicit `ask`
71
+ * permission rule (`matchedAskRule`) are exactly as out of the FROZEN classifier's reach as the live
72
+ * one's — the person's / hook's standing decision travels with the frozen entry, and a frozen decider
73
+ * resolving it would re-open the same hole one process over. An ask that already IS a denial-limit
74
+ * fallback (a deeper ancestor's) is not re-judged either. Excluded asks fall through to the frozen
75
+ * approver chain, the delegation lane's original chain.
76
+ */
77
+ export declare function frozenClassifierExcluded(d: {
78
+ decisionReason?: string;
79
+ matchedAskRule?: string;
80
+ denialLimitFallback?: unknown;
81
+ }): boolean;
82
+ /** What {@link judgeInheritedClassifier} hands back to the arm that called it. */
83
+ export type InheritedClassifierJudgment<A extends InheritedAsk> =
84
+ /** The frozen classifier allowed — the arm returns its own allow shape (with or without the ask's rewrite). */
85
+ {
86
+ kind: "allow";
87
+ }
88
+ /** A block below the bound — the exact deny the ancestor's own gate would have produced. */
89
+ | {
90
+ kind: "deny";
91
+ result: PermissionResult;
92
+ }
93
+ /** Proceed to the frozen approver with `ask`: the incoming ask unchanged (classifier unavailable,
94
+ * excluded, or not armed), or — at the bound — the fallback ask minted from it (`mintedHere`).
95
+ * `fallback` is the member riding the ask either way (an incoming one is a deeper ancestor's). */
96
+ | {
97
+ kind: "resolve";
98
+ ask: A;
99
+ fallback: DenialLimitFallback | undefined;
100
+ mintedHere: boolean;
101
+ };
102
+ /**
103
+ * The ancestor's frozen classifier, run BEFORE its frozen approver in the ancestor's own gate order,
104
+ * with the denial-limit count folded in (CC 2.1.250 count-then-judge): `allow` ⇒ the classifier's
105
+ * auto-allow (and the streak resets); `block` below the bound ⇒ deny, the exact verdict the ancestor's
106
+ * gate would have produced; `block` AT the bound ⇒ the ask is re-spoken in the classifier's voice as
107
+ * the fallback (`decisionReason:"classifier"`, `requiresRealApproval`, the member carrying counts +
108
+ * window) for the arm to resolve at the frozen approver; anything else (unavailable / parse error /
109
+ * excluded / no classifier) ⇒ the incoming ask, unchanged. The member may ALREADY be on the incoming
110
+ * ask (a deeper ancestor's fallback, minted inside the nested fold — the case the exclusion names):
111
+ * it rides to the approver and the headless arm unchanged, whichever tracker minted it; only a
112
+ * tracker that counted THIS streak is reset by the person's later allow (`mintedHere`).
113
+ * `req` is the request as the classifier must see it (the arm's presented / edited args);
114
+ * `subject` is the noun the deny names ("this call" on the wrapper arms, "the approved edit" on the
115
+ * re-check).
116
+ */
117
+ export declare function judgeInheritedClassifier<A extends InheritedAsk>(opts: {
118
+ autoMode: InheritedAutoMode | undefined;
119
+ ask: A;
120
+ req: ToolCallRequest;
121
+ signal: AbortSignal;
122
+ subject: "this call" | "the approved edit";
123
+ }): Promise<InheritedClassifierJudgment<A>>;
124
+ /** The resolver's answer as the settlement reads it. */
125
+ export type FallbackResolution = Pick<ResolvedAsk, "action" | "resolution" | "approverUnavailable">;
126
+ /** A deny nobody made, at a wrapper arm: no approver wired, or a blanket `onAsk:"allow"` refused as
127
+ * no approver. `approver_unavailable` is NOT headless here — it floats to the main gate, which owns
128
+ * that arm (a park, or the fail-closed refusal). */
129
+ export declare function headlessDenyAtFold(r: FallbackResolution): boolean;
130
+ /** The re-check's reading: the approved-edit helper has NO park route (an approved edit is
131
+ * re-adjudicated in place), so an approver that reports unavailable is as headless as none at all. */
132
+ export declare function headlessDenyAtRecheck(r: FallbackResolution): boolean;
133
+ /**
134
+ * After the frozen approver answered a fallback ask (CC `yR`): a person's allow ends the ancestor's
135
+ * streak — only on the tracker that counted it (`mintedHere`; a deeper ancestor's fallback leaves
136
+ * this entry's count alone); a deny nobody made (`headless`, the arm's own reading) is the headless
137
+ * arm — the run stops through the one seat (`stop`). Any other answer settles nothing here. A no-op
138
+ * when the ask was not a fallback.
139
+ */
140
+ export declare function settleDenialLimitFallback(opts: {
141
+ fallback: DenialLimitFallback | undefined;
142
+ mintedHere: boolean;
143
+ tracker: AutoModeDenialTracker | undefined;
144
+ resolved: FallbackResolution;
145
+ headless: (r: FallbackResolution) => boolean;
146
+ stop: StopForDenialLimit;
147
+ toolName: string;
148
+ toolCallId: string;
149
+ }): void;