@sema-agent/core 7.5.1 → 7.6.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 (132) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/agents/cascade.d.ts +2 -2
  3. package/dist/agents/cascade.js +12 -10
  4. package/dist/agents/repair-loop.d.ts +5 -3
  5. package/dist/agents/repair-loop.js +13 -15
  6. package/dist/agents/subagent.d.ts +24 -42
  7. package/dist/agents/subagent.js +119 -105
  8. package/dist/agents/suspend-guard.d.ts +31 -19
  9. package/dist/agents/suspend-guard.js +14 -8
  10. package/dist/agents/teacher.js +9 -9
  11. package/dist/agents/team.d.ts +4 -3
  12. package/dist/agents/team.js +10 -8
  13. package/dist/agents/verify.d.ts +3 -3
  14. package/dist/agents/verify.js +17 -17
  15. package/dist/core/a2a.js +2 -1
  16. package/dist/core/ask-origin.d.ts +60 -7
  17. package/dist/core/ask-origin.js +26 -1
  18. package/dist/core/checkpoint-store.d.ts +78 -76
  19. package/dist/core/checkpoint-store.js +17 -1
  20. package/dist/core/gate-outcome.d.ts +189 -0
  21. package/dist/core/gate-outcome.js +70 -0
  22. package/dist/core/hooks.d.ts +18 -92
  23. package/dist/core/hooks.js +88 -85
  24. package/dist/core/mcp-failure.d.ts +104 -0
  25. package/dist/core/mcp-failure.js +128 -0
  26. package/dist/core/mcp.d.ts +21 -77
  27. package/dist/core/mcp.js +76 -150
  28. package/dist/core/pause-registry.d.ts +131 -0
  29. package/dist/core/pause-registry.js +27 -0
  30. package/dist/core/protocol-table.d.ts +5 -0
  31. package/dist/core/protocol-table.js +1 -0
  32. package/dist/core/runner/abort-race.d.ts +41 -0
  33. package/dist/core/runner/abort-race.js +38 -0
  34. package/dist/core/runner/assemble-result.d.ts +32 -41
  35. package/dist/core/runner/assemble-result.js +55 -74
  36. package/dist/core/runner/checkpoint-scope.d.ts +15 -3
  37. package/dist/core/runner/checkpoint-scope.js +3 -0
  38. package/dist/core/runner/compaction-call-options.d.ts +1 -1
  39. package/dist/core/runner/content-ask-bindings.d.ts +27 -0
  40. package/dist/core/runner/content-ask-bindings.js +1 -0
  41. package/dist/core/runner/contracts.d.ts +144 -50
  42. package/dist/core/runner/denial-limit-arms.d.ts +24 -1
  43. package/dist/core/runner/denial-limit-arms.js +24 -3
  44. package/dist/core/runner/gate-exit.d.ts +74 -0
  45. package/dist/core/runner/gate-exit.js +55 -0
  46. package/dist/core/runner/inherited-ask-grants.d.ts +46 -0
  47. package/dist/core/runner/inherited-ask-grants.js +29 -0
  48. package/dist/core/runner/park-commit.d.ts +102 -0
  49. package/dist/core/runner/park-commit.js +31 -0
  50. package/dist/core/runner/{prepare-permission-rules.d.ts → permission-rule-lanes.d.ts} +109 -3
  51. package/dist/core/runner/{prepare-permission-rules.js → permission-rule-lanes.js} +47 -1
  52. package/dist/core/runner/prepare-ask-lane.d.ts +107 -0
  53. package/dist/core/runner/prepare-ask-lane.js +131 -0
  54. package/dist/core/runner/prepare-boundary-parks.d.ts +102 -0
  55. package/dist/core/runner/prepare-boundary-parks.js +169 -0
  56. package/dist/core/runner/prepare-caps-and-workflow.js +1 -1
  57. package/dist/core/runner/prepare-context-lane.d.ts +119 -0
  58. package/dist/core/runner/prepare-context-lane.js +230 -0
  59. package/dist/core/runner/prepare-gate-stations.d.ts +174 -0
  60. package/dist/core/runner/prepare-gate-stations.js +265 -0
  61. package/dist/core/runner/prepare-hands-readface.d.ts +4 -4
  62. package/dist/core/runner/prepare-inherited-gate.d.ts +4 -4
  63. package/dist/core/runner/prepare-inherited-gate.js +1 -1
  64. package/dist/core/runner/prepare-memory-engine-session.d.ts +84 -0
  65. package/dist/core/runner/prepare-memory-engine-session.js +233 -0
  66. package/dist/core/runner/prepare-memory.d.ts +44 -26
  67. package/dist/core/runner/prepare-park-ask.d.ts +162 -0
  68. package/dist/core/runner/prepare-park-ask.js +377 -0
  69. package/dist/core/runner/prepare-policy-chain.d.ts +208 -0
  70. package/dist/core/runner/prepare-policy-chain.js +584 -0
  71. package/dist/core/runner/prepare-project-context.d.ts +1 -13
  72. package/dist/core/runner/prepare-project-context.js +1 -3
  73. package/dist/core/runner/prepare-prompt-assembly.d.ts +95 -0
  74. package/dist/core/runner/prepare-prompt-assembly.js +162 -0
  75. package/dist/core/runner/prepare-prompt-inputs.d.ts +1 -20
  76. package/dist/core/runner/prepare-protocol-tools.d.ts +3 -3
  77. package/dist/core/runner/prepare-protocol-tools.js +0 -3
  78. package/dist/core/runner/prepare-question-face.d.ts +3 -21
  79. package/dist/core/runner/prepare-question-face.js +2 -1
  80. package/dist/core/runner/prepare-safety-scan.d.ts +0 -5
  81. package/dist/core/runner/prepare-safety-scan.js +1 -2
  82. package/dist/core/runner/prepare-suspend-saga.d.ts +170 -0
  83. package/dist/core/runner/prepare-suspend-saga.js +308 -0
  84. package/dist/core/runner/prepare-task.d.ts +9 -136
  85. package/dist/core/runner/prepare-task.js +47 -2745
  86. package/dist/core/runner/prepare-turn-wiring.d.ts +154 -0
  87. package/dist/core/runner/prepare-turn-wiring.js +201 -0
  88. package/dist/core/runner/prepare-wiring-manifest.d.ts +17 -17
  89. package/dist/core/runner/prepare-wiring-manifest.js +16 -10
  90. package/dist/core/runner/prepare-workspace-restore.d.ts +2 -29
  91. package/dist/core/runner/prepare-workspace-restore.js +3 -16
  92. package/dist/core/runner/prompt-hash-salt.d.ts +1 -0
  93. package/dist/core/runner/prompt-hash-salt.js +2 -0
  94. package/dist/core/runner/remote-env-retry.d.ts +29 -0
  95. package/dist/core/runner/remote-env-retry.js +16 -0
  96. package/dist/core/runner/runtask.d.ts +16 -31
  97. package/dist/core/runner/runtask.js +110 -121
  98. package/dist/core/runner/terminal-projection.d.ts +22 -0
  99. package/dist/core/runner/terminal-projection.js +28 -0
  100. package/dist/core/session.d.ts +12 -0
  101. package/dist/core/session.js +3 -0
  102. package/dist/core/store-contracts/checkpoint-store-contract.d.ts +4 -1
  103. package/dist/core/store-contracts/checkpoint-store-contract.js +8 -2
  104. package/dist/core/terminal-cause.d.ts +137 -0
  105. package/dist/core/terminal-cause.js +9 -0
  106. package/dist/core/tool-policy.d.ts +43 -139
  107. package/dist/core/tool-policy.js +79 -112
  108. package/dist/core/types.d.ts +67 -164
  109. package/dist/core/wiring-manifest.d.ts +6 -3
  110. package/dist/core/workflow-journal-store.js +3 -4
  111. package/dist/engine/harness/agent-harness.d.ts +1 -1
  112. package/dist/index.d.ts +10 -7
  113. package/dist/index.js +8 -5
  114. package/dist/orchestration/builtin-workflows.d.ts +2 -2
  115. package/dist/orchestration/builtin-workflows.js +1 -1
  116. package/dist/orchestration/goal.js +8 -7
  117. package/dist/orchestration/run-spec.js +5 -3
  118. package/dist/orchestration/run-workflow-tool.d.ts +1 -1
  119. package/dist/orchestration/run-workflow-tool.js +4 -4
  120. package/dist/orchestration/workflow-governance.d.ts +4 -4
  121. package/dist/orchestration/workflow-governance.js +4 -2
  122. package/dist/orchestration/workflow-primitives.d.ts +1 -1
  123. package/dist/orchestration/workflow-primitives.js +1 -1
  124. package/dist/orchestration/workflow.d.ts +11 -0
  125. package/dist/orchestration/workflow.js +64 -39
  126. package/dist/prompts/supervisor.d.ts +1 -1
  127. package/dist/prompts/supervisor.js +3 -3
  128. package/dist/scenarios/scenario-registry.js +1 -1
  129. package/package.json +3 -1
  130. package/test/export-surface.snapshot.json +74 -22
  131. /package/dist/core/runner/{prepare-announce-once.d.ts → announce-once-ledger.d.ts} +0 -0
  132. /package/dist/core/runner/{prepare-announce-once.js → announce-once-ledger.js} +0 -0
@@ -0,0 +1,102 @@
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 what a pause 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
+ * "Which holder wins" is no longer a question: there is one holder, and a pause that holds no call
62
+ * publishes absence.
63
+ *
64
+ * The frame side must not read the id back out of a tool RESULT even though the marker is there: a
65
+ * result's `details` is written by the tool (and replaceable by post-tool hooks), so lifting a
66
+ * cross-call attribution from it would let any failing tool name an arbitrary call and put a phantom
67
+ * approval wait on someone else's frame. Same rule, same reason, as `tool_end.gate`.
68
+ *
69
+ */
70
+ export declare function gatedCallIdOf(p: Pick<Prepared, "pausedRef">): 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 (`pausedRef.current`, whatever the pause's kind), the contaminated
74
+ * 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, "pausedRef">): Record<string, unknown> | undefined;
87
+ /**
88
+ * THE COMMIT-SIDE PUBLICATION. Called AFTER `commitSuspendSaga` returns true (the checkpoint is durably
89
+ * committed) to publish the run's one committed pause for the run loop's terminal assembly. There is no
90
+ * routing: the cause carries the committed gate whole, and WHICH terminal status it reads
91
+ * (`suspended` / `needs_review`) is the gate kind's registry row — the two holders the review family and
92
+ * the approval family used to be split across, and the branch that chose between them, are gone.
93
+ * (Review checkpoints themselves are minted by a PROFILE, out of core scope; core owns this seam and the
94
+ * gate/status/resume discriminants.)
95
+ *
96
+ * Module-level, taking the holder as a parameter (design/238 D-7: extract, don't accrete) — beside the
97
+ * two readers of what it publishes ({@link gatedCallIdOf}, {@link parkContaminationMarker}).
98
+ */
99
+ export declare function publishCommittedSuspend(refs: Pick<Prepared, "pausedRef">, token: CheckpointToken, gate: CheckpointGate, scope: string, remoteHandle: WorkspaceHandle | undefined, checkpointId?: string,
100
+ /** The COMMITTED row's pendingAction — passed whole, not as a pre-extracted id, so no call site can
101
+ * hand this seam an id the committed row does not actually park on. */
102
+ pendingAction?: PendingAction): void;
@@ -0,0 +1,31 @@
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
+ return p.pausedRef.current?.gatedCallId;
11
+ }
12
+ export function parkContaminationMarker(refs) {
13
+ if (refs.pausedRef.current === undefined)
14
+ return undefined;
15
+ const gatedCallId = gatedCallIdOf(refs);
16
+ return { code: "gate.parked", ...(gatedCallId !== undefined ? { gatedCallId } : {}) };
17
+ }
18
+ export function publishCommittedSuspend(refs, token, gate, scope, remoteHandle, checkpointId, pendingAction) {
19
+ const pause = {
20
+ cause: {
21
+ kind: "paused",
22
+ gate,
23
+ token,
24
+ ...(checkpointId !== undefined ? { checkpointId } : {}),
25
+ ...(remoteHandle !== undefined ? { restoreMode: remoteHandle.restoreMode === "park_only" ? "park_only" : "snapshot" } : {}),
26
+ },
27
+ scope,
28
+ ...(pendingAction?.kind === "tool_approval" ? { gatedCallId: pendingAction.toolCallId } : {}),
29
+ };
30
+ refs.pausedRef.current = pause;
31
+ }
@@ -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
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * design/390 §1.2 M19 (ask lane) — prepareTask's IN-STREAM ASK LANE, verbatim from the driver's gate-machinery block:
3
+ * the per-call signal composition bound to this task's run signal, the abort-bound `adjudicate` wrapper (the budget
4
+ * snapshot and the live tracked cwd stamped onto every request), the approval-preview projection over the live roster,
5
+ * the `resolveAskBound` mint (the inherited-unavailable intercept, the duplicate-frame grant reuse, the human-review
6
+ * ledger, the bare-human-rejection record), the pre-wrapped deny observer and the hook-crash notifier. The three
7
+ * module-scope helpers only this segment called moved with it (the deny-observer factory, the preview sanitizer and
8
+ * resolver). The interface is the dependency list the segment had implicitly (design/238 R-1) — the closures below
9
+ * used to capture every seat here off the driver's scope.
10
+ *
11
+ * SYNCHRONOUS FUNCTION, SYNCHRONOUS CALL: the stretch has no await of its own (every await lives inside the closures
12
+ * it builds, which run per tool call), so the phase adds no yield between the wiring-manifest station before it and
13
+ * the suspend saga after it (design/390 §1.5: a stretch with no await is extracted as a sync function). Read-stability
14
+ * of the host handles for the call: {@link RunInternals} (`@contract prepare.deps-read-stable`).
15
+ *
16
+ * The lane exists exactly when the wiring-manifest phase's `gateMachineryActive` holds — the driver's `if` around the
17
+ * whole block became this phase's one early return (the block's other four phases key off the lane's presence), so
18
+ * the orchestrator grows no branch.
19
+ */
20
+ import type { AgentTool } from "../../internal/harness.js";
21
+ import type { AutoModeDenialTracker } from "../auto-mode.js";
22
+ import { type Hooks } from "../hooks.js";
23
+ import { type OnAsk, type ToolCallRequest, type ToolPolicy } from "../tool-policy.js";
24
+ import type { RunnerDeps, TaskSpec } from "../types.js";
25
+ import type { CwdRef } from "../../tools/fs/fs-shared.js";
26
+ import type { AskLane, Prepared } from "./contracts.js";
27
+ import { consumeInheritedAskGrant } from "./inherited-ask-grants.js";
28
+ import type { createRuleOffersOf } from "./permission-rule-lanes.js";
29
+ export interface PrepareAskLaneInput {
30
+ /** borrowed-readonly — the wiring-manifest phase's ONE activation predicate; false ⇒ this phase builds nothing and
31
+ * returns an absent lane. */
32
+ gateMachineryActive: boolean;
33
+ /** borrowed-readonly — prepare's abort seat; only `.signal` is read (composed into every per-call signal and bound
34
+ * onto the deny observer). Never aborted here. */
35
+ abortController: {
36
+ readonly signal: AbortSignal;
37
+ };
38
+ /** borrowed-readonly — the policy chain's effective policy, or undefined (then no `adjudicate` wrapper is built and
39
+ * the gate reads a missing policy as allow). Called at RUN time, per tool call, through the abort-bound wrapper. */
40
+ effectivePolicy: ToolPolicy | undefined;
41
+ /** borrowed-readonly — the per-leg-immutable durable budget snapshot stamped onto EVERY adjudicated request. Same
42
+ * object every call; the policy cannot write it back. */
43
+ budgetSnapshot: NonNullable<ToolCallRequest["budget"]>;
44
+ /** borrowed-readonly — the hand's live tracked cwd, or undefined (hands-less). Read per call at RUN time (`current`),
45
+ * never captured: the fs hand tools resolve their relative paths against this very ref. Not written here. */
46
+ handsCwdRef: CwdRef | undefined;
47
+ /** borrowed-readonly — the shared mount roster; read at RUN time by the approval-preview projection (alias-aware
48
+ * lookup over whatever the roster holds when the ask is minted). Never mutated here. */
49
+ tools: AgentTool[];
50
+ /** borrowed-mutable — the inherited-unavailable marker set. Writer here: `resolveAskBound` consumes (`delete`) a
51
+ * marked call at its fail-closed intercept; the policy-chain phase records, the gate station sweeps. */
52
+ inheritedUnavailableAsks: Set<string>;
53
+ /** borrowed-mutable — the duplicate-frame ask-grant record. Writer here: the grant-reuse consume half
54
+ * (`consumeInheritedAskGrant`); the policy-chain phase records, the gate station sweeps. */
55
+ inheritedAskGrants: Parameters<typeof consumeInheritedAskGrant>[0];
56
+ /** borrowed-readonly — the run's own approver seat (spec over deps), or undefined: the resolve target, the
57
+ * live-approver test the carry mint reads, and the grant-reuse identity law's approver. */
58
+ onAsk: OnAsk | undefined;
59
+ /** borrowed-mutable — the per-task human-review accumulator (`Prepared.humanReviewRef`). Writers here: every
60
+ * synchronous resolve that a person actually judged (count / totalWaitMs / gates push); runtask adds the durable
61
+ * resume latency. */
62
+ humanReviewRef: Prepared["humanReviewRef"];
63
+ /** borrowed-readonly — the injectable wall clock every human-time observable reads (`Prepared.now`). */
64
+ now: () => number;
65
+ /** borrowed-readonly — the policy-chain phase's rule-offer factory (the suggestion members the ask mint threads). */
66
+ ruleOffersOf: ReturnType<typeof createRuleOffersOf>;
67
+ /** borrowed-readonly — the policy-chain phase's Runner-filled source identity (one factory, every ask mint site). */
68
+ askSourceIdentity: () => {
69
+ principal?: string;
70
+ sourceTaskId?: string;
71
+ fromSubagent?: true;
72
+ sourceAgentName?: string;
73
+ isDelegatedChild?: true;
74
+ };
75
+ /** borrowed-readonly — the policy-chain phase's per-tool risk-axes projection (`riskAxes` on the ask). */
76
+ riskAxesOf: (toolName: string) => {
77
+ riskAxes?: {
78
+ irreversible?: boolean;
79
+ egress?: boolean;
80
+ };
81
+ };
82
+ /** borrowed-mutable — the auto-mode denial tracker, or undefined. Writer here: the carry mint arms the auto-deny
83
+ * window on it iff the seat about to be called is live (the tracker's one writer). */
84
+ autoModeDenialTracking: AutoModeDenialTracker | undefined;
85
+ /** borrowed-readonly — the REBOUND spec; only `taskId` is read (the late-settlement observer's tag). */
86
+ spec: Pick<TaskSpec, "taskId">;
87
+ /** borrowed-readonly — the deployment seats this lane reports on, as a Pick over the SAME `deps` object (receiver
88
+ * preserved for `deps.onError?.()`): the late-settlement observer's notice/error sinks and the hook-crash notifier. */
89
+ deps: Pick<RunnerDeps, "onNotice" | "onError">;
90
+ /** borrowed-readonly — the acquired session id (the observer's tag, the hook-phase error tag). */
91
+ sessionId: string;
92
+ /** borrowed-readonly — the run's invocation id (the observer's tag). */
93
+ runId: string;
94
+ /** borrowed-readonly — the resolved hook record; only `permissionDenied` is read (the deny observer's seat). */
95
+ hooks: Hooks | undefined;
96
+ /** borrowed-readonly — the validated per-prepare hook seat bound (the deny observer's timeout). */
97
+ hookTimeoutMs: number;
98
+ /** borrowed-readonly — the own hook-crash reporter (the deny observer's swallow-guarded report sink). */
99
+ notifyOwnHookCrash: (err: unknown) => void;
100
+ }
101
+ export interface PrepareAskLaneResult {
102
+ /** owned — the lane, or undefined exactly when `gateMachineryActive` is false (then the suspend saga, the boundary
103
+ * parks, the park closure and the gate station are not built either — they key off this seat). */
104
+ askLane: AskLane | undefined;
105
+ }
106
+ /** The M19 ask-lane phase body — prepareTask's in-stream ask stretch, verbatim (see the module header). */
107
+ export declare function prepareAskLane(input: PrepareAskLaneInput): PrepareAskLaneResult;
@@ -0,0 +1,131 @@
1
+ import { primaryActivityArg } from "../arg-summary.js";
2
+ import { hookSeatExpiredError, runHookSeat } from "../hooks.js";
3
+ import { isLiveApproverSeat, resolveAsk } from "../tool-policy.js";
4
+ import { composeCallSignal, raceAbort } from "./abort-race.js";
5
+ import { gateAskCarry, lateAskSettlementObserver } from "./denial-limit-arms.js";
6
+ import { consumeInheritedAskGrant } from "./inherited-ask-grants.js";
7
+ function createDenyObserverNotifier(hooks, hookTimeoutMs, signal, report) {
8
+ if (hooks?.permissionDenied === undefined)
9
+ return undefined;
10
+ return async (payload) => {
11
+ try {
12
+ const seat = await runHookSeat("permissionDenied", { timeoutMs: hookTimeoutMs, signal }, (sig) => hooks.permissionDenied({ ...payload, signal: sig }));
13
+ if (seat.expired)
14
+ report(hookSeatExpiredError("permissionDenied", hookTimeoutMs, seat.cause, "the deny observation was abandoned; the deny itself is unchanged"));
15
+ }
16
+ catch (err) {
17
+ report(err);
18
+ }
19
+ };
20
+ }
21
+ const sanitizePreview = (node, depth = 0) => {
22
+ if (depth > 6)
23
+ return undefined;
24
+ if (typeof node === "string") {
25
+ return node.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "\u2400");
26
+ }
27
+ if (node === null || typeof node !== "object")
28
+ return node;
29
+ if (Array.isArray(node))
30
+ return node.map((v) => sanitizePreview(v, depth + 1));
31
+ const out = {};
32
+ for (const [k, v] of Object.entries(node)) {
33
+ out[sanitizePreview(k, depth + 1)] = sanitizePreview(v, depth + 1);
34
+ }
35
+ return out;
36
+ };
37
+ function resolveApprovalPreview(tools, toolName, args) {
38
+ const t = tools.find((x) => x.name === toolName || (x.aliases?.includes(toolName) ?? false));
39
+ if (t?.approvalPreview === undefined)
40
+ return {};
41
+ try {
42
+ const raw = t.approvalPreview(args);
43
+ if (raw === undefined)
44
+ return {};
45
+ const bytes = JSON.stringify(raw);
46
+ if (bytes === undefined)
47
+ return { withheld: "unavailable" };
48
+ if (bytes.length > 16_384) {
49
+ return { preview: { truncated: true, note: `approval preview exceeded 16KiB (${bytes.length} chars serialized)` }, withheld: "oversize" };
50
+ }
51
+ return { preview: sanitizePreview(raw) };
52
+ }
53
+ catch {
54
+ return { withheld: "unavailable" };
55
+ }
56
+ }
57
+ export function prepareAskLane(input) {
58
+ const { gateMachineryActive, abortController, effectivePolicy, budgetSnapshot, handsCwdRef, tools, inheritedUnavailableAsks, inheritedAskGrants, onAsk, humanReviewRef, now, ruleOffersOf, askSourceIdentity, riskAxesOf, autoModeDenialTracking, spec, deps, sessionId, runId, hooks, hookTimeoutMs, notifyOwnHookCrash } = input;
59
+ if (!gateMachineryActive)
60
+ return { askLane: undefined };
61
+ const composedCallSignal = (callSignal) => composeCallSignal(abortController.signal, callSignal);
62
+ const adjudicate = effectivePolicy
63
+ ? (req, callSignal) => {
64
+ const signal = composedCallSignal(callSignal);
65
+ return raceAbort(Promise.resolve(effectivePolicy.check({ ...req, budget: budgetSnapshot, ...(handsCwdRef !== undefined ? { cwd: handsCwdRef.current } : {}) }, signal)), signal, () => ({
66
+ action: "deny",
67
+ message: "policy check aborted (task timed out or cancelled)",
68
+ }));
69
+ }
70
+ : undefined;
71
+ const approvalPreviewOf = (toolName, args) => resolveApprovalPreview(tools, toolName, args);
72
+ const resolveAskBound = async (decision, req, callSignal) => {
73
+ if (inheritedUnavailableAsks.delete(req.toolCallId)) {
74
+ return {
75
+ action: "deny",
76
+ message: `approval for "${req.toolName}" requires an ancestor task's approval that cannot be resolved here ` +
77
+ `(no live approver reachable, or a durable-park mandate applies), and no durable approval park is ` +
78
+ `available — denied fail-closed (the inherited constraint stands).`,
79
+ decisionReason: "mode",
80
+ settlement: { kind: "approver_unavailable", who: { party: "none" }, when: now() },
81
+ approverUnavailable: true,
82
+ };
83
+ }
84
+ const grantReuse = consumeInheritedAskGrant(inheritedAskGrants, onAsk, humanReviewRef, decision, req);
85
+ if (grantReuse !== undefined)
86
+ return grantReuse;
87
+ const t0 = now();
88
+ const resolved = await resolveAsk({
89
+ toolName: req.toolName,
90
+ toolCallId: req.toolCallId,
91
+ args: req.args,
92
+ ...(() => {
93
+ const p = approvalPreviewOf(req.toolName, req.args);
94
+ return { ...(p.preview !== undefined ? { preview: p.preview } : {}), ...(p.withheld !== undefined ? { previewWithheld: p.withheld } : {}) };
95
+ })(),
96
+ ...ruleOffersOf(req.toolName, req.args, decision.action === "ask" ? decision : undefined),
97
+ message: decision.message ?? `approval required for "${req.toolName}"`,
98
+ ...askSourceIdentity(),
99
+ ...riskAxesOf(req.toolName),
100
+ ...(decision.action === "ask" && decision.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
101
+ ...(decision.action === "ask" && decision.persistedRuleShadowed !== undefined ? { persistedRuleShadowed: decision.persistedRuleShadowed } : {}),
102
+ ...(decision.action === "ask" ? gateAskCarry(decision, isLiveApproverSeat(onAsk), autoModeDenialTracking) : {}),
103
+ ...(decision.action === "ask" && decision.probeReason !== undefined ? { probeReason: decision.probeReason } : {}),
104
+ ...(decision.action === "ask" && decision.probeCause !== undefined ? { probeCause: decision.probeCause } : {}),
105
+ ...(decision.action === "ask" && decision.ruleEvidence !== undefined ? { ruleEvidence: decision.ruleEvidence } : {}),
106
+ }, onAsk, composedCallSignal(callSignal), lateAskSettlementObserver({ toolName: req.toolName, toolCallId: req.toolCallId, sessionId, runId, ...(spec.taskId !== undefined ? { taskId: spec.taskId } : {}), onNotice: deps.onNotice, onError: deps.onError }));
107
+ const waitMs = Math.max(0, now() - t0);
108
+ if (resolved.approverUnavailable !== true && resolved.settlement?.kind !== "task_aborted") {
109
+ humanReviewRef.count += 1;
110
+ humanReviewRef.totalWaitMs += waitMs;
111
+ const toolArg = primaryActivityArg(req.args);
112
+ humanReviewRef.gates.push({
113
+ kind: "human",
114
+ waitMs,
115
+ decision: resolved.action === "allow" ? "allow" : "deny",
116
+ toolName: req.toolName,
117
+ ...(toolArg !== undefined ? { toolArg } : {}),
118
+ });
119
+ }
120
+ return resolved;
121
+ };
122
+ const notifyPermissionDenied = createDenyObserverNotifier(hooks, hookTimeoutMs, abortController.signal, notifyOwnHookCrash);
123
+ const notifyHookError = (err) => {
124
+ try {
125
+ deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "hook", sessionId });
126
+ }
127
+ catch {
128
+ }
129
+ };
130
+ return { askLane: { composedCallSignal, adjudicate, approvalPreviewOf, resolveAskBound, notifyPermissionDenied, notifyHookError } };
131
+ }