@sema-agent/core 5.37.0 → 5.38.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 (39) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/agents/subagent.js +6 -0
  3. package/dist/agents/teacher.js +3 -0
  4. package/dist/agents/team.d.ts +7 -1
  5. package/dist/agents/team.js +11 -9
  6. package/dist/agents/verify.js +3 -0
  7. package/dist/core/auto-mode-prompt-assets.d.ts +5 -3
  8. package/dist/core/auto-mode-prompt-assets.js +1 -1
  9. package/dist/core/checkpoint-store.d.ts +26 -1
  10. package/dist/core/hooks.d.ts +129 -2
  11. package/dist/core/hooks.js +20 -3
  12. package/dist/core/runner/prepare-config-doors.d.ts +17 -0
  13. package/dist/core/runner/prepare-config-doors.js +33 -2
  14. package/dist/core/runner/prepare-task.d.ts +17 -2
  15. package/dist/core/runner/prepare-task.js +124 -41
  16. package/dist/core/runner/runtask.js +46 -11
  17. package/dist/core/tool-model-gate.d.ts +125 -0
  18. package/dist/core/tool-model-gate.js +303 -0
  19. package/dist/core/tool-policy.d.ts +1 -1
  20. package/dist/core/types.d.ts +189 -1
  21. package/dist/core/types.js +21 -0
  22. package/dist/core/untrusted-text.d.ts +1 -1
  23. package/dist/index.d.ts +4 -3
  24. package/dist/index.js +2 -1
  25. package/dist/orchestration/builtin-workflows.d.ts +68 -6
  26. package/dist/orchestration/builtin-workflows.js +26 -9
  27. package/dist/orchestration/run-workflow-tool.d.ts +10 -1
  28. package/dist/orchestration/run-workflow-tool.js +70 -27
  29. package/dist/orchestration/workflow-script-store.d.ts +8 -3
  30. package/dist/prompts/coordinator.d.ts +4 -1
  31. package/dist/prompts/coordinator.js +8 -0
  32. package/dist/prompts/default.d.ts +14 -4
  33. package/dist/prompts/default.js +2 -1
  34. package/dist/scenarios/full-body.d.ts +5 -0
  35. package/dist/scenarios/full-body.js +8 -4
  36. package/dist/tools/fs/fs-shared.d.ts +3 -2
  37. package/dist/tools/fs/fs-shared.js +19 -9
  38. package/package.json +1 -1
  39. package/test/export-surface.snapshot.json +12 -1
@@ -1034,6 +1034,27 @@ export interface CheckpointState {
1034
1034
  * with a recorder armed; ABSENT on an unarmed leg AND on an older checkpoint — the resume side reads
1035
1035
  * absence-on-an-armed-child fail-closed as `incomplete`. */
1036
1036
  delegationProvenance?: import("./memory-engine/delegation-provenance.js").DelegationProvenanceAggregate;
1037
+ /**
1038
+ * #281 件B follow-up — `true` iff the suspended leg ran as a DELEGATED CHILD (the trusted
1039
+ * `RunInternals.isDelegatedChild` fact, read off the leg's own minted
1040
+ * {@link import("./runner/prepare-task.js").Prepared.hookIdentity} so a re-suspend chain carries it
1041
+ * forward). Without it a deps-only resume (`resume(token, outcome, config)` — no trusted internals
1042
+ * re-supplied) re-minted the resumed leg's identity with `isDelegatedChild: false`, so the
1043
+ * delegation-lifecycle observer's emission gate stayed shut and the resumed leg emitted NEITHER
1044
+ * frame — a deployment watching only that seat saw a human-approved delegation frozen at its
1045
+ * park-leg `"suspended"` terminal forever. Live internals still win when re-supplied (the in-engine
1046
+ * resume lanes all do); the checkpoint key is the fallback for the deps-only leg. Only this ONE
1047
+ * axis persists — `insideFork`/`agentName`/`parentToolCallId` stay internals-only, honestly absent
1048
+ * on a deps-only resume rather than guessed; the accepted residue of the absent `insideFork` is
1049
+ * that a FORK child resumed deps-only reads as a plain delegated child on the non-fork consumer
1050
+ * facet (subagent persona/context trim) — documented here rather than fixed with a new
1051
+ * persistence axis. Schema ADDITION (additive, old readers ignore it —
1052
+ * the Q5 posture; no version movement): an older worker resuming a newer row keeps the pre-key
1053
+ * behavior, and an older row without the key resumes frame-less by documented honest absence
1054
+ * (see {@link import("./types.js").DelegationLifecycleEvent}). Absent = the leg was not a
1055
+ * delegated child (never `false` — presence-coded like the other monotonic bits here).
1056
+ */
1057
+ isDelegatedChild?: true;
1037
1058
  }
1038
1059
  /**
1039
1060
  * {@link CheckpointState} with **every** field made required-PRESENT, while each value keeps its original
@@ -2099,7 +2120,11 @@ export interface CheckpointStore {
2099
2120
  * a real pin is the in-memory `TtlSessionStore` (single-instance / tests), where an un-released pin
2100
2121
  * keeps an abandoned suspended session in the cache until process exit. v1 leaves the runner without an
2101
2122
  * auto-reaper (the service owns the TTL worker); a reaper over the in-memory store should track and
2102
- * `unpin` the expired sessions itself if that leak matters for its deployment.
2123
+ * `unpin` the expired sessions itself if that leak matters for its deployment. This no-unpin posture
2124
+ * is LOCAL to this live-path `reap`: the managed-retention contract's `expireCheckpoints`
2125
+ * ({@link import("./retention.js").ManagedRetentionCapability}) deliberately closes the reap/unpin
2126
+ * split — a retention-terminal row has its pin released BY that method, so pin release stays
2127
+ * exactly-once per row (each row is terminated by exactly one of the two lanes).
2103
2128
  */
2104
2129
  reap(scope: string, cutoff: number): Promise<number>;
2105
2130
  /**
@@ -2,6 +2,7 @@ import type { DocumentContent, ImageContent, TextContent } from "../internal/llm
2
2
  import type { ExecutionEnv, FileError, Result, SessionTreeEntry } from "../internal/harness-types.js";
3
3
  import type { DecisionReason, PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
4
4
  import { type AskClass } from "./ask-class.js";
5
+ import type { WiringLegKind } from "./wiring-manifest.js";
5
6
  /**
6
7
  * In-process hook seam (design/37) — a provider-agnostic interception layer modeled on CC's hooks,
7
8
  * kept process-internal (no shell/HTTP executors, no settings files). The original tool-call trio:
@@ -67,7 +68,10 @@ export interface Hooks {
67
68
  */
68
69
  preToolUseObservational?: true;
69
70
  postToolUse?(toolName: string, input: unknown, output: HookToolOutput, ctx: HookToolContext): PostToolUseResult | undefined | Promise<PostToolUseResult | undefined>;
70
- userPromptSubmit?(prompt: string): UserPromptSubmitResult | undefined | Promise<UserPromptSubmitResult | undefined>;
71
+ userPromptSubmit?(prompt: string,
72
+ /** #281 件A (additive): the run/leg identity envelope — an implementation declaring only
73
+ * `(prompt)` keeps working. Always supplied on the engine's emission. */
74
+ ctx?: UserPromptSubmitContext): UserPromptSubmitResult | undefined | Promise<UserPromptSubmitResult | undefined>;
71
75
  /**
72
76
  * roadmap #5 (CC 198 Stop hook, :473831-): runs when the agent WOULD OTHERWISE END its run (no
73
77
  * more tool calls, steering and follow-up queues dry). Return `{ block: reason }` to PUSH BACK —
@@ -106,7 +110,11 @@ export interface Hooks {
106
110
  */
107
111
  postToolBatch?(batch: PostToolBatchCall[], meta?: {
108
112
  injectedThisTurn: "final_verification" | "finalize";
109
- }): PostToolBatchResult | undefined | Promise<PostToolBatchResult | undefined>;
113
+ },
114
+ /** #281 件A (additive): the run/leg identity envelope, on its OWN parameter — `meta`'s presence
115
+ * is an existing signal ("the engine injected at this boundary") that must not become
116
+ * always-true just because identity rides along. Always supplied on the engine's emission. */
117
+ ctx?: PostToolBatchContext): PostToolBatchResult | undefined | Promise<PostToolBatchResult | undefined>;
110
118
  /**
111
119
  * design/134 (CC PreCompact parity): runs before each compaction, AFTER the trigger gate and a
112
120
  * valid cut point are confirmed (so every preCompact corresponds to a compaction that would
@@ -177,6 +185,10 @@ export interface Hooks {
177
185
  export type PermissionDeniedSource = "policy" | "hook" | "safety" | "shellGate" | "planMode" | "classifier" | "org";
178
186
  /** The payload a {@link Hooks.permissionDenied} callback observes (CC-exact fields + `source`). */
179
187
  export interface PermissionDeniedPayload {
188
+ /** #281 件A — the run/leg identity envelope. Present on every ENGINE emission (the gate's single
189
+ * deny exit, the hook-crash intercept, the plan-mode and compliance denies); absent only when a
190
+ * host drives {@link runToolGate} without `ToolGateInput.identity`. */
191
+ identity?: HookInvocationIdentity;
180
192
  toolName: string;
181
193
  /** The FINAL (post-hook-rewrite / post-policy-rewrite) args the chain adjudicated — what would have
182
194
  * executed; not necessarily the model's original args. */
@@ -198,6 +210,9 @@ export interface PermissionDeniedPayload {
198
210
  export declare function cloneObserverInput(input: unknown): unknown;
199
211
  /** Context for {@link Hooks.stopFailure} — aligned with the TaskResult error face (observe-only). */
200
212
  export interface StopFailureContext {
213
+ /** #281 件A — the run/leg identity envelope (always present on the engine's emission; the seat is
214
+ * optional only because the envelope's whole contract is). See {@link HookInvocationIdentity}. */
215
+ identity?: HookInvocationIdentity;
201
216
  /** Human-readable error message (the assembled `errorMessage`; any `[code]` prefix already stripped). */
202
217
  error: string;
203
218
  /** Machine-readable kind — the assembled `errorCode` lifted from the brain's `[code]` prefix
@@ -242,6 +257,10 @@ export interface PostToolBatchResult {
242
257
  }
243
258
  /** Context for {@link Hooks.preCompact} (design/134). */
244
259
  export interface PreCompactContext {
260
+ /** #281 件A — the run/leg identity envelope. Present when the Runner drives the compaction (its
261
+ * wrapper injects it); honestly absent when a deployment drives the compaction module directly
262
+ * (presence law arm 3 on {@link HookInvocationIdentity}). */
263
+ identity?: HookInvocationIdentity;
245
264
  /** "auto" = threshold-triggered; "manual" = /compact; "forced" = promptTooLong recovery or
246
265
  * trim-pressure propagation (block is ignored on forced — the compaction is not optional). */
247
266
  trigger: "auto" | "manual" | "forced";
@@ -260,6 +279,8 @@ export interface PreCompactResult {
260
279
  }
261
280
  /** Context for {@link Hooks.postCompact} (design/134, observe-only). */
262
281
  export interface PostCompactContext {
282
+ /** #281 件A — same presence law as {@link PreCompactContext.identity}. */
283
+ identity?: HookInvocationIdentity;
263
284
  trigger: "auto" | "manual" | "forced";
264
285
  /** The conversation summary the compaction produced (CC `compact_summary` parity). */
265
286
  summary: string;
@@ -268,6 +289,11 @@ export interface PostCompactContext {
268
289
  }
269
290
  /** Context for the {@link Hooks.stop} hook (CC `stop_hook_active` parity). */
270
291
  export interface StopHookContext {
292
+ /** #281 件A — the run/leg identity envelope. The load-bearing member for THIS seat: a process-level
293
+ * `deps.hooks.stop` fires in root, delegated-child and resume legs alike, and previously could not
294
+ * tell which run it was being asked to end (matrix row 10/13 residual). Always present on the
295
+ * engine's stop-gate emission. */
296
+ identity?: HookInvocationIdentity;
271
297
  /** True when this run is already continuing because a previous stop() blocked — check it and
272
298
  * return success (undefined) once your condition can't be improved, or you will loop to the cap. */
273
299
  stopHookActive: boolean;
@@ -394,10 +420,94 @@ export interface HookEnvCapabilities {
394
420
  * {@link createCwdReader} carries the one live read under the same rules.
395
421
  */
396
422
  export declare function createHookEnvCapabilities(env: ExecutionEnv): HookEnvCapabilities;
423
+ /**
424
+ * #281 件A — the UNIFIED IDENTITY ENVELOPE every hook invocation can carry (`ctx.identity`): which
425
+ * run, which leg, and where in the delegation tree the hook is firing. The CC analog is
426
+ * `createBaseHookInput`'s per-event base fields; the members here are sema's OWN identity facts
427
+ * (session/leg/delegation axes), not a transcription of CC's field names.
428
+ *
429
+ * WHY: `deps.hooks` is PROCESS-level — one `stop`/`preToolUse`/`permissionDenied` seat serves the
430
+ * root run, every delegated child and every resume leg at once, and before this envelope none of
431
+ * those invocations could say which leg they belonged to. Additive: every seat is
432
+ * `identity?: HookInvocationIdentity`, so a consumer that never reads it sees nothing new.
433
+ *
434
+ * PRESENCE LAW (which invocations carry it — stated here once, referenced per seat):
435
+ * · PRESENT on every ENGINE-driven invocation of every hook seat: the tool gate's PreToolUse
436
+ * screenings (phase 1 + the approval-edit re-screen), postToolUse / postToolUseFailure,
437
+ * postToolBatch, userPromptSubmit, stop, stopFailure, preCompact / postCompact (the Runner's
438
+ * wrapper injects it), and every engine `permissionDenied` emission (gate deny exit, hook-crash
439
+ * intercept, plan-mode deny, compliance deny). The engine mints ONE frozen envelope per prepared
440
+ * leg ({@link mintHookInvocationIdentity} — single mint home) and every station reuses it.
441
+ * · HONESTLY ABSENT (no fabrication) at exactly four places, each structurally unable to know:
442
+ * 1. a HOST driving the exported {@link runToolGate} directly without supplying
443
+ * `ToolGateInput.identity` — the engine cannot know a leg it is not running;
444
+ * 2. an inherited PreToolUse screening face riding a DESCENDANT's constraint fold
445
+ * ({@link createPreToolUseConstraintPolicy}) — the fold consumes it as a `ToolPolicy`, whose
446
+ * request shape carries no leg identity, and stamping the INSTALL-site leg would name the
447
+ * wrong run;
448
+ * 3. a deployment driving the compaction module directly with its own `preCompact`/`postCompact`
449
+ * options — those contexts are built below the runner, which is where the envelope lives;
450
+ * 4. the PRE-CAS durable-resume observation of a declared-observational PreToolUse face
451
+ * (resumeStream's edited-args leg) — it runs BEFORE the resume leg prepares, so the leg's
452
+ * envelope does not exist yet, and minting a partial one off the checkpoint would guess at
453
+ * the delegation axes (the row persists `isDelegatedChild` alone; `insideFork`/`agentName`/
454
+ * `parentToolCallId` are internals-only, and a wrong guess is worse than an honest
455
+ * absence). The SAME face is re-consulted with full identity by the resumed leg's own gate.
456
+ *
457
+ * CONSUMER NOTE — the minted object is FROZEN and carries a NULL PROTOTYPE (same rule and same
458
+ * trade as {@link HookEnvCapabilities}: absence must be a fact about this object, not about what
459
+ * `Object.prototype` happens to hold). Read members directly (`identity.agentName`), test presence
460
+ * with `in` or `!== undefined`; do NOT call inherited methods on it — `identity.hasOwnProperty(...)`
461
+ * or implicit string coercion would throw, exactly as on the env capability face.
462
+ */
463
+ export interface HookInvocationIdentity {
464
+ /** The run's session id (uuid domain) — the same key `TaskResult.sessionId` reports. */
465
+ readonly sessionId: string;
466
+ /** The run's unified task id (`spec.taskId ?? sessionId` — the value TaskEvent's `sourceTaskId`
467
+ * and `task_progress` frames key on). Equal to {@link sessionId} when no task id was declared. */
468
+ readonly taskId: string;
469
+ /** Which prepared leg this invocation belongs to — the wiring manifest's own `leg.kind`
470
+ * derivation, verbatim ({@link WiringLegKind}: `"root" | "child" | "resume"`). NOTE the fork
471
+ * lane's legs read `"child"` here like any other delegation; {@link insideFork} is the
472
+ * fork-lane discriminator. */
473
+ readonly legKind: WiringLegKind;
474
+ /** RB-204 delegation fact, verbatim: `true` iff this leg runs as a delegated child (every core
475
+ * spawn lane — sync/steer/background/fork/workflow — sets it; a top-level run reads `false`). */
476
+ readonly isDelegatedChild: boolean;
477
+ /** Present (`true`) iff this leg IS a forked child (`Agent(subagent_type:"fork")` — the
478
+ * design/110 trusted internals fact). Absent everywhere else. */
479
+ readonly insideFork?: true;
480
+ /** The delegated child's display name, when one was threaded at spawn (taskName / agent-type).
481
+ * UNTRUSTED display text (model-chosen) — already single-line-bounded at mint (same
482
+ * `inlineUntrusted` treatment as the `task_progress` name lane); never an identity key. */
483
+ readonly agentName?: string;
484
+ /** The spawning Agent tool call's own id, when this leg runs under one — the delegation-chain
485
+ * position anchor ({@link import("./types.js").TaskEvent}'s `parentToolCallId` twin). Absent on
486
+ * a top-level run and on a directly-started workflow child (no launching tool call — no id is
487
+ * fabricated). */
488
+ readonly parentToolCallId?: string;
489
+ }
490
+ /**
491
+ * #281 件A — the ONE construction home of {@link HookInvocationIdentity} (domain-lexicon: one mint
492
+ * site; the runner calls it once per prepared leg and every hook station reuses the same object).
493
+ * Frozen AND null-prototype: the envelope is handed to arbitrarily many deployment callbacks, and a
494
+ * hook mutating `ctx.identity` must not rewrite what a later hook (or the delegation-lifecycle
495
+ * observer) reads. The null prototype is the {@link createHookEnvCapabilities} rule applied to the
496
+ * other absence-signaling face this module mints — "absent member" must be a fact about THIS object,
497
+ * and a frozen object with an ordinary prototype still answers `insideFork`/`agentName` reads (and
498
+ * `in` probes) from `Object.prototype`, so a prototype write elsewhere in the process could hand
499
+ * every leg a delegation axis the mint never stamped (codex r1). `agentName` is sanitized here — it
500
+ * is the only member whose value a model influences.
501
+ */
502
+ export declare function mintHookInvocationIdentity(facts: HookInvocationIdentity): HookInvocationIdentity;
397
503
  /** Identifying context passed to tool hooks. */
398
504
  export interface HookToolContext {
399
505
  toolCallId: string;
400
506
  toolName: string;
507
+ /** #281 件A — the run/leg identity envelope; see {@link HookInvocationIdentity} for the presence
508
+ * law (present on engine-driven gates; absent on a host-driven gate without
509
+ * `ToolGateInput.identity` and on the delegation-fold twin). */
510
+ identity?: HookInvocationIdentity;
401
511
  /**
402
512
  * The read-only path-resolution face over the env the hands run against (see
403
513
  * {@link HookEnvCapabilities} for the why, and for why it is capabilities rather than the env object).
@@ -450,6 +560,18 @@ export interface PostToolUseResult {
450
560
  /** Appended to the result as a `<system-reminder>` the model can read. */
451
561
  additionalContext?: string;
452
562
  }
563
+ /** #281 件A — the context of a {@link Hooks.userPromptSubmit} invocation (second parameter,
564
+ * additive). Carries only the identity envelope today; an object so later additions stay additive. */
565
+ export interface UserPromptSubmitContext {
566
+ /** The run/leg identity envelope — see {@link HookInvocationIdentity} for the presence law. */
567
+ identity?: HookInvocationIdentity;
568
+ }
569
+ /** #281 件A — the context of a {@link Hooks.postToolBatch} invocation (third parameter, additive —
570
+ * deliberately NOT folded into `meta`, whose presence already means "the engine injected here"). */
571
+ export interface PostToolBatchContext {
572
+ /** The run/leg identity envelope — see {@link HookInvocationIdentity} for the presence law. */
573
+ identity?: HookInvocationIdentity;
574
+ }
453
575
  /** A UserPromptSubmit hook result: block the submission, or inject context ahead of the prompt. */
454
576
  export interface UserPromptSubmitResult {
455
577
  /** Block submission entirely; the task fails with this model-readable reason. */
@@ -672,6 +794,11 @@ export interface ToolGateInput {
672
794
  toolName: string;
673
795
  input: Record<string, unknown>;
674
796
  };
797
+ /** #281 件A — the run/leg identity envelope, put verbatim on every {@link HookToolContext} this
798
+ * gate call builds and on every {@link PermissionDeniedPayload} it emits. The Runner supplies its
799
+ * per-leg mint; a host driving the gate directly may omit it, and the contexts then carry no
800
+ * identity (honest absence — this layer never fabricates a leg). */
801
+ identity?: HookInvocationIdentity;
675
802
  preToolUse?: Hooks["preToolUse"];
676
803
  /** The read-only env capability face put on every {@link HookToolContext} this gate call
677
804
  * builds ({@link HookEnvCapabilities}). Built ONCE per task by the runner (after the env is minted) and
@@ -78,6 +78,18 @@ export function createHookEnvCapabilities(env) {
78
78
  }
79
79
  return Object.freeze(face);
80
80
  }
81
+ const IDENTITY_AGENT_NAME_MAX = 80;
82
+ export function mintHookInvocationIdentity(facts) {
83
+ return Object.freeze(Object.assign(Object.create(null), {
84
+ sessionId: facts.sessionId,
85
+ taskId: facts.taskId,
86
+ legKind: facts.legKind,
87
+ isDelegatedChild: facts.isDelegatedChild,
88
+ ...(facts.insideFork === true ? { insideFork: true } : {}),
89
+ ...(facts.agentName !== undefined ? { agentName: inlineUntrusted(facts.agentName.slice(0, 320), IDENTITY_AGENT_NAME_MAX) } : {}),
90
+ ...(facts.parentToolCallId !== undefined ? { parentToolCallId: facts.parentToolCallId } : {}),
91
+ }));
92
+ }
81
93
  export function formatHookFeedback(text) {
82
94
  return `<system-reminder>\n${text}\n</system-reminder>`;
83
95
  }
@@ -270,7 +282,12 @@ export function persistedRuleMandateOf(marks) {
270
282
  export async function runToolGate(input) {
271
283
  const { event, preToolUse, adjudicate, resolveAsk, suspendAsk } = input;
272
284
  const { toolCallId, toolName } = event;
273
- const hookCtx = () => ({ toolCallId, toolName, ...(input.hookEnv !== undefined ? { env: input.hookEnv } : {}) });
285
+ const hookCtx = () => ({
286
+ toolCallId,
287
+ toolName,
288
+ ...(input.hookEnv !== undefined ? { env: input.hookEnv } : {}),
289
+ ...(input.identity !== undefined ? { identity: input.identity } : {}),
290
+ });
274
291
  let currentInput = event.input;
275
292
  const preToolContext = [];
276
293
  let hookAsk;
@@ -285,7 +302,7 @@ export async function runToolGate(input) {
285
302
  const reason = preToolUseCrashReason(`this call to "${toolName}"`, err);
286
303
  traceHookCrash(input, err, notifier);
287
304
  if (input.permissionDenied) {
288
- await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason, source: "hook" }), "toolGate.permissionDenied");
305
+ await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason, source: "hook", ...(input.identity !== undefined ? { identity: input.identity } : {}) }), "toolGate.permissionDenied");
289
306
  }
290
307
  return { block: true, reason: formatHookFeedback(reason), preToolContext };
291
308
  }
@@ -741,7 +758,7 @@ export async function runToolGate(input) {
741
758
  currentInput = decision.updatedInput;
742
759
  }
743
760
  if (input.permissionDenied) {
744
- await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource }), "toolGate.permissionDenied");
761
+ await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource, ...(input.identity !== undefined ? { identity: input.identity } : {}) }), "toolGate.permissionDenied");
745
762
  }
746
763
  const denySettledBy = decision.settledBy;
747
764
  const denyApprover = denySettledBy !== undefined ? resolvedApprover : undefined;
@@ -107,6 +107,10 @@ export interface PrepareConfigDoorsResult {
107
107
  exclude: readonly string[] | undefined;
108
108
  defer: readonly string[] | undefined;
109
109
  alwaysLoad: readonly string[] | undefined;
110
+ /** design/277 — the model-gate restore selector ({@link TaskSpec.restoreGatedTools}), fourth
111
+ * seat of the same frozen task-start snapshot: the gate decision and the delegation carrier
112
+ * read THIS, never the live spec. */
113
+ restoreGated: readonly string[] | true | undefined;
110
114
  };
111
115
  /** owned — the profile half of the RB-50 single decision point (model-independent by contract). */
112
116
  promptProfile: "simple" | "classic";
@@ -127,6 +131,19 @@ export interface PrepareConfigDoorsResult {
127
131
  compModel: Model | undefined;
128
132
  /** owned — the mitigations half of the RB-50 decision point (model-family fact). */
129
133
  fableMitigations: boolean;
134
+ /** owned, frozen — design/277 model-gate DISCLOSURE data (the application itself already
135
+ * happened on {@link spec}: its `tools` is the survivor rebind when anything was removed).
136
+ * prepareTask emits the notices from this after the doors return (the onNotice station);
137
+ * the doors stay side-effect-free on the announcement axis. */
138
+ modelGate: {
139
+ /** gate-class → removed wire names (sorted, unique); empty map = nothing removed. */
140
+ removedByClass: ReadonlyMap<string, readonly string[]>;
141
+ /** stamped classes with no merged-table row (inert tags, fail-open — announce material). */
142
+ unknownClasses: readonly string[];
143
+ /** a not-in-force `SEMA_TOOL_MODEL_GATE` value outside the closed set (discard-announce
144
+ * material; the in-force arm never lands here — it throws at the door). */
145
+ discardedEnvRaw: string | undefined;
146
+ };
130
147
  /** owned — validated deployment governance windows (undefined = ungoverned). */
131
148
  usageWindows: readonly UsageWindow[] | undefined;
132
149
  /** owned, out-param cell — created EMPTY here; the brain-call wiring later installs into
@@ -4,6 +4,7 @@ import { resolveCheckpointStore } from "../checkpoint-store.js";
4
4
  import { preflightLockedConfig } from "../locked-config.js";
5
5
  import { assertRetentionCapability } from "../retention.js";
6
6
  import { resolveModel, resolveTaskModel, roleModelIfSet } from "../roles.js";
7
+ import { applyToolModelGate, assertRestoreGatedToolsValue, modelIdTail } from "../tool-model-gate.js";
7
8
  import { resolveUsageWindows } from "../usage-window-store.js";
8
9
  import { deriveAskEffective, resolveAskSeamForm, resolveQuestionSeam } from "../wiring-manifest.js";
9
10
  const TASK_LIMIT_KEY_DICT = {
@@ -73,7 +74,7 @@ export function resolveTaskLimits(limits) {
73
74
  return limits;
74
75
  }
75
76
  export function isFableFamilyModelId(id) {
76
- const tail = id.toLowerCase().split("/").pop() ?? "";
77
+ const tail = modelIdTail(id);
77
78
  return /^claude-fable-\d/.test(tail) || /^claude-mythos-5(?!\d)/.test(tail);
78
79
  }
79
80
  export function resolveModelPromptTraits(model, spec, internals) {
@@ -85,10 +86,16 @@ export function resolveModelPromptTraits(model, spec, internals) {
85
86
  export function prepareConfigDoors(input) {
86
87
  const { deps, sessions, resume, internals } = input;
87
88
  let spec = input.spec;
89
+ assertRestoreGatedToolsValue(spec.restoreGatedTools);
88
90
  const toolFaceSnapshot = {
89
91
  exclude: spec.excludeTools ? Object.freeze([...spec.excludeTools]) : undefined,
90
92
  defer: spec.deferTools ? Object.freeze([...spec.deferTools]) : undefined,
91
93
  alwaysLoad: spec.alwaysLoadTools ? Object.freeze([...spec.alwaysLoadTools]) : undefined,
94
+ restoreGated: spec.restoreGatedTools === true
95
+ ? true
96
+ : spec.restoreGatedTools !== undefined
97
+ ? Object.freeze([...spec.restoreGatedTools])
98
+ : undefined,
92
99
  };
93
100
  const promptProfile = resolveModelPromptTraits({ id: "" }, spec, internals).promptProfile;
94
101
  if (spec.resumeAt !== undefined) {
@@ -223,11 +230,34 @@ export function prepareConfigDoors(input) {
223
230
  throw e;
224
231
  }
225
232
  const perTask = spec.agents;
226
- spec = { ...spec, tools: pool.map((t) => (typeof t.withAgents === "function" ? t.withAgents(perTask) : t)) };
233
+ spec = {
234
+ ...spec,
235
+ tools: pool.map((t) => {
236
+ if (typeof t.withAgents !== "function")
237
+ return t;
238
+ const rebuilt = t.withAgents(perTask);
239
+ return t.modelGate !== undefined && rebuilt.modelGate === undefined ? { ...rebuilt, modelGate: t.modelGate } : rebuilt;
240
+ }),
241
+ };
227
242
  }
228
243
  const resolvedRole = resolveTaskModel(spec, deps);
229
244
  const model = resolvedRole.model;
230
245
  const fableMitigations = resolveModelPromptTraits(model, spec, internals).fableMitigations;
246
+ const gateDecision = applyToolModelGate({
247
+ tools: spec.tools,
248
+ modelId: model.id,
249
+ depsSeat: deps.toolModelGate,
250
+ restoreGated: toolFaceSnapshot.restoreGated,
251
+ envRaw: process.env.SEMA_TOOL_MODEL_GATE,
252
+ });
253
+ if (gateDecision.survivors !== undefined) {
254
+ spec = { ...spec, tools: gateDecision.survivors };
255
+ }
256
+ const modelGate = Object.freeze({
257
+ removedByClass: gateDecision.removedByClass,
258
+ unknownClasses: gateDecision.unknownClasses,
259
+ discardedEnvRaw: gateDecision.discardedEnvRaw,
260
+ });
231
261
  const thinking = spec.thinking ?? resolvedRole.thinking ?? model.defaultThinking;
232
262
  const compModel = spec.compactionModel
233
263
  ? resolveModel(spec.compactionModel, deps.models)
@@ -243,6 +273,7 @@ export function prepareConfigDoors(input) {
243
273
  thinking,
244
274
  compModel,
245
275
  fableMitigations,
276
+ modelGate,
246
277
  usageWindows,
247
278
  brainCallGuardrailRef,
248
279
  brainCallGuardrailMs,
@@ -11,7 +11,7 @@ import { SubagentRetainLedger } from "../../agents/retain-ledger.js";
11
11
  import type { OnAsk, ToolCallRequest, ToolPolicy } from "../tool-policy.js";
12
12
  import { type ActiveSkillFrame } from "./active-skill-scope.js";
13
13
  import type { SessionPermissionRules } from "../session-policy-store.js";
14
- import { type Hooks, type OrgGateVerdict } from "../hooks.js";
14
+ import { type Hooks, type HookInvocationIdentity, type OrgGateVerdict } from "../hooks.js";
15
15
  import type { RecoveredOrphan } from "../session-reconcile.js";
16
16
  import { CacheBreakDetector, type ToolFingerprintInput } from "../cache-break-detector.js";
17
17
  import { type BrainCallGuardrailRef } from "../../brain/timeout.js";
@@ -29,8 +29,14 @@ import { type WiringManifest } from "../wiring-manifest.js";
29
29
  import type { ActiveWorktreeSession, AgentMessage, AgentTool, ExecutionEnv } from "../../internal/harness.js";
30
30
  import type { NestedUsageAccum, RunnerDeps, TaskEvent, TaskResult, TaskSpec, ToolActivity, ToolEffect } from "../types.js";
31
31
  import type { RepairBundle } from "../../agents/repair-loop.js";
32
- /** Test seam (mirrors `__resetBashTimeoutAnnouncements`): never called by production code. */
32
+ /** Test seam (mirrors `__resetToolModelGateAnnouncements`): never called by production code.
33
+ * Re-arms BOTH arms (a WeakMap has no clear — it is re-minted). */
33
34
  export declare function __resetMaterializeEnvAnnouncements(): void;
35
+ /** Test seam (mirrors `__resetMaterializeEnvAnnouncements`): never called by production code.
36
+ * Re-arms BOTH arms (a WeakMap has no clear — it is re-minted). Deliberately UNLIKE the read-face
37
+ * seam below (console latch only): tests here legitimately reuse ONE sink across prepares to pin
38
+ * the per-sink dedup itself, so the seam must be able to re-arm a still-referenced sink. */
39
+ export declare function __resetToolModelGateAnnouncements(): void;
34
40
  /** Test seam (mirrors `__resetMalformedNoticeSeatAnnouncement`): never called by production code.
35
41
  * Deliberately asymmetric — it resets only the console latch: the WeakSet arm needs no seam
36
42
  * because a test resets it by minting a fresh sink function (identity IS the ledger key), while
@@ -279,6 +285,15 @@ export interface Prepared {
279
285
  * model/tool interaction. Host/operator plane — never enters model context.
280
286
  */
281
287
  wiringManifest: WiringManifest;
288
+ /**
289
+ * #281 件A — this leg's frozen identity envelope, minted ONCE in prepare beside the wiring
290
+ * manifest (same leg derivation, one mint — {@link mintHookInvocationIdentity}). Every hook
291
+ * station runtask drives (stop/stopFailure/userPromptSubmit/postToolBatch, the compaction
292
+ * wrapper) and the 件B delegation-lifecycle observer read THIS object; prepare's own stations
293
+ * (the tool gate, the post-tool contexts) close over the same const. Always present — a prepared
294
+ * leg always knows its identity.
295
+ */
296
+ hookIdentity: HookInvocationIdentity;
282
297
  promptManifest: {
283
298
  constitution: "core" | "replaced" | "provider-assembled";
284
299
  blocks: Array<{