@sema-agent/core 5.36.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 (56) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/dist/agents/subagent.d.ts +10 -0
  3. package/dist/agents/subagent.js +6 -0
  4. package/dist/agents/teacher.js +3 -0
  5. package/dist/agents/team.d.ts +7 -1
  6. package/dist/agents/team.js +11 -9
  7. package/dist/agents/verify.js +3 -0
  8. package/dist/core/auto-mode-prompt-assets.d.ts +5 -3
  9. package/dist/core/auto-mode-prompt-assets.js +1 -1
  10. package/dist/core/checkpoint-store.d.ts +26 -1
  11. package/dist/core/governance-codes.js +4 -0
  12. package/dist/core/hooks.d.ts +129 -2
  13. package/dist/core/hooks.js +20 -3
  14. package/dist/core/memory-engine/engine.d.ts +142 -0
  15. package/dist/core/memory-engine/engine.js +264 -2
  16. package/dist/core/memory-engine/file-backend.d.ts +490 -16
  17. package/dist/core/memory-engine/file-backend.js +1099 -36
  18. package/dist/core/memory-engine/index.d.ts +2 -2
  19. package/dist/core/memory-engine/index.js +1 -1
  20. package/dist/core/memory-engine/layout.d.ts +42 -2
  21. package/dist/core/memory-engine/layout.js +76 -12
  22. package/dist/core/memory-engine/memory-backend-contract.d.ts +13 -0
  23. package/dist/core/memory-engine/memory-backend-contract.js +89 -0
  24. package/dist/core/protocol-table.d.ts +4 -4
  25. package/dist/core/runner/assemble-result.d.ts +5 -0
  26. package/dist/core/runner/assemble-result.js +1 -1
  27. package/dist/core/runner/prepare-config-doors.d.ts +17 -0
  28. package/dist/core/runner/prepare-config-doors.js +33 -2
  29. package/dist/core/runner/prepare-memory.d.ts +11 -1
  30. package/dist/core/runner/prepare-memory.js +48 -2
  31. package/dist/core/runner/prepare-task.d.ts +22 -2
  32. package/dist/core/runner/prepare-task.js +125 -42
  33. package/dist/core/runner/runtask.js +50 -11
  34. package/dist/core/tool-model-gate.d.ts +125 -0
  35. package/dist/core/tool-model-gate.js +303 -0
  36. package/dist/core/tool-policy.d.ts +1 -1
  37. package/dist/core/types.d.ts +284 -1
  38. package/dist/core/types.js +21 -0
  39. package/dist/core/untrusted-text.d.ts +1 -1
  40. package/dist/index.d.ts +5 -4
  41. package/dist/index.js +3 -2
  42. package/dist/orchestration/builtin-workflows.d.ts +68 -6
  43. package/dist/orchestration/builtin-workflows.js +26 -9
  44. package/dist/orchestration/run-workflow-tool.d.ts +10 -1
  45. package/dist/orchestration/run-workflow-tool.js +70 -27
  46. package/dist/orchestration/workflow-script-store.d.ts +8 -3
  47. package/dist/prompts/coordinator.d.ts +4 -1
  48. package/dist/prompts/coordinator.js +8 -0
  49. package/dist/prompts/default.d.ts +14 -4
  50. package/dist/prompts/default.js +2 -1
  51. package/dist/scenarios/full-body.d.ts +5 -0
  52. package/dist/scenarios/full-body.js +8 -4
  53. package/dist/tools/fs/fs-shared.d.ts +3 -2
  54. package/dist/tools/fs/fs-shared.js +19 -9
  55. package/package.json +1 -1
  56. package/test/export-surface.snapshot.json +24 -1
@@ -1,5 +1,6 @@
1
1
  import { persistedReadDenyEntryProblem } from "../../tools/fs/read-deny.js";
2
2
  import { createSafeNotifier, observeThenableRejection } from "../safe-notify.js";
3
+ import { deliverDelegationLifecycle } from "../types.js";
3
4
  import { AgentHarness, DEFAULT_COMPACTION_SETTINGS, uuidv7 } from "../../internal/harness.js";
4
5
  import { snapshotActorAssertion } from "../../internal/llm.js";
5
6
  import { CheckpointError, BINDING_CHECKPOINT_VERSION, checkpointVersionOf, F012_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, REAL_APPROVAL_CHECKPOINT_VERSION, FACE_CHECKPOINT_VERSION, remainingBudgetMicroUsd, readPendingSteerQueue, remainingTokens, LEGACY_PENDING_STEER_INPUT_ID, MAX_STEER_INPUT_ID_CHARS, validatePendingSteer, winnerFromOutcome, } from "../checkpoint-store.js";
@@ -757,7 +758,7 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
757
758
  const injectedThisTurn = finalVerifyInjectedThisTurn ? "final_verification" : undefined;
758
759
  if (!boundarySteered && !prepared.abortController.signal.aborted) {
759
760
  try {
760
- const r = await postToolBatchHook(batch, injectedThisTurn !== undefined ? { injectedThisTurn } : undefined);
761
+ const r = await postToolBatchHook(batch, injectedThisTurn !== undefined ? { injectedThisTurn } : undefined, { identity: prepared.hookIdentity });
761
762
  if (r?.additionalContext && injectedThisTurn === undefined) {
762
763
  const body = sanitizeUntrustedText(r.additionalContext);
763
764
  const budget = ATTACHMENT_BYTE_CAP - boundaryAttachmentBytes;
@@ -1484,12 +1485,12 @@ export class Runner {
1484
1485
  ...(typeof h.preToolUse === "function" ? { preToolUse: (t, i, c) => h.preToolUse(t, i, c) } : {}),
1485
1486
  ...(typeof h.preToolUse === "function" && h.preToolUseObservational === true ? { preToolUseObservational: true } : {}),
1486
1487
  ...(typeof h.postToolUse === "function" ? { postToolUse: (t, i, o, c) => h.postToolUse(t, i, o, c) } : {}),
1487
- ...(typeof h.userPromptSubmit === "function" ? { userPromptSubmit: (p) => h.userPromptSubmit(p) } : {}),
1488
+ ...(typeof h.userPromptSubmit === "function" ? { userPromptSubmit: (p, c) => h.userPromptSubmit(p, c) } : {}),
1488
1489
  ...(typeof h.stop === "function" ? { stop: (c) => h.stop(c) } : {}),
1489
1490
  ...(typeof h.postToolUseFailure === "function"
1490
1491
  ? { postToolUseFailure: (t, i, f, c) => h.postToolUseFailure(t, i, f, c) }
1491
1492
  : {}),
1492
- ...(typeof h.postToolBatch === "function" ? { postToolBatch: (b) => h.postToolBatch(b) } : {}),
1493
+ ...(typeof h.postToolBatch === "function" ? { postToolBatch: (b, m, c) => h.postToolBatch(b, m, c) } : {}),
1493
1494
  ...(typeof h.preCompact === "function" ? { preCompact: (c) => h.preCompact(c) } : {}),
1494
1495
  ...(typeof h.postCompact === "function" ? { postCompact: (c) => h.postCompact(c) } : {}),
1495
1496
  ...(typeof h.stopFailure === "function" ? { stopFailure: (c) => h.stopFailure(c) } : {}),
@@ -1659,6 +1660,7 @@ export class Runner {
1659
1660
  errorMessage: err instanceof Error ? err.message : String(err),
1660
1661
  errorCode: code,
1661
1662
  ...(remoteEnvFailure !== undefined ? { remoteEnvFailures: remoteEnvFailure } : {}),
1663
+ ...(taskIdRef.effectiveMemoryScopes !== undefined ? { effectiveMemoryScopes: taskIdRef.effectiveMemoryScopes } : {}),
1662
1664
  ...(() => {
1663
1665
  const hinted = err.retryAfterMs;
1664
1666
  return code === "memory.admission_required" && typeof hinted === "number" && Number.isFinite(hinted) && hinted > 0
@@ -1678,6 +1680,13 @@ export class Runner {
1678
1680
  durationMs: Date.now() - runStartedAt,
1679
1681
  ts: Date.now(),
1680
1682
  }));
1683
+ const owedTerminal = taskIdRef.delegationTerminalOwed;
1684
+ if (owedTerminal !== undefined) {
1685
+ taskIdRef.delegationTerminalOwed = undefined;
1686
+ deliverDelegationLifecycle(this.deps.onDelegationLifecycle, { phase: "terminal", identity: owedTerminal, status: "failed", turns: 0, ...(code !== undefined ? { errorCode: code } : {}) }, createSafeNotifier({
1687
+ onError: (f) => console.warn(`[sema-core] ${f.site}: delegation-lifecycle observer threw (contained; further failures counted, not re-disclosed): ${f.error.message}`),
1688
+ }), "runtask.onDelegationLifecycle");
1689
+ }
1681
1690
  queue.push({ type: "done", result: resultValue });
1682
1691
  queue.close();
1683
1692
  });
@@ -2029,11 +2038,27 @@ export class Runner {
2029
2038
  }, this);
2030
2039
  notificationHarness = prepared.harness;
2031
2040
  notificationSessionId = prepared.sessionId;
2041
+ if (taskIdRef)
2042
+ taskIdRef.effectiveMemoryScopes = prepared.effectiveMemoryScopes;
2032
2043
  const runSourceTaskId = spec.taskId ?? prepared.sessionId;
2033
2044
  const parentToolCallId = internals?.parentToolCallId;
2034
2045
  const ident = () => parentToolCallId !== undefined ? { eventId: uuidv7(), parentToolCallId, sourceTaskId: runSourceTaskId } : { eventId: uuidv7() };
2035
2046
  notificationIdent = ident;
2036
2047
  queue.push({ type: "wiring_manifest", manifest: prepared.wiringManifest, ...ident() });
2048
+ const delegationLifecycleNotifier = createSafeNotifier({
2049
+ onError: (f) => console.warn(`[sema-core] ${f.site}: delegation-lifecycle observer threw (contained; further failures counted, not re-disclosed): ${f.error.message}`),
2050
+ });
2051
+ const emitDelegationLifecycle = (event) => {
2052
+ if (!prepared.hookIdentity.isDelegatedChild)
2053
+ return;
2054
+ if (this.deps.onDelegationLifecycle === undefined)
2055
+ return;
2056
+ deliverDelegationLifecycle(this.deps.onDelegationLifecycle, event, delegationLifecycleNotifier, "runtask.onDelegationLifecycle");
2057
+ };
2058
+ emitDelegationLifecycle({ phase: "spawn", identity: prepared.hookIdentity });
2059
+ if (taskIdRef !== undefined && prepared.hookIdentity.isDelegatedChild && this.deps.onDelegationLifecycle !== undefined) {
2060
+ taskIdRef.delegationTerminalOwed = prepared.hookIdentity;
2061
+ }
2037
2062
  manualCompactRef.emitMooted = (reason) => {
2038
2063
  queue.push({ type: "compaction_outcome", outcome: "mooted", trigger: "manual", reason, ...ident() });
2039
2064
  };
@@ -2726,6 +2751,7 @@ export class Runner {
2726
2751
  stopHookActive: consecutiveBlocks > 0,
2727
2752
  consecutiveBlocks,
2728
2753
  getBranch: () => prepared.session.getBranch(),
2754
+ identity: prepared.hookIdentity,
2729
2755
  });
2730
2756
  }
2731
2757
  catch (err) {
@@ -2801,7 +2827,7 @@ export class Runner {
2801
2827
  ...this.seamCCompactionOptions(prepared),
2802
2828
  ...gitRestateOption(prepared),
2803
2829
  ...windowSafetyOptions(prepared.harness.getModel()),
2804
- ...this.compactionHookOptions(spec, prepared.sessionId, "forced"),
2830
+ ...this.compactionHookOptions(spec, prepared.sessionId, "forced", prepared.hookIdentity),
2805
2831
  });
2806
2832
  if (comp.compacted) {
2807
2833
  compactionBreaker.failures = 0;
@@ -2866,7 +2892,7 @@ export class Runner {
2866
2892
  runnerHooks: {
2867
2893
  onError: this.deps.onError,
2868
2894
  seamCCompactionOptions: (p) => this.seamCCompactionOptions(p),
2869
- compactionHookOptions: (s, sid, trig) => this.compactionHookOptions(s, sid, trig),
2895
+ compactionHookOptions: (s, sid, trig) => this.compactionHookOptions(s, sid, trig, prepared.hookIdentity),
2870
2896
  recordCompactionReuse: (p, c) => this.recordCompactionReuse(p, c),
2871
2897
  },
2872
2898
  });
@@ -3096,7 +3122,7 @@ export class Runner {
3096
3122
  const userPromptSubmit = (spec.hooks ?? this.deps.hooks)?.userPromptSubmit;
3097
3123
  if (userPromptSubmit) {
3098
3124
  try {
3099
- const decision = await userPromptSubmit(spec.objective);
3125
+ const decision = await userPromptSubmit(spec.objective, { identity: prepared.hookIdentity });
3100
3126
  if (decision?.block) {
3101
3127
  prepared.blockedRef.reason = formatHookFeedback(decision.block);
3102
3128
  promptBlocked = true;
@@ -3426,6 +3452,7 @@ export class Runner {
3426
3452
  remoteEnvFailures: prepared.remoteEnvFailures,
3427
3453
  effectiveReadFace: prepared.effectiveReadFace,
3428
3454
  effectiveReadDenyPatterns: prepared.effectiveReadDenyPatterns,
3455
+ effectiveMemoryScopes: prepared.effectiveMemoryScopes,
3429
3456
  retryAfterMs: rs.limits.platformTerminal?.retryAfterMs,
3430
3457
  abortedForTimeout: timeout.fired,
3431
3458
  abortedForTurns: rs.limits.turnsExceeded,
@@ -3499,6 +3526,7 @@ export class Runner {
3499
3526
  result.errorCode !== "conflict") {
3500
3527
  try {
3501
3528
  await stopFailureHook({
3529
+ identity: prepared.hookIdentity,
3502
3530
  error: result.errorMessage ?? "model error",
3503
3531
  ...(result.errorCode !== undefined ? { errorKind: result.errorCode } : {}),
3504
3532
  turns: stats.turns,
@@ -3512,6 +3540,15 @@ export class Runner {
3512
3540
  }
3513
3541
  }
3514
3542
  }
3543
+ emitDelegationLifecycle({
3544
+ phase: "terminal",
3545
+ identity: prepared.hookIdentity,
3546
+ status: result.status,
3547
+ turns: stats.turns,
3548
+ ...(result.errorCode !== undefined ? { errorCode: result.errorCode } : {}),
3549
+ });
3550
+ if (taskIdRef !== undefined)
3551
+ taskIdRef.delegationTerminalOwed = undefined;
3515
3552
  if (rs.degrade.degraded)
3516
3553
  result.degraded = rs.degrade.degraded;
3517
3554
  if (prepared.outputRef.set)
@@ -4499,15 +4536,17 @@ export class Runner {
4499
4536
  consecutiveProviderReuse: prepared.compactionReuseRef.consecutive,
4500
4537
  };
4501
4538
  }
4502
- compactionHookOptions(spec, sessionId, trigger) {
4539
+ compactionHookOptions(spec, sessionId, trigger, identity) {
4503
4540
  const hooks = spec.hooks ?? this.deps.hooks;
4504
4541
  const pre = hooks?.preCompact;
4505
4542
  const post = hooks?.postCompact;
4543
+ const withIdentity = (ctx) => identity !== undefined ? { ...ctx, identity } : ctx;
4506
4544
  return {
4507
4545
  trigger,
4508
4546
  ...(pre
4509
4547
  ? {
4510
- preCompact: async (ctx) => {
4548
+ preCompact: async (rawCtx) => {
4549
+ const ctx = withIdentity(rawCtx);
4511
4550
  const report = (err) => {
4512
4551
  try {
4513
4552
  this.deps.onError?.(err, { phase: "hook", sessionId });
@@ -4531,9 +4570,9 @@ export class Runner {
4531
4570
  : {}),
4532
4571
  ...(post
4533
4572
  ? {
4534
- postCompact: async (ctx) => {
4573
+ postCompact: async (rawCtx) => {
4535
4574
  try {
4536
- await post.call(hooks, ctx);
4575
+ await post.call(hooks, withIdentity(rawCtx));
4537
4576
  }
4538
4577
  catch (err) {
4539
4578
  try {
@@ -4577,7 +4616,7 @@ export class Runner {
4577
4616
  ...(prepared.onCompactionApplied ? { onApplied: prepared.onCompactionApplied } : {}),
4578
4617
  ...this.seamCCompactionOptions(prepared),
4579
4618
  ...gitRestateOption(prepared),
4580
- ...this.compactionHookOptions(spec, prepared.sessionId, "auto"),
4619
+ ...this.compactionHookOptions(spec, prepared.sessionId, "auto", prepared.hookIdentity),
4581
4620
  });
4582
4621
  this.recordCompactionReuse(prepared, finishComp);
4583
4622
  if (finishComp.unevaluableWindow) {
@@ -0,0 +1,125 @@
1
+ /**
2
+ * design/277 — the tool-registration MODEL GATE (CC 233 counterpart, first formalized there as a
3
+ * family+version-floor table over the todo/task-board tool family).
4
+ *
5
+ * WHAT THE GATE IS: a DEFAULT-MOUNT trim, never a capability ban. A {@link ToolSpec.modelGate}
6
+ * tag declares "this entry is a default-mounted scaffold of the named class"; when the task's
7
+ * RESOLVED model id matches the class's rule below, that entry is dropped from the roster at
8
+ * prepare (true unmount — the schema never reaches `tools[]`). An EXPLICITLY composed tool is
9
+ * never tagged (the bundle only stamps its default arms), so "the user asked for it" is the
10
+ * first restore channel by construction. Restore channels beyond that:
11
+ * `TaskSpec.restoreGatedTools` (per task-tree), env `SEMA_TOOL_MODEL_GATE=off` (process),
12
+ * `RunnerDeps.toolModelGate: false` (deployment).
13
+ *
14
+ * FAIL-OPEN, BY CONTRACT (not by accident): sema is BYOM — model ids are an OPEN set, and this
15
+ * table encodes POSITIVE knowledge ("this family at this version and above manages multi-step
16
+ * work without the scaffold"). An id the table knows nothing about (deepseek-* / gpt-* / qwen-* / any
17
+ * custom name) is NOT gated: no knowledge ⇒ no trim ⇒ tool stays. The matcher's three pass-through
18
+ * edges (family absent, id shape unmatched, version below floor) all point the same way, and it is
19
+ * the only polarity compatible with BYOM — the reverse would trim every non-claude deployment's
20
+ * default face. The engine does NOT guarantee a canonical id either: a deployment-authored
21
+ * `Model.id` (a raw Model object, or a catalog key shadowing the tier expansion) that is not in
22
+ * canonical `claude-<family>-<version>` shape simply falls off the regex and stays open — the
23
+ * explicit-id discipline's deployment-side duty. To gate a non-claude strong model, write the
24
+ * exact id into a {@link ToolModelGateRule.modelIds} row (the only non-claude channel; no prefix
25
+ * or wildcard syntax exists).
26
+ *
27
+ * DIVERGENCE (registered, deliberate): CC judges the MAIN-LOOP canonical model once per process;
28
+ * sema judges the PER-TASK resolved model on every prepare — BYOM per-task models are first-class,
29
+ * so a delegated child on a different model re-judges under its own (strong parent / weak child ⇒
30
+ * the child gets the scaffold back). And the env valve's polarity is reversed (CC: enable the
31
+ * tools; sema: disable the GATE) because sema's class vocabulary is open — a per-family enable
32
+ * env would have to grow with every class.
33
+ */
34
+ import type { ToolSpec } from "./types.js";
35
+ /** The shared tail extractor (BYOM ids may carry provider prefixes — "openrouter/anthropic/claude-fable-5"):
36
+ * boundary-aware LAST path segment, case-folded. Single source for every model-family/model-id
37
+ * comparison site (`isFableFamilyModelId` consumes it too — the prompt-shape axis, semantically
38
+ * independent but sharing the one tail-extraction posture). */
39
+ export declare function modelIdTail(id: string): string;
40
+ /** One gate class's matching rule (both axes optional; a rule with BOTH axes empty gates nothing —
41
+ * the per-class OFF shape a deployment reaches by explicitly clearing the axes). */
42
+ export interface ToolModelGateRule {
43
+ /** claude-syntax family+floor rows — meaningful only for ids whose TAIL SEGMENT matches
44
+ * `claude-<letters>-<digits(-digits)*>`. Version tuples compare positionally, missing positions
45
+ * read 0, tuple ≥ floor ⇒ gated (CC 233 comparator, verbatim semantics). Any other id shape
46
+ * falls through this axis entirely (fail-open). */
47
+ floors?: ReadonlyArray<readonly [family: string, floor: ReadonlyArray<number>]>;
48
+ /** Exact-id rows (explicit-id discipline): the resolved `Model.id`'s tail segment must equal the
49
+ * row case-insensitively. The ONLY channel by which a BYOM deployment gates its own non-claude
50
+ * strong model — deliberately no prefix/wildcard syntax (a substring guess against an open id
51
+ * set is how the `deepseek-chat` alias downgrade class of accident happens). */
52
+ modelIds?: ReadonlyArray<string>;
53
+ }
54
+ /**
55
+ * The built-in gate table: gate-class → rule. CC 233 z_S floors, verbatim (evidence D2; the
56
+ * `mythos` row rides per the anchor). v1 vocabulary = ONE class, `"task-scaffold"` — the
57
+ * TodoWrite/TaskCreate/TaskGet/TaskUpdate/TaskList default bundle (`assembleCodeTools`). One class
58
+ * is evidence discipline, not mechanism limit: a family enters this table only with positive
59
+ * "strong models don't need it" evidence (a CC anchor or deployment measurement); classes are an
60
+ * open vocabulary and a deployment adds its own via `RunnerDeps.toolModelGate.classes`.
61
+ */
62
+ export declare const TOOL_MODEL_GATE_CLASSES: Readonly<Record<string, ToolModelGateRule>>;
63
+ /**
64
+ * Pure matcher: is `modelId` gated under `rule`? Every unmatched shape returns `false` (the
65
+ * fail-open contract in the module header). Exported for deployment pre-flight ("would my model
66
+ * lose the scaffold?") — the engine's own decision point calls this same function.
67
+ */
68
+ export declare function isModelGatedForClass(modelId: string, rule: ToolModelGateRule): boolean;
69
+ /**
70
+ * #123 value screen for `TaskSpec.restoreGatedTools` (same posture as `assertReadFaceValue`: the
71
+ * VALUE is screened at the door, unconditionally — a garbage restore list must refuse loudly on
72
+ * every leg, never be silently read as "no restore" in the narrowing direction). Legal: absent,
73
+ * literal `true` (restore every class), or an array of strings (wire names; unknown names are
74
+ * inert by contract, like `excludeTools`).
75
+ */
76
+ export declare function assertRestoreGatedToolsValue(value: unknown): asserts value is true | readonly string[] | undefined;
77
+ /** Doors-facing decision (built + applied inside `prepareConfigDoors`' synchronous stretch —
78
+ * decision and application share one atomic window, no drift gap). */
79
+ export interface ToolModelGateDecision {
80
+ /** Present ⇔ at least one entry was removed: the fresh, PRIVATE survivor array `spec.tools` is
81
+ * rebound to (a caller mutating its original live array afterwards can neither re-add a removed
82
+ * entry nor displace anything — there are no indices left to misalign). `undefined` = no
83
+ * removal, the spec is left untouched. */
84
+ survivors: ToolSpec[] | undefined;
85
+ /** gate-class → removed wire names (sorted, unique) — the removal-notice payload. */
86
+ removedByClass: ReadonlyMap<string, readonly string[]>;
87
+ /** Stamped classes with no row in the merged table — announce-once material (the loud half of
88
+ * fail-open: a tag typo must not silently become "never gated" with nobody told). */
89
+ unknownClasses: readonly string[];
90
+ /** An env value outside the closed set that was NOT in force (nothing this prepare would gate) —
91
+ * discarded-value announce material. In-force garbage never lands here: it throws. */
92
+ discardedEnvRaw: string | undefined;
93
+ }
94
+ /**
95
+ * The single decision point, called from `prepareConfigDoors` with the model id read ONCE into the
96
+ * decision (the Model object is mutable; decision and later reads must not diverge) and the frozen
97
+ * restore snapshot (never the live spec).
98
+ *
99
+ * Channel semantics (a UNION of loosening channels — any hit ⇒ don't trim; the order below is
100
+ * evaluation order, not priority):
101
+ * - `depsSeat === false` — deployment kill switch; nothing is scanned.
102
+ * - env `SEMA_TOOL_MODEL_GATE` `off|0|false` (case-folded) — process kill switch; `on|1|true` is
103
+ * the explicit default (no-op). A value outside the closed set follows the loud-bad-value
104
+ * dialect split (#123, the SEMA_TOOL_MATERIALIZE_STRATEGY precedent): where the value is IN
105
+ * FORCE — its reading would change this prepare's outcome, i.e. after folding the other
106
+ * channels there remains a stamped entry the gate would remove — the prepare REFUSES
107
+ * (`config.tool_model_gate_env_invalid`; a mistyped `off` must not silently select the
108
+ * narrowing arm). Everywhere else it is a DISCARDED value: announced once per process, never a
109
+ * veto of the configuration that outranks it.
110
+ * - `restoreGated === true` — every class exempt for this task; an array exempts the WHOLE class
111
+ * of any stamped tool it names (CC "opting into any tool of the family restores the family",
112
+ * judged against the FULL stamp set — a name `excludeTools` will later remove still selects its
113
+ * class here, while the exclusion itself stands downstream; unknown names inert).
114
+ * - a stamped class with no merged row ⇒ kept + reported in `unknownClasses` (fail-open, loud).
115
+ * - a merged rule with both axes empty ⇒ kept, silent (the documented per-class OFF).
116
+ * - the exclusion valve (`excludeTools`, applied downstream at the roster splice) ALWAYS wins in
117
+ * the result: no restore channel resurrects an excluded name.
118
+ */
119
+ export declare function applyToolModelGate(input: {
120
+ tools: ReadonlyArray<ToolSpec> | undefined;
121
+ modelId: string;
122
+ depsSeat: unknown;
123
+ restoreGated: true | readonly string[] | undefined;
124
+ envRaw: string | undefined;
125
+ }): ToolModelGateDecision;
@@ -0,0 +1,303 @@
1
+ export function modelIdTail(id) {
2
+ return id.toLowerCase().split("/").pop() ?? "";
3
+ }
4
+ export const TOOL_MODEL_GATE_CLASSES = Object.freeze({
5
+ "task-scaffold": Object.freeze({
6
+ floors: Object.freeze([
7
+ Object.freeze(["opus", Object.freeze([4, 8])]),
8
+ Object.freeze(["sonnet", Object.freeze([5])]),
9
+ Object.freeze(["fable", Object.freeze([5])]),
10
+ Object.freeze(["mythos", Object.freeze([5])]),
11
+ ]),
12
+ }),
13
+ });
14
+ const CLAUDE_ID_RE = /^claude-([a-z]+)-(\d+(?:-\d+)*)$/;
15
+ function versionTupleGte(version, floor) {
16
+ const n = Math.max(version.length, floor.length);
17
+ for (let i = 0; i < n; i++) {
18
+ const v = version[i] ?? 0;
19
+ const f = floor[i] ?? 0;
20
+ if (v > f)
21
+ return true;
22
+ if (v < f)
23
+ return false;
24
+ }
25
+ return true;
26
+ }
27
+ export function isModelGatedForClass(modelId, rule) {
28
+ const tail = modelIdTail(modelId);
29
+ if (rule.modelIds !== undefined) {
30
+ for (const row of rule.modelIds) {
31
+ if (typeof row === "string" && tail === row.toLowerCase())
32
+ return true;
33
+ }
34
+ }
35
+ if (rule.floors !== undefined) {
36
+ const m = CLAUDE_ID_RE.exec(tail);
37
+ if (m !== null) {
38
+ const family = m[1] ?? "";
39
+ const version = (m[2] ?? "").split("-").map(Number);
40
+ for (const row of rule.floors) {
41
+ if (!Array.isArray(row) || row.length !== 2)
42
+ continue;
43
+ const [fam, floor] = row;
44
+ if (typeof fam !== "string" || fam.toLowerCase() !== family)
45
+ continue;
46
+ if (Array.isArray(floor) && versionTupleGte(version, floor))
47
+ return true;
48
+ }
49
+ }
50
+ }
51
+ return false;
52
+ }
53
+ function gateConfigError(message) {
54
+ const e = new Error(message);
55
+ e.code = "config.tool_model_gate_invalid";
56
+ return e;
57
+ }
58
+ export function assertRestoreGatedToolsValue(value) {
59
+ if (value === undefined || value === true)
60
+ return;
61
+ if (Array.isArray(value)) {
62
+ const entries = value;
63
+ for (const entry of entries) {
64
+ if (typeof entry !== "string") {
65
+ throw gateConfigError(`TaskSpec.restoreGatedTools entries must be strings (got ${describeValue(entry)}) — an unevaluable selector must not silently restore nothing.`);
66
+ }
67
+ }
68
+ return;
69
+ }
70
+ throw gateConfigError(`TaskSpec.restoreGatedTools must be \`true\` or an array of tool names (got ${describeValue(value)}).`);
71
+ }
72
+ const isPlainObject = (v) => {
73
+ if (typeof v !== "object" || v === null || Array.isArray(v))
74
+ return false;
75
+ const proto = Object.getPrototypeOf(v);
76
+ return proto === Object.prototype || proto === null;
77
+ };
78
+ const describeValue = (v) => {
79
+ try {
80
+ const s = JSON.stringify(v);
81
+ return s === undefined ? String(v) : s;
82
+ }
83
+ catch {
84
+ return typeof v === "bigint" ? `${String(v)}n` : "[unserializable value]";
85
+ }
86
+ };
87
+ function mergeToolModelGateClasses(depsSeat) {
88
+ const merged = new Map();
89
+ for (const [cls, rule] of Object.entries(TOOL_MODEL_GATE_CLASSES))
90
+ merged.set(cls, rule);
91
+ if (depsSeat === undefined)
92
+ return merged;
93
+ if (!isPlainObject(depsSeat)) {
94
+ throw gateConfigError(`RunnerDeps.toolModelGate must be \`false\` or a plain object — prototype included (got ${Array.isArray(depsSeat) ? "an array" : describeValue(depsSeat)}).`);
95
+ }
96
+ for (const key of Object.keys(depsSeat)) {
97
+ if (key !== "classes") {
98
+ throw gateConfigError(`RunnerDeps.toolModelGate has unknown key ${JSON.stringify(key)} — the only legal key is "classes".`);
99
+ }
100
+ }
101
+ const classes = Object.hasOwn(depsSeat, "classes") ? depsSeat.classes : undefined;
102
+ if (classes === undefined)
103
+ return merged;
104
+ if (!isPlainObject(classes)) {
105
+ throw gateConfigError(`RunnerDeps.toolModelGate.classes must be a plain object (got ${Array.isArray(classes) ? "an array" : describeValue(classes)}).`);
106
+ }
107
+ for (const [cls, row] of Object.entries(classes)) {
108
+ if (cls === "")
109
+ throw gateConfigError("RunnerDeps.toolModelGate.classes has an empty-string class name.");
110
+ if (row === undefined)
111
+ continue;
112
+ if (!isPlainObject(row)) {
113
+ throw gateConfigError(`RunnerDeps.toolModelGate.classes[${JSON.stringify(cls)}] must be a plain object (got ${Array.isArray(row) ? "an array" : describeValue(row)}).`);
114
+ }
115
+ for (const key of Object.keys(row)) {
116
+ if (key !== "floors" && key !== "modelIds") {
117
+ throw gateConfigError(`RunnerDeps.toolModelGate.classes[${JSON.stringify(cls)}] has unknown key ${JSON.stringify(key)} — legal keys: "floors", "modelIds".`);
118
+ }
119
+ }
120
+ let floors;
121
+ if (row.floors !== undefined) {
122
+ if (!Array.isArray(row.floors)) {
123
+ throw gateConfigError(`RunnerDeps.toolModelGate.classes[${JSON.stringify(cls)}].floors must be an array (got ${describeValue(row.floors)}).`);
124
+ }
125
+ const seen = new Set();
126
+ const out = [];
127
+ const rows = row.floors;
128
+ for (const entry of rows) {
129
+ if (!Array.isArray(entry) || entry.length !== 2) {
130
+ throw gateConfigError(`RunnerDeps.toolModelGate.classes[${JSON.stringify(cls)}].floors rows must be [family, floor] pairs (got ${describeValue(entry)}).`);
131
+ }
132
+ const famRaw = entry[0];
133
+ const floorRaw = entry[1];
134
+ if (typeof famRaw !== "string" || famRaw.length === 0) {
135
+ throw gateConfigError(`RunnerDeps.toolModelGate.classes[${JSON.stringify(cls)}].floors family must be a non-empty string (got ${describeValue(famRaw)}).`);
136
+ }
137
+ const fam = famRaw.toLowerCase();
138
+ if (!/^[a-z]+$/.test(fam)) {
139
+ throw gateConfigError(`RunnerDeps.toolModelGate.classes[${JSON.stringify(cls)}].floors family ${JSON.stringify(famRaw)} cannot ever match — the canonical id shape only admits letters. Refused rather than kept as a dead row.`);
140
+ }
141
+ if (seen.has(fam)) {
142
+ throw gateConfigError(`RunnerDeps.toolModelGate.classes[${JSON.stringify(cls)}].floors lists family ${JSON.stringify(fam)} twice.`);
143
+ }
144
+ seen.add(fam);
145
+ const floorNums = [];
146
+ if (Array.isArray(floorRaw)) {
147
+ const floorEntries = floorRaw;
148
+ for (const n of floorEntries) {
149
+ if (typeof n === "number" && Number.isSafeInteger(n) && n >= 0)
150
+ floorNums.push(n);
151
+ }
152
+ }
153
+ if (!Array.isArray(floorRaw) || floorRaw.length === 0 || floorNums.length !== floorRaw.length) {
154
+ throw gateConfigError(`RunnerDeps.toolModelGate.classes[${JSON.stringify(cls)}].floors floor for ${JSON.stringify(fam)} must be a non-empty array of non-negative safe integers (got ${describeValue(floorRaw)}).`);
155
+ }
156
+ out.push([fam, floorNums]);
157
+ }
158
+ floors = out;
159
+ }
160
+ let modelIds;
161
+ if (row.modelIds !== undefined) {
162
+ if (!Array.isArray(row.modelIds)) {
163
+ throw gateConfigError(`RunnerDeps.toolModelGate.classes[${JSON.stringify(cls)}].modelIds must be an array (got ${describeValue(row.modelIds)}).`);
164
+ }
165
+ const seen = new Set();
166
+ const out = [];
167
+ const ids = row.modelIds;
168
+ for (const entry of ids) {
169
+ if (typeof entry !== "string" || entry.length === 0) {
170
+ throw gateConfigError(`RunnerDeps.toolModelGate.classes[${JSON.stringify(cls)}].modelIds entries must be non-empty strings (got ${describeValue(entry)}).`);
171
+ }
172
+ const folded = entry.toLowerCase();
173
+ if (folded.includes("/")) {
174
+ throw gateConfigError(`RunnerDeps.toolModelGate.classes[${JSON.stringify(cls)}].modelIds entry ${JSON.stringify(entry)} contains "/" — matching is against the id's TAIL segment, so a prefixed row can never fire. Write the bare id.`);
175
+ }
176
+ if (seen.has(folded)) {
177
+ throw gateConfigError(`RunnerDeps.toolModelGate.classes[${JSON.stringify(cls)}].modelIds lists ${JSON.stringify(folded)} twice (case-folded).`);
178
+ }
179
+ seen.add(folded);
180
+ out.push(folded);
181
+ }
182
+ modelIds = out;
183
+ }
184
+ const base = merged.get(cls);
185
+ merged.set(cls, {
186
+ ...(floors !== undefined ? { floors } : base?.floors !== undefined ? { floors: base.floors } : {}),
187
+ ...(modelIds !== undefined ? { modelIds } : base?.modelIds !== undefined ? { modelIds: base.modelIds } : {}),
188
+ });
189
+ }
190
+ return merged;
191
+ }
192
+ const ENV_OFF = new Set(["off", "0", "false"]);
193
+ const ENV_ON = new Set(["on", "1", "true"]);
194
+ export function applyToolModelGate(input) {
195
+ const noop = (discardedEnvRaw) => ({
196
+ survivors: undefined,
197
+ removedByClass: new Map(),
198
+ unknownClasses: [],
199
+ discardedEnvRaw,
200
+ });
201
+ let merged;
202
+ try {
203
+ if (input.depsSeat !== false && input.depsSeat !== undefined && !isPlainObject(input.depsSeat)) {
204
+ throw gateConfigError(`RunnerDeps.toolModelGate must be \`false\` or a plain object — prototype included (got ${describeValue(input.depsSeat)}).`);
205
+ }
206
+ merged = input.depsSeat === false ? undefined : mergeToolModelGateClasses(input.depsSeat);
207
+ }
208
+ catch (e) {
209
+ let code;
210
+ try {
211
+ code = e?.code;
212
+ }
213
+ catch {
214
+ code = undefined;
215
+ }
216
+ if (code !== undefined)
217
+ throw e;
218
+ let msg;
219
+ try {
220
+ msg = e instanceof Error ? e.message : String(e);
221
+ }
222
+ catch {
223
+ msg = "[unreportable throw]";
224
+ }
225
+ throw gateConfigError(`RunnerDeps.toolModelGate could not be read (${msg}) — a config seat whose reads throw is a bad value, refused rather than passed.`);
226
+ }
227
+ const raw = input.envRaw;
228
+ let envVerdict;
229
+ if (raw === undefined)
230
+ envVerdict = "absent";
231
+ else {
232
+ const tok = raw.toLowerCase();
233
+ envVerdict = ENV_OFF.has(tok) ? "off" : ENV_ON.has(tok) ? "on" : "invalid";
234
+ }
235
+ const invalidRaw = envVerdict === "invalid" ? raw : undefined;
236
+ if (merged === undefined)
237
+ return noop(invalidRaw);
238
+ if (envVerdict === "off")
239
+ return noop();
240
+ const tools = input.tools ?? [];
241
+ const stamped = [];
242
+ for (const entry of tools) {
243
+ if (entry.modelGate !== undefined)
244
+ stamped.push({ entry, cls: entry.modelGate });
245
+ }
246
+ if (stamped.length === 0)
247
+ return noop(invalidRaw);
248
+ const restoredClasses = new Set();
249
+ if (input.restoreGated === true) {
250
+ for (const s of stamped)
251
+ restoredClasses.add(s.cls);
252
+ }
253
+ else if (input.restoreGated !== undefined) {
254
+ const names = new Set(input.restoreGated);
255
+ for (const s of stamped)
256
+ if (names.has(s.entry.name))
257
+ restoredClasses.add(s.cls);
258
+ }
259
+ const unknownClasses = [];
260
+ const unknownSeen = new Set();
261
+ const removed = new Set();
262
+ const removedByClass = new Map();
263
+ for (const { entry, cls } of stamped) {
264
+ const rule = merged.get(cls);
265
+ if (rule === undefined) {
266
+ if (!unknownSeen.has(cls)) {
267
+ unknownSeen.add(cls);
268
+ unknownClasses.push(cls);
269
+ }
270
+ continue;
271
+ }
272
+ if (rule.floors === undefined && rule.modelIds === undefined)
273
+ continue;
274
+ if (restoredClasses.has(cls))
275
+ continue;
276
+ if (!isModelGatedForClass(input.modelId, rule))
277
+ continue;
278
+ removed.add(entry);
279
+ const list = removedByClass.get(cls) ?? [];
280
+ if (!list.includes(entry.name))
281
+ list.push(entry.name);
282
+ removedByClass.set(cls, list);
283
+ }
284
+ if (invalidRaw !== undefined) {
285
+ if (removed.size > 0) {
286
+ const e = new Error(`SEMA_TOOL_MODEL_GATE=${JSON.stringify(invalidRaw)} is not in the closed set (on|1|true|off|0|false, case-insensitive) and IS in force on this task (the model gate would remove default-mounted tool(s)). Refused rather than guessed — "off" mistyped must not silently keep the trim armed.`);
287
+ e.code = "config.tool_model_gate_env_invalid";
288
+ throw e;
289
+ }
290
+ return { survivors: undefined, removedByClass: new Map(), unknownClasses, discardedEnvRaw: invalidRaw };
291
+ }
292
+ if (removed.size === 0) {
293
+ return { survivors: undefined, removedByClass: new Map(), unknownClasses, discardedEnvRaw: undefined };
294
+ }
295
+ for (const list of removedByClass.values())
296
+ list.sort();
297
+ return {
298
+ survivors: tools.filter((t) => !removed.has(t)),
299
+ removedByClass,
300
+ unknownClasses,
301
+ discardedEnvRaw: undefined,
302
+ };
303
+ }
@@ -866,7 +866,7 @@ export interface AskRequest {
866
866
  * run's model: a parent-thread run is told to stop and wait for the user (its transcript has a
867
867
  * user turn coming), a delegated child is told to adapt or report the limitation. A fork is
868
868
  * deliberately IN: it inherits the parent's authority (design/110 — which is why the RB-330
869
- * `isDelegatedNonForkChild` derivation, serving the authority/context faces, excludes it), but
869
+ * non-fork facet of `effectiveDelegationFacts`, serving the authority/context faces, excludes it), but
870
870
  * its interaction contract is one-shot — "report once and stop … no waiting for the user"
871
871
  * (FORK_DIRECTIVE_FRAME) — so a stop-and-wait refusal would instruct it to do the impossible
872
872
  * (codex adversarial round, confirmed). NOT the same fact as {@link fromSubagent} either: that is