@sema-agent/core 5.37.0 → 5.39.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 (58) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/dist/agents/send-message-tool.d.ts +8 -0
  3. package/dist/agents/send-message-tool.js +8 -0
  4. package/dist/agents/subagent.js +6 -0
  5. package/dist/agents/teacher.js +12 -3
  6. package/dist/agents/team.d.ts +7 -1
  7. package/dist/agents/team.js +11 -9
  8. package/dist/agents/verify.js +12 -3
  9. package/dist/core/auto-mode-prompt-assets.d.ts +5 -3
  10. package/dist/core/auto-mode-prompt-assets.js +1 -1
  11. package/dist/core/checkpoint-store.d.ts +26 -1
  12. package/dist/core/hooks.d.ts +152 -2
  13. package/dist/core/hooks.js +65 -7
  14. package/dist/core/mailbox-store.d.ts +39 -0
  15. package/dist/core/mailbox-store.js +9 -0
  16. package/dist/core/permission-rule-consent.d.ts +27 -4
  17. package/dist/core/permission-rule-consent.js +29 -4
  18. package/dist/core/permission-rule-model.d.ts +7 -1
  19. package/dist/core/runner/prepare-config-doors.d.ts +17 -0
  20. package/dist/core/runner/prepare-config-doors.js +33 -2
  21. package/dist/core/runner/prepare-task.d.ts +17 -2
  22. package/dist/core/runner/prepare-task.js +135 -44
  23. package/dist/core/runner/runtask.js +46 -11
  24. package/dist/core/sensitive-path-policy.js +3 -3
  25. package/dist/core/store-contracts/mailbox-store-contract.d.ts +29 -1
  26. package/dist/core/store-contracts/mailbox-store-contract.js +78 -0
  27. package/dist/core/tool-model-gate.d.ts +125 -0
  28. package/dist/core/tool-model-gate.js +303 -0
  29. package/dist/core/tool-policy.d.ts +1 -1
  30. package/dist/core/types.d.ts +210 -1
  31. package/dist/core/types.js +21 -0
  32. package/dist/core/untrusted-text.d.ts +1 -1
  33. package/dist/core/write-protect.d.ts +93 -0
  34. package/dist/core/write-protect.js +194 -0
  35. package/dist/index.d.ts +7 -5
  36. package/dist/index.js +5 -3
  37. package/dist/orchestration/builtin-workflows.d.ts +68 -6
  38. package/dist/orchestration/builtin-workflows.js +26 -9
  39. package/dist/orchestration/governance-baseline-validity.d.ts +44 -0
  40. package/dist/orchestration/governance-baseline-validity.js +55 -0
  41. package/dist/orchestration/run-workflow-tool.d.ts +10 -1
  42. package/dist/orchestration/run-workflow-tool.js +99 -31
  43. package/dist/orchestration/workflow-script-runner.js +9 -4
  44. package/dist/orchestration/workflow-script-store.d.ts +8 -3
  45. package/dist/prompts/coordinator.d.ts +4 -1
  46. package/dist/prompts/coordinator.js +8 -0
  47. package/dist/prompts/default.d.ts +14 -4
  48. package/dist/prompts/default.js +2 -1
  49. package/dist/scenarios/full-body.d.ts +5 -0
  50. package/dist/scenarios/full-body.js +8 -4
  51. package/dist/tools/fs/fs-shared.d.ts +3 -2
  52. package/dist/tools/fs/fs-shared.js +19 -9
  53. package/dist/tools/fs/read-deny.d.ts +15 -5
  54. package/dist/tools/fs/read-deny.js +33 -12
  55. package/dist/tools/fs/safety.d.ts +4 -1
  56. package/dist/tools/fs/safety.js +4 -2
  57. package/package.json +1 -1
  58. package/test/export-surface.snapshot.json +24 -1
@@ -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
@@ -381,6 +381,25 @@ export interface ToolSpec<TParams extends TSchema = TSchema> {
381
381
  * Exclusion still wins ({@link TaskSpec.excludeTools} unmounts — nothing left to keep inline).
382
382
  */
383
383
  alwaysLoad?: boolean;
384
+ /**
385
+ * design/277 — model-gate CLASS tag (open vocabulary; the built-in table is
386
+ * {@link import("./tool-model-gate.js").TOOL_MODEL_GATE_CLASSES}, v1 vocabulary =
387
+ * `"task-scaffold"`). A tagged entry declares "I am a default-mounted scaffold of this class":
388
+ * at prepare, when the task's RESOLVED model id matches the class's rule, THIS entry is dropped
389
+ * from the roster (true unmount, entry-level — a same-name untagged entry is untouched; the
390
+ * removed entry stops occupying its name on every downstream surface). Untagged = never gated.
391
+ * The default-bundle assemblers stamp their default arms only; an EXPLICITLY composed tool is
392
+ * not tagged (user asked ⇒ user gets) — so don't stamp hand-mounted factories. FAIL-OPEN: an id
393
+ * the merged table knows nothing about is never gated (BYOM open set — the table encodes
394
+ * positive knowledge only), and a tag naming an unknown class is inert + announced
395
+ * (`config.tool_model_gate_unknown_class`). Restore channels: explicit composition (no tag),
396
+ * {@link TaskSpec.restoreGatedTools}, env `SEMA_TOOL_MODEL_GATE=off`,
397
+ * `RunnerDeps.toolModelGate: false`; {@link TaskSpec.excludeTools} always wins regardless.
398
+ * The materialized `AgentTool` face does NOT carry this field (`defineTool` is a whitelist
399
+ * constructor) — the decision completes on the ToolSpec face inside `prepareConfigDoors`,
400
+ * before any conversion.
401
+ */
402
+ modelGate?: string;
384
403
  /**
385
404
  * Tool contract identity (campaign S2, prompt-assembly protocol §7): declares the EXECUTION
386
405
  * CONTRACT this tool implements, independent of its presentation text. `defineTool` attaches it
@@ -751,6 +770,13 @@ export interface ToolExecuteContext {
751
770
  * X arrives deferred, the exemption having been dropped in transit — which is the one outcome the
752
771
  * parent explicitly ruled out. Children merge this with their own spec value. */
753
772
  alwaysLoadTools?: readonly string[];
773
+ /** design/277 — the model-gate restore selector ({@link TaskSpec.restoreGatedTools}), inherited
774
+ * down the delegation tree on the same trusted seat as the three tool-face controls above
775
+ * (Runner-filled, read-only, frozen snapshot). Restoration is a TASK-TREE intent ("this work
776
+ * wants the scaffold back") and each child re-judges the gate under its OWN resolved model —
777
+ * dropping the selector in transit would trim a same-model child's roster in a way nobody
778
+ * chose (the gate is a default, not a policy; there is no tighten-only axis to protect). */
779
+ restoreGatedTools?: readonly string[] | true;
754
780
  /** R2 双形轴 — the parent's resolved prompt profile, inherited down the delegation tree like the
755
781
  * tool-face controls (Runner-filled): a classic-profile parent's children speak classic too
756
782
  * unless the child spec says otherwise (child spec wins — profile is presentation, not policy). */
@@ -1867,6 +1893,24 @@ export interface TaskSpec {
1867
1893
  * wins — an unmounted tool has no schema to keep inline).
1868
1894
  */
1869
1895
  alwaysLoadTools?: string[];
1896
+ /**
1897
+ * design/277 — per-task restore valve for the tool-registration MODEL GATE. The gate trims
1898
+ * default-mounted scaffold entries (tagged via {@link ToolSpec.modelGate}) from the roster when
1899
+ * the task's resolved model id matches the gate table — this valve opts the task (and its whole
1900
+ * delegation tree — the selector inherits down like `excludeTools`) back in:
1901
+ * · an ARRAY of wire names — naming ANY tagged tool restores that tool's WHOLE class for this
1902
+ * task (CC parity: opting into any member of the family restores the family). Judged against
1903
+ * the full stamp set, so a name that `excludeTools` also lists still works as a class
1904
+ * SELECTOR — while the exclusion itself still wins for that name (exclusion is the final
1905
+ * valve; no restore channel resurrects an excluded name). Unknown names are inert (a
1906
+ * center-distributed list may be a superset), same posture as `excludeTools`.
1907
+ * · literal `true` — every gate class restored for this task (the shortest "this task wants
1908
+ * all its scaffolding" spelling for center-distributed specs).
1909
+ * A malformed value refuses at prepare (`config.tool_model_gate_invalid`, #123) — garbage must
1910
+ * not silently read as "restore nothing" in the trimming direction. Absent = the gate's verdict
1911
+ * stands. Explicit composition needs no valve: a hand-mounted (untagged) tool is never gated.
1912
+ */
1913
+ restoreGatedTools?: string[] | true;
1870
1914
  /**
1871
1915
  * RB-403 — whether a schema-VALID call on a still-deferred tool runs the real tool directly
1872
1916
  * (activating it as a side effect). Default `true`, matching the upstream posture where the
@@ -4440,6 +4484,93 @@ export interface BackgroundChildEvent {
4440
4484
  costMicroUsd?: number;
4441
4485
  };
4442
4486
  }
4487
+ /**
4488
+ * #281 件B — one lifecycle phase of a DELEGATED child leg, delivered to the process-level
4489
+ * {@link RunnerDeps.onDelegationLifecycle} observer: the public, deps-level home of the delegation
4490
+ * lifecycle that previously lived only on the trusted RunInternals third parameter
4491
+ * (`onSubagentSpawn` — sync lane, a steer handle whose `settled` is a void promise) and on the
4492
+ * background-lane-only {@link BackgroundChildEvent}. A deployment wired ONLY through `RunnerDeps`
4493
+ * now sees every delegation lane through one seat.
4494
+ *
4495
+ * EMISSION CHOKEPOINT (the reason this seat covers every lane at once): frames are minted by the
4496
+ * RUNNER at the child LEG itself — spawn right after the leg's `wiring_manifest` (prepared, nothing
4497
+ * run yet), terminal when the leg's `TaskResult` assembles — not by the individual spawn lanes. Every
4498
+ * delegation lane (sync/steer/background/fork/revive, workflow-spawned agents) runs its child through
4499
+ * this chokepoint, so none of them needs its own emission and none can drift.
4500
+ *
4501
+ * PER-LEG semantics, deliberately: a durable park + resume, or a retained child's revive, is a NEW
4502
+ * leg — each emits its own spawn/terminal pair, and `identity.legKind` (`"resume"`) says which cycle
4503
+ * a frame belongs to. Consumers correlate legs of one delegation by `identity.taskId` /
4504
+ * `identity.parentToolCallId` (stable across cycles).
4505
+ *
4506
+ * HONEST ABSENCES (recorded, not gaps to fix silently):
4507
+ * · a child whose PREPARE throws emits neither frame (there is no leg identity to report);
4508
+ * · the ROOT leg emits nothing here — the deployment called `runTask` itself and holds the result;
4509
+ * this seat is the delegation observer, not a run observer;
4510
+ * · a deps-only resume (`resume(token, outcome, config)` — no trusted internals re-supplied) of a
4511
+ * checkpoint minted BEFORE the delegation axis was persisted
4512
+ * ({@link import("./checkpoint-store.js").CheckpointState.isDelegatedChild}) emits neither frame:
4513
+ * the row carries no evidence the parked leg was a delegated child, and fabricating the axis
4514
+ * would stamp delegation frames onto host-resumed root tasks. Rows minted WITH the axis resume
4515
+ * with their spawn/terminal pair even deps-only; a resume that re-supplies trusted internals
4516
+ * (every in-engine lane does) was never affected.
4517
+ * PAIRING (codex r2-D2 closed the one hole): a spawn frame is always closed by a terminal frame —
4518
+ * the ordinary path emits it where the `TaskResult` assembles, and a POST-SPAWN throw that the
4519
+ * stream layer converts into a synthesized failed result (e.g. `resume.tool_unavailable` re-thrown
4520
+ * past the run tail) emits the failed terminal from that backstop, gated on a carrier that is set
4521
+ * only after the spawn emission and cleared by the ordinary terminal (so the two sites can never
4522
+ * both fire for one leg). A leg whose PROCESS dies mid-flight is the only unpaired spawn.
4523
+ *
4524
+ * OBSERVATION ONLY (matrix §5.2 Q3: frame-rate lifecycle facts ride an observer seat, never
4525
+ * onNotice): no return capability, and delivery can never alter the child run —
4526
+ * {@link deliverDelegationLifecycle} contains a throwing sink and an async sink's rejection alike.
4527
+ *
4528
+ * FRAME OBJECT SHAPE (consumer contract, not an implementation detail): every delivered frame is
4529
+ * FROZEN and carries a NULL PROTOTYPE — re-minted at the one delivery point so a forged member
4530
+ * cannot ride a frame through a writable `Object.prototype`, the same rule (and the same trade) as
4531
+ * the {@link import("./hooks.js").HookInvocationIdentity} envelope inside it. `Object.keys`, spread,
4532
+ * JSON serialization and direct member reads (`frame.status`) all behave normally, but
4533
+ * `frame instanceof Object` is `false` and inherited methods are ABSENT — probe optional members
4534
+ * with `Object.hasOwn(frame, "errorCode")` or `"errorCode" in frame`, never
4535
+ * `frame.hasOwnProperty(...)` (throws) or implicit string coercion (`` `${frame}` `` throws).
4536
+ */
4537
+ export type DelegationLifecycleEvent = {
4538
+ phase: "spawn";
4539
+ /** The child LEG's identity envelope (#281 件A — the same frozen object that leg's own hook
4540
+ * invocations carry). `isDelegatedChild` is `true` by construction on every frame here. */
4541
+ identity: import("./hooks.js").HookInvocationIdentity;
4542
+ } | {
4543
+ phase: "terminal";
4544
+ /** Same envelope as the leg's spawn frame (one mint per leg). */
4545
+ identity: import("./hooks.js").HookInvocationIdentity;
4546
+ /** The leg's settled status — the `TaskResult.status` the spawning lane receives, verbatim
4547
+ * (the sync lane's previously-void `settled` payload, made public). `"suspended"` means a
4548
+ * durable park: expect a later `"resume"`-leg spawn/terminal pair if it is redeemed. */
4549
+ status: TaskResult["status"];
4550
+ /** Turns the leg completed. */
4551
+ turns: number;
4552
+ /** The leg's `TaskResult.errorCode`, when one was stamped. */
4553
+ errorCode?: string;
4554
+ };
4555
+ /** Test seam (mirrors `__resetMalformedNoticeSeatAnnouncement`): never called by production code. */
4556
+ export declare function __resetMalformedDelegationSeatAnnouncement(): void;
4557
+ /**
4558
+ * The ONE delivery form behind every {@link RunnerDeps.onDelegationLifecycle} emission point (both
4559
+ * runner stations — spawn and terminal — call this; a second spelling of the swallow/announce rules
4560
+ * would be the #170 triplication reborn). Contract:
4561
+ * · a FUNCTION seat is invoked with the frame FROZEN (a mutating observer must not rewrite what a
4562
+ * later frame consumer — or the shared identity envelope's other readers — see), contained in the
4563
+ * caller's {@link SafeNotifier} against BOTH failure shapes the void-typed seat admits: a
4564
+ * synchronous throw ({@link SafeNotifier.notify}) and an async sink's rejected promise
4565
+ * ({@link observeThenableRejection} routes it back through the same notifier/site — the #253
4566
+ * three-station form). A broken observer never faults the child run.
4567
+ * · a PRESENT NON-function seat is a bad deployment value; #123 forbids folding it to silence. There
4568
+ * is no per-frame console fallback (this is a frame-rate observer stream, not an announcement
4569
+ * channel — an unwired seat means UNOBSERVED, and echoing every spawn to stderr would flood), so
4570
+ * the loud exit is the seat DEFECT itself: announced via `console.warn` once per process.
4571
+ * · an ABSENT seat is a plain no-op (the deployment chose not to observe).
4572
+ */
4573
+ export declare function deliverDelegationLifecycle(seat: RunnerDeps["onDelegationLifecycle"], event: DelegationLifecycleEvent, notifier: import("./safe-notify.js").SafeNotifier, site: string): void;
4443
4574
  /**
4444
4575
  * parity-204 — structured `RunnerDeps.loadProjectMemory` return (backward-compatible: a bare
4445
4576
  * `string | null` keeps its exact historical meaning). Adds the CC 2.1.204 `seededFromContext`
@@ -4519,6 +4650,24 @@ export interface EngineNotice {
4519
4650
  * a library-direct `createHandsToolkit` mount announces at toolkit creation (one per mount,
4520
4651
  * through the band-local `HandsToolkitOptions.onNotice` seat, absent ⇒ `console.warn`);
4521
4652
  * `detail: { seat, declared, inForce, cause }`.
4653
+ * - `"config.tool_model_gate_removed"` (design/277) — the model gate trimmed default-mounted
4654
+ * scaffold entries from a task's roster. One notice per (model, class), de-duplicated once per
4655
+ * `onNotice` SINK on the (modelId, class, canonical sorted removed-name set) line (same unit
4656
+ * as `config.read_face_deployment_clamped`: two deployments hosted in one process each hear
4657
+ * their own trim; unwired console arm once per process) — same class with a different removal
4658
+ * shape is a distinct fact and announces again;
4659
+ * `detail: { modelId, gateClass, removed, restore }` (`restore` names the three valves).
4660
+ * - `"config.tool_model_gate_unknown_class"` (design/277) — a `ToolSpec.modelGate` tag names a
4661
+ * class the merged gate table has no row for: the tag is inert (fail-open — the tool stays
4662
+ * mounted) and this is its loud half (a tag typo must not silently become "never gated" with
4663
+ * nobody told). Once per `onNotice` sink per class (console arm once per process);
4664
+ * `detail: { gateClass }`.
4665
+ * - `"config.tool_model_gate_env_invalid"` (design/277, the NOTICE dialect of the same fact the
4666
+ * refusal code carries) — `SEMA_TOOL_MODEL_GATE` holds a value outside `on|1|true|off|0|false`
4667
+ * in a seat where it is NOT in force (nothing this prepare would gate): announced once per
4668
+ * `onNotice` sink per value (console arm once per process) instead of lying in wait; where the
4669
+ * value IS in force the prepare refuses with the same code as `TaskResult.errorCode` (one
4670
+ * fact, one code, two loudness dialects); `detail: { raw }`.
4522
4671
  *
4523
4672
  * Deliberately NOT a notice family: brain retry/reconnect liveness (a rate limit, a 5xx, a
4524
4673
  * transient network failure being retried). Those are per-attempt liveness frames with their own
@@ -4636,6 +4785,27 @@ export interface RunnerDeps {
4636
4785
  * ignored). Same deployment-only seat, clamp argument and checkpoint posture as the tiers key.
4637
4786
  */
4638
4787
  readDenyBuiltinExclude?: readonly string[];
4788
+ /**
4789
+ * backlog #286 (#279 — CC 2.1.233 `DANGEROUS_FILES`/`DANGEROUS_DIRECTORIES`/
4790
+ * `DANGEROUS_DIRECTORY_PATHS` parity): the WRITE-protection table. DEFAULT-ON: a path-confinable
4791
+ * write (Write/Edit/NotebookEdit) whose target lands on a table row has a surviving `allow`
4792
+ * demoted to `ask` at the tool gate (`decisionReason: "safety"`; a deny/ask verdict is untouched;
4793
+ * approval flows the ordinary ask-resolution chain — classifier, blanket `onAsk`, durable park —
4794
+ * with no `requiresRealApproval` mandate). Absent =
4795
+ * {@link import("./write-protect.js").WRITE_PROTECTED_DEFAULT_TABLE} (the CC triple verbatim +
4796
+ * the two argued sema rows). This key IS the whole-table escape hatch, deployment seat ONLY (no
4797
+ * TaskSpec twin, no governed-workflow channel): `[]` = no table (explicit and legal); a non-empty
4798
+ * list REPLACES the built-in table whole (compose additions as
4799
+ * `[...WRITE_PROTECTED_DEFAULT_TABLE, …]`; drop rows by filtering the exported table — the
4800
+ * visible/deletable admin face). Bad values refuse loudly at prepare (#123): garbage shapes,
4801
+ * glob metacharacters (the table speaks LITERAL names — glob semantics live in
4802
+ * `createSensitivePathPolicy`), unknown kinds, impossible kind/name combinations. Matching is
4803
+ * lexical over the spelled target with one case fold (ı/ſ included) — a symlink alias evades it
4804
+ * by construction; the canonicalizing opt-in deny policy remains the hard layer. Not frozen into
4805
+ * checkpoints: a resumed task follows the CURRENT deployment table (an approved parked call
4806
+ * bypasses the gate as always — the human already adjudicated it).
4807
+ */
4808
+ writeProtectedPaths?: readonly import("./write-protect.js").WriteProtectedEntry[];
4639
4809
  /**
4640
4810
  * design/199 件A — the DEPLOYMENT's read-face declaration
4641
4811
  * ({@link import("../tools/fs/read-face.js").ReadFace}; see {@link TaskSpec.readFace} for the
@@ -4874,6 +5044,23 @@ export interface RunnerDeps {
4874
5044
  * completion pushes from it. Observability only — a throwing observer is swallowed, never faults a run.
4875
5045
  */
4876
5046
  onBackgroundChildEvent?: (event: BackgroundChildEvent) => void;
5047
+ /**
5048
+ * #281 件B — PROCESS-level observer for EVERY delegated child leg's lifecycle (`spawn`/`terminal`),
5049
+ * all delegation lanes at once: synchronous and steer-handle delegations (whose spawn/settle
5050
+ * previously reached only the trusted `RunInternals.onSubagentSpawn` third parameter — and whose
5051
+ * `settled` is a void promise), background/fork/revive children (whose {@link BackgroundChildEvent}
5052
+ * family this seat complements, not replaces — BCE stays the fleet-row lane with registry `a*`
5053
+ * handles and ticks; this seat is the leg-identity lane), and workflow-spawned agents. Frames are
5054
+ * minted at the child leg's own runner chokepoint and carry the #281 件A identity envelope plus a
5055
+ * terminal status summary — see {@link DelegationLifecycleEvent} for the emission points, the
5056
+ * per-leg semantics and the recorded honest absences. Wire it once at Runner construction; a
5057
+ * deployment needs NO RunInternals access to observe delegation any more. Observation only — a
5058
+ * throwing or rejecting observer is contained ({@link deliverDelegationLifecycle}) and never
5059
+ * faults the child run; a PRESENT non-function value here is announced once per process and the
5060
+ * frames are simply not delivered (#123 — a bad seat must be loud, and this stream's loud exit is
5061
+ * the seat defect, not a per-frame console flood).
5062
+ */
5063
+ onDelegationLifecycle?: (event: DelegationLifecycleEvent) => void;
4877
5064
  /**
4878
5065
  * design/98 (S8) — the HARD sandbox seam for LLM-AUTHORED workflow scripts (`TaskSpec.selfOrchestration`).
4879
5066
  * A deployment supplies an isolated-vm / separate-process runner whose `safeForUntrustedScripts === true`;
@@ -4961,7 +5148,7 @@ export interface RunnerDeps {
4961
5148
  * resolves a saved workflow, and a registration's `defaultArgs` merge under the call-time args. Opt-in
4962
5149
  * (absent ⇒ inline `script` + built-in names only). */
4963
5150
  workflowScriptStore?: import("../orchestration/workflow-script-store.js").WorkflowScriptStore;
4964
- /** design/140 §6 1c — `false` removes the BUILT-IN named workflows (`team-discussion`, …) from the
5151
+ /** design/140 §6 1c — `false` removes the BUILT-IN named workflows (`discussion`, …) from the
4965
5152
  * auto-mounted run_workflow tool (the `builtinAgents:false` analog; default ON). A deployment
4966
5153
  * `workflowScriptStore` registration of the same name shadows a built-in regardless. */
4967
5154
  builtinWorkflows?: boolean;
@@ -5142,6 +5329,28 @@ export interface RunnerDeps {
5142
5329
  mcpImageResizer?: import("./mcp.js").McpImageResizer;
5143
5330
  /** Default tool-call gate for all tasks (a task's own `toolPolicy` overrides this). */
5144
5331
  toolPolicy?: import("./tool-policy.js").ToolPolicy;
5332
+ /**
5333
+ * design/277 — the deployment seat of the tool-registration MODEL GATE (default ON; the gate
5334
+ * itself only ever touches entries tagged via {@link ToolSpec.modelGate}, so an untagged roster
5335
+ * is byte-identical under any value here):
5336
+ * · `false` — deployment kill switch: the gate never trims anything.
5337
+ * · `{ classes }` — per-class rule rows merged into the built-in table
5338
+ * ({@link import("./tool-model-gate.js").TOOL_MODEL_GATE_CLASSES}) PER AXIS: a present
5339
+ * `floors`/`modelIds` axis REPLACES that axis, an absent axis INHERITS the built-in one — so
5340
+ * a row adding only `modelIds` (the BYOM channel for gating a deployment's own strong model)
5341
+ * keeps the built-in claude floors armed. `floors: []` / `modelIds: []` are the EXPLICIT
5342
+ * per-axis clears; a class whose merged rule has both axes empty gates nothing (legal
5343
+ * per-class off). New class names extend the open vocabulary for deployment-authored tags.
5344
+ * The legality set is CLOSED (#123): any other shape — array/null/true seat, unknown keys at
5345
+ * either level, malformed floors/modelIds rows, families that can never match the canonical id
5346
+ * grammar, duplicate families/ids, `/`-prefixed ids — refuses the prepare loudly
5347
+ * (`config.tool_model_gate_invalid`), never folds to a guess. Process-level counterpart: env
5348
+ * `SEMA_TOOL_MODEL_GATE=off`. Removals are announced per (model, class) through `onNotice`
5349
+ * (`config.tool_model_gate_removed`) — a silent default-face trim is forbidden (#237).
5350
+ */
5351
+ toolModelGate?: false | {
5352
+ classes?: Record<string, import("./tool-model-gate.js").ToolModelGateRule>;
5353
+ };
5145
5354
  /** Deployment default for {@link TaskSpec.basePolicyForResumeEdit} (#93 / F-012 L3): the resume-edit
5146
5355
  * re-adjudication override. Resolution: `spec.basePolicyForResumeEdit ?? THIS ?? (spec.toolPolicy ??
5147
5356
  * deps.toolPolicy)` — absence falls back to the caller policy, never to a silent skip. */
@@ -1,3 +1,24 @@
1
+ import { observeThenableRejection } from "./safe-notify.js";
2
+ let malformedDelegationSeatAnnounced = false;
3
+ export function __resetMalformedDelegationSeatAnnouncement() {
4
+ malformedDelegationSeatAnnounced = false;
5
+ }
6
+ export function deliverDelegationLifecycle(seat, event, notifier, site) {
7
+ if (typeof seat === "function") {
8
+ const frame = Object.freeze(Object.assign(Object.create(null), event));
9
+ notifier.notify(() => observeThenableRejection(seat(frame), notifier, site), site);
10
+ return;
11
+ }
12
+ if (seat !== undefined && !malformedDelegationSeatAnnounced) {
13
+ malformedDelegationSeatAnnounced = true;
14
+ try {
15
+ console.warn(`The delegation-lifecycle sink (RunnerDeps.onDelegationLifecycle) holds ${seat === null ? "null" : typeof seat} — not a ` +
16
+ `function. Delegation spawn/terminal frames are NOT delivered until the wiring is fixed (omit the key, or wire a function).`);
17
+ }
18
+ catch {
19
+ }
20
+ }
21
+ }
1
22
  let malformedNoticeSeatAnnounced = false;
2
23
  export function __resetMalformedNoticeSeatAnnouncement() {
3
24
  malformedNoticeSeatAnnounced = false;
@@ -21,7 +21,7 @@
21
21
  * model-facing instructions. Always neutralizes `<system-reminder>` / `</system-reminder>` (the codebase's
22
22
  * elevated-authority wrapper) by inserting a zero-width space after the leading `<`. Pass `extraTags` to also
23
23
  * neutralize a CALLER's own data-framing tags — e.g. `team.ts` embeds member output inside `<statement>` /
24
- * `<team-discussion>`, so an untrusted member could emit `</statement>` to break out (search [46] BUG2);
24
+ * `<discussion>`, so an untrusted member could emit `</statement>` to break out (search [46] BUG2);
25
25
  * the caller passes those tag names. Tag names must be literal (alphanumeric/hyphen) — they are code-supplied
26
26
  * wrapper names, never untrusted input. Idempotent for prompt assembly (a defused tag no longer matches).
27
27
  */