@sema-agent/core 7.13.0 → 7.15.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 (102) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/dist/agents/subagent.d.ts +4 -9
  3. package/dist/agents/subagent.js +16 -12
  4. package/dist/agents/teacher.js +1 -1
  5. package/dist/brain/reasoning.d.ts +23 -0
  6. package/dist/brain/reasoning.js +35 -4
  7. package/dist/brain/stream-engine.js +35 -17
  8. package/dist/brain/timeout.d.ts +25 -4
  9. package/dist/brain/timeout.js +1 -1
  10. package/dist/core/ask-origin.d.ts +30 -11
  11. package/dist/core/ask-origin.js +10 -5
  12. package/dist/core/auto-mode.d.ts +3 -3
  13. package/dist/core/checkpoint-seat.d.ts +18 -0
  14. package/dist/core/checkpoint-seat.js +6 -0
  15. package/dist/core/checkpoint-store.d.ts +23 -31
  16. package/dist/core/checkpoint-store.js +7 -4
  17. package/dist/core/engine-notice.d.ts +3 -1
  18. package/dist/core/env-knob-announce.d.ts +16 -0
  19. package/dist/core/env-knob-announce.js +14 -0
  20. package/dist/core/gate-fold.js +2 -0
  21. package/dist/core/gate-lanes.js +21 -12
  22. package/dist/core/gate-outcome.d.ts +42 -3
  23. package/dist/core/gate-outcome.js +22 -0
  24. package/dist/core/governance-codes.js +1 -1
  25. package/dist/core/hooks.d.ts +9 -19
  26. package/dist/core/hooks.js +6 -4
  27. package/dist/core/mcp.d.ts +3 -2
  28. package/dist/core/mcp.js +5 -11
  29. package/dist/core/memory-engine/engine.d.ts +2 -2
  30. package/dist/core/runner/advertised-writable-dirs.d.ts +74 -0
  31. package/dist/core/runner/advertised-writable-dirs.js +48 -0
  32. package/dist/core/runner/assemble-result.d.ts +5 -0
  33. package/dist/core/runner/contracts.d.ts +2 -2
  34. package/dist/core/runner/denial-limit-arms.d.ts +7 -9
  35. package/dist/core/runner/denial-limit-arms.js +8 -10
  36. package/dist/core/runner/gate-exit.d.ts +15 -3
  37. package/dist/core/runner/gate-exit.js +6 -4
  38. package/dist/core/runner/permission-rule-lanes.d.ts +3 -1
  39. package/dist/core/runner/permission-rule-lanes.js +1 -1
  40. package/dist/core/runner/prepare-caps-and-workflow.d.ts +34 -4
  41. package/dist/core/runner/prepare-caps-and-workflow.js +47 -12
  42. package/dist/core/runner/prepare-defer-classify.d.ts +1 -1
  43. package/dist/core/runner/prepare-defer-classify.js +12 -10
  44. package/dist/core/runner/prepare-gate-stations.d.ts +4 -5
  45. package/dist/core/runner/prepare-gate-stations.js +2 -2
  46. package/dist/core/runner/prepare-hands-readface.d.ts +18 -12
  47. package/dist/core/runner/prepare-hands-readface.js +32 -43
  48. package/dist/core/runner/prepare-inherited-gate.d.ts +7 -5
  49. package/dist/core/runner/prepare-inherited-gate.js +1 -1
  50. package/dist/core/runner/prepare-memory.d.ts +31 -34
  51. package/dist/core/runner/prepare-memory.js +73 -51
  52. package/dist/core/runner/prepare-park-ask.d.ts +5 -6
  53. package/dist/core/runner/prepare-park-ask.js +3 -3
  54. package/dist/core/runner/prepare-policy-chain.d.ts +2 -2
  55. package/dist/core/runner/prepare-policy-chain.js +7 -4
  56. package/dist/core/runner/prepare-prompt-inputs.d.ts +4 -0
  57. package/dist/core/runner/prepare-prompt-inputs.js +2 -2
  58. package/dist/core/runner/prepare-question-face.d.ts +10 -8
  59. package/dist/core/runner/prepare-question-face.js +1 -3
  60. package/dist/core/runner/prepare-safety-scan.js +1 -1
  61. package/dist/core/runner/prepare-task.js +60 -53
  62. package/dist/core/runner/prepare-wiring-manifest.d.ts +2 -2
  63. package/dist/core/runner/prepare-wiring-manifest.js +1 -1
  64. package/dist/core/runner/run-compaction-machinery.js +2 -0
  65. package/dist/core/runner/run-harness-handlers.js +3 -1
  66. package/dist/core/runner/stream-settle-backstop.js +1 -1
  67. package/dist/core/runner/tool-defer-gate.d.ts +86 -0
  68. package/dist/core/runner/tool-defer-gate.js +57 -0
  69. package/dist/core/runner/tool-disclosure.d.ts +0 -36
  70. package/dist/core/runner/tool-disclosure.js +0 -43
  71. package/dist/core/runner-deps.d.ts +16 -9
  72. package/dist/core/runtime-caps.d.ts +21 -0
  73. package/dist/core/runtime-caps.js +5 -1
  74. package/dist/core/task-event.d.ts +11 -2
  75. package/dist/core/task-registry-shared.js +8 -6
  76. package/dist/core/task-result.d.ts +15 -0
  77. package/dist/core/task-spec.d.ts +8 -2
  78. package/dist/core/terminal-cause.d.ts +6 -2
  79. package/dist/core/tool-policy.d.ts +34 -28
  80. package/dist/core/tool-policy.js +29 -5
  81. package/dist/core/tool-roster.js +2 -0
  82. package/dist/core/tool-spec.d.ts +20 -16
  83. package/dist/core/types.d.ts +3 -2
  84. package/dist/core/types.js +1 -0
  85. package/dist/core/wiring-manifest.d.ts +16 -9
  86. package/dist/core/wiring-manifest.js +8 -3
  87. package/dist/index.d.ts +7 -6
  88. package/dist/index.js +5 -4
  89. package/dist/orchestration/run-workflow-tool.d.ts +8 -8
  90. package/dist/orchestration/run-workflow-tool.js +1 -1
  91. package/dist/orchestration/workflow-primitives.d.ts +4 -3
  92. package/dist/orchestration/workflow-primitives.js +3 -3
  93. package/dist/orchestration/workflow-types.d.ts +14 -0
  94. package/dist/orchestration/workflow.d.ts +41 -4
  95. package/dist/orchestration/workflow.js +23 -5
  96. package/dist/tools/fs/fs-bash.d.ts +41 -0
  97. package/dist/tools/fs/fs-bash.js +86 -29
  98. package/dist/tools/fs/fs-shared.js +9 -3
  99. package/dist/tools/fs/read-deny.d.ts +24 -8
  100. package/dist/tools/fs/read-deny.js +20 -1
  101. package/package.json +2 -1
  102. package/test/export-surface.snapshot.json +29 -1
@@ -1370,15 +1370,22 @@ export interface RunnerDeps {
1370
1370
  removedBytes: number;
1371
1371
  }) => void;
1372
1372
  /**
1373
- * Deferred-tool disclosure policy (design/36). Default (omitted): deterministic per-tool, so the same
1374
- * `TaskSpec` always yields the same `tools[]`. The default deferral sources are `ToolSpec.defer === true`,
1375
- * every REMOTE-PROTOCOL tool name (MCP **and** A2A peer tools), {@link TaskSpec.deferTools}, and built-in
1376
- * tools that declare their own `defer` (today the shared-memory pair) — minus anything pinned inline by
1377
- * `ToolSpec.alwaysLoad` / {@link TaskSpec.alwaysLoadTools}. `classifyDeferred`'s own contract is the
1378
- * single authority if this list ever drifts again. `"auto"`: additionally auto-defer tools when the total
1379
- * inlined schema size would exceed a fraction of the model's context window. Auto is an explicit opt-in
1380
- * (council minor #6) because an implicit threshold makes `tools[]` depend on the model/context, which
1381
- * callers find hard to predict; explicit `defer` flags always win regardless of this setting.
1373
+ * Deferred-tool disclosure policy (design/36). Two rules govern deferral. The caller's list is
1374
+ * literal: {@link TaskSpec.deferTools} names defer on every request, `ToolSpec.alwaysLoad` /
1375
+ * {@link TaskSpec.alwaysLoadTools} pins inline on every request (judged first) neither reads this
1376
+ * knob. The engine's own candidates — `ToolSpec.defer === true`, every REMOTE-PROTOCOL tool name
1377
+ * (MCP **and** A2A peer tools), and built-in tools that declare their own `defer` sit behind ONE
1378
+ * threshold gate, all or nothing: deferred iff their inlined size (name + description + schema; the
1379
+ * candidate set only the caller's list, pins and non-candidates are not measured) reaches 10% of the
1380
+ * model's context window; below that they ship inline in full and no ToolSearch is mounted.
1381
+ *
1382
+ * `"auto"` is a CANDIDATE-WIDENING knob, not a second gate: it adds every caller spec — undeclared
1383
+ * caller tools included — to the candidate set, measured and decided by the same threshold. Engine
1384
+ * built-ins the deployment did not name are candidates under no setting. An explicit opt-in (council
1385
+ * minor #6) because it makes `tools[]` depend on the caller roster's total weight, which a deployment
1386
+ * with many thin tools may not want. Any other value refuses at prepare (`config.defer_mode_invalid`).
1387
+ * `classifyDeferred` / `deferralGate` in `src/core/runner/tool-disclosure.ts` are the single authority
1388
+ * if this text drifts.
1382
1389
  */
1383
1390
  deferMode?: "auto";
1384
1391
  /**
@@ -84,6 +84,19 @@ export interface WorkflowGovernanceBaseline {
84
84
  * These six are the caps the ENGINE enforces; `allowUltracode`/`allowBypassPermissions` are shell-UX /
85
85
  * service-settings-layer concerns (see {@link RunnerDeps.runtimeCapsResolver}).
86
86
  */
87
+ /**
88
+ * #692 C-d — WHICH plane's ratchet denied auto mode, when the resolver knows: the three settings sources
89
+ * CC 2.1.250's `permissions.disableAutoMode` can come from, as the resolver folds them into the one deny bit
90
+ * ({@link RuntimeCaps.autoMode} `=== false`). `org` = a managed / organization policy plane; `local` = the
91
+ * machine's local settings; `settings` = the user's / project's settings file. Read ONLY beside the deny bit
92
+ * (a source with no denial is a contradiction the value screen refuses as a resolver fault); projected onto
93
+ * the leg's manifest read face (`WiringManifest.autoMode.deniedSource`) so a shell can say "your organization
94
+ * turned auto mode off" vs "your settings did" instead of one word for both.
95
+ */
96
+ export declare const AUTO_MODE_DENY_SOURCES: readonly ["org", "local", "settings"];
97
+ export type AutoModeDenySource = (typeof AUTO_MODE_DENY_SOURCES)[number];
98
+ /** Membership test for {@link AutoModeDenySource}. */
99
+ export declare function isAutoModeDenySource(v: unknown): v is AutoModeDenySource;
87
100
  export interface RuntimeCaps {
88
101
  /** `false` DENIES workflow self-orchestration for this principal server-side — even on a deployment that is
89
102
  * capable ({@link RunnerDeps.workflowScriptRunner} + governance). The third stage of the workflows gate.
@@ -136,6 +149,14 @@ export interface RuntimeCaps {
136
149
  * A denial resolves to a no-op (asks flow the original chain; no warn — the gate, not a mistake).
137
150
  */
138
151
  autoMode?: boolean;
152
+ /**
153
+ * #692 C-d (additive) — WHICH source's ratchet set {@link autoMode} to `false`, when the resolver folded
154
+ * several ({@link AutoModeDenySource}). Optional: a resolver that does not track sources omits it and the
155
+ * manifest reads `denied` with no source. Present with `autoMode` not `false`, or spelled outside the set,
156
+ * is a resolver FAULT (announced through `onError`, the deny bit coined fail-closed, `resolver_fault` on the
157
+ * manifest) — a source named for a denial that did not happen is a marshalling error, never a fact.
158
+ */
159
+ autoModeDenySource?: AutoModeDenySource;
139
160
  /**
140
161
  * design/383 §3.1 — may this principal declare a session memory-capture OPT-OUT
141
162
  * (`TaskSpec.memory.capture: "off"` / the Runner flip verb)? `false` = the deployment REQUIRES
@@ -1 +1,5 @@
1
- export {};
1
+ export const AUTO_MODE_DENY_SOURCES = ["org", "local", "settings"];
2
+ const AUTO_MODE_DENY_SOURCE_SET = new Set(AUTO_MODE_DENY_SOURCES);
3
+ export function isAutoModeDenySource(v) {
4
+ return AUTO_MODE_DENY_SOURCE_SET.has(v);
5
+ }
@@ -97,7 +97,16 @@ export type BrainStatusPhase = "rate_limited" | "retrying" | "reconnecting" | "c
97
97
  * - `connect_refused` — the attempt got a definite negative about the target itself (nothing accepts
98
98
  * at that address, or the name has no address). This is the class the SHORT retry lane serves.
99
99
  * - `transport` — any other transport-level failure: a connect timeout, a reset, a mid-stream
100
- * tear, a stalled stream. No verdict about the target; the full ladder applies.
100
+ * tear. No verdict about the target; the full ladder applies.
101
+ * - `stall` — no failure was observed on the link at all: THIS process cancelled its own
102
+ * request because one of its stall watchdogs reached its deadline while the stream produced
103
+ * nothing. The distinction from `transport` is WHO GAVE UP, and nothing more: a `transport` frame
104
+ * reports something that happened to the call, a `stall` frame reports a local deadline. It is not
105
+ * a verdict on the remedy — a blackholed connection (no reset, no close) produces silence, and
106
+ * silence is what a watchdog measures, so a `stall` can still turn out to be a network fault; what
107
+ * it always tells you truthfully is that the request was cancelled here, at a configured deadline,
108
+ * rather than lost out there. Reporting these as `transport` asserted the opposite — that the link
109
+ * had dropped — when nothing about the link was known at all.
101
110
  * - `rate_limit` — the provider asked the caller to slow down.
102
111
  * - `server` — the provider reported a failure on its own side.
103
112
  * - `http` — a response the status predicate calls terminal, retried anyway because the
@@ -105,7 +114,7 @@ export type BrainStatusPhase = "rate_limited" | "retrying" | "reconnecting" | "c
105
114
  * - `output_cap` — not a failure of the connection at all: the request is being re-sent with a
106
115
  * lowered output cap after the provider reported the context limit exceeded (no backoff).
107
116
  */
108
- export type BrainRetryErrClass = "connect_refused" | "transport" | "rate_limit" | "server" | "http" | "output_cap";
117
+ export type BrainRetryErrClass = "connect_refused" | "transport" | "stall" | "rate_limit" | "server" | "http" | "output_cap";
109
118
  /** design/99 §E3/§E10 — the payload of a {@link TaskEvent} `status` event (and the brain→runner signal). */
110
119
  export interface BrainStatus {
111
120
  phase: BrainStatusPhase;
@@ -3,6 +3,7 @@ import { summarizeWorkflowRun } from "./workflow-run-store.js";
3
3
  import { delimitUntrusted } from "./untrusted-text.js";
4
4
  import { boundedRedactedSummary } from "./untrusted-egress.js";
5
5
  import { clipWithFilePointer } from "./tool-errors.js";
6
+ import { announceEnvKnob } from "./env-knob-announce.js";
6
7
  export function mintCompletionId(target) {
7
8
  if (target.completionId === undefined)
8
9
  target.completionId = uuidv7();
@@ -61,14 +62,15 @@ const TASK_OUTPUT_DEFAULT_CHARS = 32_000;
61
62
  export const TASK_OUTPUT_MAX_CHARS = 160_000;
62
63
  const TASK_OUTPUT_MIN_CHARS = 512;
63
64
  function taskMaxOutputChars() {
64
- const raw = process.env.TASK_MAX_OUTPUT_LENGTH;
65
- if (!raw)
65
+ const rawEnv = process.env.TASK_MAX_OUTPUT_LENGTH;
66
+ if (rawEnv === undefined || rawEnv.trim() === "")
66
67
  return TASK_OUTPUT_DEFAULT_CHARS;
67
- if (!/^\d+$/.test(raw.trim()))
68
- return TASK_OUTPUT_DEFAULT_CHARS;
69
- const n = parseInt(raw.trim(), 10);
70
- if (!Number.isFinite(n) || n <= 0)
68
+ const raw = rawEnv.trim();
69
+ const n = /^\d+$/.test(raw) ? parseInt(raw, 10) : Number.NaN;
70
+ if (!Number.isFinite(n) || n <= 0) {
71
+ announceEnvKnob(`TASK_MAX_OUTPUT_LENGTH=${rawEnv} was ignored — it is not a positive whole number of characters. Using ${TASK_OUTPUT_DEFAULT_CHARS} instead.`);
71
72
  return TASK_OUTPUT_DEFAULT_CHARS;
73
+ }
72
74
  return Math.min(Math.max(n, TASK_OUTPUT_MIN_CHARS), TASK_OUTPUT_MAX_CHARS);
73
75
  }
74
76
  export function clipTaskOutput(s, fullOutputPath) {
@@ -25,6 +25,14 @@ import type { NestedUsage } from "./tool-spec.js";
25
25
  * Orchestrators that map an unexpected `suspended` should map an unexpected `needs_review` the same
26
26
  * hard-boundary way (it is, like `suspended`, only safe at the top-level `runTask` boundary).
27
27
  */
28
+ /**
29
+ * Terminal vs waiting. Three words are TERMINAL — the leg is over and nobody is expected to act: `completed`,
30
+ * `failed`, and `blocked` (the AGENT's own report, through `report_blocked`, that it cannot finish and why —
31
+ * a self-report, not a pause; it waits for nobody). Two words are WAITING — a person's decision resumes the
32
+ * same leg through `runner.resume(token, …)`: `suspended` (a durable pre-action approval of a tool call) and
33
+ * `needs_review` (a durable review pause). The task-registry face spells the two waiting words as `parked`.
34
+ * A consumer that files `blocked` beside `suspended` is waiting on a decision that never comes.
35
+ */
28
36
  export type TaskStatus = "completed" | "blocked" | "failed" | "suspended" | "needs_review";
29
37
  /**
30
38
  * RB-439-a — one remote-workspace lifecycle failure, reported as data on {@link TaskResult.remoteEnvFailures}.
@@ -598,6 +606,13 @@ export interface TaskResult {
598
606
  * the compaction subtotal). OBSERVE-ONLY (never gates budget). The workflow display is unaffected (its agents
599
607
  * fail-on-suspend, never durably resume). A `spentToolCalls` ledger fold is a follow-on. */
600
608
  toolCalls?: number;
609
+ /** #692 C-b (additive): present (`true`) when the usage figures on this face are a LOWER BOUND rather than a
610
+ * measurement — at least one model round of the leg reported no usage frame (the brain's `usageMissing`
611
+ * mark; the same word the per-turn `turn_end` frame carries), or the result was minted by a host / an
612
+ * orchestration lane that never ran a model round it could measure (a refused spawn's journal entry, a
613
+ * prepare-time refusal, a synthetic abort). The numeric members stay required and stay what was counted:
614
+ * `tokens: 0` beside this key reads "unknown", never "free". Absent ⇔ every round reported usage. */
615
+ usageMissing?: true;
601
616
  /** Prompt (input) tokens that MISSED the prefix cache, summed across the task's turns and normalized
602
617
  * across providers (Anthropic already reports `input_tokens` this way; OpenAI/vLLM include the cache
603
618
  * in theirs, so the subsets are removed). Mutually exclusive with `cachedTokens`/`cacheWriteTokens*`;
@@ -31,6 +31,10 @@ export type ImageInput = {
31
31
  url: string;
32
32
  };
33
33
  /** A single self-contained task with its full configuration. */
34
+ /** The shell-gate doctrine vocabulary — {@link TaskSpec.shellGate}'s value set, and the word the engine persists on a
35
+ * shell-gated ask (`RiskDescriptor.shellGateDoctrine`) and folds across a delegation chain (max-rank: `off` <
36
+ * `classify` < `always`). One closed set; every seat that names a doctrine spells it through this alias. */
37
+ export type ShellGateDoctrine = "off" | "classify" | "always";
34
38
  export interface TaskSpec {
35
39
  taskId?: string;
36
40
  /** The instruction / latest user message for this turn. */
@@ -400,6 +404,8 @@ export interface TaskSpec {
400
404
  * included — `ToolSpec.defer` only covers caller specs): wire names listed here ship as
401
405
  * lightweight placeholders (name + one-line hint, schema bytes OFF the cache prefix) and are
402
406
  * activated on demand via the injected ToolSearch (design/36 materialization — prefix untouched).
407
+ * This list is LITERAL — an intentional deferral, deferred on every request regardless of the
408
+ * deferral gate ({@link RunnerDeps.deferMode}) and not counted toward its threshold.
403
409
  * "Default-on but not exposed" = the deployment lists the tool here on requests where no
404
410
  * activation source fired (e.g. Workflow unless the user said "workflow"/"ultracode"); omitting
405
411
  * the name on a later request mounts it fully (activation = not deferring). Unknown names are
@@ -445,7 +451,7 @@ export interface TaskSpec {
445
451
  * direction; CC 220 `alwaysLoad` 对位): names listed here are NEVER deferred — not by
446
452
  * `ToolSpec.defer`, not by the MCP constant-defer arm (besides the server's own
447
453
  * `_meta["anthropic/alwaysLoad"]` declaration this list is the only channel that keeps a chosen
448
- * MCP tool's full schema inlined), not by `deferTools`, not by `deferMode:"auto"`. Unknown names
454
+ * MCP tool's full schema inlined), not by `deferTools`, not by the deferral gate under any `deferMode`. Unknown names
449
455
  * are ignored (the valve only subtracts). Names also in `excludeTools` stay excluded (exclusion
450
456
  * wins — an unmounted tool has no schema to keep inline).
451
457
  */
@@ -862,7 +868,7 @@ export interface TaskSpec {
862
868
  * task config — a resume that omits it leaves the resumed run's SUBSEQUENT `bash` calls ungated (the approved
863
869
  * pending call itself runs once, having been adjudicated). Re-pass the same value the original task used.
864
870
  */
865
- shellGate?: "off" | "always" | "classify";
871
+ shellGate?: ShellGateDoctrine;
866
872
  /**
867
873
  * The caller's AUTO-MODE INTENT for this task — the "user turned auto on" half of the classifier
868
874
  * arming (CC 2.1.250 polarity: auto mode is something the USER enables at the permission-mode
@@ -20,7 +20,10 @@ import type { AssertAllKeysHandled } from "./ask-origin.js";
20
20
  * the one cause→plane derivation, for the faces that still speak the word.
21
21
  * - `completed` — the run finished (a person's clean halt included — see `TaskResult.haltedByUser`).
22
22
  * - `failed` — a limit, a provider failure, an abort, an invalid output…
23
- * - `blocked` — the agent could not finish and said why.
23
+ * - `blocked` — the AGENT reported, through `report_blocked`, that it cannot finish, and why. A TERMINAL
24
+ * cause: the leg is over and it waits for nobody (nothing to resume, no token). The waiting-on-a-person
25
+ * states are the `paused` cause's two status words — `suspended` (a pre-action approval of a tool
26
+ * call) and `needs_review` (a review pause); the task-registry face spells both as `parked`.
24
27
  * - `paused` — a durable pause committed a checkpoint and the run is resumable.
25
28
  * A `GateOutcome` is the record of ONE gate pass (per call); a `TerminalCause` is the record of ONE run
26
29
  * (per leg); a paused run's `gate` and the park row's `gate.kind` are the same word, joined by the token.
@@ -73,7 +76,8 @@ export type TerminalCause = {
73
76
  nestedPause?: PausedCause;
74
77
  } | {
75
78
  kind: "blocked";
76
- /** Why the agent could not finish. */
79
+ /** Why the agent could not finish — the agent's own words from its `report_blocked` call. Terminal: the
80
+ * run is over and nobody is being waited on (contrast `paused`, whose token is the resume capability). */
77
81
  reason: string;
78
82
  } | {
79
83
  kind: "paused";
@@ -106,6 +106,27 @@ export interface ToolCallRequest {
106
106
  * deny/ask lane and the person's own allow rules, never over a mandated ask. */
107
107
  declare const DECISION_REASONS: readonly ["rule", "mode", "hook", "safety", "classifier", "persisted_rule", "sandbox", "org_rule", "org_unavailable", "read_only"];
108
108
  export type DecisionReason = (typeof DECISION_REASONS)[number];
109
+ /** Carry every engine attestation (settlement, classifier cause) from a decision onto a reconstruction of it — the
110
+ * seam for the engine's OWN clone sites outside this module (the inherited chain's final-form re-check carries the
111
+ * post-rewrite args on a spread of an ancestor's deny). Not re-exported from `src/index.ts`. */
112
+ export declare function carryEngineAttestations(from: object, to: object): void;
113
+ /** Carry a PRECEDING decision's settlement onto a later deny that has none of its own — the veto record (a person's
114
+ * yes on the settlement, the refusing layer on the disposition; invariant I3) for the engine's own clone sites,
115
+ * where the deny is minted over a decision that already carried an attested approval. A deny that carries its own
116
+ * settlement keeps it (the latest resolution wins). Not re-exported from `src/index.ts`. */
117
+ export declare function carryPrecedingSettlement(preceding: object, to: object): void;
118
+ /** Attest that `d` is a deny a classifier station minted for `call` out of a round of the named FORM. */
119
+ export declare function attestClassifierDenyCause<T extends object>(d: T, cause: import("./gate-outcome.js").ClassifierDenyCause, call: {
120
+ toolCallId: string;
121
+ toolName: string;
122
+ }): T;
123
+ /** Read the attested classifier cause off a deny FOR the named call (the gate's fold and exit are the
124
+ * consumers): a binding to a different call is a replayed object and answers absence. An internal seam
125
+ * between engine modules — deliberately NOT re-exported from `src/index.ts`. */
126
+ export declare function classifierDenyCauseOf(d: unknown, call: {
127
+ toolCallId: string;
128
+ toolName: string;
129
+ }): import("./gate-outcome.js").ClassifierDenyCause | undefined;
109
130
  /** Read the engine-attested settlement off a funneled decision FOR the named call (the gate's exit is the
110
131
  * one consumer): an attestation bound to a different call is a replayed object and answers absence.
111
132
  * Exported for the gate module only — deliberately NOT re-exported from `src/index.ts` (an internal seam
@@ -209,15 +230,6 @@ export type PermissionResult = {
209
230
  * same safe direction as `matchedAskRule`); it cannot state a window of its own, because the
210
231
  * route it would be a window for has not been chosen yet. */
211
232
  denialLimitFallback?: import("./auto-mode.js").UnarmedDenialLimitFallback;
212
- /** #616 (additive): the classifier-unavailable FACT on an ASK — `cause` is the verdict's own word
213
- * ({@link import("./auto-mode.js").AutoModeUnavailableCause}). Since #661 the ENGINE no longer writes
214
- * it here: an `unavailable` verdict is a DENY (the deny arm's member above), never an ask handed to a
215
- * person. The member stays on the ask arm as display metadata a policy may self-declare on its own
216
- * ask (nothing reads it to decide anything); the carry stations still copy it onto the approval
217
- * request and the durable row when present. Retirement candidate for the next wire window. */
218
- classifierUnavailable?: {
219
- readonly cause: import("./auto-mode.js").AutoModeUnavailableCause;
220
- };
221
233
  /** #144 disclosure (additive): a persisted allow rule MATCHED this call but could not clear the
222
234
  * ask, because the ask is MANDATED (operator shellGate:"always", or the tool's own
223
235
  * egress/irreversibility marks) rather than a classifier's hesitation — "allow rules silence
@@ -287,21 +299,18 @@ export type PermissionResult = {
287
299
  * exclusions; an approval surface may render the card by it. Optional on the TYPE only because a
288
300
  * decision is caller-composable; every ask the gate hands to its resolver carries it. */
289
301
  origin?: import("./ask-origin.js").AskOrigin;
302
+ /** #688 C3 (additive): WHICH mechanism stands behind an `origin: "rule_store_unavailable"` — one word from
303
+ * {@link import("./ask-origin.js").RULE_STORE_UNREADABLE_KINDS} (`store`: the wired store could not be read;
304
+ * `call`: the call could not be read against the person's rows — the lexer's sentence is in `message`).
305
+ * ENGINE-STAMPED at the same single stamp point as `origin`, from the gate's own lane read; a policy's
306
+ * self-declared value is overwritten or removed there. Present ⇔ the origin is `rule_store_unavailable`.
307
+ * Carried onto the approval request and the durable row beside the origin word. */
308
+ ruleStoreUnreadable?: import("./ask-origin.js").RuleStoreUnreadable;
290
309
  } | {
291
310
  action: "deny";
292
311
  updatedInput?: unknown;
293
312
  message?: string;
294
313
  decisionReason?: DecisionReason;
295
- /** #661 (additive): this deny is the classifier's UNAVAILABILITY — the auto-mode classifier was consulted
296
- * and could not run, and the call is refused with a sentence that says so (CC 2.1.250 `Ze.unavailable` ⇒
297
- * `deny`, `x1t`). `cause` is the verdict's own word ({@link import("./auto-mode.js").AutoModeUnavailableCause}).
298
- * ENGINE-STAMPED beside `decisionReason: "classifier"` at the classifier stations (the gate's own and the
299
- * inherited-lane arms) on an `unavailable` verdict only; a `block` and a `parse_error` deny carry nothing
300
- * here. Read by the deny observer's payload ({@link import("./hooks.js").PermissionDeniedPayload}) and by
301
- * nothing that decides. */
302
- classifierUnavailable?: {
303
- readonly cause: import("./auto-mode.js").AutoModeUnavailableCause;
304
- };
305
314
  };
306
315
  /**
307
316
  * design/252 G-2 — WHY a piece of rule-provenance evidence is not on an ask.
@@ -1157,15 +1166,12 @@ export interface AskRequest {
1157
1166
  * that forwards a decision's member here does not compile, which is the point: the window is the
1158
1167
  * route's fact, and forwarding it silently is how a configured window becomes a wait with none. */
1159
1168
  readonly denialLimitFallback?: import("./auto-mode.js").DenialLimitFallback;
1160
- /** #616 (additive) — present ⇔ the auto-mode classifier was consulted on this ask and could not run (see
1161
- * the {@link PermissionResult} ask-arm member of the same name): the card's "asked because the
1162
- * classifier was unavailable" fact, with the verdict's own cause word. Absent on every ask the classifier
1163
- * answered, was not eligible for, or was not wired for read presence, never absence. Filled by the
1164
- * carry stations from the decision, never a caller/worker-settable field; the durable park row carries
1165
- * the same member (`PendingAction.tool_approval.classifierUnavailable`). */
1166
- readonly classifierUnavailable?: {
1167
- readonly cause: import("./auto-mode.js").AutoModeUnavailableCause;
1168
- };
1169
+ /** #688 C3 (additive) — the wire twin of the {@link PermissionResult} ask-arm member of the same name: which
1170
+ * mechanism stands behind an `origin: "rule_store_unavailable"` (`store` / `call`,
1171
+ * {@link import("./ask-origin.js").RULE_STORE_UNREADABLE_KINDS}). Present that origin; a consumer that
1172
+ * renders "the rule store is unreachable" vs "this command could not be read against your rules" branches
1173
+ * here, never on the message text. Filled by the gate's own carry station from the engine-stamped decision. */
1174
+ readonly ruleStoreUnreadable?: import("./ask-origin.js").RuleStoreUnreadable;
1169
1175
  /** (additive) WHICH AUTHORITY raised this ask — the wire twin of the {@link PermissionResult} ask-arm
1170
1176
  * member of the same name, one word from the closed {@link import("./ask-origin.js").AskOrigin} set.
1171
1177
  * The gate's own mint station copies the engine-stamped word; the three inherited-lane stations
@@ -29,6 +29,32 @@ function transferEngineSettlement(from, to) {
29
29
  const v = ENGINE_SETTLEMENTS.get(from);
30
30
  if (v !== undefined)
31
31
  ENGINE_SETTLEMENTS.set(to, v);
32
+ const c = CLASSIFIER_DENY_CAUSES_SEAT.get(from);
33
+ if (c !== undefined)
34
+ CLASSIFIER_DENY_CAUSES_SEAT.set(to, c);
35
+ }
36
+ export function carryEngineAttestations(from, to) {
37
+ transferEngineSettlement(from, to);
38
+ }
39
+ export function carryPrecedingSettlement(preceding, to) {
40
+ if (ENGINE_SETTLEMENTS.get(to) !== undefined)
41
+ return;
42
+ const v = ENGINE_SETTLEMENTS.get(preceding);
43
+ if (v !== undefined)
44
+ ENGINE_SETTLEMENTS.set(to, v);
45
+ }
46
+ const CLASSIFIER_DENY_CAUSES_SEAT = new WeakMap();
47
+ export function attestClassifierDenyCause(d, cause, call) {
48
+ CLASSIFIER_DENY_CAUSES_SEAT.set(d, { cause, toolCallId: call.toolCallId, toolName: call.toolName });
49
+ return d;
50
+ }
51
+ export function classifierDenyCauseOf(d, call) {
52
+ if (typeof d !== "object" || d === null)
53
+ return undefined;
54
+ const v = CLASSIFIER_DENY_CAUSES_SEAT.get(d);
55
+ if (v === undefined || v.toolCallId !== call.toolCallId || v.toolName !== call.toolName)
56
+ return undefined;
57
+ return v.cause;
32
58
  }
33
59
  export function engineSettlementOf(d, call) {
34
60
  if (typeof d !== "object" || d === null)
@@ -400,7 +426,6 @@ export function combinePolicies(...policies) {
400
426
  let ruleAskText;
401
427
  let probeMandateSeen = false;
402
428
  let fallbackSeen;
403
- let classifierUnavailableSeen;
404
429
  for (const p of policies) {
405
430
  const d = refuseOutOfContractDecision(await p.check(current, signal));
406
431
  if (d.action === "deny") {
@@ -410,6 +435,8 @@ export function combinePolicies(...policies) {
410
435
  }
411
436
  if (settledAllow !== undefined && ENGINE_SETTLEMENTS.get(out) === undefined) {
412
437
  const owned = out === d ? { ...d } : out;
438
+ if (owned !== d)
439
+ transferEngineSettlement(d, owned);
413
440
  transferEngineSettlement(settledAllow, owned);
414
441
  return owned;
415
442
  }
@@ -431,16 +458,13 @@ export function combinePolicies(...policies) {
431
458
  probeMandateSeen = true;
432
459
  if (d.action === "ask" && d.denialLimitFallback !== undefined && fallbackSeen === undefined)
433
460
  fallbackSeen = d.denialLimitFallback;
434
- if (d.action === "ask" && d.classifierUnavailable !== undefined && classifierUnavailableSeen === undefined)
435
- classifierUnavailableSeen = d.classifierUnavailable;
436
461
  }
437
462
  if (asked) {
438
463
  const merged = rewrite?.updatedInput;
439
464
  const withRuleAsk = ruleAskText !== undefined && asked.matchedAskRule === undefined ? { ...asked, matchedAskRule: ruleAskText } : asked;
440
465
  const withMark = probeMandateSeen && withRuleAsk.probeMandated !== true ? { ...withRuleAsk, probeMandated: true } : withRuleAsk;
441
466
  const withFallback = fallbackSeen !== undefined && withMark.denialLimitFallback === undefined ? { ...withMark, denialLimitFallback: fallbackSeen, requiresRealApproval: true } : withMark;
442
- const withFact = classifierUnavailableSeen !== undefined && withFallback.classifierUnavailable === undefined ? { ...withFallback, classifierUnavailable: { cause: classifierUnavailableSeen.cause } } : withFallback;
443
- return merged !== undefined ? { ...withFact, updatedInput: merged } : withFact;
467
+ return merged !== undefined ? { ...withFallback, updatedInput: merged } : withFallback;
444
468
  }
445
469
  const allowed = rewrite ?? ALLOW;
446
470
  if (settledAllow === undefined)
@@ -208,6 +208,8 @@ export function schemaKeyBoundProblem(schema) {
208
208
  return undefined;
209
209
  }
210
210
  export function rosterMemberBoundProblem(m) {
211
+ if (typeof m.name !== "string")
212
+ return { code: "config.tool_roster_bound", message: `tool has no string name (got ${m.name === null ? "null" : typeof m.name}); every roster row is keyed by a string \`name\`` };
211
213
  const over = (label, value, bound) => `${label} ${JSON.stringify(value.slice(0, 40) + "…")} is ${value.length} characters; the roster's bound is ${bound}`;
212
214
  const of = (text) => `tool ${JSON.stringify(m.name.slice(0, 40))}: ${text}`;
213
215
  if (m.name.length > TOOL_WIRE_NAME_MAX_CHARS)
@@ -363,9 +363,12 @@ export interface ToolSpec<TParams extends TSchema = TSchema> {
363
363
  * for this tool — it is not a "threshold so low that everything offloads". */
364
364
  offloadThresholdChars?: number;
365
365
  /**
366
- * Deferred disclosure (design/36): when `true`, this tool is NOT inlined with its full JSON Schema in
367
- * every request. It appears as a lightweight placeholder (`{name, one-line hint, empty params}`) so
368
- * the model knows it exists. Activation is a DISCLOSURE fact (has the model seen the schema?), not an
366
+ * Deferred disclosure (design/36): when `true`, this tool is a CANDIDATE of the engine's deferral gate.
367
+ * When the gate opens (the candidate set's inlined size reaching 10% of the model's context window; see
368
+ * {@link RunnerDeps.deferMode}) it is NOT inlined with its full JSON Schema: it appears as a lightweight
369
+ * placeholder (`{name, one-line hint, empty params}`) so the model knows it exists; when the gate stays
370
+ * shut it ships inline in full. (A caller that wants a tool deferred on every request names it in
371
+ * {@link TaskSpec.deferTools} — that list is literal.) Activation is a DISCLOSURE fact (has the model seen the schema?), not an
369
372
  * execution precondition: the model activates the tool via the injected ToolSearch, or by making a call
370
373
  * whose arguments already match the real parameters (see {@link TaskSpec.deferSelfResolve}, on by
371
374
  * default). Where the schema then lands depends on {@link TaskSpec.toolMaterializeStrategy} — the next
@@ -386,7 +389,7 @@ export interface ToolSpec<TParams extends TSchema = TSchema> {
386
389
  * `alwaysLoad`, declared per MCP tool via `_meta["anthropic/alwaysLoad"]`, whose `isDeferredTool`
387
390
  * checks it before every deferral arm): `true` pins this tool's full schema inline in every
388
391
  * request — it is never deferred, regardless of its own {@link defer}, {@link TaskSpec.deferTools},
389
- * or `deferMode:"auto"`. The deferral sources only ever ADD; this is the explicit subtract valve.
392
+ * or the deferral gate. The deferral sources only ever ADD; this is the explicit subtract valve.
390
393
  * Exclusion still wins ({@link TaskSpec.excludeTools} unmounts — nothing left to keep inline).
391
394
  */
392
395
  alwaysLoad?: boolean;
@@ -1077,19 +1080,20 @@ export interface ToolExecuteContext {
1077
1080
  ttlMs?: number;
1078
1081
  };
1079
1082
  /**
1080
- * Ruled 2026-08-04 — the host run set {@link TaskSpec.checkpointStore} to `"disabled"` (the per-run
1081
- * off switch for the durable machine). Runner-filled, read-only, NEVER a model/tool argument; absent
1082
- * on every other run, so a deployment that does not use the off switch sees no change at all.
1083
+ * The host run's RESOLVED checkpoint seat, for its children — the store object the run parks in (the ONE value
1084
+ * `resolveCheckpointStore(spec, deps)` produced), or the word `"disabled"` when the caller disarmed the run.
1085
+ * Runner-filled, read-only, NEVER a model/tool argument. ABSENT when the tool runs outside a Runner task and
1086
+ * when the host has no seat at all (unset spec, no deployment store): nothing to inherit, so the child's own
1087
+ * door resolves its runner's deployment — a split child-execution runner may carry a store the host has none of.
1083
1088
  *
1084
- * A delegation tool MUST copy `checkpointStore: "disabled"` onto every child spec it builds (sync,
1085
- * background and fork alike). Without that copy the off switch is a ONE-LEVEL guarantee: the child
1086
- * runs on the same Runner, so it resolves `RunnerDeps.checkpointStore` on its own and re-arms every
1087
- * suspend leg the parent just disarmed a machine-started run would then park in its subtree, which
1088
- * is exactly the permanently-pending checkpoint the null exists to make impossible. Same discipline
1089
- * as `principal` (inherit verbatim down the delegation tree off the trusted seat), and strictly a
1090
- * capability REMOVAL, so it can never widen a child.
1091
- */
1092
- checkpointStoreDisabledForChildren?: true;
1089
+ * A delegation tool copies this seat VERBATIM onto every child spec it builds (sync, background and fork
1090
+ * alike) one rule: a store the parent resolved at SPEC level reaches the child (a child that re-resolved
1091
+ * `RunnerDeps.checkpointStore` on its own found no store on a deployment that wires it per task, and its ask
1092
+ * fell to the fail-closed deny while the parent could park), and `"disabled"` presses on the whole subtree (a
1093
+ * capability REMOVAL, never re-armed one level down by a deployment store a machine-started run must not
1094
+ * park in its subtree). Same discipline as `principal`: inherit verbatim off the trusted seat.
1095
+ */
1096
+ checkpointStoreForChildren?: import("./checkpoint-store.js").CheckpointStore | "disabled";
1093
1097
  /**
1094
1098
  * design/99 (nested-subagent live observability): the host run's stable taskId (`spec.taskId ?? sessionId`),
1095
1099
  * filled by the Runner. A delegation tool (`createSubagentTool`) reads it AT SPAWN TIME and threads it into the
@@ -12,7 +12,7 @@
12
12
  import type { AgentTool, ThinkingLevel, ToolInputValidationContext, ToolInputVerdict } from "../internal/harness.js";
13
13
  import type { Model, StreamFn } from "../internal/llm.js";
14
14
  export type { ModelRef, ModelRole, RoleSpec, ModelRoles, Brain } from "./model-seat.js";
15
- export type { StaleToolResultOffloadOptions, ImageInput, TaskSpec } from "./task-spec.js";
15
+ export type { StaleToolResultOffloadOptions, ImageInput, TaskSpec, ShellGateDoctrine } from "./task-spec.js";
16
16
  export type { ToolEffect, ToolContentOrigin, ReversibilityVerdict, ToolSpec, ToolReturn, NestedUsage, NestedUsageAccum, ToolExecuteContext } from "./tool-spec.js";
17
17
  export type { BeforeWriteRequest, BeforeWriteResult, BeforeWriteHook, TrackEditRequest, TrackEditResult, TrackFileEditHook, FileEditedNotice, FileEditedHook, HandsBandOptions } from "./hands-band.js";
18
18
  export type { AgentDefinition, SkillManifest, SkillSpec } from "./agent-definition.js";
@@ -22,7 +22,8 @@ export type { TaskStatus, RemoteEnvFailureNote, EffectiveMemoryScopes, TaskResul
22
22
  export type { TerminalCause, PausedCause } from "./terminal-cause.js";
23
23
  export type { TaskEventIdentity, BrainStatusPhase, BrainRetryErrClass, BrainStatus, ToolActivity, HumanInputSource, HumanInputDelivery, DelegationTaskType, TaskEvent, HumanInputEvent, CompactOutcome } from "./task-event.js";
24
24
  export type { TaskStream } from "./task-stream.js";
25
- export type { WorkflowGovernanceBaseline, RuntimeCaps } from "./runtime-caps.js";
25
+ export type { WorkflowGovernanceBaseline, RuntimeCaps, AutoModeDenySource } from "./runtime-caps.js";
26
+ export { AUTO_MODE_DENY_SOURCES, isAutoModeDenySource } from "./runtime-caps.js";
26
27
  export type { BackgroundChildEvent, DelegationLifecycleEvent } from "./delegation-frames.js";
27
28
  export { __resetMalformedDelegationSeatAnnouncement, deliverDelegationLifecycle } from "./delegation-frames.js";
28
29
  export type { ProjectMemoryLoad, ResumePreflightInfo, ResumePreflightVerdict, RunnerDeps } from "./runner-deps.js";
@@ -1,2 +1,3 @@
1
+ export { AUTO_MODE_DENY_SOURCES, isAutoModeDenySource } from "./runtime-caps.js";
1
2
  export { __resetMalformedDelegationSeatAnnouncement, deliverDelegationLifecycle } from "./delegation-frames.js";
2
3
  export { __resetMalformedNoticeSeatAnnouncement, deliverEngineNotice, undrainedUserInputNotices } from "./engine-notice.js";
@@ -74,6 +74,18 @@ export type ParkLaneReason = "no_checkpoint_store" | "no_durable_approval_opt_in
74
74
  */
75
75
  export declare const AUTO_MODE_ARM_REASONS: readonly ["armed", "no_intent", "no_face", "denied", "resolver_fault"];
76
76
  export type AutoModeArmReason = (typeof AUTO_MODE_ARM_REASONS)[number];
77
+ /**
78
+ * The leg's auto-mode arming FACT as the manifest carries it: `armed ⇔ reason === "armed"`; `deniedSource` (#692 C-d,
79
+ * additive) is present only beside a STATED `denied` and names which settings plane's ratchet turned auto mode off
80
+ * ({@link import("./runtime-caps.js").AUTO_MODE_DENY_SOURCES}: `org` / `local` / `settings`) — the resolver's
81
+ * own word, carried through, never inferred; a `denied` with no source is a resolver that does not track sources,
82
+ * and a `resolver_fault` never carries one (the screen strips a source it refused).
83
+ */
84
+ export interface AutoModeArmFact {
85
+ armed: boolean;
86
+ reason: AutoModeArmReason;
87
+ deniedSource?: import("./runtime-caps.js").AutoModeDenySource;
88
+ }
77
89
  /**
78
90
  * One declared MCP server's materialization result for THIS leg — the per-server connection-state
79
91
  * read face on {@link WiringManifest.mcp}. `status` is the connect-time verdict of this leg's
@@ -242,10 +254,7 @@ export interface WiringManifest {
242
254
  * round is a deny that says so, and the next round is classified afresh), so there is no session-level
243
255
  * breaker face here and nothing about availability enters {@link configFingerprint}.
244
256
  */
245
- autoMode?: {
246
- armed: boolean;
247
- reason: AutoModeArmReason;
248
- };
257
+ autoMode?: AutoModeArmFact;
249
258
  /**
250
259
  * EFFECTIVE half only, and ALWAYS present on an engine-minted effective manifest (the `autoMode`
251
260
  * posture: every leg has an answer — an empty array is "this leg declared no servers", absence is
@@ -333,11 +342,9 @@ export interface WiringFacts {
333
342
  restore: string;
334
343
  };
335
344
  /** Effective half only — see {@link WiringManifest.autoMode}; the static half has no leg to arm.
336
- * `armed` must agree with `reason` (`armed ⇔ reason === "armed"`); a contradicting pair is refused. */
337
- autoMode?: {
338
- armed: boolean;
339
- reason: AutoModeArmReason;
340
- };
345
+ * `armed` must agree with `reason` (`armed ⇔ reason === "armed"`); a contradicting pair is refused, and so
346
+ * is a `deniedSource` beside any reason but `denied` or spelled outside its closed set. */
347
+ autoMode?: AutoModeArmFact;
341
348
  /** Effective half only — see {@link WiringManifest.mcp}; the static half materializes nothing.
342
349
  * Copied entry-wise onto the manifest (the caller's array is never aliased). */
343
350
  mcp?: readonly WiringManifestMcpEntry[];
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { canonicalize } from "./canonical-json.js";
3
3
  import { resolveCheckpointStore } from "./checkpoint-store.js";
4
4
  import { isLiveQuestionFace } from "./ask-question.js";
5
+ import { AUTO_MODE_DENY_SOURCES, isAutoModeDenySource } from "./runtime-caps.js";
5
6
  export const AUTO_MODE_ARM_REASONS = ["armed", "no_intent", "no_face", "denied", "resolver_fault"];
6
7
  export function resolveDeclaredDurability(store, storeName) {
7
8
  const declared = store?.durability;
@@ -68,10 +69,14 @@ function deriveParkLane(facts) {
68
69
  return { capable: true, effective: unresolved ? "unresolved" : false, reasons, ...durability };
69
70
  }
70
71
  function readAutoModeFact(fact) {
71
- if (!AUTO_MODE_ARM_REASONS.includes(fact.reason) || fact.armed !== (fact.reason === "armed")) {
72
- throw new Error(`the auto-mode wiring fact is inconsistent (armed=${String(fact.armed)}, reason=${JSON.stringify(fact.reason)}) armed must hold exactly when reason is "armed", and reason must be one of ${AUTO_MODE_ARM_REASONS.join("|")}`);
72
+ const { armed, reason, deniedSource } = fact;
73
+ if (!AUTO_MODE_ARM_REASONS.includes(reason) || armed !== (reason === "armed")) {
74
+ throw new Error(`the auto-mode wiring fact is inconsistent (armed=${String(armed)}, reason=${JSON.stringify(reason)}) — armed must hold exactly when reason is "armed", and reason must be one of ${AUTO_MODE_ARM_REASONS.join("|")}`);
73
75
  }
74
- return { armed: fact.armed, reason: fact.reason };
76
+ if (deniedSource !== undefined && (reason !== "denied" || !isAutoModeDenySource(deniedSource))) {
77
+ throw new Error(`the auto-mode wiring fact is inconsistent (reason=${JSON.stringify(reason)}, deniedSource=${JSON.stringify(deniedSource)}) — deniedSource may stand only beside reason "denied", as one of ${AUTO_MODE_DENY_SOURCES.join("|")}`);
78
+ }
79
+ return { armed, reason, ...(deniedSource !== undefined ? { deniedSource } : {}) };
75
80
  }
76
81
  export function deriveWiringManifest(facts) {
77
82
  if (facts.half === "static" && facts.leg !== undefined) {