@sema-agent/core 7.11.2 → 7.13.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 (91) hide show
  1. package/CHANGELOG.md +86 -13
  2. package/dist/core/ask-origin.d.ts +55 -0
  3. package/dist/core/ask-origin.js +21 -0
  4. package/dist/core/auto-mode-arming.d.ts +10 -14
  5. package/dist/core/auto-mode-arming.js +3 -9
  6. package/dist/core/auto-mode-defaults.d.ts +0 -2
  7. package/dist/core/auto-mode-defaults.js +0 -1
  8. package/dist/core/auto-mode-rebuild.d.ts +6 -13
  9. package/dist/core/auto-mode-rebuild.js +0 -2
  10. package/dist/core/auto-mode.d.ts +30 -89
  11. package/dist/core/auto-mode.js +12 -59
  12. package/dist/core/checkpoint-store.d.ts +1 -3
  13. package/dist/core/engine-notice.d.ts +18 -0
  14. package/dist/core/gate-fold.js +1 -9
  15. package/dist/core/gate-lanes.d.ts +0 -40
  16. package/dist/core/gate-lanes.js +18 -26
  17. package/dist/core/governance-codes.d.ts +1 -1
  18. package/dist/core/governance-codes.js +2 -0
  19. package/dist/core/hooks.d.ts +7 -1
  20. package/dist/core/hooks.js +1 -1
  21. package/dist/core/runner/assemble-result.d.ts +45 -1
  22. package/dist/core/runner/assemble-result.js +4 -1
  23. package/dist/core/runner/compaction-seams.d.ts +42 -0
  24. package/dist/core/runner/compaction-seams.js +80 -0
  25. package/dist/core/runner/contracts.d.ts +85 -15
  26. package/dist/core/runner/denial-limit-arms.d.ts +10 -13
  27. package/dist/core/runner/denial-limit-arms.js +12 -9
  28. package/dist/core/runner/gate-exit.js +9 -1
  29. package/dist/core/runner/permission-rule-lanes.d.ts +2 -1
  30. package/dist/core/runner/permission-rule-lanes.js +2 -1
  31. package/dist/core/runner/prepare-caps-and-workflow.d.ts +1 -1
  32. package/dist/core/runner/prepare-caps-and-workflow.js +0 -5
  33. package/dist/core/runner/prepare-policy-chain.js +3 -2
  34. package/dist/core/runner/prepare-protocol-tools.js +5 -0
  35. package/dist/core/runner/prepare-safety-scan.js +11 -16
  36. package/dist/core/runner/prepare-suspend-saga.d.ts +0 -2
  37. package/dist/core/runner/prepare-suspend-saga.js +2 -10
  38. package/dist/core/runner/prepare-task.d.ts +9 -1
  39. package/dist/core/runner/prepare-task.js +7 -2
  40. package/dist/core/runner/prepare-wiring-manifest.d.ts +1 -1
  41. package/dist/core/runner/prepare-wiring-manifest.js +1 -8
  42. package/dist/core/runner/resume-claim.d.ts +2 -2
  43. package/dist/core/runner/resume-preflight.d.ts +2 -2
  44. package/dist/core/runner/run-attachment-seats.d.ts +6 -4
  45. package/dist/core/runner/run-attachment-seats.js +2 -2
  46. package/dist/core/runner/run-git-lane.d.ts +1 -1
  47. package/dist/core/runner/run-harness-handlers.js +2 -0
  48. package/dist/core/runner/run-identity-wiring.d.ts +21 -33
  49. package/dist/core/runner/run-identity-wiring.js +6 -3
  50. package/dist/core/runner/run-leg.d.ts +108 -0
  51. package/dist/core/runner/run-leg.js +713 -0
  52. package/dist/core/runner/run-notification-lane.d.ts +56 -0
  53. package/dist/core/runner/run-notification-lane.js +128 -0
  54. package/dist/core/runner/run-reasoning-seat.d.ts +5 -5
  55. package/dist/core/runner/run-reasoning-seat.js +7 -7
  56. package/dist/core/runner/run-recovery-lanes.d.ts +4 -17
  57. package/dist/core/runner/run-recovery-lanes.js +5 -4
  58. package/dist/core/runner/run-settle-and-teardown.d.ts +113 -0
  59. package/dist/core/runner/run-settle-and-teardown.js +432 -0
  60. package/dist/core/runner/run-stop-and-final-verify.d.ts +2 -2
  61. package/dist/core/runner/run-terminal-adoption.d.ts +99 -0
  62. package/dist/core/runner/run-terminal-adoption.js +200 -0
  63. package/dist/core/runner/run-turn-boundary.js +5 -4
  64. package/dist/core/runner/runtask.d.ts +10 -79
  65. package/dist/core/runner/runtask.js +83 -1557
  66. package/dist/core/runner/stream-lifecycle-verbs.js +10 -1
  67. package/dist/core/runner-deps.d.ts +4 -14
  68. package/dist/core/store-contracts/workflow-journal-store-contract.d.ts +7 -0
  69. package/dist/core/store-contracts/workflow-journal-store-contract.js +85 -0
  70. package/dist/core/task-stream.d.ts +10 -1
  71. package/dist/core/tool-face.d.ts +8 -0
  72. package/dist/core/tool-face.js +1 -0
  73. package/dist/core/tool-policy.d.ts +42 -96
  74. package/dist/core/tool-policy.js +1 -11
  75. package/dist/core/tool-registry.d.ts +11 -3
  76. package/dist/core/tool-registry.js +7 -1
  77. package/dist/core/tool-roster.d.ts +26 -0
  78. package/dist/core/tool-roster.js +38 -7
  79. package/dist/core/trace.d.ts +6 -7
  80. package/dist/core/wiring-manifest.d.ts +5 -22
  81. package/dist/core/wiring-manifest.js +3 -11
  82. package/dist/core/workflow-journal-store.d.ts +35 -4
  83. package/dist/core/workflow-journal-store.js +19 -2
  84. package/dist/index.d.ts +6 -5
  85. package/dist/index.js +6 -5
  86. package/dist/orchestration/workflow.js +2 -0
  87. package/dist/server/http.js +2 -1
  88. package/dist/stores/file/workflow-journal-store.d.ts +7 -10
  89. package/dist/stores/file/workflow-journal-store.js +2 -4
  90. package/package.json +1 -1
  91. package/test/export-surface.snapshot.json +21 -11
@@ -1,13 +1,12 @@
1
1
  import type { ToolCallRequest } from "./tool-policy.js";
2
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
3
+ * WHY an `unavailable` verdict could not run — the closed set behind the verdict arm, the deny's
4
+ * `classifierUnavailable` fact (the deny observer's word and the model-facing sentence's parenthetical) and
5
+ * the `auto_mode.classified` trace frame's `cause`, so the three spell one word. `error` = the model leg
6
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.
7
+ * back BEFORE any decide, so there is no separate word for it); `timeout` = the round-trip cap.
9
8
  */
10
- export declare const AUTO_MODE_UNAVAILABLE_CAUSES: readonly ["error", "timeout", "breaker_open"];
9
+ export declare const AUTO_MODE_UNAVAILABLE_CAUSES: readonly ["error", "timeout"];
11
10
  export type AutoModeUnavailableCause = (typeof AUTO_MODE_UNAVAILABLE_CAUSES)[number];
12
11
  /** Whether a value is a member of the unavailable-cause set — the screen a row reader applies to a stored word. */
13
12
  export declare function isAutoModeUnavailableCause(v: unknown): v is AutoModeUnavailableCause;
@@ -21,26 +20,30 @@ export type AutoModeVerdict = {
21
20
  category: string;
22
21
  reason: string;
23
22
  }
24
- /** The classifier could not run: model error/timeout, or the session breaker is open. NOT a decision. */
23
+ /** The classifier could not run: the model leg threw / rejected, or ran past the cap. NOT a decision — the
24
+ * gate denies and says so ({@link classifierUnavailableDenyMessage}). */
25
25
  | {
26
26
  kind: "unavailable";
27
27
  cause: AutoModeUnavailableCause;
28
28
  }
29
- /** The model responded but not in the `<block>…` contract shape. NOT a decision. */
29
+ /** The model responded but not in the `<block>…` contract shape. Handled as a BLOCK with the CC
30
+ * parse-failure sentence ({@link CLASSIFIER_PARSE_FAILURE_DENY_MESSAGE}), counted by the denial limit. */
30
31
  | {
31
32
  kind: "parse_error";
32
33
  raw: string;
33
34
  };
34
35
  /**
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).
36
+ * The model-facing deny text for an `unavailable` verdictCC 2.1.250 `x1t`, with the parenthetical `EIt`
37
+ * spells for the causes this engine distinguishes (a wall-clock / connection timeout reads " (timed out)";
38
+ * an error of unknown kind reads nothing CC spells HTTP statuses it has, and this decider does not). The
39
+ * subject is the classifier itself rather than a model id: the decider does not know which model answered,
40
+ * and the sentence must not name one it cannot vouch for. One writer for every deny site (the gate's own
41
+ * station and the inherited-lane arms).
39
42
  */
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;
43
+ export declare function classifierUnavailableDenyMessage(toolName: string, cause: AutoModeUnavailableCause): string;
44
+ /** The model-facing deny text for a `parse_error` verdict — CC 2.1.250 `Ure` over the R3t stem: the classifier
45
+ * answered, but not with a verdict, and auto mode blocks what it could not evaluate. */
46
+ export declare const CLASSIFIER_PARSE_FAILURE_DENY_MESSAGE = "Auto mode could not evaluate this action and is blocking it for safety \u2014 the classifier's reply carried no verdict.";
44
47
  /**
45
48
  * Parse the classifier's output contract — the CC 2.1.250 verdict reader (`wCe`, its thinking strip
46
49
  * `xCe`, its reason reader `yCe`) ported as is:
@@ -65,11 +68,12 @@ export declare function isAutoModeBreakerCause(v: unknown): v is AutoModeBreaker
65
68
  * stripped text, the tag spelled as the prompt spells it, trimmed) are carried when present and `""`
66
69
  * when absent; the consumers render their
67
70
  * 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.
71
+ * downgrading a stated block to `parse_error` (a block with the generic parse-failure sentence instead
72
+ * of the classifier's own reason) would lose the classifier's explicit verdict on the exact calls it flagged.
70
73
  *
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.
74
+ * `parse_error` is fail-closed: the gate handles it as a BLOCK (CC 2.1.250's own outcome for a reply with no
75
+ * verdict) that the denial limit counts like any other — never a silent allow, and never a question handed
76
+ * to a person on the classifier's behalf.
73
77
  */
74
78
  export declare function parseAutoModeResponse(text: string): AutoModeVerdict;
75
79
  /** What the decider hands the deployment's `classify` hook (the assembled prompt is the hook's job
@@ -88,21 +92,11 @@ export interface AutoModeDeciderOptions {
88
92
  /** Hard cap on one classification round-trip. Default 15_000 ms (sema 裁量 — CC's constant is not
89
93
  * established; a permission gate must not stall the whole run on a slow classifier). */
90
94
  timeoutMs?: number;
91
- /** Consecutive-failure threshold that opens the session breaker (default 3 "连续 N 失败").
92
- * Failures = unavailable(error|timeout) + parse_error. A successful round (allow/block) resets it. */
93
- failureThreshold?: number;
94
- /** Fired ONCE when the breaker opens ("本 session 退回非 auto + 一次性告警"). `lastCause` is the failure that tripped it
95
- * ({@link AutoModeBreakerCause}). */
96
- onBreakerOpen?: (info: {
97
- consecutiveFailures: number;
98
- lastCause: AutoModeBreakerCause;
99
- }) => void;
100
95
  /**
101
96
  * Fired once per `decide` call with the verdict the gate is about to act on and the wall time it
102
- * waited (`ms`, integer ≥ 0; a timed-out round reads the deadline; a breaker-open short-circuit
103
- * reads ~0 with `cause:"breaker_open"`). `cause` is present iff the verdict is `unavailable`. The
104
- * engine's own wiring turns this into the `auto_mode.classified` trace frame; a hand-built decider
105
- * need not implement it. Like the breaker alarm, a throwing hook never breaks the gate.
97
+ * waited (`ms`, integer ≥ 0; a timed-out round reads the deadline). `cause` is present iff the verdict
98
+ * is `unavailable`. The engine's own wiring turns this into the `auto_mode.classified` trace frame; a
99
+ * hand-built decider need not implement it. A throwing hook never breaks the gate.
106
100
  */
107
101
  onClassified?: (info: AutoModeClassified) => void;
108
102
  }
@@ -120,66 +114,13 @@ export interface AutoModeClassified {
120
114
  export interface AutoModeDecider {
121
115
  /** Never rejects. Any internal failure surfaces as `unavailable`/`parse_error` (fail-closed). */
122
116
  decide(input: AutoModeClassifyInput, signal?: AbortSignal): Promise<AutoModeVerdict>;
123
- /** True once the session breaker has opened (it never half-opens: is a SESSION fallback
124
- * to non-auto, not a retry window — a flapping classifier must not oscillate the permission mode). */
125
- breakerOpen(): boolean;
126
- /**
127
- * The CURRENT consecutive-failure streak — the part of the breaker's state that `breakerOpen()` alone
128
- * cannot express. A latch that has not tripped yet still carries how much budget is left before it
129
- * does, and #503 needs exactly that: an arming recorded for cross-process rebuild starts a FRESH
130
- * decider, so recording one from a decider that is already 2 failures into a threshold of 3 would hand
131
- * the redemption more tolerance for a failing classifier than the ancestor had left — the redeemed leg
132
- * stays auto-classified through failures that would have dropped the ancestor back to non-auto.
133
- * {@link import("./tool-policy.js").constraintChainEntryOfLayer} therefore records an arming only at a
134
- * ZERO streak, and reads a decider that does not implement this member as unknown (⇒ records nothing).
135
- *
136
- * OPTIONAL so that hand-built deciders keep type-checking; the engine's own
137
- * {@link createAutoModeDecider} always implements it. A deployment that hand-rolls a decider and wants
138
- * the persisted-arming path must implement it too — the conservative reading of absence is deliberate.
139
- */
140
- consecutiveFailures?(): number;
141
117
  }
142
118
  /**
143
- * Session-scoped decider: timeout + fail-closed error mapping + a one-way circuit breaker.
144
- * One instance per run/session the breaker state is the session's "退回非 auto" latch.
119
+ * The per-run decider: timeout + fail-closed error mapping, and nothing carried between rounds. One instance
120
+ * per run (it binds the run's model leg and assembled prompt); a round that failed says nothing about the
121
+ * next one, which asks the model again.
145
122
  */
146
123
  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
- }
183
124
  /** The deployment's bounds for the denial limit (`RunnerDeps.autoMode.denialLimit`). Every member
184
125
  * optional; an omitted member takes its CC default. A present member with a bad value is REFUSED
185
126
  * loudly at construction (never clamped, never read as the default). */
@@ -1,14 +1,17 @@
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"];
1
+ import { 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"];
3
3
  const AUTO_MODE_UNAVAILABLE_CAUSE_SET = new Set(AUTO_MODE_UNAVAILABLE_CAUSES);
4
4
  export function isAutoModeUnavailableCause(v) {
5
5
  return AUTO_MODE_UNAVAILABLE_CAUSE_SET.has(v);
6
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);
7
+ export function classifierUnavailableDenyMessage(toolName, cause) {
8
+ const parenthetical = cause === "timeout" ? " (timed out)" : "";
9
+ return (`The auto-mode classifier is temporarily unavailable${parenthetical}, so auto mode cannot determine the safety of ${toolName} right now. ` +
10
+ "Wait a moment and then try this action again. " +
11
+ "If it keeps failing, continue with other tasks that don't require this action and come back to it later. " +
12
+ "Note: reading files, searching code, and other read-only operations do not require the classifier and can still be used.");
11
13
  }
14
+ export const CLASSIFIER_PARSE_FAILURE_DENY_MESSAGE = "Auto mode could not evaluate this action and is blocking it for safety — the classifier's reply carried no verdict.";
12
15
  export function parseAutoModeResponse(text) {
13
16
  const rawAnswers = new Set([...text.matchAll(/<block>(yes|no)\b/gi)].map((m) => m[1].toLowerCase()));
14
17
  if (rawAnswers.size > 1)
@@ -28,20 +31,6 @@ export function parseAutoModeResponse(text) {
28
31
  }
29
32
  export function createAutoModeDecider(opts) {
30
33
  const timeoutMs = opts.timeoutMs ?? AUTO_MODE_DEFAULT_TIMEOUT_MS;
31
- const threshold = Math.max(1, Math.floor(opts.failureThreshold ?? AUTO_MODE_DEFAULT_FAILURE_THRESHOLD));
32
- let consecutiveFailures = 0;
33
- let open = false;
34
- const recordFailure = (cause) => {
35
- consecutiveFailures++;
36
- if (!open && consecutiveFailures >= threshold) {
37
- open = true;
38
- try {
39
- opts.onBreakerOpen?.({ consecutiveFailures, lastCause: cause });
40
- }
41
- catch {
42
- }
43
- }
44
- };
45
34
  const decide = async (input, signal) => {
46
35
  const startedAt = performance.now();
47
36
  const verdict = await decideUnreported(input, signal);
@@ -59,14 +48,8 @@ export function createAutoModeDecider(opts) {
59
48
  }
60
49
  return verdict;
61
50
  };
62
- return {
63
- breakerOpen: () => open,
64
- consecutiveFailures: () => consecutiveFailures,
65
- decide,
66
- };
51
+ return { decide };
67
52
  async function decideUnreported(input, signal) {
68
- if (open)
69
- return { kind: "unavailable", cause: "breaker_open" };
70
53
  let timer;
71
54
  const inner = new AbortController();
72
55
  const onOuterAbort = () => inner.abort();
@@ -98,21 +81,12 @@ export function createAutoModeDecider(opts) {
98
81
  }
99
82
  });
100
83
  });
101
- if (open)
102
- return { kind: "unavailable", cause: "breaker_open" };
103
- const verdict = parseAutoModeResponse(raw);
104
- if (verdict.kind === "parse_error")
105
- recordFailure("parse_error");
106
- else
107
- consecutiveFailures = 0;
108
- return verdict;
84
+ return parseAutoModeResponse(raw);
109
85
  }
110
86
  catch (e) {
111
87
  if (signal?.aborted)
112
88
  return { kind: "unavailable", cause: "error" };
113
- const timedOut = e instanceof AutoModeTimeout;
114
- recordFailure(timedOut ? "timeout" : "error");
115
- return { kind: "unavailable", cause: timedOut ? "timeout" : "error" };
89
+ return { kind: "unavailable", cause: e instanceof AutoModeTimeout ? "timeout" : "error" };
116
90
  }
117
91
  finally {
118
92
  if (timer !== undefined)
@@ -127,27 +101,6 @@ class AutoModeTimeout extends Error {
127
101
  super("auto-mode classify timeout");
128
102
  }
129
103
  }
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
- }
151
104
  export function readDenialLimitFallback(v) {
152
105
  if (typeof v !== "object" || v === null)
153
106
  return undefined;
@@ -1123,9 +1123,7 @@ export interface CheckpointState {
1123
1123
  * suspend leg did. It is a MEMORY of intent, never an authorization: the resuming deployment's
1124
1124
  * face (`RunnerDeps.autoMode`) and the resuming principal's deny bit (`RuntimeCaps.autoMode`)
1125
1125
  * are judged afresh on every leg — a bit on the row cannot arm where the redeeming deployment
1126
- * would not. Follows the classifier's latch: a leg armed here writes it only while its own breaker
1127
- * is untripped and untouched (a session that fell back to non-auto hands nothing forward); a leg
1128
- * never armed here carries the memory as is. Absent on older checkpoints and on non-auto tasks
1126
+ * would not. A leg never armed here carries the memory as is. Absent on older checkpoints and on non-auto tasks
1129
1127
  * (byte-identical to the pre-bit row); an older worker that ignores it resumes un-armed, the
1130
1128
  * narrower direction. */
1131
1129
  autoModeRequested?: true;
@@ -235,6 +235,24 @@ export interface EngineNotice {
235
235
  * (whoever answered the card is the one entitled to hear the answer ran nothing); a host may
236
236
  * forward it on its own wire.
237
237
  *
238
+ * - `"task.interrupt_unconsumed"` — a HARD stop ({@link TaskStream.interrupt}, the task's `signal`,
239
+ * the walltime or the turns limit) landed AFTER the run's last turn had already given its answer
240
+ * (`stopReason:"stop"`, not partial, and no steer / follow-up of the person's still pending — a
241
+ * queued continuation makes that "stop" work in progress, and a stop that cuts it stays the
242
+ * ordinary abort with its undrained-input account) and before the loop's end — what it aborted was the run's
243
+ * tail (a final-boundary compaction is mooted, a stop hook's wait is cut), never its work. The
244
+ * answer stands: the run ends `completed` with it as `result`, no `[Request interrupted by user]`
245
+ * marker is written and no `limits.*` terminal is minted for the axis that fired; this notice is
246
+ * the stop's only trace. Minted once per such run, after the terminal arbitration (so it never
247
+ * claims a completed ending the terminal did not give — a budget ceiling crossed by the last
248
+ * turn's own spend still fails the run and mints nothing here); `detail: { sessionId, runId,
249
+ * taskId?, origin }` with `origin` ∈ `"interrupt"` (the verb) | `"walltime"` | `"turns"` |
250
+ * `"signal"` (every other run-level abort: the task's own `signal`, a parent's cancel, a
251
+ * `destroy()`, a refused preempt — the leg names the three it can tell apart and files the rest
252
+ * here). Audience `"user"`. A stop that arrives
253
+ * AFTER the loop's end is refused typed (`steering.not_running`, every verb alike) and mints
254
+ * nothing: the refusal itself is the loud answer.
255
+ *
238
256
  * - `"checkpoint.execution_outcome_unrecorded"` — the resumed leg's pending-call resolver disposed
239
257
  * the decided action (executed it, delivered the refusal, or vetoed the person's yes) and filed
240
258
  * the frame's record through `CheckpointStore.recordExecutionOutcome`, and the store answered
@@ -128,15 +128,7 @@ export async function runGateFold(pass) {
128
128
  pass.decision = { ...pass.decision, probeMandated: true };
129
129
  }
130
130
  }
131
- const breakerKnownOpen = (d) => {
132
- try {
133
- return d.breakerOpen();
134
- }
135
- catch {
136
- return false;
137
- }
138
- };
139
- if (input.peerMessage === true && pass.decision.action === "allow" && input.autoMode !== undefined && !breakerKnownOpen(input.autoMode.decider)) {
131
+ if (input.peerMessage === true && pass.decision.action === "allow" && input.autoMode !== undefined) {
140
132
  pass.decision = {
141
133
  action: "ask",
142
134
  message: `tool "${toolName}" sends a message to another agent — routed for classifier review in auto mode`,
@@ -48,46 +48,6 @@ export declare function normalizePersistedRuleHit(hit: PersistedRuleAnswer): {
48
48
  reason?: string;
49
49
  coverage?: readonly import("./permission-rule-model.js").SegmentCoverage[];
50
50
  };
51
- /**
52
- * The mandate provenance of one call, judged from the SAME mark inputs the gate is driven with —
53
- * the single source for "could a persisted allow rule clear this ask?". Allow rules silence the
54
- * classifier's questions, never a mandated one, and this predicate is the mandated-family half of
55
- * that boundary (the real-approval/governance half rides the decision's own `requiresRealApproval`
56
- * bit, which the org layer stamps):
57
- * · `probeMandated` — this CALL's own reversibility probe declared its demotion STRUCTURAL
58
- * (`"probe_mandate"`), judged FIRST because it is the only per-call member here: the three below
59
- * are properties of the TOOL and are true of every call on the seat, so a mandate that is true of
60
- * this one call must not be shadowed by the tier that happens to carry it (#502: the built-in
61
- * shell probe raises it for a listed reader naming a path outside the session's roots — a boundary
62
- * the deployment declared, which is exactly what the classify tier alone cannot say);
63
- * · `egress` — the tool's own external-write mark, judged next: it is the tool's declaration even
64
- * when the coarse doctrine also installed a shell tier on the same seat;
65
- * · `shellGated` + tier `"always"` — the operator's per-call confirmation doctrine
66
- * (`"operator_always"`); the classify doctrine installs `"maybe"`, and THOSE asks stay the rule
67
- * lane's home turf (`undefined`) — that is the don't-ask-again main case, and the per-call member
68
- * above is deliberately the ONLY thing that carves a mandate out of it;
69
- * · a tool's OWN `"always"`/`"maybe"` irreversibility tier without the doctrine (`"tool_marks"`).
70
- *
71
- * Two consumers, one derivation: the gate's silencing arm (a matching rule is disclosed as shadowed
72
- * instead of clearing the ask) and the runner's suggestion factory (a mandated ask offers no
73
- * "stop asking me this" option — a rule minted from it would never clear it). A drift between the
74
- * two would let a card offer a rule the lane then refuses to honor. The per-call member reaches both
75
- * the same way every other per-call fact does: the gate stamps it on the surviving ask, the
76
- * synchronous sites read it off the decision they spread, and the park leg threads its own parameter.
77
- */
78
- export declare function persistedRuleMandateOf(marks: {
79
- egress?: boolean;
80
- shellGated?: boolean;
81
- irreversibility?: "never" | "maybe" | "always";
82
- /** #502: the surviving ask's engine-stamped `probeMandated` — see
83
- * {@link import("./types.js").ReversibilityVerdict.mandated}. */
84
- probeMandated?: boolean;
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;
91
51
  /**
92
52
  * Run the lanes over the pass: the org layer over the fold's verdict, then — for a surviving ask — the rewrite
93
53
  * merge, the rule lane, the stamp, the classifier, the sandbox leg, the parks, the content lane, the resolution
@@ -1,6 +1,6 @@
1
1
  import { decisionText, describeThrown } from "./tool-policy.js";
2
- import { askOriginOf, classifierMayAnswer } from "./ask-origin.js";
3
- import { denialLimitFallbackMessage, unarmedWindow } from "./auto-mode.js";
2
+ import { askOriginOf, classifierMayAnswer, persistedRuleMandateOf, probeBoundaryMandate } from "./ask-origin.js";
3
+ import { CLASSIFIER_PARSE_FAILURE_DENY_MESSAGE, classifierUnavailableDenyMessage, denialLimitFallbackMessage, unarmedWindow } from "./auto-mode.js";
4
4
  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";
@@ -144,21 +144,6 @@ export function normalizePersistedRuleHit(hit) {
144
144
  return {};
145
145
  return { hit: { behavior, rules: members.map((m) => ({ rule: m.rule, ...copiedUsableDots(m.dots) })) } };
146
146
  }
147
- export function persistedRuleMandateOf(marks) {
148
- return marks.probeMandated === true
149
- ? "probe_mandate"
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;
161
- }
162
147
  function parkWith(suspendAsk, parkArgs, carry) {
163
148
  return suspendAsk(...parkArgs, carry);
164
149
  }
@@ -375,7 +360,7 @@ export async function runGateLanes(pass) {
375
360
  }
376
361
  if (pass.decision.action === "ask")
377
362
  ledger.supersede();
378
- if (input.autoMode && pass.decision.action === "ask" && pass.decision.origin !== undefined && classifierMayAnswer(pass.decision.origin)) {
363
+ if (input.autoMode && pass.decision.action === "ask" && pass.decision.origin !== undefined && classifierMayAnswer(pass.decision.origin) && !probeBoundaryMandate(persistedRuleMandate)) {
379
364
  const verdict = await input.autoMode.decider
380
365
  .decide({ req: pass.req, askMessage: decisionText(pass.decision) }, input.abortSignal)
381
366
  .catch(() => ({ kind: "unavailable", cause: "error" }));
@@ -388,9 +373,17 @@ export async function runGateLanes(pass) {
388
373
  ...(pass.policyRewrite !== undefined ? { updatedInput: pass.policyRewrite } : {}),
389
374
  };
390
375
  }
391
- else if (verdict.kind === "block") {
392
- const reason = verdict.reason ? inlineUntrusted(verdict.reason) : "";
393
- const category = verdict.category ? inlineUntrusted(verdict.category) : "";
376
+ else if (verdict.kind === "unavailable") {
377
+ pass.decision = {
378
+ action: "deny",
379
+ message: classifierUnavailableDenyMessage(toolName, verdict.cause),
380
+ decisionReason: "classifier",
381
+ classifierUnavailable: { cause: verdict.cause },
382
+ };
383
+ }
384
+ else {
385
+ const reason = verdict.kind === "block" ? (verdict.reason ? inlineUntrusted(verdict.reason) : "") : CLASSIFIER_PARSE_FAILURE_DENY_MESSAGE;
386
+ const category = verdict.kind === "block" && verdict.category ? inlineUntrusted(verdict.category) : "";
394
387
  const tracked = input.autoMode.denialTracking?.recordBlock();
395
388
  if (tracked?.limitReached === true) {
396
389
  const fallbackAsk = {
@@ -405,15 +398,13 @@ export async function runGateLanes(pass) {
405
398
  else {
406
399
  pass.decision = {
407
400
  action: "deny",
408
- message: `auto-mode classifier blocked this call${reason ? `: ${reason}` : category ? `: [${category}]` : ""}`,
401
+ message: verdict.kind === "block" ? `auto-mode classifier blocked this call${reason ? `: ${reason}` : category ? `: [${category}]` : ""}` : reason,
409
402
  decisionReason: "classifier",
410
403
  };
411
- pass.deniedBy = "classifier";
412
404
  }
413
405
  }
414
- else if (verdict.kind === "unavailable") {
415
- pass.decision = { ...pass.decision, classifierUnavailable: { cause: verdict.cause } };
416
- }
406
+ if (pass.decision.action === "deny")
407
+ pass.deniedBy = "classifier";
417
408
  }
418
409
  if (input.sandboxAdmission !== undefined &&
419
410
  pass.decision.action === "ask" &&
@@ -425,6 +416,7 @@ export async function runGateLanes(pass) {
425
416
  pass.hookAsk === undefined &&
426
417
  input.egress !== true &&
427
418
  input.peerMessage !== true &&
419
+ !probeBoundaryMandate(persistedRuleMandate) &&
428
420
  input.sandboxAdmission.boundaryCapable !== true) {
429
421
  const recorded = input.sandboxAdmission.askClassesOf(toolCallId);
430
422
  const admissible = recorded !== undefined && recorded.length > 0 && recorded.every((r) => r.cls === "sandbox_local");
@@ -104,7 +104,7 @@ export type NoticeAudience = "user" | "operator";
104
104
  * src/ for notice mint shapes and names any code that is minted but unregistered, or registered but
105
105
  * no longer minted.
106
106
  */
107
- 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
+ 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", "task.interrupt_unconsumed", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
108
108
  /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
109
109
  * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
110
110
  * sink is a supported shape, and narrowing that field would break it. */
@@ -141,6 +141,7 @@ export const ENGINE_NOTICE_CODES = [
141
141
  "task.turn_interrupted",
142
142
  "task.halt_unconsumed",
143
143
  "task.late_approval",
144
+ "task.interrupt_unconsumed",
144
145
  "memory.capture_opted_out",
145
146
  "memory.capture_optout_unpersisted",
146
147
  "tool_result.offload_put_failed",
@@ -159,6 +160,7 @@ const NOTICE_AUDIENCE_TABLE = {
159
160
  "steering.parked_input_blocked": "user",
160
161
  "task.halt_unconsumed": "user",
161
162
  "task.late_approval": "user",
163
+ "task.interrupt_unconsumed": "user",
162
164
  "config.durable_gate_unavailable": "user",
163
165
  "memory.capture_opted_out": "user",
164
166
  "memory.capture_optout_unpersisted": "user",
@@ -2,7 +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, ToolCallFace, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
4
4
  import type { GateOutcome } from "./gate-outcome.js";
5
- export { normalizeOrgGateVerdict, normalizePersistedRuleHit, persistedRuleMandateOf } from "./gate-lanes.js";
5
+ export { normalizeOrgGateVerdict, normalizePersistedRuleHit } from "./gate-lanes.js";
6
6
  export { cloneObserverInput } from "./runner/gate-exit.js";
7
7
  import { type AskClass } from "./ask-class.js";
8
8
  import { type AutoModeDenialTracker } from "./auto-mode.js";
@@ -411,6 +411,12 @@ export interface PermissionDeniedPayload {
411
411
  * settled none.
412
412
  */
413
413
  gate: GateOutcome;
414
+ /** #661 (additive): present ⇔ this deny is the auto-mode classifier's UNAVAILABILITY (`deniedBy: "classifier"`
415
+ * with the verdict's cause word) — the structured half of the deny text's "the classifier is temporarily
416
+ * unavailable" sentence. A classifier BLOCK or parse-failure deny carries no member here. */
417
+ classifierUnavailable?: {
418
+ readonly cause: import("./auto-mode.js").AutoModeUnavailableCause;
419
+ };
414
420
  /** {@link HookSeatSignal} — this invocation's own abort signal. On an OBSERVATION seat the deny has
415
421
  * already happened and nothing this callback does can change it, so the signal says exactly one
416
422
  * thing: stop reading, nobody is waiting for your answer any more. */
@@ -2,7 +2,7 @@ import { decisionText, refuseOutOfContractDecision } from "./tool-policy.js";
2
2
  import { createSettlementLedger, engineFailClosedExit, hookDenyExit, traceHookCrash } from "./runner/gate-exit.js";
3
3
  import { runGateFold } from "./gate-fold.js";
4
4
  import { runGateLanes } from "./gate-lanes.js";
5
- export { normalizeOrgGateVerdict, normalizePersistedRuleHit, persistedRuleMandateOf } from "./gate-lanes.js";
5
+ export { normalizeOrgGateVerdict, normalizePersistedRuleHit } from "./gate-lanes.js";
6
6
  export { cloneObserverInput } from "./runner/gate-exit.js";
7
7
  import { brandPolicyAskClass } from "./ask-class.js";
8
8
  import { isAskOrigin } from "./ask-origin.js";
@@ -178,6 +178,10 @@ export interface ResultFlags {
178
178
  * abort (no synthetic stopReason:"aborted" assistant is appended), `final` is the last REAL assistant
179
179
  * message — this flag is what keeps an interrupted run from being mistaken for a completed one. */
180
180
  abortedLive?: boolean;
181
+ /** The leg's ONE evaluation of {@link answerSettled} (a clean `"stop"` final with no queued continuation
182
+ * lost): a live abort that found this true landed on the run's tail, not on work — slot 9 stands down
183
+ * and the run takes the completed floor. Absent/false = the abort's ordinary reading. */
184
+ answerSettled?: boolean;
181
185
  budgetHit?: "exceeded" | "precall";
182
186
  /** design/164 — which budget ceiling the hit belongs to. Set together with {@link budgetHit} at every
183
187
  * site that records one; it picks the terminal code (`limits.max_tokens_exceeded` vs
@@ -225,6 +229,43 @@ export declare function errorCodeOf(err: unknown): string | undefined;
225
229
  * in-presence rule the usage-window wait hint states for itself), and it is not part of the cause's
226
230
  * public projection.
227
231
  */
232
+ /**
233
+ * Has the run's ANSWER settled — is `final` the model's own clean end of its work? A run whose last turn
234
+ * ended `stopReason:"stop"` has said everything it was going to say; whatever ran after that turn (the
235
+ * final boundary's compaction, a stop hook, the teardown) is the run's tail, not its work. A hard stop
236
+ * that lands in that tail — `TaskStream.interrupt()`, the task's `signal`, the walltime or turns limit —
237
+ * cuts nothing the person or the caller wanted, so the run is NOT cancellable there: it ends `completed`
238
+ * with the answer as `result` (slot 9 yields to the completed floor), no interruption marker is written
239
+ * for it, and the end-of-task hook seats keep their full deadline. One predicate, read by every site that
240
+ * used to decide the question for itself; there is no second spelling.
241
+ *
242
+ * What does NOT count as settled — each is "the model was still working" and stays cancellable:
243
+ * - `stopReason:"length"` — the output was truncated; the auto-continue chain owns what follows;
244
+ * - `stopReason:"toolUse"` — a batch was in flight (the orphan reconcile owns the abort);
245
+ * - `stopReason:"aborted"` / `"error"` — the turn itself was cut or failed;
246
+ * - a `partialFinalized` `"stop"` — a mid-stream loss promoted to a final; the loop's bounded
247
+ * continue nudge owns it, so the stop the caller sees is a salvage, not the model's own end;
248
+ * - no `final` at all;
249
+ * - `continuationCut` — the run was cut with a continuation still owed: a steer / follow-up the person
250
+ * had already queued was still pending (queued, or drained at the final boundary and never served —
251
+ * the harness's agent_end account, which also mints `task.user_steer_undrained` /
252
+ * `task.user_followup_undrained`), or the loop itself had decided to run another turn (a drained
253
+ * frame, a truncated-output / thinking-only / degenerate-output nudge) and the abort caught it before
254
+ * the dispatch (`Prepared.workCutRef.continuation`, from the loop's own trace steps) — in both, a
255
+ * stop landing between the decision and the next stream returns the PREVIOUS clean answer as
256
+ * `final` (the loop exits at its pre-dispatch guard), so `final` alone cannot tell, and a
257
+ * thinking-only "stop" the loop was about to nudge into a visible answer is an empty result, not
258
+ * an answer; or the abort was already in force when the last turn ENDED
259
+ * (`Prepared.workCutRef.abortedBeforeTurnEnd`) — a brain that ignored its signal and delivered a
260
+ * full "stop" through the abort still answered a run somebody had stopped mid-work.
261
+ * Three axes, one law: the `abortedLive` flag alone decides that a stop landed; this decides whether it
262
+ * landed on work or on the tail. Evaluated ONCE per leg (the terminal-adoption lane, where both facts
263
+ * are in hand) and handed on as a value — {@link ResultFlags.answerSettled} — so the arbitration, the
264
+ * marker gate, the hook-seat grace, the teardown attribution and the notice read the same verdict.
265
+ * (A limit that is NOT a stop — a budget ceiling crossed by the last turn's own spend — is arbitrated
266
+ * above slot 9 and is unaffected.)
267
+ */
268
+ export declare function answerSettled(final: AssistantMessage | undefined, continuationCut?: boolean): boolean;
228
269
  export declare function terminalCauseOf(final: AssistantMessage | undefined, stats: Pick<Stats, "humanReview">, flags: ResultFlags): {
229
270
  terminal: TerminalCause;
230
271
  apiFailure: TaskResult["apiFailure"];
@@ -252,7 +293,10 @@ export declare function terminalCauseOf(final: AssistantMessage | undefined, sta
252
293
  * 9. `abortedLive` | stopReason aborted → `"limits.max_walltime_exceeded"` | `"limits.max_turns_exceeded"` | undefined
253
294
  * (audit A-1: hoisted ABOVE no-final — the loop exits cleanly on abort, so `final` may be a
254
295
  * normal message or absent; a plain user interrupt carries NO errorCode — switch on `status`),
255
- * EXCEPT when the aborted `final` is the bare halt's OWN cut artifact (`haltOwnsAbortedFinal`)
296
+ * EXCEPT when the aborted `final` is the bare halt's OWN cut artifact (`haltOwnsAbortedFinal`),
297
+ * and EXCEPT when the answer had already settled (`answerSettled`: a clean `"stop"` final, not
298
+ * partial) — a stop landing on the run's tail cuts no work, so the run falls through to 12
299
+ * (`completed`, the answer as `result`, no `limits.*` code for the axis; three axes, one law)
256
300
  * 9.7 `userHalted` + (no `final` | the halt's own aborted `final`) → status `"completed"` (#504: a
257
301
  * bare user halt — the verb working, not a defect; below the aborts on purpose, a real
258
302
  * abort/limit that also fired owns the terminal and the halt rides as the pass-through seat)