@sema-agent/core 7.12.0 → 7.14.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 (122) hide show
  1. package/CHANGELOG.md +124 -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 +83 -9
  11. package/dist/core/ask-origin.js +31 -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 +9 -16
  16. package/dist/core/checkpoint-store.js +7 -4
  17. package/dist/core/engine-notice.d.ts +21 -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.d.ts +0 -40
  22. package/dist/core/gate-lanes.js +24 -29
  23. package/dist/core/gate-outcome.d.ts +42 -3
  24. package/dist/core/gate-outcome.js +22 -0
  25. package/dist/core/governance-codes.d.ts +1 -1
  26. package/dist/core/governance-codes.js +3 -1
  27. package/dist/core/hooks.d.ts +6 -17
  28. package/dist/core/hooks.js +7 -5
  29. package/dist/core/mcp.d.ts +3 -2
  30. package/dist/core/mcp.js +5 -11
  31. package/dist/core/memory-engine/engine.d.ts +2 -2
  32. package/dist/core/runner/advertised-writable-dirs.d.ts +74 -0
  33. package/dist/core/runner/advertised-writable-dirs.js +48 -0
  34. package/dist/core/runner/assemble-result.d.ts +50 -1
  35. package/dist/core/runner/assemble-result.js +4 -1
  36. package/dist/core/runner/compaction-seams.d.ts +42 -0
  37. package/dist/core/runner/compaction-seams.js +80 -0
  38. package/dist/core/runner/contracts.d.ts +44 -6
  39. package/dist/core/runner/denial-limit-arms.d.ts +7 -9
  40. package/dist/core/runner/denial-limit-arms.js +11 -12
  41. package/dist/core/runner/gate-exit.d.ts +15 -3
  42. package/dist/core/runner/gate-exit.js +6 -4
  43. package/dist/core/runner/permission-rule-lanes.d.ts +2 -1
  44. package/dist/core/runner/permission-rule-lanes.js +2 -1
  45. package/dist/core/runner/prepare-caps-and-workflow.d.ts +34 -4
  46. package/dist/core/runner/prepare-caps-and-workflow.js +47 -12
  47. package/dist/core/runner/prepare-gate-stations.d.ts +1 -1
  48. package/dist/core/runner/prepare-hands-readface.d.ts +10 -4
  49. package/dist/core/runner/prepare-hands-readface.js +5 -14
  50. package/dist/core/runner/prepare-inherited-gate.d.ts +5 -3
  51. package/dist/core/runner/prepare-inherited-gate.js +1 -1
  52. package/dist/core/runner/prepare-memory.d.ts +31 -34
  53. package/dist/core/runner/prepare-memory.js +73 -51
  54. package/dist/core/runner/prepare-policy-chain.js +8 -4
  55. package/dist/core/runner/prepare-prompt-inputs.d.ts +4 -0
  56. package/dist/core/runner/prepare-prompt-inputs.js +2 -2
  57. package/dist/core/runner/prepare-protocol-tools.js +5 -0
  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 +11 -16
  61. package/dist/core/runner/prepare-task.d.ts +9 -1
  62. package/dist/core/runner/prepare-task.js +63 -49
  63. package/dist/core/runner/prepare-wiring-manifest.d.ts +2 -2
  64. package/dist/core/runner/prepare-wiring-manifest.js +1 -1
  65. package/dist/core/runner/resume-claim.d.ts +2 -2
  66. package/dist/core/runner/resume-preflight.d.ts +2 -2
  67. package/dist/core/runner/run-attachment-seats.d.ts +2 -2
  68. package/dist/core/runner/run-compaction-machinery.js +2 -0
  69. package/dist/core/runner/run-git-lane.d.ts +1 -1
  70. package/dist/core/runner/run-harness-handlers.js +5 -1
  71. package/dist/core/runner/run-identity-wiring.d.ts +7 -0
  72. package/dist/core/runner/run-identity-wiring.js +3 -1
  73. package/dist/core/runner/run-leg.d.ts +7 -5
  74. package/dist/core/runner/run-leg.js +256 -5
  75. package/dist/core/runner/run-notification-lane.d.ts +4 -3
  76. package/dist/core/runner/run-recovery-lanes.d.ts +4 -17
  77. package/dist/core/runner/run-recovery-lanes.js +5 -4
  78. package/dist/core/runner/run-settle-and-teardown.d.ts +14 -10
  79. package/dist/core/runner/run-settle-and-teardown.js +112 -4
  80. package/dist/core/runner/run-stop-and-final-verify.d.ts +2 -2
  81. package/dist/core/runner/run-terminal-adoption.d.ts +16 -16
  82. package/dist/core/runner/run-terminal-adoption.js +87 -7
  83. package/dist/core/runner/run-turn-boundary.js +5 -4
  84. package/dist/core/runner/runtask.d.ts +10 -90
  85. package/dist/core/runner/runtask.js +77 -596
  86. package/dist/core/runner/stream-lifecycle-verbs.js +10 -1
  87. package/dist/core/runner/stream-settle-backstop.js +1 -1
  88. package/dist/core/runtime-caps.d.ts +21 -0
  89. package/dist/core/runtime-caps.js +5 -1
  90. package/dist/core/task-event.d.ts +11 -2
  91. package/dist/core/task-registry-shared.js +8 -6
  92. package/dist/core/task-result.d.ts +15 -0
  93. package/dist/core/task-stream.d.ts +10 -1
  94. package/dist/core/terminal-cause.d.ts +6 -2
  95. package/dist/core/tool-face.d.ts +8 -0
  96. package/dist/core/tool-face.js +1 -0
  97. package/dist/core/tool-policy.d.ts +39 -31
  98. package/dist/core/tool-policy.js +29 -5
  99. package/dist/core/tool-registry.d.ts +11 -3
  100. package/dist/core/tool-registry.js +7 -1
  101. package/dist/core/tool-roster.d.ts +26 -0
  102. package/dist/core/tool-roster.js +40 -7
  103. package/dist/core/tool-spec.d.ts +13 -12
  104. package/dist/core/types.d.ts +2 -1
  105. package/dist/core/types.js +1 -0
  106. package/dist/core/wiring-manifest.d.ts +16 -9
  107. package/dist/core/wiring-manifest.js +8 -3
  108. package/dist/index.d.ts +8 -7
  109. package/dist/index.js +7 -6
  110. package/dist/orchestration/run-workflow-tool.d.ts +8 -8
  111. package/dist/orchestration/run-workflow-tool.js +1 -1
  112. package/dist/orchestration/workflow-primitives.d.ts +4 -3
  113. package/dist/orchestration/workflow-primitives.js +3 -3
  114. package/dist/orchestration/workflow-types.d.ts +14 -0
  115. package/dist/orchestration/workflow.d.ts +41 -4
  116. package/dist/orchestration/workflow.js +23 -5
  117. package/dist/server/http.js +2 -1
  118. package/dist/tools/fs/fs-shared.js +9 -3
  119. package/dist/tools/fs/read-deny.d.ts +24 -8
  120. package/dist/tools/fs/read-deny.js +20 -1
  121. package/package.json +2 -1
  122. package/test/export-surface.snapshot.json +35 -1
@@ -0,0 +1,48 @@
1
+ export class AdvertisedWritableDirs {
2
+ env;
3
+ onSkipped;
4
+ admitted = [];
5
+ verdicts = new Map();
6
+ constructor(env, onSkipped) {
7
+ this.env = env;
8
+ this.onSkipped = onSkipped;
9
+ }
10
+ get roots() {
11
+ return this.admitted;
12
+ }
13
+ verdictOf(advertiser) {
14
+ return this.verdicts.get(advertiser) ?? "not-asked";
15
+ }
16
+ async admit(dir) {
17
+ const ok = await this.judge(dir);
18
+ this.verdicts.set(dir.advertiser, ok ? "admitted" : "skipped");
19
+ return ok;
20
+ }
21
+ async judge(dir) {
22
+ if (!dir.path || !dir.path.trim()) {
23
+ this.onSkipped(dir, "empty path");
24
+ return false;
25
+ }
26
+ let c = await this.env.canonicalPath(dir.path);
27
+ if (!c.ok && c.error.code === "not_found" && dir.materializeMarker !== undefined) {
28
+ const marker = await this.env.joinPath([dir.path, dir.materializeMarker]);
29
+ const exclusiveCreate = this.env.writeFileExclusive?.bind(this.env);
30
+ if (marker.ok && exclusiveCreate) {
31
+ const w = await exclusiveCreate(marker.value, "");
32
+ if (w.ok || w.error.code === "already_exists")
33
+ c = await this.env.canonicalPath(dir.path);
34
+ }
35
+ }
36
+ if (!c.ok) {
37
+ this.onSkipped(dir, `${c.error.code}: ${c.error.message}`);
38
+ return false;
39
+ }
40
+ if (dir.expectCanonical !== undefined && c.value !== dir.expectCanonical) {
41
+ this.onSkipped(dir, `the execution environment resolves it to ${c.value} while the advertiser's gates key on ${dir.expectCanonical} — a fence hole those gates cannot see is not opened`);
42
+ return false;
43
+ }
44
+ if (!this.admitted.includes(c.value))
45
+ this.admitted.push(c.value);
46
+ return true;
47
+ }
48
+ }
@@ -18,6 +18,11 @@ export interface Stats {
18
18
  * it fails the type check instead of silently reporting a total short by its own leg. */
19
19
  totalInputTokens: number;
20
20
  costMicroUsd: number;
21
+ /** #692 C-b — present (`true`) when at least one model round of this leg reported NO usage frame (the brain's
22
+ * own `usageMissing` mark on the assistant message: an aborted / errored / usage-less provider reply). The
23
+ * numeric fields stay required and stay what was COUNTED — with this key present they are a LOWER BOUND,
24
+ * not a measurement ("0 is unknown, not free"). Absent ⇔ every round reported usage. */
25
+ usageMissing?: true;
21
26
  nested?: NestedUsage;
22
27
  /** Post-task memory-consolidation usage (design/41); set after the result is assembled. Kept out of
23
28
  * the budget gate (`overBudget` reads `costMicroUsd`/`tokens`, never this). */
@@ -178,6 +183,10 @@ export interface ResultFlags {
178
183
  * abort (no synthetic stopReason:"aborted" assistant is appended), `final` is the last REAL assistant
179
184
  * message — this flag is what keeps an interrupted run from being mistaken for a completed one. */
180
185
  abortedLive?: boolean;
186
+ /** The leg's ONE evaluation of {@link answerSettled} (a clean `"stop"` final with no queued continuation
187
+ * lost): a live abort that found this true landed on the run's tail, not on work — slot 9 stands down
188
+ * and the run takes the completed floor. Absent/false = the abort's ordinary reading. */
189
+ answerSettled?: boolean;
181
190
  budgetHit?: "exceeded" | "precall";
182
191
  /** design/164 — which budget ceiling the hit belongs to. Set together with {@link budgetHit} at every
183
192
  * site that records one; it picks the terminal code (`limits.max_tokens_exceeded` vs
@@ -225,6 +234,43 @@ export declare function errorCodeOf(err: unknown): string | undefined;
225
234
  * in-presence rule the usage-window wait hint states for itself), and it is not part of the cause's
226
235
  * public projection.
227
236
  */
237
+ /**
238
+ * Has the run's ANSWER settled — is `final` the model's own clean end of its work? A run whose last turn
239
+ * ended `stopReason:"stop"` has said everything it was going to say; whatever ran after that turn (the
240
+ * final boundary's compaction, a stop hook, the teardown) is the run's tail, not its work. A hard stop
241
+ * that lands in that tail — `TaskStream.interrupt()`, the task's `signal`, the walltime or turns limit —
242
+ * cuts nothing the person or the caller wanted, so the run is NOT cancellable there: it ends `completed`
243
+ * with the answer as `result` (slot 9 yields to the completed floor), no interruption marker is written
244
+ * for it, and the end-of-task hook seats keep their full deadline. One predicate, read by every site that
245
+ * used to decide the question for itself; there is no second spelling.
246
+ *
247
+ * What does NOT count as settled — each is "the model was still working" and stays cancellable:
248
+ * - `stopReason:"length"` — the output was truncated; the auto-continue chain owns what follows;
249
+ * - `stopReason:"toolUse"` — a batch was in flight (the orphan reconcile owns the abort);
250
+ * - `stopReason:"aborted"` / `"error"` — the turn itself was cut or failed;
251
+ * - a `partialFinalized` `"stop"` — a mid-stream loss promoted to a final; the loop's bounded
252
+ * continue nudge owns it, so the stop the caller sees is a salvage, not the model's own end;
253
+ * - no `final` at all;
254
+ * - `continuationCut` — the run was cut with a continuation still owed: a steer / follow-up the person
255
+ * had already queued was still pending (queued, or drained at the final boundary and never served —
256
+ * the harness's agent_end account, which also mints `task.user_steer_undrained` /
257
+ * `task.user_followup_undrained`), or the loop itself had decided to run another turn (a drained
258
+ * frame, a truncated-output / thinking-only / degenerate-output nudge) and the abort caught it before
259
+ * the dispatch (`Prepared.workCutRef.continuation`, from the loop's own trace steps) — in both, a
260
+ * stop landing between the decision and the next stream returns the PREVIOUS clean answer as
261
+ * `final` (the loop exits at its pre-dispatch guard), so `final` alone cannot tell, and a
262
+ * thinking-only "stop" the loop was about to nudge into a visible answer is an empty result, not
263
+ * an answer; or the abort was already in force when the last turn ENDED
264
+ * (`Prepared.workCutRef.abortedBeforeTurnEnd`) — a brain that ignored its signal and delivered a
265
+ * full "stop" through the abort still answered a run somebody had stopped mid-work.
266
+ * Three axes, one law: the `abortedLive` flag alone decides that a stop landed; this decides whether it
267
+ * landed on work or on the tail. Evaluated ONCE per leg (the terminal-adoption lane, where both facts
268
+ * are in hand) and handed on as a value — {@link ResultFlags.answerSettled} — so the arbitration, the
269
+ * marker gate, the hook-seat grace, the teardown attribution and the notice read the same verdict.
270
+ * (A limit that is NOT a stop — a budget ceiling crossed by the last turn's own spend — is arbitrated
271
+ * above slot 9 and is unaffected.)
272
+ */
273
+ export declare function answerSettled(final: AssistantMessage | undefined, continuationCut?: boolean): boolean;
228
274
  export declare function terminalCauseOf(final: AssistantMessage | undefined, stats: Pick<Stats, "humanReview">, flags: ResultFlags): {
229
275
  terminal: TerminalCause;
230
276
  apiFailure: TaskResult["apiFailure"];
@@ -252,7 +298,10 @@ export declare function terminalCauseOf(final: AssistantMessage | undefined, sta
252
298
  * 9. `abortedLive` | stopReason aborted → `"limits.max_walltime_exceeded"` | `"limits.max_turns_exceeded"` | undefined
253
299
  * (audit A-1: hoisted ABOVE no-final — the loop exits cleanly on abort, so `final` may be a
254
300
  * 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`)
301
+ * EXCEPT when the aborted `final` is the bare halt's OWN cut artifact (`haltOwnsAbortedFinal`),
302
+ * and EXCEPT when the answer had already settled (`answerSettled`: a clean `"stop"` final, not
303
+ * partial) — a stop landing on the run's tail cuts no work, so the run falls through to 12
304
+ * (`completed`, the answer as `result`, no `limits.*` code for the axis; three axes, one law)
256
305
  * 9.7 `userHalted` + (no `final` | the halt's own aborted `final`) → status `"completed"` (#504: a
257
306
  * bare user halt — the verb working, not a defect; below the aborts on purpose, a real
258
307
  * abort/limit that also fired owns the terminal and the halt rides as the pass-through seat)
@@ -41,6 +41,9 @@ function assistantText(msg) {
41
41
  .join("");
42
42
  return stripThinkTags(text);
43
43
  }
44
+ export function answerSettled(final, continuationCut = false) {
45
+ return final?.stopReason === "stop" && final.partialFinalized !== true && !continuationCut;
46
+ }
44
47
  export function terminalCauseOf(final, stats, flags) {
45
48
  let terminal;
46
49
  let apiFailure;
@@ -96,7 +99,7 @@ export function terminalCauseOf(final, stats, flags) {
96
99
  else if (flags.paused) {
97
100
  terminal = flags.paused;
98
101
  }
99
- else if ((flags.abortedLive || final?.stopReason === "aborted") && !haltOwnsAbortedFinal) {
102
+ else if ((flags.abortedLive || final?.stopReason === "aborted") && !haltOwnsAbortedFinal && flags.answerSettled !== true) {
100
103
  const code = flags.abortedForTimeout ? "limits.max_walltime_exceeded" : flags.abortedForTurns ? "limits.max_turns_exceeded" : undefined;
101
104
  terminal = { kind: "failed", ...(code !== undefined ? { code } : {}), message: final?.errorMessage ?? (flags.abortedForTurns ? "max turns exceeded" : "run aborted") };
102
105
  }
@@ -0,0 +1,42 @@
1
+ import type { MaybeCompactOptions } from "../auto-compaction.js";
2
+ import type { TaskSpec } from "../types.js";
3
+ import type { Prepared, RunnerDepsSeat } from "./contracts.js";
4
+ /**
5
+ * design/84 Seam C — the cost-optimization compaction options threaded into BOTH `maybeCompact` call
6
+ * sites (within-task turn boundary + `finish()`). All three fields come from `RunnerDeps` (a trusted
7
+ * FUNCTION seam — never `TaskSpec`, which is serializable/durable-resumable/untrusted-caller). The
8
+ * Runner OWNS the consecutive-reuse counter (`prepared.compactionReuseRef`) so the
9
+ * `maxConsecutiveProviderReuse` drift guard spans the whole task across both sites: it FEEDS the current
10
+ * count in as `consecutiveProviderReuse`, and {@link recordCompactionReuse} updates it from the result.
11
+ * Returns `undefined` when no provider is wired (so the call site spreads nothing → byte-identical to
12
+ * the pre-design/84 behavior).
13
+ */
14
+ export declare function seamCCompactionOptions(runner: RunnerDepsSeat, prepared: Prepared): Pick<MaybeCompactOptions, "summaryProvider" | "onCompaction" | "maxConsecutiveProviderReuse" | "consecutiveProviderReuse"> | undefined;
15
+ /** design/134 §3.2 — resolve the pre/postCompact lifecycle callbacks (whole-slot `spec.hooks ??
16
+ * deps.hooks`, same resolution as the stop hook) and wrap each in a SWALLOW+TRACE shell before
17
+ * threading them into maybeCompact. The wrapper owns the observability half of the R3 MED contract
18
+ * (maybeCompact swallows defensively too, but has no sink): a throwing callback is reported via
19
+ * `onError(phase:"hook")` and treated as absent; a `block` returned under a "forced" trigger is
20
+ * reported as ignored (maybeCompact enforces the ignore — blocking a compaction the provider/trim
21
+ * layer already demanded would kill the run). */
22
+ export declare function compactionHookOptions(runner: RunnerDepsSeat, spec: TaskSpec, sessionId: string, trigger: "auto" | "manual" | "forced",
23
+ /** #281 件A — the leg's identity envelope, spread onto both compaction contexts by the wrappers
24
+ * below (the compaction module builds its contexts below the runner and cannot know the leg;
25
+ * the wrapper is the runner's seam). Every runner call site supplies `prepared.hookIdentity`;
26
+ * optional only so a context built without a leg stays honestly identity-less. */
27
+ identity?: import("../hooks.js").HookInvocationIdentity,
28
+ /** The leg's already-validated seat bound (`Prepared.hookTimeoutMs`) and its abort signal. Optional
29
+ * for the same reason `identity` is — a wrapper built without a prepared leg still works, on the
30
+ * default bound and with no signal. The signal is what keeps a hung compaction hook from holding a
31
+ * CANCELLED run's turn boundary for the whole deadline (the seat's post-abort grace). */
32
+ seatBound?: {
33
+ timeoutMs: number;
34
+ signal?: AbortSignal;
35
+ }): Pick<MaybeCompactOptions, "trigger" | "preCompact" | "postCompact">;
36
+ /** design/84 Seam C — fold a finished compaction's `reused` flag into the run-scoped consecutive-reuse
37
+ * counter: a reused (provider) summary increments it, a real (LLM) summary resets it to 0. A no-op
38
+ * compaction (`compacted:false`) leaves the counter untouched. No-op when no provider is wired. */
39
+ export declare function recordCompactionReuse(runner: RunnerDepsSeat, prepared: Prepared, comp: {
40
+ compacted: boolean;
41
+ reused?: boolean;
42
+ }): void;
@@ -0,0 +1,80 @@
1
+ import { hookSeatExpiredError, resolveHookTimeoutMs, runHookSeat } from "../hooks.js";
2
+ export function seamCCompactionOptions(runner, prepared) {
3
+ if (!runner.deps.summaryProvider)
4
+ return undefined;
5
+ return {
6
+ summaryProvider: runner.deps.summaryProvider,
7
+ onCompaction: runner.deps.onCompaction,
8
+ maxConsecutiveProviderReuse: runner.deps.maxConsecutiveProviderReuse,
9
+ consecutiveProviderReuse: prepared.compactionReuseRef.consecutive,
10
+ };
11
+ }
12
+ export function compactionHookOptions(runner, spec, sessionId, trigger, identity, seatBound) {
13
+ const hooks = spec.hooks ?? runner.deps.hooks;
14
+ const pre = hooks?.preCompact;
15
+ const post = hooks?.postCompact;
16
+ const seatMs = seatBound?.timeoutMs ?? resolveHookTimeoutMs(hooks?.timeoutMs);
17
+ const seatSignal = seatBound?.signal;
18
+ const withIdentity = (ctx) => identity !== undefined ? { ...ctx, identity } : ctx;
19
+ return {
20
+ trigger,
21
+ ...(pre
22
+ ? {
23
+ preCompact: async (rawCtx) => {
24
+ const ctx = withIdentity(rawCtx);
25
+ const report = (err) => {
26
+ try {
27
+ runner.deps.onError?.(err, { phase: "hook", sessionId });
28
+ }
29
+ catch {
30
+ }
31
+ };
32
+ try {
33
+ const seat = await runHookSeat("preCompact", { timeoutMs: seatMs, ...(seatSignal !== undefined ? { signal: seatSignal } : {}) }, (sig) => pre.call(hooks, { ...ctx, signal: sig }));
34
+ if (seat.expired) {
35
+ report(hookSeatExpiredError("preCompact", seatMs, seat.cause, `the "${ctx.trigger}" compaction PROCEEDED unblocked and with no additional instructions`));
36
+ return undefined;
37
+ }
38
+ const r = seat.value;
39
+ if (r?.block && ctx.trigger === "forced") {
40
+ report(new Error(`a preCompact callback blocked a "forced" compaction — ignored (PTL/trim-pressure compaction is not optional): ${r.block}`));
41
+ }
42
+ return r;
43
+ }
44
+ catch (err) {
45
+ report(err instanceof Error ? err : new Error(String(err)));
46
+ return undefined;
47
+ }
48
+ },
49
+ }
50
+ : {}),
51
+ ...(post
52
+ ? {
53
+ postCompact: async (rawCtx) => {
54
+ try {
55
+ const seat = await runHookSeat("postCompact", { timeoutMs: seatMs, ...(seatSignal !== undefined ? { signal: seatSignal } : {}) }, (sig) => post.call(hooks, { ...withIdentity(rawCtx), signal: sig }));
56
+ if (seat.expired) {
57
+ try {
58
+ runner.deps.onError?.(hookSeatExpiredError("postCompact", seatMs, seat.cause, "the observation was abandoned; the compaction that already landed is unchanged"), { phase: "hook", sessionId });
59
+ }
60
+ catch {
61
+ }
62
+ }
63
+ }
64
+ catch (err) {
65
+ try {
66
+ runner.deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "hook", sessionId });
67
+ }
68
+ catch {
69
+ }
70
+ }
71
+ },
72
+ }
73
+ : {}),
74
+ };
75
+ }
76
+ export function recordCompactionReuse(runner, prepared, comp) {
77
+ if (!runner.deps.summaryProvider || !comp.compacted)
78
+ return;
79
+ prepared.compactionReuseRef.consecutive = comp.reused ? prepared.compactionReuseRef.consecutive + 1 : 0;
80
+ }
@@ -652,6 +652,24 @@ export interface Prepared {
652
652
  callIssuedAtRef: {
653
653
  current?: number;
654
654
  };
655
+ /** #677 — was the run's WORK cut by a stop? Two observation points, one record, read by the answer-settled
656
+ * verdict (`answerSettled`) beside the harness's own undrained-input account:
657
+ * - `continuation` — the loop decided to CONTINUE past its last assistant turn and the abort caught it
658
+ * before that continuation dispatched. Written by the loop-trace sink from the loop's own steps: a
659
+ * `continue` step (a drained steer / follow-up, a truncated-output or thinking-only or degenerate-output
660
+ * nudge, a next tool turn) sets it; the terminal step clears it unless the loop exited at its
661
+ * pre-dispatch abort guard (`aborted_before_stream`) — the one exit where `final` is still the PREVIOUS
662
+ * turn's message. A clean "stop" the loop itself had decided was not the end is not a settled answer.
663
+ * - `abortedBeforeTurnEnd` — the run's abort was already in force when a turn ENDED (written by the
664
+ * `turn_end` handler before the turns cap fires its own abort). The ruling's line is the last
665
+ * `turn_end`: a stop that landed before it landed on work — even when the brain ignored the signal
666
+ * and delivered a full "stop" answer through it (the loop then ends at its own decision, so the
667
+ * continuation bit never sets); a stop that landed after it landed on the tail.
668
+ * Always present; both false on a run nothing stopped. */
669
+ workCutRef: {
670
+ continuation: boolean;
671
+ abortedBeforeTurnEnd: boolean;
672
+ };
655
673
  /** RB-458 — records the FIRST brain call this run's outer guardrail gave up on (see
656
674
  * {@link import("../../brain/timeout.js").withBrainCallGuardrail}). Always present; `timedOut`
657
675
  * stays absent unless the guardrail fired. The run loop reads it AFTER the loop settles and gives
@@ -2332,13 +2350,11 @@ export interface TurnBoundaryDeps {
2332
2350
  drainManualCompact: (outcome: CompactOutcome) => void;
2333
2351
  runnerHooks: {
2334
2352
  onError: RunnerDeps["onError"];
2335
- seamCCompactionOptions: (prepared: Prepared) => Pick<MaybeCompactOptions, "summaryProvider" | "onCompaction" | "maxConsecutiveProviderReuse" | "consecutiveProviderReuse"> | undefined;
2336
- compactionHookOptions: (spec: TaskSpec, sessionId: string, trigger: "auto" | "manual" | "forced") => Pick<MaybeCompactOptions, "trigger" | "preCompact" | "postCompact">;
2337
- recordCompactionReuse: (prepared: Prepared, comp: {
2338
- compacted: boolean;
2339
- reused?: boolean;
2340
- }) => void;
2341
2353
  };
2354
+ /** The Runner's deployment deps, read LIVE ({@link RunnerDepsSeat}): the compaction seams (compaction-seams.ts) the
2355
+ * boundary's compaction pass reaches down for read the summary provider, the hooks slot and the error sink through it
2356
+ * on every pass — the three Runner methods that used to sit on `runnerHooks` as delegates (design/393 S7). */
2357
+ runner: RunnerDepsSeat;
2342
2358
  }
2343
2359
  /** design/157 B15 尾件 — R5(harness 事件处理器族)的依赖包。全部为 runLocked 内声明顺序早于
2344
2360
  * 工厂调用点的 const 稳定引用 + 三个 runLocked 形参;this 面(deps.onError)经 runnerHooks 打包
@@ -2423,6 +2439,28 @@ export interface RunnerSelfSeat {
2423
2439
  /** Streaming form of {@link RunnerSelfSeat.resume}: the pre-CAS guards and the CAS run first, then the live stream is returned. */
2424
2440
  resumeStream(token: CheckpointToken, outcome: ResumeOutcome, taskConfig: ResumeTaskConfig, internals?: RunInternals): Promise<TaskStream>;
2425
2441
  }
2442
+ /**
2443
+ * design/393 S7 (#675) — the orchestrator's entry (`prepareTask`, prepare-task.ts) as ONE contract: the notification lane's
2444
+ * `prepareTask` seat names it, and the orchestrator pins its own declaration against it (`PrepareTaskIsTheContract`), so a
2445
+ * signature change reds at the declaration — not at the driver's hand-in, and not as a seat spelled a second time. It lives
2446
+ * on the floor because a lane may not name the orchestrator (docs/LAYERING.json: layer 4 sits above the lanes). Positional,
2447
+ * exactly as the entry is declared — the seat carries the orchestrator's function itself, not a lane Input.
2448
+ */
2449
+ export type PrepareTaskFn = (spec: TaskSpec, deps: RunnerDeps, sessions: SessionStore, resume?: PrepareResume, internals?: RunInternals, runnerSelf?: RunnerSelfSeat, runIdSink?: {
2450
+ runId?: string;
2451
+ }) => Promise<Prepared>;
2452
+ /**
2453
+ * design/393 S7 (#670) — the Result of an INSTALLING phase: a lane whose every product is installed on a seat it borrowed
2454
+ * (the harness's stop gate and recovery chain, the run state's counter groups, the stream's result setter, the claimed
2455
+ * row's registries) hands nothing back, and says so by extending this marker. The marker is machine-read by gate:phase-api
2456
+ * (design/238 R-1's third clause): a Result with no members MUST extend it, a Result with members MAY NOT, and no Result
2457
+ * inherits anything else — so an empty Result is a stated fact about the phase, never an interface someone forgot to
2458
+ * fill, and a phase that starts handing a product back must drop the declaration in the same edit. Six lanes carry it:
2459
+ * the attachment seats, the stop gate, the recovery lanes, the settle and teardown (run-), the preflight and the claim
2460
+ * (resume-).
2461
+ */
2462
+ export interface InstallingPhaseResult {
2463
+ }
2426
2464
  /** The live-task handle `runLocked` publishes once the harness exists (design/47): the harness + abort
2427
2465
  * controller, the loop-liveness latch (`ended` flips when the single `harness.prompt` settles; `userInterrupted`
2428
2466
  * / `userHalted` are the interrupt and halt verbs' attribution seats), the run's reminder mark, its session and
@@ -1,8 +1,8 @@
1
- import { type AutoModeDecider, type AutoModeDenialLimitOptions, type AutoModeDenialTracker, type AutoModeUnavailableCause, type DenialLimitFallback, type DenialLimitFallbackFace, type UnarmedDenialLimitFallback } from "../auto-mode.js";
1
+ import { type AutoModeDecider, type AutoModeDenialLimitOptions, type AutoModeDenialTracker, type DenialLimitFallback, type DenialLimitFallbackFace, type UnarmedDenialLimitFallback } from "../auto-mode.js";
2
2
  import { type AutoModeArmingRecipe } from "../auto-mode-arming.js";
3
- import type { PermissionResult, ResolvedAsk, ToolCallRequest } from "../tool-policy.js";
3
+ import { type PermissionResult, type ResolvedAsk, type ToolCallRequest } from "../tool-policy.js";
4
4
  import { type EngineNotice } from "../types.js";
5
- import { type AskOrigin } from "../ask-origin.js";
5
+ import { type AskOrigin, type RuleStoreUnreadable } from "../ask-origin.js";
6
6
  import type { Prepared } from "./contracts.js";
7
7
  /**
8
8
  * #548 — the classifier DENIAL-LIMIT arms of the tool gate's inherited (delegation) lane, extracted
@@ -83,14 +83,12 @@ export type InheritedAsk = Extract<PermissionResult, {
83
83
  }>;
84
84
  /** The members of a surviving ask that ride onto the approval request with a COMPUTED value — what the
85
85
  * four request mint stations (the gate's own and the three inherited-lane ones) spread after the seats
86
- * they spell themselves: the ask's origin word, its denial-limit fallback and a policy-declared
87
- * classifier-unavailable fact (#616; the engine's own fact rides the DENY since #661). */
86
+ * they spell themselves: the ask's origin word and its denial-limit fallback. */
88
87
  export interface AskRequestCarry {
89
88
  origin?: AskOrigin;
89
+ /** #688 C3: rides only from the gate's OWN station (the inherited stations' facts never read a rule store). */
90
+ ruleStoreUnreadable?: RuleStoreUnreadable;
90
91
  denialLimitFallback?: DenialLimitFallback;
91
- classifierUnavailable?: {
92
- readonly cause: AutoModeUnavailableCause;
93
- };
94
92
  }
95
93
  /** The GATE's own mint station: both members are read off the surviving decision, which is the gate's
96
94
  * OWN snapshot (its stamp point spreads the policy's object and stamps the word onto the copy), so the
@@ -124,7 +122,7 @@ export type InheritedClassifierJudgment<A extends InheritedAsk> =
124
122
  * stateful accessor cannot answer the exclusion with one word and the card with another); on the
125
123
  * bound branch it is derived from the re-spoken ask this station minted (its own object). */
126
124
  /** `ask` is the incoming ask unchanged (the caller's own object); an UNAVAILABLE round never reaches this arm —
127
- * it is a `deny` carrying `classifierUnavailable: { cause }` (#661). */
125
+ * it is a `deny` whose attested cause reads `unavailable` (#661). */
128
126
  | {
129
127
  kind: "resolve";
130
128
  ask: A;
@@ -1,8 +1,9 @@
1
1
  import { CLASSIFIER_PARSE_FAILURE_DENY_MESSAGE, classifierUnavailableDenyMessage, createAutoModeDenialTracker, denialLimitFallbackMessage, denialLimitSentence, unarmedWindow } from "../auto-mode.js";
2
2
  import { sanitizeAutoModeArmingRecipe, tightenDenialLimit } from "../auto-mode-arming.js";
3
+ import { attestClassifierDenyCause } from "../tool-policy.js";
3
4
  import { deliverEngineNotice } from "../types.js";
4
5
  import { inlineUntrusted } from "../untrusted-text.js";
5
- import { askOriginOf, classifierMayAnswer } from "../ask-origin.js";
6
+ import { askOriginOf, classifierMayAnswer, probeMandatedAsk } from "../ask-origin.js";
6
7
  export function attachRebuiltDenialTrackers(entries, denialLimit) {
7
8
  if (entries === undefined)
8
9
  return undefined;
@@ -62,22 +63,22 @@ function fallbackCarry(fallback, liveApprover, tracker) {
62
63
  export function gateAskCarry(decision, liveApprover, tracker) {
63
64
  return {
64
65
  ...(decision.origin !== undefined ? { origin: decision.origin } : {}),
66
+ ...(decision.ruleStoreUnreadable !== undefined ? { ruleStoreUnreadable: decision.ruleStoreUnreadable } : {}),
65
67
  ...fallbackCarry(decision.denialLimitFallback, liveApprover, tracker),
66
- ...(decision.classifierUnavailable !== undefined ? { classifierUnavailable: { cause: decision.classifierUnavailable.cause } } : {}),
67
68
  };
68
69
  }
69
70
  export function inheritedAskCarry(judged, liveApprover, tracker) {
70
71
  return {
71
72
  origin: judged.origin,
72
73
  ...fallbackCarry(judged.fallback, liveApprover, tracker),
73
- ...(judged.ask.classifierUnavailable !== undefined ? { classifierUnavailable: { cause: judged.ask.classifierUnavailable.cause } } : {}),
74
74
  };
75
75
  }
76
76
  export async function judgeInheritedClassifier(opts) {
77
77
  const { autoMode, ask, req } = opts;
78
78
  const origin = askOriginOf(ask, INHERITED_STATION_FACTS);
79
- if (autoMode === undefined || !classifierMayAnswer(origin))
79
+ if (autoMode === undefined || !classifierMayAnswer(origin) || probeMandatedAsk(ask)) {
80
80
  return { kind: "resolve", ask, fallback: ask.denialLimitFallback, mintedHere: false, origin };
81
+ }
81
82
  const verdict = await autoMode.decider
82
83
  .decide({ req, ...(ask.message !== undefined ? { askMessage: ask.message } : {}) }, opts.signal)
83
84
  .catch(() => ({ kind: "unavailable", cause: "error" }));
@@ -88,21 +89,19 @@ export async function judgeInheritedClassifier(opts) {
88
89
  if (verdict.kind === "unavailable") {
89
90
  return {
90
91
  kind: "deny",
91
- result: { action: "deny", message: classifierUnavailableDenyMessage(req.toolName, verdict.cause), decisionReason: "classifier", classifierUnavailable: { cause: verdict.cause } },
92
+ result: attestClassifierDenyCause({ action: "deny", message: classifierUnavailableDenyMessage(req.toolName, verdict.cause), decisionReason: "classifier" }, "unavailable", req),
92
93
  };
93
94
  }
94
95
  const reason = verdict.kind === "block" ? (verdict.reason ? inlineUntrusted(verdict.reason) : "") : CLASSIFIER_PARSE_FAILURE_DENY_MESSAGE;
95
96
  const category = verdict.kind === "block" && verdict.category ? inlineUntrusted(verdict.category) : "";
96
97
  const tracked = autoMode.denialTracking?.recordBlock();
97
98
  if (tracked?.limitReached !== true) {
98
- return {
99
- kind: "deny",
100
- result: {
101
- action: "deny",
102
- message: verdict.kind === "block" ? `auto-mode classifier blocked ${opts.subject} at an inherited ancestor layer${reason ? `: ${reason}` : category ? `: [${category}]` : ""}` : `${reason} (${opts.subject}, at an inherited ancestor layer)`,
103
- decisionReason: "classifier",
104
- },
99
+ const denied = {
100
+ action: "deny",
101
+ message: verdict.kind === "block" ? `auto-mode classifier blocked ${opts.subject} at an inherited ancestor layer${reason ? `: ${reason}` : category ? `: [${category}]` : ""}` : `${reason} (${opts.subject}, at an inherited ancestor layer)`,
102
+ decisionReason: "classifier",
105
103
  };
104
+ return { kind: "deny", result: verdict.kind === "parse_error" ? attestClassifierDenyCause(denied, "parse_error", req) : denied };
106
105
  }
107
106
  const fallback = tracked.fallback;
108
107
  const minted = { ...ask, message: denialLimitFallbackMessage(fallback, reason || category || req.toolName), decisionReason: "classifier", requiresRealApproval: true, denialLimitFallback: fallback };
@@ -21,7 +21,7 @@
21
21
  * the reminder envelope and the text helpers. Its `import type` edges up to `hooks.ts` name the gate's public
22
22
  * contract (`ToolGateInput`, `ToolGateResult`, the observer payload), which stays declared there.
23
23
  */
24
- import { type DeniedBy, type GateOutcome, type Settlement } from "../gate-outcome.js";
24
+ import { type ClassifierDenyCause, type DeniedBy, type GateOutcome, type Settlement } from "../gate-outcome.js";
25
25
  import type { AskOrigin, AskOriginFacts } from "../ask-origin.js";
26
26
  import { type PermissionResult, type ResolvedAsk, type ToolCallRequest } from "../tool-policy.js";
27
27
  import type { SafeNotifier } from "../safe-notify.js";
@@ -32,10 +32,13 @@ export interface SettledAsk {
32
32
  settlement: Settlement;
33
33
  origin: AskOrigin;
34
34
  }
35
- /** The facts a gate pass hands the mint. `deniedBy` is present exactly when the pass refused; `settled`
36
- * exactly when the pass consumed an ask's settlement (a person's yes that was later vetoed included). */
35
+ /** The facts a gate pass hands the mint. `deniedBy` is present exactly when the pass refused; `cause` only
36
+ * beside it, when the refusing round was a classifier round that did not rule (the station's attestation,
37
+ * read once by the fold or the classifier step); `settled` exactly when the pass consumed an ask's
38
+ * settlement (a person's yes that was later vetoed included). */
37
39
  export interface GateExitFacts {
38
40
  deniedBy?: DeniedBy;
41
+ cause?: ClassifierDenyCause;
39
42
  settled?: SettledAsk;
40
43
  }
41
44
  /**
@@ -80,6 +83,10 @@ export interface SettlementLedger {
80
83
  * for an attested refusal, `policy` otherwise — a veto when a yes rides the settlement); undefined
81
84
  * for an allow or an ask. */
82
85
  fromDecision(decision: PermissionResult): DeniedBy | undefined;
86
+ /** Read a deny's attested classifier CAUSE for this call (an inherited station's `unavailable` /
87
+ * `parse_error` round travelling through an ancestor wrapper into this fold); undefined for a ruling
88
+ * block, a non-classifier deny, an allow or an ask. */
89
+ causeOf(decision: PermissionResult): ClassifierDenyCause | undefined;
83
90
  /** A surviving ask supersedes whatever an earlier decision settled. */
84
91
  supersede(): void;
85
92
  }
@@ -208,6 +215,11 @@ export interface GatePass {
208
215
  * that ended as a refusal). The exit mints the outcome from it beside the ledger's `settled`; a refusal
209
216
  * that reaches the exit without it is an engine defect and the mint throws. */
210
217
  deniedBy: DeniedBy | undefined;
218
+ /** borrowed-mutable — the FORM of the classifier round behind a deny (`unavailable` / `parse_error`), written
219
+ * beside `deniedBy` by the gate's own classifier step (its attestation, read once) or by the fold / the
220
+ * re-check when an inherited station's attested deny arrives through the chain; undefined for a ruling
221
+ * block and every non-classifier deny. The exit mints `GateDisposition.cause` from it. */
222
+ denyCause: ClassifierDenyCause | undefined;
211
223
  /** borrowed-mutable — the standing verdict. The no-policy default until the adjudication station writes the
212
224
  * policy's answer; then rewritten by every station that changes the verdict (fold, tightens, org, rule
213
225
  * lane, stamp, classifier, sandbox, content lane, resolution, edit chain). */
@@ -1,11 +1,11 @@
1
1
  import { screenGateOutcome, SETTLEMENT_IS_REFUSAL } from "../gate-outcome.js";
2
- import { decisionText, engineSettlementOf } from "../tool-policy.js";
2
+ import { classifierDenyCauseOf, decisionText, engineSettlementOf } from "../tool-policy.js";
3
3
  import { inlineUntrusted } from "../untrusted-text.js";
4
4
  import { formatHookFeedback } from "../reminder-mint.js";
5
5
  export function mintGateOutcome(facts) {
6
6
  const settlement = facts.settled !== undefined ? Object.freeze({ ...facts.settled.settlement, who: Object.freeze({ ...facts.settled.settlement.who }) }) : undefined;
7
7
  const outcome = Object.freeze({
8
- disposition: Object.freeze(facts.deniedBy !== undefined ? { kind: "denied", deniedBy: facts.deniedBy } : { kind: "allowed" }),
8
+ disposition: Object.freeze(facts.deniedBy !== undefined ? { kind: "denied", deniedBy: facts.deniedBy, ...(facts.cause !== undefined ? { cause: facts.cause } : {}) } : { kind: "allowed" }),
9
9
  ...(facts.settled !== undefined ? { settlement: settlement, origin: facts.settled.origin } : {}),
10
10
  });
11
11
  const defects = screenGateOutcome(outcome);
@@ -50,6 +50,9 @@ export function createSettlementLedger(call) {
50
50
  return undefined;
51
51
  return attested !== undefined && SETTLEMENT_IS_REFUSAL[attested.settlement.kind] ? "ask_resolution" : "policy";
52
52
  },
53
+ causeOf(decision) {
54
+ return decision.action === "deny" ? classifierDenyCauseOf(decision, call) : undefined;
55
+ },
53
56
  supersede() {
54
57
  settled = undefined;
55
58
  },
@@ -102,7 +105,7 @@ export async function exitGate(pass) {
102
105
  }
103
106
  if (pass.deniedBy === undefined)
104
107
  throw new Error(`the tool gate refused "${toolName}" without a refusing layer — every deny site attributes itself`);
105
- const gate = mintGateOutcome({ deniedBy: pass.deniedBy, ...(ledger.settled !== undefined ? { settled: ledger.settled } : {}) });
108
+ const gate = mintGateOutcome({ deniedBy: pass.deniedBy, ...(pass.denyCause !== undefined ? { cause: pass.denyCause } : {}), ...(ledger.settled !== undefined ? { settled: ledger.settled } : {}) });
106
109
  await notifyPermissionDenied({
107
110
  toolName,
108
111
  input: cloneObserverInput(pass.currentInput),
@@ -110,7 +113,6 @@ export async function exitGate(pass) {
110
113
  reason: denyReason,
111
114
  gate,
112
115
  ...(input.identity !== undefined ? { identity: input.identity } : {}),
113
- ...(pass.decision.action === "deny" && pass.decision.classifierUnavailable !== undefined ? { classifierUnavailable: { cause: pass.decision.classifierUnavailable.cause } } : {}),
114
116
  });
115
117
  return {
116
118
  block: true,
@@ -26,7 +26,8 @@
26
26
  * silence the ungated-write warning for deployments that wired no policy at all.
27
27
  */
28
28
  import type { AskRuleEvidence, ToolCallRequest } from "../tool-policy.js";
29
- import { persistedRuleMandateOf, type OrgGateVerdict, type PersistedRuleAnswer, type PersistedRuleHit, type PersistedRuleUnreadable } from "../hooks.js";
29
+ import { persistedRuleMandateOf } from "../ask-origin.js";
30
+ import { type OrgGateVerdict, type PersistedRuleAnswer, type PersistedRuleHit, type PersistedRuleUnreadable } from "../hooks.js";
30
31
  import type { PersistedRule, PersistedRuleVerdict, RuleOffer, SegmentCoverage } from "../permission-rule-model.js";
31
32
  import { type OrgRuleResolution } from "../permission-rule-org.js";
32
33
  import type { PermissionRuleStoreProvider } from "../permission-rule-provider.js";
@@ -1,4 +1,5 @@
1
- import { persistedRuleMandateOf } from "../hooks.js";
1
+ import { persistedRuleMandateOf } from "../ask-origin.js";
2
+ import {} from "../hooks.js";
2
3
  import { adjudicatePersistedPathRules, adjudicatePersistedRules, ruleToolGrammarOf, segmentCoverageOf, suggestRulesForCommand } from "../permission-rule-model.js";
3
4
  import { isAbsolutePathForm } from "../../tools/fs/safety.js";
4
5
  import { effectivePathTargetOf } from "../effective-path-target.js";