@sema-agent/core 7.5.0 → 7.5.2

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 (106) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/core/auto-mode.d.ts +9 -0
  3. package/dist/core/auto-mode.js +11 -0
  4. package/dist/core/checkpoint-store.js +5 -1
  5. package/dist/core/protocol-table.d.ts +5 -0
  6. package/dist/core/protocol-table.js +1 -0
  7. package/dist/core/runner/abort-race.d.ts +41 -0
  8. package/dist/core/runner/abort-race.js +38 -0
  9. package/dist/core/runner/checkpoint-scope.d.ts +44 -0
  10. package/dist/core/runner/checkpoint-scope.js +7 -0
  11. package/dist/core/runner/compaction-call-options.d.ts +1 -1
  12. package/dist/core/runner/content-ask-bindings.d.ts +27 -0
  13. package/dist/core/runner/content-ask-bindings.js +1 -0
  14. package/dist/core/runner/contracts.d.ts +1990 -0
  15. package/dist/core/runner/contracts.js +1 -0
  16. package/dist/core/runner/denial-limit-arms.d.ts +24 -1
  17. package/dist/core/runner/denial-limit-arms.js +21 -0
  18. package/dist/core/runner/derived-route-fallback.d.ts +34 -0
  19. package/dist/core/runner/derived-route-fallback.js +16 -0
  20. package/dist/core/runner/inherited-ask-grants.d.ts +46 -0
  21. package/dist/core/runner/inherited-ask-grants.js +29 -0
  22. package/dist/core/runner/park-commit.d.ts +108 -0
  23. package/dist/core/runner/park-commit.js +32 -0
  24. package/dist/core/runner/{prepare-permission-rules.d.ts → permission-rule-lanes.d.ts} +109 -3
  25. package/dist/core/runner/{prepare-permission-rules.js → permission-rule-lanes.js} +47 -1
  26. package/dist/core/runner/prepare-acquire-reconcile.d.ts +1 -1
  27. package/dist/core/runner/prepare-ask-lane.d.ts +110 -0
  28. package/dist/core/runner/prepare-ask-lane.js +133 -0
  29. package/dist/core/runner/prepare-boundary-parks.d.ts +105 -0
  30. package/dist/core/runner/prepare-boundary-parks.js +169 -0
  31. package/dist/core/runner/prepare-caps-and-workflow.d.ts +170 -0
  32. package/dist/core/runner/prepare-caps-and-workflow.js +255 -0
  33. package/dist/core/runner/prepare-config-doors.d.ts +2 -10
  34. package/dist/core/runner/prepare-context-lane.d.ts +119 -0
  35. package/dist/core/runner/prepare-context-lane.js +230 -0
  36. package/dist/core/runner/prepare-defer-classify.d.ts +86 -0
  37. package/dist/core/runner/prepare-defer-classify.js +107 -0
  38. package/dist/core/runner/prepare-delegation-surface.d.ts +104 -0
  39. package/dist/core/runner/prepare-delegation-surface.js +144 -0
  40. package/dist/core/runner/prepare-execution-env.d.ts +54 -0
  41. package/dist/core/runner/prepare-execution-env.js +86 -0
  42. package/dist/core/runner/prepare-file-history.d.ts +95 -0
  43. package/dist/core/runner/prepare-file-history.js +383 -0
  44. package/dist/core/runner/prepare-gate-stations.d.ts +177 -0
  45. package/dist/core/runner/prepare-gate-stations.js +290 -0
  46. package/dist/core/runner/prepare-hands-readface.d.ts +9 -11
  47. package/dist/core/runner/prepare-hands-readface.js +1 -1
  48. package/dist/core/runner/prepare-inherited-gate.d.ts +268 -0
  49. package/dist/core/runner/prepare-inherited-gate.js +266 -0
  50. package/dist/core/runner/prepare-listings.d.ts +77 -0
  51. package/dist/core/runner/prepare-listings.js +76 -0
  52. package/dist/core/runner/prepare-lsp.d.ts +55 -0
  53. package/dist/core/runner/prepare-lsp.js +27 -0
  54. package/dist/core/runner/prepare-memory-engine-session.d.ts +84 -0
  55. package/dist/core/runner/prepare-memory-engine-session.js +233 -0
  56. package/dist/core/runner/prepare-memory.d.ts +1 -1
  57. package/dist/core/runner/prepare-offload-wrappers.d.ts +62 -0
  58. package/dist/core/runner/prepare-offload-wrappers.js +45 -0
  59. package/dist/core/runner/prepare-park-ask.d.ts +164 -0
  60. package/dist/core/runner/prepare-park-ask.js +377 -0
  61. package/dist/core/runner/prepare-policy-chain.d.ts +208 -0
  62. package/dist/core/runner/prepare-policy-chain.js +584 -0
  63. package/dist/core/runner/prepare-project-context.d.ts +119 -0
  64. package/dist/core/runner/prepare-project-context.js +148 -0
  65. package/dist/core/runner/prepare-prompt-assembly.d.ts +95 -0
  66. package/dist/core/runner/prepare-prompt-assembly.js +162 -0
  67. package/dist/core/runner/prepare-prompt-inputs.d.ts +119 -0
  68. package/dist/core/runner/prepare-prompt-inputs.js +141 -0
  69. package/dist/core/runner/prepare-protocol-tools.d.ts +91 -0
  70. package/dist/core/runner/prepare-protocol-tools.js +179 -0
  71. package/dist/core/runner/prepare-question-face.d.ts +101 -0
  72. package/dist/core/runner/prepare-question-face.js +84 -0
  73. package/dist/core/runner/prepare-run-refs.d.ts +89 -0
  74. package/dist/core/runner/prepare-run-refs.js +39 -0
  75. package/dist/core/runner/prepare-safety-scan.d.ts +3 -7
  76. package/dist/core/runner/prepare-safety-scan.js +1 -2
  77. package/dist/core/runner/prepare-suspend-saga.d.ts +170 -0
  78. package/dist/core/runner/prepare-suspend-saga.js +308 -0
  79. package/dist/core/runner/prepare-task.d.ts +14 -1976
  80. package/dist/core/runner/prepare-task.js +182 -5162
  81. package/dist/core/runner/prepare-tool-disclosure-mount.d.ts +111 -0
  82. package/dist/core/runner/prepare-tool-disclosure-mount.js +219 -0
  83. package/dist/core/runner/prepare-turn-wiring.d.ts +154 -0
  84. package/dist/core/runner/prepare-turn-wiring.js +201 -0
  85. package/dist/core/runner/prepare-wiring-manifest.d.ts +192 -0
  86. package/dist/core/runner/prepare-wiring-manifest.js +247 -0
  87. package/dist/core/runner/prepare-workspace-restore.d.ts +3 -56
  88. package/dist/core/runner/prepare-workspace-restore.js +4 -38
  89. package/dist/core/runner/prompt-hash-salt.d.ts +1 -0
  90. package/dist/core/runner/prompt-hash-salt.js +2 -0
  91. package/dist/core/runner/remote-env-retry.d.ts +29 -0
  92. package/dist/core/runner/remote-env-retry.js +16 -0
  93. package/dist/core/runner/rollback-stack.d.ts +32 -0
  94. package/dist/core/runner/rollback-stack.js +30 -0
  95. package/dist/core/runner/runtask.js +1 -1
  96. package/dist/core/runner/workspace-path.d.ts +33 -0
  97. package/dist/core/runner/workspace-path.js +22 -0
  98. package/dist/core/session.d.ts +12 -0
  99. package/dist/core/session.js +3 -0
  100. package/dist/core/tool-policy.d.ts +16 -0
  101. package/dist/core/tool-policy.js +3 -0
  102. package/dist/core/types.d.ts +2 -2
  103. package/dist/core/write-protect.js +3 -2
  104. package/package.json +6 -2
  105. /package/dist/core/runner/{prepare-announce-once.d.ts → announce-once-ledger.d.ts} +0 -0
  106. /package/dist/core/runner/{prepare-announce-once.js → announce-once-ledger.js} +0 -0
@@ -0,0 +1 @@
1
+ export {};
@@ -3,7 +3,7 @@ import { type AutoModeArmingRecipe } from "../auto-mode-arming.js";
3
3
  import type { PermissionResult, ResolvedAsk, ToolCallRequest } from "../tool-policy.js";
4
4
  import { type EngineNotice } from "../types.js";
5
5
  import { type AskOrigin } from "../ask-origin.js";
6
- import type { Prepared } from "./prepare-task.js";
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
9
9
  * from `prepareTask` as a phase module (design/238 D-7: extract, don't accrete). The gate's OWN
@@ -173,3 +173,26 @@ export declare function settleDenialLimitFallback(opts: {
173
173
  toolName: string;
174
174
  toolCallId: string;
175
175
  }): void;
176
+ /**
177
+ * design/384 slice 1 — the observer `resolveAskBound` hands `resolveAsk` for a DETACHED approver's
178
+ * settlement (the race arm released the wait; the approver's promise lives on):
179
+ * · an approval the released wait never consumed becomes the `task.late_approval` notice a host
180
+ * can forward on its own wire (the wire-facing half of "release, not a verdict" — the notice
181
+ * asserts non-consumption only, never an arrival order the resolver cannot prove);
182
+ * · an unconsumed rejection goes to the deployment's error face (`onError(phase:"hook")`) — a
183
+ * callback failure the engine no longer awaits is still a callback failure, never silence.
184
+ */
185
+ export declare function lateAskSettlementObserver(args: {
186
+ toolName: string;
187
+ toolCallId: string;
188
+ sessionId: string;
189
+ runId: string;
190
+ taskId?: string;
191
+ onNotice: import("../types.js").RunnerDeps["onNotice"];
192
+ onError: import("../types.js").RunnerDeps["onError"];
193
+ }): (late: {
194
+ kind: "approve";
195
+ } | {
196
+ kind: "error";
197
+ error: unknown;
198
+ }) => void;
@@ -114,3 +114,24 @@ export function settleDenialLimitFallback(opts) {
114
114
  opts.stop({ toolName: opts.toolName, toolCallId: opts.toolCallId, fallback });
115
115
  }
116
116
  }
117
+ export function lateAskSettlementObserver(args) {
118
+ return (late) => {
119
+ if (late.kind === "approve") {
120
+ deliverEngineNotice(args.onNotice, {
121
+ code: "task.late_approval",
122
+ message: `an approval for "${args.toolName}" was not consumed: a run or turn interrupt released the ask wait, ` +
123
+ `so the tool did NOT run and the approval was not honored (an unconsumed answer is the approver ` +
124
+ `releasing its wait, never a verdict).`,
125
+ detail: {
126
+ toolName: args.toolName,
127
+ toolCallId: args.toolCallId,
128
+ sessionId: args.sessionId,
129
+ runId: args.runId,
130
+ ...(args.taskId !== undefined ? { taskId: args.taskId } : {}),
131
+ },
132
+ });
133
+ return;
134
+ }
135
+ args.onError?.(late.error, { phase: "hook", sessionId: args.sessionId });
136
+ };
137
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The derived-leg route pre-flight shared by prepareTask's two derived seats: the compaction-summary model (the
3
+ * driver's own seat, resolved before the offload prelude) and the auto-mode classifier model (the caps-and-workflow
4
+ * phase's arming). Moved whole out of the orchestrator so the phase reaches DOWN for it instead of the two callers
5
+ * spelling it twice. The judgment law itself lives in the brain's route adjudicator; this is the one guarded seat
6
+ * around it.
7
+ */
8
+ import type { Model } from "../../internal/llm.js";
9
+ import type { RunnerDeps, TaskSpec } from "../types.js";
10
+ /**
11
+ * The derived-leg pairing pre-flight as ONE guarded seat: judge the derived model where its route
12
+ * differs from the primary's, announce the fallback on a broken pairing, and ABSTAIN (keep the
13
+ * derived model) on ANY throw. The pre-flight is advisory — the brain's request gate re-runs the
14
+ * same law — so neither a throwing judge face nor a garbage catalog value (the route-identity
15
+ * normalization throws a TypeError on a non-string baseUrl) may widen into "task preparation
16
+ * failed": at the compaction seat the session is already acquired and the throw-cleanup contract
17
+ * is not yet armed, so a throw here would leak the acquired session view, and the garbage value
18
+ * still earns its loud refusal at the request gate the moment the derived leg is actually used.
19
+ * Returns whether the seat should fall back to the primary model.
20
+ */
21
+ export declare function derivedRouteFallsBack(args: {
22
+ seat: string;
23
+ derived: Model;
24
+ primary: Model;
25
+ brain: RunnerDeps["brain"];
26
+ getApiKeyAndHeaders: TaskSpec["getApiKeyAndHeaders"];
27
+ onNotice: RunnerDeps["onNotice"];
28
+ /** #433 — the run this seat resolution belongs to; carried onto the notice as its routing key
29
+ * (the audience stays operator: correlation, not entitlement). Absent ⇒ nothing is fabricated. */
30
+ sessionId?: string;
31
+ /** #499 — the INVOCATION this seat resolution belongs to. The session cannot stand in for it: two
32
+ * runs of one session that both fall back on the same seat mint otherwise byte-identical lines. */
33
+ runId?: string;
34
+ }): Promise<boolean>;
@@ -0,0 +1,16 @@
1
+ import { adjudicateDerivedRoute, fallbackToPrimaryNotice, sameRouteIdentity } from "../../brain/route-adjudicator.js";
2
+ import { deliverEngineNotice } from "../types.js";
3
+ export async function derivedRouteFallsBack(args) {
4
+ try {
5
+ if (sameRouteIdentity(args.derived, args.primary))
6
+ return false;
7
+ const verdict = await adjudicateDerivedRoute({ brain: args.brain, model: args.derived, getApiKeyAndHeaders: args.getApiKeyAndHeaders });
8
+ if (verdict === undefined || verdict.ok)
9
+ return false;
10
+ deliverEngineNotice(args.onNotice, fallbackToPrimaryNotice({ seat: args.seat, from: args.derived.id, to: args.primary.id, verdict, ...(args.sessionId !== undefined ? { sessionId: args.sessionId } : {}), ...(args.runId !== undefined ? { runId: args.runId } : {}) }));
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
@@ -0,0 +1,46 @@
1
+ import { type OnAsk, type PermissionResult, type ResolvedAsk, type ToolCallRequest } from "../tool-policy.js";
2
+ /** RB-40 byte-shape key, throw-proofed: JSON.stringify throws on a cyclic value (a deployment
3
+ * policy's rewrite object carries no acyclicity guarantee) — `undefined` here means "no comparable
4
+ * shape", which every consumer treats as a mismatch (skip the record / skip the reuse ⇒ the child
5
+ * gate just re-asks — the safe direction, never a gate failure). */
6
+ export declare function askGrantShapeOf(args: unknown): string | undefined;
7
+ /**
8
+ * RB-40 — duplicate-frame collapse (single-Runner embedded shape), the CONSUME half: an inherited
9
+ * layer already resolved THIS call as a clean allow at the SAME approver function, on EXACTLY the
10
+ * byte-shape this caller-slot ask now presents. The human's consent covers this consultation
11
+ * verbatim — asking again is the 2-frame UX debt, not a second authority. Guards, all mismatches
12
+ * re-ask (⇒ `undefined`):
13
+ * · SOURCE (review F-2): only a caller-slot POLICY ask may reuse — a safety tighten
14
+ * (egress/irreversible/shellGate stamp `decisionReason:"safety"`), a hook-promoted ask
15
+ * ("hook"), or a classifier ask carries a DIFFERENT authority/context and keeps its frame.
16
+ * · IDENTITY: the approver FUNCTION reference (never a name) — compared through
17
+ * `askApproverIdentity`, not by raw reference (ruled 2026-08-04): a delegated child's approver
18
+ * is the ancestor's own, wrapped with delegation provenance — a DIFFERENT function object, the
19
+ * SAME operator. Bare `===` therefore stopped collapsing the frame the moment the ask seam
20
+ * started forwarding approvers, and one tool call presented the same person the same card
21
+ * twice. The wrapper adds metadata and delegates every decision, so it grants no authority of
22
+ * its own; identity here only answers "same consultation".
23
+ * · SHAPE: byte-equality against the [1462] r5 presented SNAPSHOT; reuse re-emits that snapshot
24
+ * as the executed shape via `presentedInput` (review F-1) — the same schema-revalidated binding
25
+ * as a main-gate approval (shown == executed, by construction).
26
+ * · LEDGER (review F-3): the ONE real consultation (timed on the wrapper arm) is recorded here —
27
+ * one frame happened, one frame is booked.
28
+ * The grant lives only within one gate evaluation (consumed on reuse, swept at the call site) — a
29
+ * REOPENED checkpoint's re-adjudication never sees a stale grant.
30
+ */
31
+ export declare function consumeInheritedAskGrant(grants: Map<string, {
32
+ approver: unknown;
33
+ argsJson: string;
34
+ presented: unknown;
35
+ waitMs: number;
36
+ }>, onAsk: OnAsk | undefined, humanReviewRef: {
37
+ count: number;
38
+ totalWaitMs: number;
39
+ gates: Array<{
40
+ kind: string;
41
+ waitMs: number;
42
+ decision?: string;
43
+ toolName?: string;
44
+ toolArg?: string;
45
+ }>;
46
+ }, decision: PermissionResult, req: ToolCallRequest): ResolvedAsk | undefined;
@@ -0,0 +1,29 @@
1
+ import { primaryActivityArg } from "../arg-summary.js";
2
+ import { askApproverIdentity } from "../tool-policy.js";
3
+ export function askGrantShapeOf(args) {
4
+ try {
5
+ return JSON.stringify(args ?? null);
6
+ }
7
+ catch {
8
+ return undefined;
9
+ }
10
+ }
11
+ export function consumeInheritedAskGrant(grants, onAsk, humanReviewRef, decision, req) {
12
+ if (decision.decisionReason !== undefined && decision.decisionReason !== "rule")
13
+ return undefined;
14
+ const grant = grants.get(req.toolCallId);
15
+ if (grant === undefined || askApproverIdentity(grant.approver) !== askApproverIdentity(onAsk) || grant.argsJson !== askGrantShapeOf(req.args))
16
+ return undefined;
17
+ grants.delete(req.toolCallId);
18
+ humanReviewRef.count += 1;
19
+ humanReviewRef.totalWaitMs += grant.waitMs;
20
+ const toolArg = primaryActivityArg(req.args);
21
+ humanReviewRef.gates.push({
22
+ kind: "human",
23
+ waitMs: grant.waitMs,
24
+ decision: "allow",
25
+ toolName: req.toolName,
26
+ ...(toolArg !== undefined ? { toolArg } : {}),
27
+ });
28
+ return { action: "allow", presentedInput: grant.presented };
29
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The commit-side vocabulary of a durable park: which holder a COMMITTED checkpoint publishes to, the abort-classification
3
+ * marker the loop stamps on the contaminated siblings, and the deadline arithmetic every mint shares (the retention
4
+ * defaults and the one TTL sanitizer). Moved whole out of the orchestrator so the boundary-parks phase (resource /
5
+ * platform / review) and the park-ask phase (the approval park) reach DOWN for the same publication seam and the same
6
+ * numbers, and the driver keeps reaching down for the marker its harness stamps. Pure: no engine state; the only clock
7
+ * is the caller's.
8
+ */
9
+ import type { CheckpointGate, CheckpointToken, PendingAction } from "../checkpoint-store.js";
10
+ import type { WorkspaceHandle } from "../remote-env.js";
11
+ import type { Prepared } from "./contracts.js";
12
+ /** design/74 (HIGH-2): default resource-checkpoint retention (30 days, the spec's
13
+ * `RESOURCE_SUSPEND_TTL_SEC`). Stamped onto the checkpoint `deadline` so the durable reaper can GC an
14
+ * ORPHANED resource suspend; a legitimate auto-resume fires long before this. Override via
15
+ * `TaskSpec.resourceSuspend.ttlMs`. */
16
+ export declare const DEFAULT_RESOURCE_TTL_MS: number;
17
+ /**
18
+ * design/164 件五 — how long AFTER a governance window frees a `usage_window` checkpoint stays reapable-free.
19
+ * The retention TTL answers "was this abandoned?"; a usage-window suspend is not abandoned while the window
20
+ * it waits on is still full, so its deadline is pushed to `retryAfterMs + this`. One hour gives a host's
21
+ * scheduler a realistic chance to pick the resume up (a cron tick, a queue drain) before the row is
22
+ * treated as garbage — small next to the 30-day retention it is compared against, and large next to any
23
+ * polling interval a driver would sanely use.
24
+ */
25
+ export declare const USAGE_WINDOW_REAP_MARGIN_MS: number;
26
+ /** design/80 D-2 (BLOCKER): abandonment TTL stamped on an UNATTENDED human/irreversible_ask suspend (no
27
+ * `durableApproval` = no operator/resolver wired). Without it the checkpoint has `deadline=undefined` and the
28
+ * reaper (which filters on a non-null deadline) can NEVER GC it → a pinned session + a stranded paused VM +
29
+ * an orphan checkpoint leak forever. A `durableApproval` suspend keeps the operator's `ttlMs` (undefined =
30
+ * unbounded by the operator's choice; D-D `terminalAt` is the eventual liveness backstop there). 30 days. */
31
+ export declare const DEFAULT_UNATTENDED_APPROVAL_TTL_MS: number;
32
+ /**
33
+ * RB-68 (2026-07-25, red probe): a caller-supplied TTL is only a TTL if it is a finite, positive number.
34
+ * `TaskSpec.durableApproval.ttlMs` / `resourceSuspend.ttlMs` are typed as a bare `number`, and the mint
35
+ * arithmetic was `Date.now() + ttlMs` with NO validation — so `Number(process.env.APPROVAL_TTL_MS)` on an
36
+ * unset/malformed env var (a config-center miss, an upstream arithmetic slip) silently produced
37
+ * `deadline = NaN`. Every reaper comparison is `deadline <= cutoff`, and `NaN <= anything` is FALSE, so a
38
+ * checkpoint that LOOKS bounded (it carries a ttlMs and a deadline) became permanently IMMORTAL: no reap
39
+ * cutoff, however far in the future, could ever expire it → a pinned session + a stranded paused VM, and
40
+ * a misconfigured deployment silently defeating its own abandoned-approval cleanup with no error anywhere.
41
+ *
42
+ * Handling follows the codebase's established treatment of malformed numeric config — sanitize to the
43
+ * documented default rather than throw — `task-registry-shared.ts`'s `TASK_OUTPUT_DEFAULT_CHARS` fallbacks
44
+ * (non-numeric / non-finite / non-positive all return the documented default) and `media-byte-cap.ts`'s
45
+ * `!Number.isFinite(limitBytes) || limitBytes <= 0` guard are the two live precedents: garbage in a bound
46
+ * field must never widen the bound, and
47
+ * killing an in-flight task over it would cost more than it saves. `undefined` (the deliberate
48
+ * "unbounded, D-D terminalAt is the backstop" choice) is preserved as-is and is NOT what this rejects.
49
+ */
50
+ export declare function sanitizedTtlMs(ttlMs: number | undefined): number | undefined;
51
+ /**
52
+ * WHICH tool call a committed durable park is holding this run — `undefined` when nothing parked, or
53
+ * when the park that did commit holds no call (a resource slice, a plan review).
54
+ *
55
+ * SINGLE derivation on purpose. Two consumers need this answer: the abort-classification seam, which
56
+ * stamps it into the `details` of the results the loop mints for the contaminated siblings, and the
57
+ * `tool_end` projection, which puts it on the wire. Reading it from one function keeps the two from
58
+ * disagreeing about WHICH HOLDER WINS or about what a holder with no call means. They are not otherwise
59
+ * interchangeable: the frame face applies strictly narrower conditions on top of this answer (see
60
+ * `tool_end.gatedCallId`), so a frame may omit an id this function returns — never the reverse.
61
+ *
62
+ * The frame side must not read the id back out of a tool RESULT even though the marker is there: a
63
+ * result's `details` is written by the tool (and replaceable by post-tool hooks), so lifting a
64
+ * cross-call attribution from it would let any failing tool name an arbitrary call and put a phantom
65
+ * approval wait on someone else's frame. Same rule, same reason, as `settledBy`.
66
+ *
67
+ * The two holders are never both set (the commit-side discriminant writes exactly one); they are read
68
+ * here in assemble-result's slot order so the winner is the same one the terminal status is built from.
69
+ */
70
+ export declare function gatedCallIdOf(p: Pick<Prepared, "suspendRef" | "reviewRef">): string | undefined;
71
+ /**
72
+ * The abort-classification marker for the loop's short-circuit "Operation aborted" family: when a gate
73
+ * committed a durable park this run (`suspendRef` for human/irreversible/content asks, `reviewRef` for
74
+ * the dry-run/plan-review family), the contaminated sibling frames carry something a consumer can key
75
+ * on instead of parsing the (contract-stable) result text (ruled 2026-08-04). The commit is published
76
+ * BEFORE the abort fires, so this read is race-free at mint time.
77
+ *
78
+ * `gatedCallId` NAMES the call the gate is holding, so a consumer can attribute a contaminated frame to
79
+ * its cause instead of inferring the pair from batch adjacency. Absent, never approximated: a park that
80
+ * holds no tool call (a resource slice, a plan review) publishes no id, and this marker does not invent
81
+ * one from whatever call happens to be in flight.
82
+ *
83
+ * Module-level rather than a closure in `prepareTask` (design/238 D-7: extract, don't accrete) — the
84
+ * marker's shape and its rules belong beside {@link gatedCallIdOf}, the derivation it reads.
85
+ */
86
+ export declare function parkContaminationMarker(refs: Pick<Prepared, "suspendRef" | "reviewRef">): Record<string, unknown> | undefined;
87
+ /**
88
+ * design/76 §2.5 — THE COMMIT-SIDE DISCRIMINANT (v4 MAJOR-A, the load-bearing dry-run fix). Called
89
+ * AFTER `commitSuspendSaga` returns true (the checkpoint is durably committed) to publish which holder
90
+ * the run loop reads for the terminal status. Branch on the COMMITTED `gate.kind`: a REVIEW-PAUSE gate
91
+ * writes `reviewRef` (→ assemble-result slot 8.6 → `status:"needs_review"` / `review.pending`); every
92
+ * approval/resource gate (human / irreversible_ask / resource_limit / task_done) writes `suspendRef` as
93
+ * before (→ slot 8.5 → `status:"suspended"`). NEVER both — a review pause that also set `suspendRef`
94
+ * would assemble as `"suspended"` and make slot 8.6 dead code. The review-pause family is BOTH the
95
+ * dry-run `{kind:"needs_review"}` gate (design/76 §2.5) AND the design/80 D-B `{kind:"plan_review"}`
96
+ * gate — both are human-review pauses that share the `needs_review` terminal (a `plan_review` resumes
97
+ * with a `plan_review` outcome, a `needs_review` with `dry_run_review`; the distinct gate.kind keeps the
98
+ * resume discriminant unambiguous). (The review checkpoints themselves are minted by a PROFILE, out of
99
+ * core scope; core only owns this routing seam + the gate/status/resume discriminants.)
100
+ *
101
+ * Module-level, taking the holders as a parameter (design/238 D-7: extract, don't accrete) — the
102
+ * closure form captured nothing else, so hoisting it costs nothing and puts the discriminant beside the
103
+ * two readers of what it publishes ({@link gatedCallIdOf}, {@link parkContaminationMarker}).
104
+ */
105
+ export declare function publishCommittedSuspend(refs: Pick<Prepared, "suspendRef" | "reviewRef">, token: CheckpointToken, gate: CheckpointGate, scope: string, remoteHandle: WorkspaceHandle | undefined, checkpointId?: string,
106
+ /** The COMMITTED row's pendingAction — passed whole, not as a pre-extracted id, so no call site can
107
+ * hand this seam an id the committed row does not actually park on. */
108
+ pendingAction?: PendingAction): void;
@@ -0,0 +1,32 @@
1
+ export const DEFAULT_RESOURCE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
2
+ export const USAGE_WINDOW_REAP_MARGIN_MS = 60 * 60 * 1000;
3
+ export const DEFAULT_UNATTENDED_APPROVAL_TTL_MS = 30 * 24 * 60 * 60 * 1000;
4
+ export function sanitizedTtlMs(ttlMs) {
5
+ if (ttlMs === undefined)
6
+ return undefined;
7
+ return Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : DEFAULT_RESOURCE_TTL_MS;
8
+ }
9
+ export function gatedCallIdOf(p) {
10
+ if (p.suspendRef.token !== undefined)
11
+ return p.suspendRef.gatedCallId;
12
+ if (p.reviewRef.token !== undefined)
13
+ return p.reviewRef.gatedCallId;
14
+ return undefined;
15
+ }
16
+ export function parkContaminationMarker(refs) {
17
+ if (refs.suspendRef.token === undefined && refs.reviewRef.token === undefined)
18
+ return undefined;
19
+ const gatedCallId = gatedCallIdOf(refs);
20
+ return { code: "gate.parked", ...(gatedCallId !== undefined ? { gatedCallId } : {}) };
21
+ }
22
+ export function publishCommittedSuspend(refs, token, gate, scope, remoteHandle, checkpointId, pendingAction) {
23
+ const ref = gate.kind === "needs_review" || gate.kind === "plan_review" ? refs.reviewRef : refs.suspendRef;
24
+ ref.token = token;
25
+ if (checkpointId !== undefined)
26
+ ref.checkpointId = checkpointId;
27
+ ref.gate = gate;
28
+ ref.gatedCallId = pendingAction?.kind === "tool_approval" ? pendingAction.toolCallId : undefined;
29
+ if (remoteHandle !== undefined)
30
+ ref.restoreMode = remoteHandle.restoreMode === "park_only" ? "park_only" : "snapshot";
31
+ ref.scope = scope;
32
+ }
@@ -1,5 +1,6 @@
1
1
  /**
2
- * design/389 — prepare's permission-rule phase: the TWO gate lanes the unified store feeds.
2
+ * design/389 — the permission-rule LANES the unified store feeds (prepare-path machinery, design/390 L1): the two gate
3
+ * lanes, and — since the policy-chain phase took the chain assembly — the rule-offer factory every ask-mint site calls.
3
4
  *
4
5
  * `prepareTask` used to assemble the personal lane inline (a store read + a hand-spliced session
5
6
  * overlay + two admission arms) and the org lane from a second seam. Both now read the ONE store query —
@@ -23,8 +24,8 @@
23
24
  * silence the ungated-write warning for deployments that wired no policy at all.
24
25
  */
25
26
  import type { AskRuleEvidence, ToolCallRequest } from "../tool-policy.js";
26
- import type { OrgGateVerdict, PersistedRuleAnswer, PersistedRuleHit } from "../hooks.js";
27
- import type { PersistedAllowRule } from "../permission-rule-model.js";
27
+ import { persistedRuleMandateOf, type OrgGateVerdict, type PersistedRuleAnswer, type PersistedRuleHit } from "../hooks.js";
28
+ import type { PersistedAllowRule, RuleOffer, SegmentCoverage } from "../permission-rule-model.js";
28
29
  import { type OrgRuleResolution } from "../permission-rule-org.js";
29
30
  import type { PermissionRuleStoreProvider } from "../permission-rule-provider.js";
30
31
  /** The one SHELL tool the persisted-rule lane speaks for. */
@@ -130,3 +131,108 @@ export declare function createPermissionRuleLanes(cfg: {
130
131
  * lane took the read. */
131
132
  onDisclosure: (message: string) => void;
132
133
  }): PermissionRuleLanes;
134
+ /** The closed reason set for the rule-offer factory's empty answer (#490 修②) — the same three
135
+ * spellings the `AskRequest` and durable-row seats declare, kept at the one factory that fills both.
136
+ * Deliberately not exported: this is a wire vocabulary, and its two faces declare it literally (the
137
+ * `previewWithheld` precedent) so a consumer reads the closed set on the type it is holding rather
138
+ * than through an import. */
139
+ type RuleOffersAbsence = "mandated" | "lane_cannot_speak" | "shadowed";
140
+ /** The surviving-ask facts the rule-offer factory reads (see {@link createRuleOffersOf}): the
141
+ * synchronous mint sites spread the surviving decision, which carries them; the park leg threads
142
+ * each as its own parameter. */
143
+ interface RuleOffersAskFacts {
144
+ requiresRealApproval?: boolean;
145
+ persistedRuleShadowed?: string;
146
+ decisionReason?: import("../tool-policy.js").DecisionReason;
147
+ /** #457 ④: an EXPLICIT `ask` permission rule matched this call (design/127 DSL, stamped at
148
+ * the rule policy's three matched-ask exits — bare, covering and param). */
149
+ matchedAskRule?: string;
150
+ /** #502: the engine-stamped probe mandate (the reversibility probe declared this call's
151
+ * demotion structural — for the built-in shell probe, a read outside the session's
152
+ * allowed directories). */
153
+ probeMandated?: boolean;
154
+ inheritedUnresolved?: boolean;
155
+ /** The ask is being resolved at an ANCESTOR's frozen approver (the three inherited-lane
156
+ * mint sites pass it literally) — this task's rule lane never adjudicates it. */
157
+ ancestorResolved?: boolean;
158
+ /** design/375 §5.2②: the surviving ask's engine-stamped per-segment coverage table — the
159
+ * batch offer carries exactly the segments this table calls uncovered. Absent ⇒ all
160
+ * segments read as uncovered (over-offer, the safe direction). */
161
+ segmentCoverage?: readonly SegmentCoverage[];
162
+ }
163
+ /**
164
+ * design/179 §4 — the rule forms that could cover this exact call, for a surface's "stop asking me
165
+ * this" option (extracted from the prepare body, the spliceSessionOverlayRows precedent; the maker
166
+ * captures the per-task wiring once and the returned factory serves all five ask-mint sites — the
167
+ * approval-preview field is a standing example of what happens otherwise: it reached two of the
168
+ * five, so a delegated child's card was silently poorer than the top-level one).
169
+ *
170
+ * Empty unless a rule lane is armed — offering an option that redemption would refuse is worse than
171
+ * offering none — and empty for any call the lane cannot speak for (a compound, a redirection, a
172
+ * substitution, or a tool that is not the shell tool).
173
+ *
174
+ * Empty, too, on any ask a persisted rule could never CLEAR — "allow rules silence the classifier's
175
+ * questions, never a mandated one". Two doors, matching the two halves of that boundary:
176
+ * · the marks half, judged from the SAME resolved sources the gate input is built from, through the
177
+ * gate's own single-source predicate (an operator's shellGate:"always", the tool's own
178
+ * egress/irreversibility marks, and — #502 — the PER-CALL member that predicate also takes: a
179
+ * demotion this call's reversibility probe declared structural, read off the ask the gate
180
+ * stamped rather than off the tool seat, because it is true of one call and not of the seat) —
181
+ * a drift here would offer a rule the lane then refuses to honor;
182
+ * · the decision half (`ask`, REQUIRED at every mint site so a site added later cannot skip it):
183
+ * `requiresRealApproval` (org/governance — only judgment clears it), the shadowed-rule
184
+ * disclosure (the standing proof that a matching rule does not clear THIS ask), a hook-raised
185
+ * ask (`decisionReason === "hook"` — the lane's own clearing conjuncts refuse hook asks, so a
186
+ * rule minted from one would never silence it), an EXPLICIT `ask` rule match (`matchedAskRule`
187
+ * — see below), and an inherited-unresolved marked call (the
188
+ * ask is an ANCESTOR's authority, which this task's rule lane never adjudicates). Offering
189
+ * "stop asking me this" on a card that will keep asking lets a person mint a rule that never
190
+ * takes effect where they minted it.
191
+ *
192
+ * #457 ④ (CC 2.1.245 toolPolicy 对表, F11: "wildcard `ask` … keeps every matching tool behind a
193
+ * per-call prompt (no persistent always-allow)") — `matchedAskRule` was the one conjunct of the
194
+ * lane's own clearing predicate this door did not mirror, and the asymmetry was live: the
195
+ * persisted-rule lane refuses to clear ANY ask carrying it (`hooks.ts`, the
196
+ * `decision.matchedAskRule === undefined` conjunct on its allow arm), so a person who wrote a
197
+ * standing `Bash` `ask` rule and had no allow rule yet was still offered "allow, and stop asking
198
+ * me this" — and the rule they minted came back on the very next call as a SHADOWED match
199
+ * (`persistedRuleShadowed`, which this door was already refusing). The offer was only ever
200
+ * suppressed one call too late, after the useless rule existed. The shadowed conjunct stays: it
201
+ * covers the person who ALREADY has the allow rule; this one covers the person about to mint it.
202
+ *
203
+ * #490 修② — every door above that answers "no offers" now also names WHICH door, on the
204
+ * `ruleOffersAbsence` seat (`AskRequest`'s and the durable row's, ONE factory so the two faces
205
+ * cannot disagree). The doctrine it serves is the loud-bad-value one, applied to an absence: a
206
+ * surface reading an empty card could not tell "there is nothing this lane could offer" from
207
+ * "offers were deliberately suppressed", and a person reading it could not tell "write the rule
208
+ * yourself" from "no rule can excuse this approval". The seat is CLOSED
209
+ * (`mandated | lane_cannot_speak | shadowed`), mutually exclusive with `ruleOffers` by
210
+ * construction, and structural-door silent (see the first two guards).
211
+ */
212
+ export declare function createRuleOffersOf(cfg: {
213
+ /** `permissionRuleLane !== undefined` at wiring time — the armed-lane structural door. */
214
+ laneArmed: boolean;
215
+ principal: string | undefined;
216
+ /** `deps.localOwnerRules === true` — the declared local-owner exception to the anonymous floor. */
217
+ localOwnerDeclared: boolean;
218
+ /** The live mark sources the gate input is built from — passed as REFERENCES (read per call, so a
219
+ * late-registered tool seat is seen exactly as the gate sees it). */
220
+ egressTools: {
221
+ has(name: string): boolean;
222
+ };
223
+ irreversibilityTier: {
224
+ get(name: string): Parameters<typeof persistedRuleMandateOf>[0]["irreversibility"];
225
+ };
226
+ shellGatedBash: boolean;
227
+ taskRoot: string | undefined;
228
+ /** The live tracked-cwd ref (read per call — the relative-cd resolution base, adversarial-review P1/r3). */
229
+ cwdRef: {
230
+ current: string;
231
+ } | undefined;
232
+ deniesDirectoryRead: ((directory: string) => boolean) | undefined;
233
+ }): (toolName: string, args: unknown, ask: RuleOffersAskFacts | undefined) => {
234
+ ruleOffers?: readonly RuleOffer[];
235
+ ruleOffersAbsence?: RuleOffersAbsence;
236
+ execCwd?: string;
237
+ };
238
+ export {};
@@ -1,4 +1,5 @@
1
- import { directoryRuleAdmits, eligiblePersisted, findAdmittingRule, lexicalNormalAbsolutePathOf, segmentCoverageOf } from "../permission-rule-model.js";
1
+ import { persistedRuleMandateOf } from "../hooks.js";
2
+ import { directoryRuleAdmits, eligiblePersisted, findAdmittingRule, lexicalNormalAbsolutePathOf, segmentCoverageOf, suggestRulesForCommand } from "../permission-rule-model.js";
2
3
  import { orgRuleVerdictFor } from "../permission-rule-org.js";
3
4
  export const PERSISTED_RULE_TOOL = "Bash";
4
5
  export const DIRECTORY_RULE_TOOL = "Read";
@@ -138,3 +139,48 @@ export function createPermissionRuleLanes(cfg) {
138
139
  : undefined;
139
140
  return { personal, org };
140
141
  }
142
+ export function createRuleOffersOf(cfg) {
143
+ return (toolName, args, ask) => {
144
+ if (!cfg.laneArmed || toolName !== PERSISTED_RULE_TOOL)
145
+ return {};
146
+ if ((cfg.principal === undefined || cfg.principal === "") && !cfg.localOwnerDeclared)
147
+ return {};
148
+ const closedDoor = (() => {
149
+ if (ask?.requiresRealApproval === true)
150
+ return "mandated";
151
+ if (ask?.persistedRuleShadowed !== undefined)
152
+ return "shadowed";
153
+ if (ask?.decisionReason === "hook")
154
+ return "mandated";
155
+ if (ask?.matchedAskRule !== undefined)
156
+ return "shadowed";
157
+ if (ask?.inheritedUnresolved === true)
158
+ return "mandated";
159
+ if (ask?.ancestorResolved === true)
160
+ return "mandated";
161
+ return undefined;
162
+ })();
163
+ if (closedDoor !== undefined)
164
+ return { ruleOffersAbsence: closedDoor };
165
+ if (persistedRuleMandateOf({
166
+ egress: cfg.egressTools.has(toolName),
167
+ irreversibility: cfg.irreversibilityTier.get(toolName),
168
+ shellGated: cfg.shellGatedBash,
169
+ probeMandated: ask?.probeMandated === true,
170
+ }) !== undefined) {
171
+ return { ruleOffersAbsence: "mandated" };
172
+ }
173
+ const command = args?.command;
174
+ if (typeof command !== "string")
175
+ return { ruleOffersAbsence: "lane_cannot_speak" };
176
+ const offers = suggestRulesForCommand(command, {
177
+ ...(ask?.segmentCoverage !== undefined ? { coverage: ask.segmentCoverage } : {}),
178
+ ...(cfg.taskRoot !== undefined ? { scope: { kind: "project", root: cfg.taskRoot }, cwd: cfg.taskRoot } : {}),
179
+ ...(cfg.cwdRef?.current !== undefined ? { execCwd: cfg.cwdRef.current } : {}),
180
+ ...(cfg.deniesDirectoryRead !== undefined ? { deniesDirectoryRead: cfg.deniesDirectoryRead } : {}),
181
+ });
182
+ return offers.length > 0
183
+ ? { ruleOffers: offers, ...(cfg.cwdRef?.current !== undefined ? { execCwd: cfg.cwdRef.current } : {}) }
184
+ : { ruleOffersAbsence: "lane_cannot_speak" };
185
+ };
186
+ }
@@ -22,7 +22,7 @@ import { StoredSession } from "../session.js";
22
22
  import type { AcquiredSession, SessionStore } from "../session.js";
23
23
  import { type RecoveredOrphan } from "../session-reconcile.js";
24
24
  import type { TaskSpec, ToolEffect } from "../types.js";
25
- import type { PrepareResume } from "./prepare-task.js";
25
+ import type { PrepareResume } from "./contracts.js";
26
26
  export interface PrepareAcquireReconcileInput {
27
27
  /** borrowed-mutable (service port) — the deployment's session store. This phase's verbs on it are
28
28
  * the acquire/forget half of the session resource protocol: `acquire` per attempt, `forget` to