@sema-agent/core 7.9.2 → 7.11.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 (86) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/dist/agents/child-model-seat.d.ts +71 -14
  3. package/dist/agents/child-model-seat.js +23 -7
  4. package/dist/agents/subagent.d.ts +2 -2
  5. package/dist/agents/subagent.js +8 -5
  6. package/dist/agents/teacher.js +2 -2
  7. package/dist/core/ask-unresolvable-notice.d.ts +52 -0
  8. package/dist/core/ask-unresolvable-notice.js +25 -0
  9. package/dist/core/auto-mode-defaults.d.ts +15 -3
  10. package/dist/core/auto-mode-defaults.js +1 -0
  11. package/dist/core/auto-mode.d.ts +86 -23
  12. package/dist/core/auto-mode.js +43 -12
  13. package/dist/core/checkpoint-store.d.ts +14 -0
  14. package/dist/core/checkpoint-store.js +2 -1
  15. package/dist/core/engine-notice.d.ts +28 -7
  16. package/dist/core/gate-fold.js +1 -0
  17. package/dist/core/gate-lanes.d.ts +6 -1
  18. package/dist/core/gate-lanes.js +60 -18
  19. package/dist/core/governance-codes.d.ts +1 -1
  20. package/dist/core/governance-codes.js +4 -0
  21. package/dist/core/hooks.d.ts +37 -1
  22. package/dist/core/hooks.js +2 -0
  23. package/dist/core/permission-rule-model.d.ts +53 -16
  24. package/dist/core/permission-rule-model.js +59 -21
  25. package/dist/core/permission-rules.d.ts +6 -4
  26. package/dist/core/permission-rules.js +14 -14
  27. package/dist/core/persisted-rule-arms.js +4 -3
  28. package/dist/core/read-only-shell-table.d.ts +87 -0
  29. package/dist/core/read-only-shell-table.js +485 -0
  30. package/dist/core/read-only-shell.d.ts +42 -0
  31. package/dist/core/read-only-shell.js +316 -0
  32. package/dist/core/roles.d.ts +3 -2
  33. package/dist/core/runner/contracts.d.ts +26 -2
  34. package/dist/core/runner/denial-limit-arms.d.ts +14 -3
  35. package/dist/core/runner/denial-limit-arms.js +15 -5
  36. package/dist/core/runner/gate-exit.d.ts +5 -0
  37. package/dist/core/runner/permission-rule-lanes.d.ts +7 -1
  38. package/dist/core/runner/permission-rule-lanes.js +9 -3
  39. package/dist/core/runner/prepare-caps-and-workflow.d.ts +1 -1
  40. package/dist/core/runner/prepare-caps-and-workflow.js +35 -14
  41. package/dist/core/runner/prepare-gate-stations.d.ts +3 -2
  42. package/dist/core/runner/prepare-gate-stations.js +12 -0
  43. package/dist/core/runner/prepare-policy-chain.js +7 -6
  44. package/dist/core/runner/prepare-task.js +1 -1
  45. package/dist/core/runner/prepare-turn-wiring.js +1 -1
  46. package/dist/core/runner/prepare-wiring-manifest.d.ts +1 -1
  47. package/dist/core/runner/prepare-wiring-manifest.js +8 -1
  48. package/dist/core/runner/runtask.d.ts +33 -32
  49. package/dist/core/runner/runtask.js +63 -33
  50. package/dist/core/runner-deps.d.ts +9 -2
  51. package/dist/core/shell-lexer.d.ts +18 -0
  52. package/dist/core/shell-lexer.js +17 -10
  53. package/dist/core/shell-wrapper-table.js +8 -5
  54. package/dist/core/swappable-deps.d.ts +90 -0
  55. package/dist/core/swappable-deps.js +55 -0
  56. package/dist/core/tool-policy.d.ts +30 -1
  57. package/dist/core/tool-policy.js +6 -2
  58. package/dist/core/tools.d.ts +28 -7
  59. package/dist/core/tools.js +44 -4
  60. package/dist/core/trace.d.ts +15 -0
  61. package/dist/core/wiring-manifest.d.ts +15 -1
  62. package/dist/core/wiring-manifest.js +10 -2
  63. package/dist/core/workflow-journal-store.d.ts +21 -2
  64. package/dist/core/workflow-journal-store.js +1 -1
  65. package/dist/engine/execution-env/node-execution-env.d.ts +2 -0
  66. package/dist/engine/execution-env/node-execution-env.js +2 -1
  67. package/dist/engine/harness/agent-harness.d.ts +3 -1
  68. package/dist/engine/harness/agent-harness.js +1 -1
  69. package/dist/engine/harness/types.d.ts +15 -2
  70. package/dist/index.d.ts +8 -4
  71. package/dist/index.js +7 -3
  72. package/dist/orchestration/run-workflow-tool.d.ts +22 -2
  73. package/dist/orchestration/run-workflow-tool.js +14 -1
  74. package/dist/orchestration/workflow-governance.d.ts +3 -2
  75. package/dist/orchestration/workflow-observe.d.ts +1 -1
  76. package/dist/orchestration/workflow-observe.js +2 -0
  77. package/dist/orchestration/workflow-primitives.d.ts +4 -1
  78. package/dist/orchestration/workflow-primitives.js +1 -6
  79. package/dist/orchestration/workflow-types.d.ts +37 -2
  80. package/dist/orchestration/workflow-types.js +16 -0
  81. package/dist/orchestration/workflow.d.ts +53 -6
  82. package/dist/orchestration/workflow.js +319 -51
  83. package/dist/prompt-assembly/turn-snapshot.d.ts +4 -2
  84. package/dist/stores/file/workflow-journal-store.js +10 -3
  85. package/package.json +1 -1
  86. package/test/export-surface.snapshot.json +85 -5
@@ -1,4 +1,16 @@
1
1
  import type { ToolCallRequest } from "./tool-policy.js";
2
+ /**
3
+ * WHY an `unavailable` verdict could not run — the closed set behind the verdict arm, the ask's
4
+ * `classifierUnavailable` fact (the card's "asked because the classifier could not run (timeout)" sentence)
5
+ * and the `auto_mode.classified` trace frame's `cause`, so the three spell one word. `error` = the model leg
6
+ * threw or rejected (a route failure at classify time reads here too — the derived-route pre-flight fell
7
+ * back BEFORE any decide, so there is no separate word for it); `timeout` = the round-trip cap;
8
+ * `breaker_open` = the session latch already tripped and the round was short-circuited.
9
+ */
10
+ export declare const AUTO_MODE_UNAVAILABLE_CAUSES: readonly ["error", "timeout", "breaker_open"];
11
+ export type AutoModeUnavailableCause = (typeof AUTO_MODE_UNAVAILABLE_CAUSES)[number];
12
+ /** Whether a value is a member of the unavailable-cause set — the screen a row reader applies to a stored word. */
13
+ export declare function isAutoModeUnavailableCause(v: unknown): v is AutoModeUnavailableCause;
2
14
  /** The classifier's verdict over one pending `ask`. */
3
15
  export type AutoModeVerdict = {
4
16
  kind: "allow";
@@ -12,7 +24,7 @@ export type AutoModeVerdict = {
12
24
  /** The classifier could not run: model error/timeout, or the session breaker is open. NOT a decision. */
13
25
  | {
14
26
  kind: "unavailable";
15
- cause: "error" | "timeout" | "breaker_open";
27
+ cause: AutoModeUnavailableCause;
16
28
  }
17
29
  /** The model responded but not in the `<block>…` contract shape. NOT a decision. */
18
30
  | {
@@ -20,30 +32,44 @@ export type AutoModeVerdict = {
20
32
  raw: string;
21
33
  };
22
34
  /**
23
- * Parse the 207 classifier output contract:
35
+ * The failure kinds that COUNT toward the one-way breaker and the word a trip records as its `lastCause`:
36
+ * the two health failures of the unavailable arm plus the contract failure. `breaker_open` is deliberately
37
+ * not one: it is what the latch ANSWERS once tripped, never what trips it; a cancelled round (the run's own
38
+ * abort) counts nothing (see the decider's catch arm).
39
+ */
40
+ export declare const AUTO_MODE_BREAKER_CAUSES: readonly ["error", "timeout", "parse_error"];
41
+ export type AutoModeBreakerCause = (typeof AUTO_MODE_BREAKER_CAUSES)[number];
42
+ /** Whether a value is a member of the breaker-cause set (the manifest's value screen over a recorded trip). */
43
+ export declare function isAutoModeBreakerCause(v: unknown): v is AutoModeBreakerCause;
44
+ /**
45
+ * Parse the classifier's output contract — the CC 2.1.250 verdict reader (`wCe`, its thinking strip
46
+ * `xCe`, its reason reader `yCe`) ported as is:
24
47
  * blocked: `<block>yes</block><category>Rule Name</category><reason>[Rule Name] …</reason>`
25
48
  * allowed: `<block>no</block>`
26
- * The contract says the ENTIRE response must begin with `<block>` — we strip `<thinking>` blocks
27
- * first (CC-anchored: the 206 body's `dJi` removes closed thinking blocks AND a dangling unclosed
28
- * tail before its own block-scan — a reasoning model's scratch space is not part of the verdict),
29
- * then tolerate leading whitespace (models routinely emit a leading newline) but nothing else: any
30
- * other preamble is a `parse_error` (fail-closed; 207 vocabulary: `automode-parsing-error`).
31
49
  *
32
- * Declared STRICTER than the CC parser (`iJi`: first `<block>` ANYWHERE in the response, closing tag
33
- * optional): a free-scan lets a prompt-injected preamble seed the verdict; our contract-anchored
34
- * parse costs only a fallback to the original human chain on the mismatch, never a silent allow.
50
+ * The verdict is the set of `<block>yes|no` hits, NOT a prefix match:
51
+ * 1. CONFLICT, read on the RAW text (thinking included): `<block>(yes|no)\b` collected over the whole
52
+ * response more than one distinct answer no verdict (`parse_error`). A scratch-space "yes"
53
+ * contradicting a final "no" is a contradiction, not an allow.
54
+ * 2. HITS, read on the text with `<thinking>` blocks removed (closed ones anywhere, then an unclosed
55
+ * trailing one — a reasoning model's scratch space is not part of the verdict): `<block>(yes|no)\b`
56
+ * with the closing tag OPTIONAL, at ANY position. Zero hits ⇒ `parse_error` (a reply that only
57
+ * answered inside its thinking has no verdict); the one distinct answer ⇒ `no` = allow, `yes` = block.
58
+ * So a preamble before the tag, prose after `<block>no`, a missing `</block>` and a reply that repeats
59
+ * its answer all read as the answer they carry; the tag must be spelled `<block>yes` / `<block>no` with
60
+ * no whitespace inside (the word-boundary form — `<block>yesterday` is not a hit) and case does not matter.
61
+ *
62
+ * A stated BLOCK stands with or without its tail: `<category>` (the BLOCK rule NAME the prompt asked
63
+ * for, read case-insensitively as free text — the deployment authors those names, so there is no
64
+ * fixed vocabulary to screen them against) and `<reason>` (first `<reason>…</reason>` pair on the
65
+ * stripped text, the tag spelled as the prompt spells it, trimmed) are carried when present and `""`
66
+ * when absent; the consumers render their
67
+ * own fallback line from the tool name in that case. The block intent is unambiguous either way, and
68
+ * downgrading a stated block to `parse_error` (⇒ the ask may later auto-deny OR a person may approve)
69
+ * would weaken the classifier's explicit verdict on the exact calls it flagged.
35
70
  *
36
- * Strictness is polarity-asymmetric (confirmed HIGH):
37
- * - an ALLOW must be UNAMBIGUOUS: `<block>no</block>` followed by anything but whitespace
38
- * a second conflicting `<block>yes</block>`, trailing prose, any tail at all — violates the
39
- * contract and maps to `parse_error` (the ask flows the original human chain, never a silent
40
- * allow on a malformed response);
41
- * - a stated BLOCK tolerates a tail (that is where `<category>`/`<reason>` live, and extra
42
- * content after a block verdict can only ever be judged in the blocking direction) — a
43
- * `<block>yes</block>` with MISSING/empty `<category>`/`<reason>` still maps to `block`
44
- * (with "" placeholders): the block intent is unambiguous, and downgrading a stated block
45
- * to parse_error (⇒ the ask may later auto-deny OR a human may approve) would weaken the
46
- * classifier's explicit verdict on the exact calls it flagged.
71
+ * `parse_error` is fail-closed: the ask flows the original chain (a person, a durable park, a headless
72
+ * auto-deny) exactly as if auto mode were absent never a silent allow.
47
73
  */
48
74
  export declare function parseAutoModeResponse(text: string): AutoModeVerdict;
49
75
  /** What the decider hands the deployment's `classify` hook (the assembled prompt is the hook's job
@@ -65,10 +91,11 @@ export interface AutoModeDeciderOptions {
65
91
  /** Consecutive-failure threshold that opens the session breaker (default 3 "连续 N 失败").
66
92
  * Failures = unavailable(error|timeout) + parse_error. A successful round (allow/block) resets it. */
67
93
  failureThreshold?: number;
68
- /** Fired ONCE when the breaker opens ("本 session 退回非 auto + 一次性告警"). */
94
+ /** Fired ONCE when the breaker opens ("本 session 退回非 auto + 一次性告警"). `lastCause` is the failure that tripped it
95
+ * ({@link AutoModeBreakerCause}). */
69
96
  onBreakerOpen?: (info: {
70
97
  consecutiveFailures: number;
71
- lastCause: string;
98
+ lastCause: AutoModeBreakerCause;
72
99
  }) => void;
73
100
  /**
74
101
  * Fired once per `decide` call with the verdict the gate is about to act on and the wall time it
@@ -117,6 +144,42 @@ export interface AutoModeDecider {
117
144
  * One instance per run/session — the breaker state is the session's "退回非 auto" latch.
118
145
  */
119
146
  export declare function createAutoModeDecider(opts: AutoModeDeciderOptions): AutoModeDecider;
147
+ /**
148
+ * ONE breaker trip as the session-level read face records it (`WiringManifest.autoMode.breaker`): when the
149
+ * latch opened, what tripped it, how many consecutive failures it took, and which run's decider it was.
150
+ * A decider is minted per run (its latch is a RUN fact), so a trip names one leg; the ledger below carries
151
+ * the most recent one forward per session, which is how the session's NEXT leg can say "auto mode fell
152
+ * back to asking on this session, at T, because the classifier timed out three times" — the sentence a
153
+ * shell's doctor line and a capabilities read face owe a person who wonders why auto mode is asking.
154
+ */
155
+ export interface AutoModeBreakerTrip {
156
+ /** Epoch ms at which the latch opened. */
157
+ readonly openedAtMs: number;
158
+ /** The failure that tripped it — the streak's last failure ({@link AutoModeBreakerCause}). */
159
+ readonly lastCause: AutoModeBreakerCause;
160
+ /** The consecutive-failure count at the trip (the threshold, or more under concurrent rounds). */
161
+ readonly failures: number;
162
+ /** The run whose decider tripped. */
163
+ readonly runId: string;
164
+ }
165
+ /**
166
+ * The Runner-lived, per-session record of the most recent breaker trip — the ONE writer is the engine's
167
+ * own `onBreakerOpen` wrap at the arming site (the deployment's alarm hook, when wired, is called after
168
+ * the record lands), the ONE reader the wiring-manifest phase of a later leg. Never persisted (a trip is
169
+ * process-local, like the decider it describes); bounded FIFO so a long-lived Runner cannot grow it
170
+ * without limit — an evicted session simply reads as "no trip recorded", the same as a session that
171
+ * never tripped. Threaded through the trusted `RunInternals` channel exactly as the per-session read-file
172
+ * seats are (the Runner sets it on every prepare it drives; a standalone prepare has none).
173
+ */
174
+ export declare class AutoModeBreakerLedger {
175
+ private readonly cap;
176
+ private readonly trips;
177
+ constructor(cap?: number);
178
+ /** Record THIS session's most recent trip (replacing an earlier one). */
179
+ record(sessionId: string, trip: AutoModeBreakerTrip): void;
180
+ /** The most recent trip recorded for this session, or undefined (never tripped, or evicted). */
181
+ lastTrip(sessionId: string): AutoModeBreakerTrip | undefined;
182
+ }
120
183
  /** The deployment's bounds for the denial limit (`RunnerDeps.autoMode.denialLimit`). Every member
121
184
  * optional; an omitted member takes its CC default. A present member with a bad value is REFUSED
122
185
  * loudly at construction (never clamped, never read as the default). */
@@ -1,19 +1,29 @@
1
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
+ export const AUTO_MODE_UNAVAILABLE_CAUSES = ["error", "timeout", "breaker_open"];
3
+ const AUTO_MODE_UNAVAILABLE_CAUSE_SET = new Set(AUTO_MODE_UNAVAILABLE_CAUSES);
4
+ export function isAutoModeUnavailableCause(v) {
5
+ return AUTO_MODE_UNAVAILABLE_CAUSE_SET.has(v);
6
+ }
7
+ export const AUTO_MODE_BREAKER_CAUSES = ["error", "timeout", "parse_error"];
8
+ const AUTO_MODE_BREAKER_CAUSE_SET = new Set(AUTO_MODE_BREAKER_CAUSES);
9
+ export function isAutoModeBreakerCause(v) {
10
+ return AUTO_MODE_BREAKER_CAUSE_SET.has(v);
11
+ }
2
12
  export function parseAutoModeResponse(text) {
3
- const t = text
4
- .replace(/<thinking>[\s\S]*?<\/thinking>/g, "")
5
- .replace(/<thinking>[\s\S]*$/, "")
6
- .trimStart();
7
- const m = /^<block>\s*(yes|no)\s*<\/block>/i.exec(t);
8
- if (!m)
13
+ const rawAnswers = new Set([...text.matchAll(/<block>(yes|no)\b/gi)].map((m) => m[1].toLowerCase()));
14
+ if (rawAnswers.size > 1)
15
+ return { kind: "parse_error", raw: text };
16
+ const stripped = text.replace(/<thinking>[\s\S]*?<\/thinking>/g, "").replace(/<thinking>[\s\S]*$/, "");
17
+ const hits = [...stripped.matchAll(/<block>(yes|no)\b(<\/block>)?/gi)];
18
+ if (hits.length === 0)
9
19
  return { kind: "parse_error", raw: text };
10
- if (m[1].toLowerCase() === "no") {
11
- if (t.slice(m[0].length).trim() !== "")
12
- return { kind: "parse_error", raw: text };
20
+ const answers = new Set(hits.map((m) => m[1].toLowerCase()));
21
+ if (answers.size !== 1)
22
+ return { kind: "parse_error", raw: text };
23
+ if (!answers.has("yes"))
13
24
  return { kind: "allow" };
14
- }
15
- const category = /<category>([\s\S]*?)<\/category>/i.exec(t)?.[1]?.trim() ?? "";
16
- const reason = /<reason>([\s\S]*?)<\/reason>/i.exec(t)?.[1]?.trim() ?? "";
25
+ const category = /<category>([\s\S]*?)<\/category>/i.exec(stripped)?.[1]?.trim() ?? "";
26
+ const reason = /<reason>([\s\S]*?)<\/reason>/.exec(stripped)?.[1]?.trim() ?? "";
17
27
  return { kind: "block", category, reason };
18
28
  }
19
29
  export function createAutoModeDecider(opts) {
@@ -117,6 +127,27 @@ class AutoModeTimeout extends Error {
117
127
  super("auto-mode classify timeout");
118
128
  }
119
129
  }
130
+ export class AutoModeBreakerLedger {
131
+ cap;
132
+ trips = new Map();
133
+ constructor(cap = 1024) {
134
+ this.cap = cap;
135
+ }
136
+ record(sessionId, trip) {
137
+ if (this.trips.has(sessionId))
138
+ this.trips.delete(sessionId);
139
+ this.trips.set(sessionId, trip);
140
+ while (this.trips.size > this.cap) {
141
+ const oldest = this.trips.keys().next().value;
142
+ if (oldest === undefined)
143
+ break;
144
+ this.trips.delete(oldest);
145
+ }
146
+ }
147
+ lastTrip(sessionId) {
148
+ return this.trips.get(sessionId);
149
+ }
150
+ }
120
151
  export function readDenialLimitFallback(v) {
121
152
  if (typeof v !== "object" || v === null)
122
153
  return undefined;
@@ -813,6 +813,14 @@ export type PendingAction = {
813
813
  * Written only when it is a member of the set; a pre-existing row reads absent (unreported).
814
814
  * Display metadata — the resume belts keep reading the gate's own bit. */
815
815
  origin?: import("./ask-origin.js").AskOrigin;
816
+ /** #616 (additive, same precedent) — the PARK twin of `AskRequest.classifierUnavailable`: present ⇔ the
817
+ * auto-mode classifier was consulted on the parked ask and could not run, with the verdict's own cause
818
+ * word ({@link import("./auto-mode.js").AutoModeUnavailableCause}). Written only when the cause is a
819
+ * member of the set; a pre-existing row reads absent. Display metadata (a durable card's "asked because
820
+ * the classifier was unavailable" sentence) — the resume belts never read it. */
821
+ classifierUnavailable?: {
822
+ readonly cause: import("./auto-mode.js").AutoModeUnavailableCause;
823
+ };
816
824
  /**
817
825
  * design/80 D-1 §2 (slice 1a.2): the server-minted **opaque** boundInputHash of {@link args} — a
818
826
  * SHA-256 (hex) via {@link import("./canonical-json.js").boundInputHashOf}, computed ONCE here at
@@ -1920,6 +1928,12 @@ export interface CheckpointSummary {
1920
1928
  * value is a member of the closed set ({@link import("./ask-origin.js").isAskOrigin}) — a bad value
1921
1929
  * reads as absent, never as a word. OMITTED when the row carries none (a pre-existing row). */
1922
1930
  origin?: import("./ask-origin.js").AskOrigin;
1931
+ /** The row's `PendingAction.tool_approval.classifierUnavailable` (#616), projected for the same one-call
1932
+ * reason. Echoed ONLY when the stored cause is a member of the closed set
1933
+ * ({@link import("./auto-mode.js").isAutoModeUnavailableCause}); OMITTED when the row carries none. */
1934
+ classifierUnavailable?: {
1935
+ readonly cause: import("./auto-mode.js").AutoModeUnavailableCause;
1936
+ };
1923
1937
  /**
1924
1938
  * #438: PRESENCE ONLY — this pending `tool_approval` row's payload carries at least one DIRECTIONAL
1925
1939
  * format control (the {@link import("./tool-policy.js").AskRequest.hasBidiControls} character class;
@@ -2,7 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto";
2
2
  import { uuidv7 } from "../internal/harness.js";
3
3
  import { PROBE_CAUSE_PATH_MAX, inlineUntrusted, sanitizeUntrustedText } from "./untrusted-text.js";
4
4
  import { carriesBidiControls } from "./tool-policy.js";
5
- import { readDenialLimitFallback } from "./auto-mode.js";
5
+ import { isAutoModeUnavailableCause, readDenialLimitFallback } from "./auto-mode.js";
6
6
  import { isAskOrigin } from "./ask-origin.js";
7
7
  import { checkpointExecutionRecorded, executionRecordDisposition, ExecutionOutcomeConflictError, } from "./checkpoint-execution-record.js";
8
8
  import { renderUntrustedCommandText, stripFormatCharacters } from "./permission-rule-model.js";
@@ -278,6 +278,7 @@ export function summarizeCheckpoint(cp) {
278
278
  return fb !== undefined ? { denialLimitFallback: fb } : {};
279
279
  })(),
280
280
  ...(tool !== undefined && isAskOrigin(tool.origin) ? { origin: tool.origin } : {}),
281
+ ...(tool !== undefined && isAutoModeUnavailableCause(tool.classifierUnavailable?.cause) ? { classifierUnavailable: { cause: tool.classifierUnavailable.cause } } : {}),
281
282
  ...(hasBidi ? { hasBidiControls: true } : {}),
282
283
  };
283
284
  }
@@ -69,15 +69,25 @@ export interface EngineNotice {
69
69
  * (`MCP_INJECTION_DROP_REASONS`, CLOSED-SETS), `field` names the bad key for `malformed_entry`
70
70
  * when known. `"user"` audience; `sessionId` REQUIRED at the mint (a user row with no session
71
71
  * routes nowhere — refused, never fabricated). Dedup unit: per leg per server name, the host's.
72
- * - `"config.models_swapped"` — `Runner.swapModels` replaced the model catalog generation
73
- * (zero-restart model switching). `detail: { models, tiers, occurrenceId }` key COUNTS only,
74
- * never the
75
- * catalog itself. In-flight tasks finish on the models they resolved at prepare (natural
76
- * snapshot); every later prepare resolves against the new generation. A failed swap (illegal
77
- * tier binding) throws atomically and mints nothing.
72
+ * - `"config.models_swapped"` — `Runner.swapDeps` replaced the model catalog generation
73
+ * (zero-restart model switching; the `models` / `tiers` seats of the swappable set). `detail:
74
+ * { models, tiers, occurrenceId }` — key COUNTS only, never the catalog itself. In-flight tasks
75
+ * finish on the models they resolved at prepare (natural snapshot); every later prepare resolves
76
+ * against the new generation. A failed swap (illegal tier binding, a refused candidate) throws
77
+ * atomically and mints nothing.
78
78
  * `occurrenceId` (#495, uuidv7 minted at the announcement): the verb carries no run and no
79
79
  * session, and the payload is a pair of counts, so two swaps of the same shape were byte-
80
80
  * identical lines. Dedup key: (code, occurrenceId).
81
+ * - `"config.read_face_swapped"` — `Runner.swapDeps` replaced the deployment's read-face
82
+ * declaration (`RunnerDeps.readFace`, the `readFace` seat of the swappable set) without a
83
+ * restart. `detail: { from, to, occurrenceId }` — the seat's previous and new value, each
84
+ * `"open"` | `"roots"` | `null` (`null` = no declaration; the engine default `roots` applies).
85
+ * The SEAT is announced, not a leg's resolved face: resolution (task seat, governance, the
86
+ * read-only mount) still runs per prepare. In-flight legs keep the face they prepared under;
87
+ * every later prepare resolves against the new declaration. A candidate outside the value gate
88
+ * (`assertReadFaceValue`) throws atomically and mints nothing. Own `occurrenceId` for the same
89
+ * reason as the catalog line (no run, no session, a two-word payload). Dedup key:
90
+ * (code, occurrenceId).
81
91
  * - `"route.fallback_to_primary"` (key↔URL pairing, `src/brain/route-adjudicator.ts`) — a
82
92
  * DERIVED-leg model (role/tier/system-default resolution, never a caller-explicit one) failed
83
93
  * the pairing pre-flight and the seat fell back to the primary model instead of sinking the
@@ -90,7 +100,7 @@ export interface EngineNotice {
90
100
  * session falling back on the same seat used to mint byte-identical lines; `runId` is what
91
101
  * tells one task falling back twice apart from two tasks falling back once each.
92
102
  * Explicitly-named models never mint this: they refuse at the brain's request gate instead.
93
- * - `"route.base_url_changed_key_unchanged"` (key↔URL pairing) — `Runner.swapModels` moved a
103
+ * - `"route.base_url_changed_key_unchanged"` (key↔URL pairing) — `Runner.swapDeps` moved a
94
104
  * same-name entry's `baseUrl` while its Model-visible credential half (auth-bearing headers)
95
105
  * did not change: legal (a provider changing domains), but worth one loud line — if the
96
106
  * PROVIDER changed, the credential reference needs the same update. Advisory only, never a
@@ -484,6 +494,17 @@ export interface EngineNotice {
484
494
  * access scope of the read that found the gap — from the continuation read faces (SendMessage preflight /
485
495
  * AgentTranscript's durable leg); the per-call honest refusals are unchanged, and the declared
486
496
  * tier is NOT auto-downgraded (declaration-制 — observation reports, it never re-adjudicates).
497
+ * - `"delegation.ask_unresolvable"` (#648) — an `ask` reached its FINAL deny with nobody having judged
498
+ * it: the approver consulted for the call (the run's own seat, or an ancestor's frozen seat on a
499
+ * delegated child — the sync / workflow lanes, or a background child outside the park family)
500
+ * answered `unavailable` (no operator reachable for this ask) and no durable approval park took
501
+ * the call afterwards. The deny itself is unchanged (`tool_end.gate.settlement.kind:
502
+ * "approver_unavailable"`, the fail-closed sentence as the tool result); this is its person-facing
503
+ * half. Minted at the one gate exit both unavailable deny arms reach, once per tool call, by
504
+ * `ask-unresolvable-notice.ts`. `detail: { sessionId, toolName, toolCallId, settlementKind,
505
+ * parkLaneExisted }` — `parkLaneExisted` says whether a park lane was armed and declined/failed
506
+ * (`true`) or none existed (`false`, the usual shape). Audience `"user"`: the refused work is the
507
+ * session user's own; the operator hears it through the sink like every code.
487
508
  *
488
509
  * - `"memory.consolidation_recommended"` (design/339 §2.2/§6.2) — the engine-minted per-scope
489
510
  * session count crossed the consolidation thresholds (time gate open ∧ enough distinct
@@ -107,6 +107,7 @@ export async function runGateFold(pass) {
107
107
  }
108
108
  catch (err) {
109
109
  reversible = false;
110
+ pass.probeUnanswered = true;
110
111
  if (input.abortSignal?.aborted !== true)
111
112
  traceHookCrash(input, err, notifier);
112
113
  }
@@ -82,7 +82,12 @@ export declare function persistedRuleMandateOf(marks: {
82
82
  /** #502: the surviving ask's engine-stamped `probeMandated` — see
83
83
  * {@link import("./types.js").ReversibilityVerdict.mandated}. */
84
84
  probeMandated?: boolean;
85
- }): "operator_always" | "tool_marks" | "probe_mandate" | undefined;
85
+ /** The pass's own record that the probe was consulted and did not answer (`GatePass.probeUnanswered`):
86
+ * the boundary is unknown, which no standing yes may stand in for. The runner's suggestion factory does
87
+ * not see the pass and never passes it — a card may offer a rule the lane then discloses as shadowed
88
+ * (loud, never a silent override), the same drift window the `matchedAskRule` conjunct already has. */
89
+ probeUnanswered?: boolean;
90
+ }): "operator_always" | "tool_marks" | "probe_mandate" | "probe_unanswered" | undefined;
86
91
  /**
87
92
  * Run the lanes over the pass: the org layer over the fold's verdict, then — for a surviving ask — the rewrite
88
93
  * merge, the rule lane, the stamp, the classifier, the sandbox leg, the parks, the content lane, the resolution
@@ -5,6 +5,9 @@ import { inlineUntrusted } from "./untrusted-text.js";
5
5
  import { isRuleBehavior } from "./permission-rule-model.js";
6
6
  import { ASK_USER_QUESTION_TOOL_NAME } from "./ask-question.js";
7
7
  import { applyPersistedTightening, disclosedRuleSet } from "./persisted-rule-arms.js";
8
+ import { readOnlyShellVerdict } from "./read-only-shell.js";
9
+ import { COMMAND_RULE_TOOL } from "./permission-rule-model.js";
10
+ import { catalogRuleFaceOf } from "./tool-registry.js";
8
11
  import { ORG_ADJUDICATION_TIMEOUT_MS, ORG_RULE_DECISION_REASON, ORG_UNAVAILABLE_DECISION_REASON, settleOrgVerdictWithin } from "./permission-rule-org.js";
9
12
  import { exitGate, traceHookCrash } from "./runner/gate-exit.js";
10
13
  function isPlainOwnRecord(x) {
@@ -144,15 +147,17 @@ export function normalizePersistedRuleHit(hit) {
144
147
  export function persistedRuleMandateOf(marks) {
145
148
  return marks.probeMandated === true
146
149
  ? "probe_mandate"
147
- : marks.egress === true
148
- ? "tool_marks"
149
- : marks.shellGated === true
150
- ? marks.irreversibility === "always"
151
- ? "operator_always"
152
- : undefined
153
- : marks.irreversibility === "always" || marks.irreversibility === "maybe"
154
- ? "tool_marks"
155
- : undefined;
150
+ : marks.probeUnanswered === true
151
+ ? "probe_unanswered"
152
+ : marks.egress === true
153
+ ? "tool_marks"
154
+ : marks.shellGated === true
155
+ ? marks.irreversibility === "always"
156
+ ? "operator_always"
157
+ : undefined
158
+ : marks.irreversibility === "always" || marks.irreversibility === "maybe"
159
+ ? "tool_marks"
160
+ : undefined;
156
161
  }
157
162
  function parkWith(suspendAsk, parkArgs, carry) {
158
163
  return suspendAsk(...parkArgs, carry);
@@ -160,9 +165,11 @@ function parkWith(suspendAsk, parkArgs, carry) {
160
165
  export async function runGateLanes(pass) {
161
166
  const { input, toolName, toolCallId, callSignal, ledger, notifier, screening, adjudicate, resolveAsk, suspendAsk } = pass;
162
167
  let orgRealApprovalRequired = false;
168
+ let markedThisRound;
169
+ const markedUnresolvable = () => (markedThisRound ??= input.isMarkedUnresolvable?.(input.event.toolCallId) === true);
163
170
  const askOriginFacts = (org, ruleStore) => ({
164
171
  contentQuestion: toolName === ASK_USER_QUESTION_TOOL_NAME,
165
- markedUnresolvable: input.isMarkedUnresolvable?.(input.event.toolCallId) === true,
172
+ markedUnresolvable: markedUnresolvable(),
166
173
  org,
167
174
  ...(ruleStore !== undefined ? { ruleStore } : {}),
168
175
  tightened: pass.tightenedBy,
@@ -269,6 +276,7 @@ export async function runGateLanes(pass) {
269
276
  pass.deniedBy = "persisted_rule";
270
277
  }
271
278
  if (pass.decision.action === "ask") {
279
+ pass.decision = { ...pass.decision };
272
280
  if (pass.policyRewrite !== undefined) {
273
281
  pass.currentInput = pass.policyRewrite;
274
282
  pass.req.args = pass.policyRewrite;
@@ -278,18 +286,19 @@ export async function runGateLanes(pass) {
278
286
  shellGated: input.shellGated,
279
287
  irreversibility: input.irreversibility,
280
288
  probeMandated: pass.decision.action === "ask" && pass.decision.probeMandated === true,
289
+ probeUnanswered: pass.probeUnanswered === true,
281
290
  });
282
291
  let personalEvidence = { dotsAbsent: input.persistedRules === undefined ? "not_wired" : "not_adjudicated" };
283
292
  if (laneAnswer?.unreadable === true)
284
293
  personalEvidence = { dotsAbsent: "unavailable" };
285
294
  let laneCoverage;
286
- if (input.persistedRules &&
287
- !orgRealApprovalRequired &&
295
+ const allowLayerMayClear = () => !orgRealApprovalRequired &&
288
296
  pass.decision.action === "ask" &&
289
297
  pass.decision.requiresRealApproval !== true &&
290
298
  pass.decision.decisionReason !== "hook" &&
291
299
  pass.req.toolName !== ASK_USER_QUESTION_TOOL_NAME &&
292
- input.isMarkedUnresolvable?.(input.event.toolCallId) !== true) {
300
+ !markedUnresolvable();
301
+ if (input.persistedRules && allowLayerMayClear()) {
293
302
  const answer = laneAnswer ?? {};
294
303
  const hitRules = answer.hit?.behavior === "allow" ? answer.hit.rules : undefined;
295
304
  laneCoverage = answer.coverage;
@@ -328,11 +337,13 @@ export async function runGateLanes(pass) {
328
337
  const shownRule = disclosedRuleSet(hitRules);
329
338
  const mandateNoun = persistedRuleMandate === "probe_mandate"
330
339
  ? "the reversibility check declared this call structurally gated (the built-in shell check raises this for a read outside the directories allowed for this session) — it is cleared by confirming this call, never by a standing allow rule"
331
- : persistedRuleMandate === "operator_always"
332
- ? "this deployment mandates per-call confirmation for shell commands (shellGate: always)"
333
- : persistedRuleMandate !== undefined
334
- ? "this tool carries egress/irreversibility marks (a mandated confirmation a rule cannot clear)"
335
- : "an explicit ask rule matched this call (a person's ask-me-each-time outranks a standing allow rule)";
340
+ : persistedRuleMandate === "probe_unanswered"
341
+ ? "the reversibility check did not answer for this call (it timed out or failed), so whether the call reads outside the directories allowed for this session is unknown — it is cleared by confirming this call, never by a standing allow rule"
342
+ : persistedRuleMandate === "operator_always"
343
+ ? "this deployment mandates per-call confirmation for shell commands (shellGate: always)"
344
+ : persistedRuleMandate !== undefined
345
+ ? "this tool carries egress/irreversibility marks (a mandated confirmation a rule cannot clear)"
346
+ : "an explicit ask rule matched this call (a person's ask-me-each-time outranks a standing allow rule)";
336
347
  pass.decision = {
337
348
  ...pass.decision,
338
349
  persistedRuleShadowed: shownRule,
@@ -347,6 +358,21 @@ export async function runGateLanes(pass) {
347
358
  const stamped = { ...pass.decision, ruleEvidence: mintRuleEvidence(personalEvidence), ...(laneCoverage !== undefined ? { segmentCoverage: laneCoverage } : {}) };
348
359
  pass.decision = { ...stamped, origin: askOriginOf(stamped, originFacts) };
349
360
  }
361
+ if (allowLayerMayClear() && pass.decision.action === "ask" && persistedRuleMandate === undefined && pass.decision.matchedAskRule === undefined && pass.req.toolName === COMMAND_RULE_TOOL) {
362
+ const commandParam = catalogRuleFaceOf(COMMAND_RULE_TOOL)?.primaryParams[0] ?? "command";
363
+ const args = pass.req.args;
364
+ const command = typeof args === "object" && args !== null && !Array.isArray(args) ? args[commandParam] : undefined;
365
+ const backgrounded = typeof args === "object" && args !== null && args.run_in_background === true;
366
+ if (typeof command === "string" && !backgrounded && readOnlyShellVerdict(command).readOnly) {
367
+ pass.decision = {
368
+ action: "allow",
369
+ message: "the command is read-only (upstream's read-only command tables) — allowed without asking",
370
+ decisionReason: "read_only",
371
+ ...(pass.policyRewrite !== undefined ? { updatedInput: pass.policyRewrite } : {}),
372
+ };
373
+ await notifier.notifyAsync(() => input.onReadOnlyAllowed?.({ toolName: pass.req.toolName, toolCallId, command }), "toolGate.readOnlyAllowed");
374
+ }
375
+ }
350
376
  if (pass.decision.action === "ask")
351
377
  ledger.supersede();
352
378
  if (input.autoMode && pass.decision.action === "ask" && pass.decision.origin !== undefined && classifierMayAnswer(pass.decision.origin)) {
@@ -385,6 +411,9 @@ export async function runGateLanes(pass) {
385
411
  pass.deniedBy = "classifier";
386
412
  }
387
413
  }
414
+ else if (verdict.kind === "unavailable") {
415
+ pass.decision = { ...pass.decision, classifierUnavailable: { cause: verdict.cause } };
416
+ }
388
417
  }
389
418
  if (input.sandboxAdmission !== undefined &&
390
419
  pass.decision.action === "ask" &&
@@ -425,6 +454,7 @@ export async function runGateLanes(pass) {
425
454
  ...(callSignal !== undefined ? { signal: callSignal } : {}),
426
455
  ...(d.action === "ask" && d.denialLimitFallback !== undefined ? { denialLimitFallback: unarmedWindow(d.denialLimitFallback) } : {}),
427
456
  ...(d.action === "ask" && d.origin !== undefined ? { origin: d.origin } : {}),
457
+ ...(d.action === "ask" && d.classifierUnavailable !== undefined ? { classifierUnavailable: { cause: d.classifierUnavailable.cause } } : {}),
428
458
  });
429
459
  if (suspendAsk && pass.decision.action === "ask") {
430
460
  const parkArgs = [pass.req, pass.currentInput, safety, undefined, realApprovalOf(pass.decision), pass.decision.action === "ask" ? pass.decision.persistedRuleShadowed : undefined, pass.decision.action === "ask" ? pass.decision.decisionReason : undefined, pass.decision.action === "ask" ? pass.decision.probeReason : undefined, pass.decision.action === "ask" ? pass.decision.probeCause : undefined, pass.decision.action === "ask" ? pass.decision.segmentCoverage : undefined, pass.decision.action === "ask" ? pass.decision.matchedAskRule : undefined, pass.decision.action === "ask" ? pass.decision.probeMandated : undefined];
@@ -492,6 +522,16 @@ export async function runGateLanes(pass) {
492
522
  return { suspend: suspended, preToolContext: pass.preToolContext };
493
523
  }
494
524
  }
525
+ const noteUnresolvable = (r) => {
526
+ if (r.action !== "deny" || r.approverUnavailable !== true)
527
+ return;
528
+ try {
529
+ input.onAskUnresolvable?.({ toolName, toolCallId, settlementKind: "approver_unavailable", parkLaneExisted: suspendAsk !== undefined });
530
+ }
531
+ catch {
532
+ }
533
+ };
534
+ noteUnresolvable(resolved);
495
535
  if (askBeforeResolve.action === "ask" && askBeforeResolve.denialLimitFallback !== undefined) {
496
536
  if (pass.decision.action === "allow") {
497
537
  input.autoMode?.denialTracking?.recordAllow();
@@ -620,10 +660,12 @@ export async function runGateLanes(pass) {
620
660
  editRewrittenSinceHuman = true;
621
661
  }
622
662
  const editAskSnapshot = { ...recheck, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) };
663
+ markedThisRound = undefined;
623
664
  const editAskDecision = { ...editAskSnapshot, origin: askOriginOf(editAskSnapshot, askOriginFacts(editOrg.org, editRuleStore)) };
624
665
  const editAskReq = { toolName, args: editArgs, toolCallId, ...(pass.req.face !== undefined ? { face: pass.req.face } : {}) };
625
666
  const rr = await (callSignal !== undefined ? resolveAsk(editAskDecision, editAskReq, callSignal) : resolveAsk(editAskDecision, editAskReq));
626
667
  ledger.fromResolved(rr, editAskDecision, toolName);
668
+ noteUnresolvable(rr);
627
669
  if (rr.action !== "allow") {
628
670
  editDenied = rr;
629
671
  pass.deniedBy = "ask_resolution";
@@ -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.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_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", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "mcp.injection_dropped", "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_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_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", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "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. */
@@ -97,6 +97,7 @@ export const ENGINE_NOTICE_CODES = [
97
97
  "config.env_timeout_discarded",
98
98
  "config.materialize_env_discarded",
99
99
  "config.models_swapped",
100
+ "config.read_face_swapped",
100
101
  "config.read_face_deployment_clamped",
101
102
  "config.tool_model_gate_removed",
102
103
  "config.tool_model_gate_unknown_class",
@@ -113,6 +114,7 @@ export const ENGINE_NOTICE_CODES = [
113
114
  "classifier.denial_limit",
114
115
  "checkpoint.execution_outcome_unrecorded",
115
116
  "delegation.transcript_integrity",
117
+ "delegation.ask_unresolvable",
116
118
  "mcp.injection_dropped",
117
119
  "mcp.revocation_probe_failed",
118
120
  "workflow.governance_key_stripped",
@@ -160,10 +162,12 @@ const NOTICE_AUDIENCE_TABLE = {
160
162
  "memory.capture_opted_out": "user",
161
163
  "memory.capture_optout_unpersisted": "user",
162
164
  "memory.consolidation_withheld": "user",
165
+ "delegation.ask_unresolvable": "user",
163
166
  "config.autocompact_window_clamped": "operator",
164
167
  "config.env_timeout_discarded": "operator",
165
168
  "config.materialize_env_discarded": "operator",
166
169
  "config.models_swapped": "operator",
170
+ "config.read_face_swapped": "operator",
167
171
  "config.read_face_deployment_clamped": "operator",
168
172
  "config.tool_model_gate_removed": "operator",
169
173
  "config.tool_model_gate_unknown_class": "operator",