@sema-agent/core 6.0.0 → 7.0.1

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 (34) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/agents/launch-receipt-contract.d.ts +34 -0
  3. package/dist/agents/launch-receipt-contract.js +5 -0
  4. package/dist/agents/subagent.d.ts +134 -2
  5. package/dist/agents/subagent.js +132 -31
  6. package/dist/core/file-history-store.js +24 -2
  7. package/dist/core/governance-codes.d.ts +11 -2
  8. package/dist/core/governance-codes.js +1 -0
  9. package/dist/core/permission-rule-consent.d.ts +42 -2
  10. package/dist/core/permission-rule-consent.js +93 -11
  11. package/dist/core/permission-rule-model.d.ts +51 -9
  12. package/dist/core/permission-rule-model.js +4 -2
  13. package/dist/core/permission-rule-session.d.ts +124 -0
  14. package/dist/core/permission-rule-session.js +121 -0
  15. package/dist/core/permission-rule-store.d.ts +65 -2
  16. package/dist/core/permission-rule-store.js +60 -6
  17. package/dist/core/permission-rule-sync.d.ts +9 -0
  18. package/dist/core/permission-rule-sync.js +37 -8
  19. package/dist/core/roles.js +1 -1
  20. package/dist/core/runner/prepare-task.js +35 -2
  21. package/dist/core/runner/runtask.js +2 -0
  22. package/dist/core/store-contracts/permission-rule-sync-contract.js +15 -1
  23. package/dist/core/task-notification.d.ts +20 -0
  24. package/dist/core/trace.d.ts +7 -2
  25. package/dist/core/types.d.ts +85 -1
  26. package/dist/core/wiring-manifest.d.ts +18 -1
  27. package/dist/index.d.ts +2 -1
  28. package/dist/index.js +2 -1
  29. package/dist/orchestration/run-workflow-tool.js +2 -2
  30. package/dist/orchestration/workflow.js +18 -10
  31. package/dist/stores/file/permission-rule-store.d.ts +11 -0
  32. package/dist/stores/file/permission-rule-store.js +22 -9
  33. package/package.json +1 -1
  34. package/test/export-surface.snapshot.json +15 -1
@@ -10,7 +10,11 @@ export function writerOf(store) {
10
10
  return w !== undefined && typeof w.apply === "function" && typeof w.nextDot === "function" ? w : undefined;
11
11
  }
12
12
  export function sameScope(a, b) {
13
- return a.kind === "global" ? b.kind === "global" : b.kind === "project" && a.root === b.root;
13
+ if (a.kind === "global")
14
+ return b.kind === "global";
15
+ if (a.kind === "session")
16
+ return b.kind === "session" && a.sessionId === b.sessionId;
17
+ return b.kind === "project" && a.root === b.root;
14
18
  }
15
19
  function sameDot(a, b) {
16
20
  return a.actor === b.actor && a.counter === b.counter;
@@ -47,7 +51,29 @@ export function assertDeleteDeltaCarriesNoAdd(delta) {
47
51
  throw new Error("a tighten-delete must carry a tombstone naming at least one observed add dot");
48
52
  }
49
53
  }
50
- function isValidScope(scope) {
54
+ export function assertWriteDeltaScopeDurable(delta) {
55
+ const scope = delta.kind === "redemption-add" ? delta.scope : delta.tombstone?.scope;
56
+ if (scope?.kind === "session") {
57
+ const e = new Error(delta.kind === "redemption-add"
58
+ ? "a session-scope rule cannot enter the persisted store — its home is the session's own overlay (design/382 §4.3); refusing the redemption-add"
59
+ : "a session-scope tombstone cannot enter the persisted store — session rows are not individually deletable and die with their session (design/382 §4.3/§10); refusing the tighten-delete");
60
+ e.code = "unsupported.session_scope_store";
61
+ throw e;
62
+ }
63
+ if (!isValidDurableScope(scope)) {
64
+ throw new Error(delta.kind === "redemption-add"
65
+ ? "a redemption-add whose scope is not a well-formed durable scope cannot enter the persisted store (design/382 §4.3: the face is {global, project-with-a-root}) — refusing the write"
66
+ :
67
+ "a tighten-delete must carry a tombstone whose scope is a well-formed durable scope (design/382 §4.3: the face is {global, project-with-a-root}) — refusing the write");
68
+ }
69
+ }
70
+ export function isValidConsentScope(scope) {
71
+ const sc = scope;
72
+ return (sc?.kind === "global" ||
73
+ (sc?.kind === "project" && typeof sc.root === "string" && sc.root !== "") ||
74
+ (sc?.kind === "session" && typeof sc.sessionId === "string" && sc.sessionId !== ""));
75
+ }
76
+ export function isValidDurableScope(scope) {
51
77
  const sc = scope;
52
78
  return sc?.kind === "global" || (sc?.kind === "project" && typeof sc.root === "string" && sc.root !== "");
53
79
  }
@@ -56,7 +82,7 @@ function isValidDot(dot) {
56
82
  return typeof d?.actor === "string" && d.actor !== "" && typeof d.counter === "number" && Number.isFinite(d.counter);
57
83
  }
58
84
  function scopeKey(scope) {
59
- return JSON.stringify(scope.kind === "global" ? ["g"] : ["p", scope.root]);
85
+ return JSON.stringify(scope.kind === "global" ? ["g"] : scope.kind === "session" ? ["s", scope.sessionId] : ["p", scope.root]);
60
86
  }
61
87
  function dotKey(dot) {
62
88
  return JSON.stringify([dot.actor, dot.counter]);
@@ -114,6 +140,11 @@ export function screenRuleSyncState(state) {
114
140
  for (const r of state.rules) {
115
141
  if (!Array.isArray(r.adds) || r.adds.length === 0)
116
142
  continue;
143
+ if (r.scope?.kind === "session") {
144
+ for (const a of r.adds)
145
+ rejected.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: "session_scope_not_durable" });
146
+ continue;
147
+ }
117
148
  const n = normalizePersistedRule(r);
118
149
  if ("reject" in n) {
119
150
  for (const a of r.adds)
@@ -122,7 +153,15 @@ export function screenRuleSyncState(state) {
122
153
  }
123
154
  rules.push(r);
124
155
  }
125
- return { state: { rules, tombstones: state.tombstones }, rejected };
156
+ const tombstones = [];
157
+ for (const t of state.tombstones) {
158
+ if (t?.scope?.kind === "session") {
159
+ rejected.push({ rule: t.rule, scope: t.scope, dot: t.deletedBy, reason: "session_scope_not_durable" });
160
+ continue;
161
+ }
162
+ tombstones.push(t);
163
+ }
164
+ return { state: { rules, tombstones }, rejected };
126
165
  }
127
166
  export function joinRuleStates(a, b) {
128
167
  const sa = screenRuleSyncState(a).state;
@@ -183,10 +222,12 @@ export function applySyncJoin(cur, delta, nowMs) {
183
222
  throw new Error("a sync-join delta must carry an inbound state with rules and tombstones arrays");
184
223
  }
185
224
  for (const t of delta.inbound.tombstones) {
225
+ if (t?.scope?.kind === "session")
226
+ continue;
186
227
  if (!Array.isArray(t?.removedDots) || t.removedDots.length === 0) {
187
228
  throw new Error("an inbound tombstone must name at least one observed add dot — refusing the whole sync-join (the client's partitioning must withhold the round's adds alongside a malformed tombstone)");
188
229
  }
189
- if (!isValidScope(t.scope) || !isValidDot(t.deletedBy) || !t.removedDots.every(isValidDot)) {
230
+ if (!isValidDurableScope(t.scope) || !isValidDot(t.deletedBy) || !t.removedDots.every(isValidDot)) {
190
231
  throw new Error("an inbound tombstone carries a malformed scope or dot — refusing the whole sync-join (a tombstone that cannot identity-match what it deletes would land its covered adds live)");
191
232
  }
192
233
  const canonicalTomb = parseAllowRuleText(t.rule);
@@ -229,7 +270,7 @@ export function applySyncJoin(cur, delta, nowMs) {
229
270
  withheldAdds += r.adds.length;
230
271
  continue;
231
272
  }
232
- if (!isValidScope(r.scope)) {
273
+ if (!isValidDurableScope(r.scope)) {
233
274
  for (const a of r.adds)
234
275
  droppedInbound.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: "metadata_mismatch" });
235
276
  continue;
@@ -288,6 +329,11 @@ export function applySyncJoin(cur, delta, nowMs) {
288
329
  }
289
330
  }
290
331
  for (const q of delta.quarantine ?? []) {
332
+ if (q.scope?.kind === "session") {
333
+ const e = new Error("a quarantine instruction names a session scope — the persisted store never holds a session row (design/382 §4.3); refusing the instruction");
334
+ e.code = "unsupported.session_scope_store";
335
+ throw e;
336
+ }
291
337
  for (const dot of q.dots) {
292
338
  if (joined.tombstones.some((t) => sameDot(t.deletedBy, dot))) {
293
339
  throw new Error("a quarantine instruction may not touch a tombstone — it moves adds only");
@@ -332,6 +378,12 @@ export async function removePersistedRule(opts) {
332
378
  const target = opts.principal;
333
379
  const owner = typeof target === "string" ? { kind: "principal", principal: target } : structuredClone(target);
334
380
  const provider = opts.provider;
381
+ if (scope?.kind === "session") {
382
+ return {
383
+ status: "failed",
384
+ error: "unsupported.session_scope_store: a session-scope rule cannot be removed through the persisted-store entry — session authorizations live in the session's own overlay and end with the session (design/382 §4.3)",
385
+ };
386
+ }
335
387
  if (owner.kind === "local-owner" && provider.forLocalOwner === undefined) {
336
388
  return { status: "failed", error: "this provider has no local-owner bucket (forLocalOwner is not implemented) — a local-owner rule cannot be removed through it" };
337
389
  }
@@ -435,10 +487,12 @@ export class InMemoryPermissionRuleStore {
435
487
  if (opts.expectedRev !== this.rev)
436
488
  return { conflict: true, rev: this.rev };
437
489
  if (delta.kind === "redemption-add") {
490
+ assertWriteDeltaScopeDurable(delta);
438
491
  assertRedemptionNotQuarantined(this.quarantinedRows, delta);
439
492
  this.rules = foldDelta(this.rules, delta);
440
493
  }
441
494
  else if (delta.kind === "tighten-delete") {
495
+ assertWriteDeltaScopeDurable(delta);
442
496
  assertDeleteDeltaCarriesNoAdd(delta);
443
497
  this.tombstones = [...this.tombstones, delta.tombstone];
444
498
  }
@@ -134,6 +134,15 @@ interface ParsedRuleSyncResponse {
134
134
  dot: RuleDot;
135
135
  code: RuleRejectCode;
136
136
  }>;
137
+ /** design/382 §4.3 — SESSION-scope tombstones, dropped at the parse as per-row refusals (never
138
+ * `undefined`-malformed): a session row can never land regardless of its dots, so classifying a
139
+ * DAMAGED one as generic-malformed would withhold every valid inbound add on every round — one
140
+ * peer row poisoning sync forever, the exact shape the per-row drop arm exists to avoid. */
141
+ sessionScopeTombstones: Array<{
142
+ rule: string;
143
+ scope: RuleScope;
144
+ dot: RuleDot;
145
+ }>;
137
146
  warnings: string[];
138
147
  }
139
148
  /**
@@ -91,6 +91,9 @@ export async function syncPermissionRules(opts) {
91
91
  for (const t of response.textRefusedTombstones) {
92
92
  dropped.push({ rule: t.rule, scope: t.scope, dot: t.dot, reason: "invalid_rule_text" });
93
93
  }
94
+ for (const t of response.sessionScopeTombstones) {
95
+ dropped.push({ rule: t.rule, scope: t.scope, dot: t.dot, reason: "session_scope_not_durable" });
96
+ }
94
97
  if (response.textRefusedTombstones.length > 0) {
95
98
  const named = response.textRefusedTombstones
96
99
  .slice(0, DISCLOSED_REFUSED_TOMBSTONES)
@@ -131,6 +134,10 @@ export async function syncPermissionRules(opts) {
131
134
  }
132
135
  const localDots = new Set(raw.rules.flatMap((r) => r.adds.map((a) => JSON.stringify([a.dot.actor, a.dot.counter]))));
133
136
  for (const d of response.dropped) {
137
+ if (d.scope.kind === "session") {
138
+ warnings.push(`the server's dropped list names a session-scope row (${escapeForDisclosure(d.rule)}) — the durable store never holds one, so there is no local row it could mean; ignored`);
139
+ continue;
140
+ }
134
141
  if (localDots.has(JSON.stringify([d.dot.actor, d.dot.counter]))) {
135
142
  const reason = d.reason === "below_gc_frontier" ? "below_gc_frontier" : "server_rejected";
136
143
  quarantine.push({ rule: d.rule, scope: d.scope, dots: [d.dot], reason });
@@ -190,7 +197,7 @@ export async function syncPermissionRules(opts) {
190
197
  const dotKeyOf = (d) => JSON.stringify([d.actor, d.counter]);
191
198
  const preDots = new Set(current.rules.flatMap((r) => r.adds.map((a) => dotKeyOf(a.dot))));
192
199
  const newAdds = landedRaw.rules.flatMap((r) => r.adds).filter((a) => !preDots.has(dotKeyOf(a.dot))).length;
193
- const tombKeyOf = (t) => JSON.stringify([t.rule, t.scope.kind === "global" ? null : t.scope.root, dotKeyOf(t.deletedBy)]);
200
+ const tombKeyOf = (t) => JSON.stringify([t.rule, t.scope.kind, t.scope.kind === "project" ? t.scope.root : t.scope.kind === "session" ? t.scope.sessionId : null, dotKeyOf(t.deletedBy)]);
194
201
  const preTombs = new Set(current.tombstones.map(tombKeyOf));
195
202
  const newTombstones = landedRaw.tombstones.filter((t) => !preTombs.has(tombKeyOf(t))).length;
196
203
  const landedReport = res.sync;
@@ -228,13 +235,23 @@ function pickDot(v) {
228
235
  const counter = d?.counter;
229
236
  return typeof actor === "string" && actor !== "" && typeof counter === "number" && Number.isFinite(counter) ? { actor, counter } : undefined;
230
237
  }
231
- function pickScope(v) {
238
+ function pickScopeCaptured(v) {
232
239
  const s = v;
233
- if (s?.kind === "global")
234
- return { kind: "global" };
235
- if (s?.kind === "project" && typeof s.root === "string" && s.root !== "")
236
- return { kind: "project", root: s.root };
237
- return undefined;
240
+ const kind = s?.kind;
241
+ if (kind === "global")
242
+ return { kind, scope: { kind: "global" } };
243
+ if (kind === "project") {
244
+ const root = s?.root;
245
+ return { kind, scope: typeof root === "string" && root !== "" ? { kind: "project", root } : undefined };
246
+ }
247
+ if (kind === "session") {
248
+ const sessionId = s?.sessionId;
249
+ return { kind, scope: typeof sessionId === "string" && sessionId !== "" ? { kind: "session", sessionId } : undefined };
250
+ }
251
+ return { kind, scope: undefined };
252
+ }
253
+ function pickScope(v) {
254
+ return pickScopeCaptured(v).scope;
238
255
  }
239
256
  function pickAdd(v) {
240
257
  const a = v;
@@ -325,9 +342,11 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
325
342
  }
326
343
  const tombstones = [];
327
344
  const textRefusedTombstones = [];
345
+ const sessionScopeTombstones = [];
328
346
  for (const entry of mergedTombstones) {
329
347
  const t = entry;
330
- const scope = pickScope(t?.scope);
348
+ const pickedScope = pickScopeCaptured(t?.scope);
349
+ const scope = pickedScope.scope;
331
350
  const ruleText = t?.rule;
332
351
  const deletedBy = pickDot(t?.deletedBy);
333
352
  const removedRaw = t?.removedDots;
@@ -342,6 +361,15 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
342
361
  removedDots.push(dot);
343
362
  }
344
363
  }
364
+ if (pickedScope.kind === "session") {
365
+ if (typeof ruleText === "string" && deletedBy !== undefined && scope !== undefined) {
366
+ sessionScopeTombstones.push({ rule: ruleText, scope, dot: deletedBy });
367
+ }
368
+ else {
369
+ warnings.push("the response carried a session-scope tombstone whose identity could not be read — dropped per-row (a session row never lands here, and nothing is withheld for it)");
370
+ }
371
+ continue;
372
+ }
345
373
  if (typeof ruleText !== "string" || scope === undefined || deletedBy === undefined || removedDots.length === 0) {
346
374
  tombstones.push(undefined);
347
375
  continue;
@@ -384,6 +412,7 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
384
412
  ...(gcFrontier !== undefined ? { gcFrontier } : {}),
385
413
  dropped,
386
414
  textRefusedTombstones,
415
+ sessionScopeTombstones,
387
416
  warnings,
388
417
  };
389
418
  }
@@ -3,7 +3,7 @@ export function resolveModel(ref, models) {
3
3
  if (typeof ref !== "string") {
4
4
  return ref;
5
5
  }
6
- const m = models?.[ref];
6
+ const m = models !== undefined && Object.hasOwn(models, ref) ? models[ref] : undefined;
7
7
  if (!m) {
8
8
  throw new Error(`Unknown model ref "${ref}". Provide it in RunnerDeps.models or pass a Model object.`);
9
9
  }
@@ -24,6 +24,7 @@ import { SubagentRetainLedger } from "../../agents/retain-ledger.js";
24
24
  import { askApproverIdentity, carriesBidiControls, checkToolPolicyProjection, combinePolicies, constraintChainDigest, constraintChainEntryOfLayer, isApprovalSettledBy, isAskDenyResolution, screenApproverAttribution, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, describeThrown, refuseOutOfContractDecision, resolveAsk, toolPolicyNameSets, tryCloneArgs } from "../tool-policy.js";
25
25
  const PERSISTED_RULE_TOOL = "Bash";
26
26
  import { findAdmittingRule, segmentCoverageOf, suggestRulesForCommand } from "../permission-rule-model.js";
27
+ import { normalizePersistedRule } from "../permission-rule-store.js";
27
28
  import { ActiveSkillScope, createActiveSkillScopePolicy } from "./active-skill-scope.js";
28
29
  import { CHANGED_FILES_MTIME_EPS_MS, fenceMcpServerInstructions, renderAgentListingDelta } from "./turn-attachments.js";
29
30
  import { inlineUntrusted } from "../untrusted-text.js";
@@ -893,6 +894,37 @@ function explicitlyDeferredMemoryTrio(mounted, roster, deferNames) {
893
894
  function memoryGroupRetractionSet(builtinDeferPairNames, engineTrioInPlay) {
894
895
  return new Set([...builtinDeferPairNames, ...(engineTrioInPlay ? MEMORY_ENGINE_TOOL_NAMES : [])]);
895
896
  }
897
+ async function spliceSessionOverlayRows(overlay, sessionId, persisted, tracer, hostTaskId) {
898
+ if (overlay === undefined)
899
+ return persisted;
900
+ try {
901
+ const served = structuredClone(await overlay.read(sessionId));
902
+ const sessionRows = served.filter((r) => {
903
+ const scope = r?.scope;
904
+ return scope?.kind === "session" && scope.sessionId === sessionId && !("reject" in normalizePersistedRule(r));
905
+ });
906
+ if (sessionRows.length < served.length) {
907
+ emitTrace(tracer, () => ({
908
+ kind: "permission.rule_store_unreadable",
909
+ version: 1,
910
+ taskId: hostTaskId,
911
+ message: `session-rule overlay served ${served.length - sessionRows.length} row(s) that are not canonical session rows of this session — dropped, not adjudicated`,
912
+ ts: Date.now(),
913
+ }));
914
+ }
915
+ return sessionRows.length > 0 ? [...sessionRows, ...persisted] : persisted;
916
+ }
917
+ catch (err) {
918
+ emitTrace(tracer, () => ({
919
+ kind: "permission.rule_store_unreadable",
920
+ version: 1,
921
+ taskId: hostTaskId,
922
+ message: `session-rule overlay: ${err instanceof Error ? err.message : String(err)}`,
923
+ ts: Date.now(),
924
+ }));
925
+ return persisted;
926
+ }
927
+ }
896
928
  function refuseRequireExistingWithoutSession(spec) {
897
929
  if (spec.requireExistingSession && !spec.sessionId) {
898
930
  const e = new Error(`requireExistingSession requires a sessionId — cannot require an existing session without one (design/114 Phase3)`);
@@ -3600,10 +3632,11 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3600
3632
  }));
3601
3633
  return { unreadable: true };
3602
3634
  }
3603
- const admitting = findAdmittingRule(listed.rules, { tool: req.toolName, command, cwd: root });
3635
+ const table = await spliceSessionOverlayRows(deps.sessionPermissionRules, sessionId, listed.rules, deps.tracer, hostTaskId);
3636
+ const admitting = findAdmittingRule(table, { tool: req.toolName, command, cwd: root, sessionId });
3604
3637
  if (admitting !== undefined)
3605
3638
  return persistedRuleHitOf(admitting);
3606
- const coverage = segmentCoverageOf(command, { persisted: listed.rules }, { tool: req.toolName, cwd: root });
3639
+ const coverage = segmentCoverageOf(command, { persisted: table }, { tool: req.toolName, cwd: root, sessionId });
3607
3640
  return coverage !== undefined ? { segmentCoverage: coverage } : undefined;
3608
3641
  },
3609
3642
  };
@@ -1410,6 +1410,7 @@ function makeHarnessHandlers(prepared, stats, rs, deps) {
1410
1410
  ...(internals?.cycleSeq !== undefined ? { seq: internals.cycleSeq } : {}),
1411
1411
  ...(internals?.parentTaskId !== undefined ? { parentTaskId: internals.parentTaskId } : {}),
1412
1412
  ...(subagentName ? { name: subagentName } : {}),
1413
+ model: prepared.model.id,
1413
1414
  usage: { totalTokens: stats.tokens, toolUses: stats.toolCalls, durationMs: Date.now() - rs.telemetry.taskStart },
1414
1415
  status: "running",
1415
1416
  ...ident(),
@@ -4223,6 +4224,7 @@ export class Runner {
4223
4224
  ...(internals?.cycleSeq !== undefined ? { seq: internals.cycleSeq } : {}),
4224
4225
  ...(internals?.parentTaskId !== undefined ? { parentTaskId: internals.parentTaskId } : {}),
4225
4226
  ...(subagentName ? { name: subagentName } : {}),
4227
+ model: prepared.model.id,
4226
4228
  usage: { totalTokens: stats.tokens, toolUses: stats.toolCalls, durationMs: Date.now() - rs.telemetry.taskStart },
4227
4229
  status: result.status === "completed" ? "completed" : "failed",
4228
4230
  ...ident(),
@@ -34,7 +34,11 @@ function liveDots(s) {
34
34
  return out;
35
35
  }
36
36
  function sameScope(a, b) {
37
- return a.kind === "global" ? b.kind === "global" : b.kind === "project" && a.root === b.root;
37
+ if (a.kind === "global")
38
+ return b.kind === "global";
39
+ if (a.kind === "session")
40
+ return b.kind === "session" && a.sessionId === b.sessionId;
41
+ return b.kind === "project" && a.root === b.root;
38
42
  }
39
43
  function allDots(s) {
40
44
  return new Set(s.rules.flatMap((r) => r.adds.map((a) => `${r.rule}|${a.dot.actor}#${a.dot.counter}`)));
@@ -150,6 +154,16 @@ export async function permissionRuleSyncContract(hooks = {}) {
150
154
  const merged = join(state([rule("Bash(node:*)", [add("z", 1)])]), state());
151
155
  assert.strictEqual(allDots(merged).size, 0, "Bash(node:*) must be refused by the shared validator inside the join");
152
156
  });
157
+ run("durable two-member face (design/382 §4.3): a session-scope row or tombstone never survives a join, and never poisons the round", async () => {
158
+ const sessionScope = { kind: "session", sessionId: "sess-1" };
159
+ const dirty = state([rule("Bash(ls)", [add("z", 1)], sessionScope), rule("Bash(git status)", [add("a", 1)])], [tomb("Bash(pwd)", [["z", 2]], ["z", 3], sessionScope)]);
160
+ const ab = join(dirty, state());
161
+ const ba = join(state(), dirty);
162
+ assert.ok(!allDots(ab).has("Bash(ls)|z#1"), "a session-scope add must not survive the join — the durable face holds two members");
163
+ assert.strictEqual(ab.tombstones.length, 0, "a session-scope tombstone must not survive the join");
164
+ assert.ok(allDots(ab).has("Bash(git status)|a#1"), "dropping the session row must not withhold or disturb the durable rest — per-row, never round-poisoning");
165
+ assert.deepStrictEqual(ab, ba, "the session-scope drop must preserve commutativity");
166
+ });
153
167
  run("identity keys are collision-free: roots and actors containing spaces keep DISTINCT tombstone identities, and the covered add stays dead through an identity join", async () => {
154
168
  const scopeA = { kind: "project", root: "/x" };
155
169
  const scopeB = { kind: "project", root: "/x a" };
@@ -66,6 +66,19 @@ export interface TaskNotificationPayload {
66
66
  * `TaskResult.errorCode` taxonomy (brain `[code]` prefixes via extractErrorCode, `limit.*`,
67
67
  * `budget.*`, …). Engine-minted, never model text. Absent when the failure carried no code. */
68
68
  errorCode?: string;
69
+ /** Terminal, delegation lanes: the settling run's PROVIDER-BOUNDARY fault assertion, mirrored from
70
+ * its `TaskResult.apiFailure`. Its PRESENCE is the claim — "this failure came from the transport or
71
+ * the provider, not from this deployment refusing to send, a limit, or unusable model output" — and
72
+ * `errorCode` beside it names WHICH terminal; the two answer different questions and neither implies
73
+ * the other. Members are whatever the failing attempt stated about itself (`status` when the provider
74
+ * answered with one, `requestId` when it named one), so an EMPTY object is meaningful: "provider
75
+ * fault, unlabelled", which is not the same statement as absence. Absent on every non-provider
76
+ * terminal, on every completed run, and on the non-delegation lanes (bash/monitor), whose failures do
77
+ * not pass through a model provider at all. */
78
+ apiFailure?: {
79
+ status?: number;
80
+ requestId?: string;
81
+ };
69
82
  /** Structured exit code of a background command's terminal notification (bash lane): the process
70
83
  * exited on its own with this code. Lets consumers branch on success/failure without parsing the
71
84
  * summary wording. Absent when the process never exited by itself (killed / spawn-failed lanes). */
@@ -77,6 +90,13 @@ export interface TaskNotificationPayload {
77
90
  * every other notification (a completed result is never flagged). */
78
91
  partial?: boolean;
79
92
  output_file?: string;
93
+ /** Whatever the settling lane knows about its own spend, JSON-rendered verbatim into one `<usage>`
94
+ * tag. Deliberately untyped — the lanes report different quantities and nothing here should force
95
+ * one to fabricate a figure it does not have. What IS contracted is the VOCABULARY: a name that two
96
+ * lanes both publish must mean the same thing on both. The delegation lanes
97
+ * (`background_agent`, its fork/revive cycles) and the workflow lane therefore agree on
98
+ * `tokens`/`turns`/`costMicroUsd`/`tool_uses`/`duration_ms`; the workflow lane's fan-out counters
99
+ * (`agent_count`, `agents_done`, …) and resume counters have no delegation analog and are its own. */
80
100
  usage?: unknown;
81
101
  /** CC `<diagnostics>` parity: ENGINE-MINTED teaching text for the "result is empty/unexpected —
82
102
  * now what" moment (per-agent read route / journal coordinate / resume command). Producers must mint it
@@ -449,7 +449,10 @@ export type TraceEvent = {
449
449
  /** The bucket this round synced. A local-owner bucket cannot sync, so this is always a principal. */
450
450
  principal: string;
451
451
  rule: string;
452
- scopeKind: "global" | "project";
452
+ /** `"session"` is unreachable here in a healthy fleet (design/382 §4.3: a session row never
453
+ * lands, so it can never resurrect) — declared because the sibling drop event names it and a
454
+ * lockstep union is what keeps the two from drifting. */
455
+ scopeKind: "global" | "project" | "session";
453
456
  ts: number;
454
457
  } | {
455
458
  /**
@@ -462,7 +465,9 @@ export type TraceEvent = {
462
465
  version: 1;
463
466
  principal: string;
464
467
  rule: string;
465
- scopeKind: "global" | "project";
468
+ /** `"session"` names the design/382 §4.3 durable-face drop: a peer sent a session-scope row,
469
+ * refused per-row with `session_scope_not_durable`. */
470
+ scopeKind: "global" | "project" | "session";
466
471
  /** Closed reason code ({@link import("./governance-codes.js").RuleSyncDropReason}). */
467
472
  reason: string;
468
473
  ts: number;
@@ -4646,6 +4646,31 @@ export type TaskEvent = ({
4646
4646
  * nor an agent-type — the child then keeps its taskId (NOT the raw objective, which could leak a delegated
4647
4647
  * secret to a progress-scoped consumer — dual-review Q2). Sanitized (control-char-stripped, length-capped). */
4648
4648
  name?: string;
4649
+ /**
4650
+ * The model this sub-run was PREPARED with — the resolved id, read off the leg's prepared model
4651
+ * at the mint, so it is the same value `TaskResult.model` and the `task.start` trace frame carry.
4652
+ * (`task.end` reports the run's totals and has never named a model; there is nothing to join to
4653
+ * there.)
4654
+ *
4655
+ * Why it rides the live lane at all: a delegation may name a tier word (`"sonnet"`), an agent
4656
+ * definition's model, or NOTHING (inherit the caller's current model, or fall to the `subagent`
4657
+ * role) — every one of those resolves somewhere the consumer cannot see, so a shell badging a
4658
+ * running child previously had only the REQUESTED word (or nothing) to render, which is a
4659
+ * different claim from what the child runs on. This is the resolved answer, not the request.
4660
+ *
4661
+ * PREPARED, deliberately, and NOT "whatever is serving this turn" — stated in the first sentence
4662
+ * because the difference is observable. A mid-run DEGRADE switch (`spec.limits.degrade`) or a
4663
+ * gateway re-route moves the serving model without rewriting this field, exactly as
4664
+ * `TaskResult.model` behaves; the switch is announced on its own channel (`TaskResult.degraded`),
4665
+ * and the per-call served id lives on the brain-call telemetry. One name, one meaning, across the
4666
+ * three faces that use it — at the cost of being the leg's declared model rather than a live one.
4667
+ *
4668
+ * Present on every tick this build mints (the frame family is subagent-only by construction, so
4669
+ * there is no lane where a leg has no prepared model). Declared optional for the ordinary reason:
4670
+ * a consumer folding frames from a pre-key producer must keep compiling, and absence there means
4671
+ * "this producer did not state it", never "no model".
4672
+ */
4673
+ model?: string;
4649
4674
  /** The child's most recent tool intent as one human line ("Bash npm test",
4650
4675
  * "Edit src/x.ts") — the SAME source/value as the registry sink tick's `currentAction` (residual
4651
4676
  * observability, lane B), attached to the FORWARDED frame because every client wire projects this
@@ -5246,6 +5271,40 @@ export interface BackgroundChildEvent {
5246
5271
  * waiting for spawn-frame forwarding; `name` stays the design/99 DISPLAY label (description-backed)
5247
5272
  * and was never a type field. */
5248
5273
  agentType?: string;
5274
+ /**
5275
+ * spawn + tick: the model the ROW runs on — the resolved `ModelRef` the spawner selected for this
5276
+ * child (a per-call `model`, an agent definition's, or the caller's own current model, inherited).
5277
+ * It answers with the SERVED catalog model id wherever that is knowable, because this value sits
5278
+ * beside the child's own `task_progress.model` (the leg's prepared id) and a consumer joins the
5279
+ * two: a row saying "sonnet" next to ticks naming the catalog id it routed to is indistinguishable
5280
+ * from two different children. A `Model` object carries its id — and a per-call word that passed
5281
+ * the spawn gate arrives as exactly that resolved object, so for it the row and the ticks are one
5282
+ * value by construction. A definition/tool-level STRING ref resolves through the catalog in force
5283
+ * at the spawn judgement; the child's own prepare re-resolves such a ref against the runner's live
5284
+ * table, so for those refs alone a catalog hot-swapped between the two reads can lag the row one
5285
+ * generation behind the ticks. The DISPLAY rule survives only as the FALLBACK, for a string the
5286
+ * judgement-time catalog cannot resolve (a CC tier alias then shows its sema tier, never the alias
5287
+ * verbatim). It rides ticks as well as spawn for the same reason
5288
+ * {@link agentType} does: a consumer that only forwards ticks must not have to wait for spawn-frame
5289
+ * forwarding to fill its row. Like `agentType` it is the ROW's own fact and is NEVER copied off a
5290
+ * forwarded frame — a nested descendant's `task_progress` names ITS model, not this row's.
5291
+ *
5292
+ * ABSENT is a fact, not a gap, and there are exactly three ways to get there:
5293
+ * · the delegation named no model anywhere in the chain, so the child runs on the `subagent` ROLE
5294
+ * and its concrete id is only decided at the child's own prepare;
5295
+ * · the RETAIN-LEDGER wake lane (a `SendMessage` resume of a completed child), whose frames are
5296
+ * projected from an `AccessibleTaskRow` — and that row has no model column, so this lane has
5297
+ * nothing to state and will not invent one. Stated precisely because the sibling wake lane does
5298
+ * NOT share the limitation: a tier-3 DURABLE revive re-enters through the ordinary background
5299
+ * spawn, re-derives the model like a first spawn, and its `(revived)` frames carry it whenever
5300
+ * that derivation lands on a model (the durable record keeps a `model` of its own; a recorded
5301
+ * key the current catalog no longer resolves degrades the revival to the inherited model, and a
5302
+ * mount with nothing to inherit then leaves these frames honestly silent);
5303
+ * · a pre-key producer.
5304
+ * In every case the child's own `task_progress` frames still carry the resolved answer, which is the
5305
+ * authority; this field is the row-level convenience beside it.
5306
+ */
5307
+ model?: string;
5249
5308
  /** spawn: the HOST task's DECLARED task id (parent attribution). Omitted when the host
5250
5309
  * run declared no task id (the `spec.taskId ?? sessionId` fallback would launder a session id into
5251
5310
  * a task-id field — the orphan-pointer shape); {@link parentSessionId} is the always-on linkage. */
@@ -5359,7 +5418,21 @@ export interface BackgroundChildEvent {
5359
5418
  stoppedBy?: "user" | "parent" | "system" | (string & {});
5360
5419
  /** terminal: bounded human summary (same text the task_notification carries). */
5361
5420
  summary?: string;
5362
- /** tick: live rollup (`task_progress.usage`) · terminal: final `{tokens, turns, costMicroUsd}`. */
5421
+ /** tick: live rollup (`task_progress.usage`) · terminal: the settled
5422
+ * `{tokens, turns, costMicroUsd?, toolUses?, durationMs}`. `costMicroUsd` is own+nested and present
5423
+ * only when that total is KNOWN (RB-368); `toolUses` is the run's own `stats.toolCalls` and is
5424
+ * absent — never zeroed — when the gateway reported no usage. The last two joined the terminal face
5425
+ * to close a reporting asymmetry: the workflow lane's completion notification and the Agent tool's
5426
+ * own sync `<usage>` footer had published both all along, so one child answered "how much work, how
5427
+ * long" on one lane and refused on the other.
5428
+ *
5429
+ * `durationMs` MEASURES DIFFERENT SPANS on the two kinds, which is worth knowing before plotting it:
5430
+ * on a TICK it is the child RUN's own elapsed (the forwarded frame's value, from the run's start);
5431
+ * on the TERMINAL it is the delegation LEG's, from the lane's launch instant — which is earlier, so
5432
+ * a consumer graphing one series sees a step up at settle. Both are true of what they name; neither
5433
+ * can be computed from the other without the spawn/prepare interval, which is why they are not
5434
+ * reconciled into one. The intra-turn activity beat carries a third, narrower shape (`{toolUses}`
5435
+ * alone — there is no honest live token figure on that lane). */
5363
5436
  usage?: {
5364
5437
  totalTokens?: number;
5365
5438
  toolUses?: number;
@@ -6436,6 +6509,17 @@ export interface RunnerDeps {
6436
6509
  * adopted principal's bucket forever.
6437
6510
  */
6438
6511
  localOwnerRules?: boolean;
6512
+ /**
6513
+ * design/382 §4.3 — the SESSION-RULE OVERLAY: where `{kind:"session"}` scoped allow rules live
6514
+ * (the session's own state, never the persisted store). Wired, the gate's persisted-rule lane
6515
+ * splices the CURRENT session's overlay rows in front of the store's `list()` on every
6516
+ * adjudication — session > project > global, the narrowest-first reporting order — and threads the
6517
+ * session's identity as the eligibility context's third axis; a host lands rows into it through
6518
+ * the consent protocol (`RuleConsentDeps.sessionRules`, a session-scope candidate's redemption).
6519
+ * Omitted ⇒ the session dimension does not exist at this gate and the lane is byte-identical.
6520
+ * See `permission-rule-session.ts` for the contract and the reference implementation.
6521
+ */
6522
+ sessionPermissionRules?: import("./permission-rule-session.js").SessionRuleOverlay;
6439
6523
  /**
6440
6524
  * design/182 §7 — the ORG rule overlay for an org-GOVERNED deployment. Constructed with
6441
6525
  * `createOrgRuleOverlay` (that constructor is the boot gate: a governed declaration with no snapshot
@@ -133,7 +133,12 @@ export interface WiringManifest {
133
133
  * a completed/failed/killed(non-user) subagent minted through the deps-visible assembly is
134
134
  * continuable across a process restart. A statement about the DEPS-VISIBLE assembly only: a
135
135
  * caller-mounted Agent tool over its own runner is outside this manifest's sight (per-read
136
- * honest degrade + the integrity notice own that case, never a fabricated tier). */
136
+ * honest degrade + the integrity notice own that case, never a fabricated tier) — and that
137
+ * blind spot is load-bearing here, because the store this field is derived from is the ROOT
138
+ * leg's while the store an ordinary child transcript is actually minted through is the AGENT
139
+ * TOOL's runner's ({@link resolveSubagentTranscriptTier} names the rule and the fork-lane
140
+ * exception). The word names ADDRESSABILITY: `none`/`rows` are silent on whether a child
141
+ * transcript reaches disk at all, and only `full` promises the a* handle survives a restart. */
137
142
  fleet: {
138
143
  backgroundAgentStore: boolean;
139
144
  hostChildEventSink: boolean;
@@ -254,6 +259,18 @@ export type SubagentTranscriptTier = "none" | "rows" | "full";
254
259
  * fold picked). This is the DEPLOYMENT-level judgment only; the per-handle "did this row's
255
260
  * transcript actually land" question belongs to `durableAgentRowProbe` (the S1b release-flip gate)
256
261
  * — two different questions, deliberately two named faces (do not merge them back into one).
262
+ *
263
+ * WHICH session store to pass: the one on the runner the Agent tool was mounted with
264
+ * (`SubagentToolOptions.runner.sessions`), because that is the store an ordinary (sync/background)
265
+ * child transcript is minted through — the fork lane deliberately branches through the HOST store
266
+ * instead, see the law on `SubagentToolOptions.runner`. A deployment that serves root tasks from one
267
+ * runner and delegated children from another (a throwaway sub-task store, a routing store) has two
268
+ * different stores here, and passing the ROOT deps' store reports the root lane's fate under the
269
+ * child lane's name. Note what the three words do and do not promise about DISK: this tier names
270
+ * ADDRESSABILITY, not persistence. `none` is returned before any store is inspected, so it covers
271
+ * both "the transcript is on disk but nothing can address it" and "there is no transcript" — an
272
+ * operator asking whether a child conversation is readable afterwards must read the delegation
273
+ * store's own durability + release/evict semantics, not this word.
257
274
  */
258
275
  export declare function resolveSubagentTranscriptTier(agentStoreWired: boolean, sessionStore: {
259
276
  readonly placements?: {
package/dist/index.d.ts CHANGED
@@ -167,8 +167,9 @@ export { createPermissionRulePolicy, validatePermissionRules, parsePermissionRul
167
167
  * allowed and widening is not.
168
168
  */
169
169
  export { parseAllowRuleText, formatAllowRuleText, ruleAdmitsCommand, findAdmittingRule, suggestRulesForCommand, scopeCoversCwd, pathWithinRoot, isRuleLive, renderUntrustedCommandText, BARE_INTERPRETER_NAMES, MAX_RULE_TEXT_CHARS, type PersistedAllowRule, type RuleTombstone, type RuleScope, type RuleDot, type RuleAdd, type RuleAddOrigin, type RuleOffer, type SegmentRuleSuggestion, type SegmentCoverage, type RuleReject, type RuleRejectCode, type ParsedAllowRule, type PersistedRuleTool, type PersistedRuleMatch, } from "./core/permission-rule-model.js";
170
- export { removePersistedRule, applyTombstones, sameScope, InMemoryPermissionRuleStore, EMPTY_RULE_STORE, type PermissionRuleStore, type PermissionRuleStoreProvider, type StoredAllowRules, type RemoveResult, type PutResult, joinRuleStates, screenRuleSyncState, collectBelowFrontier, ruleSyncVector, joinFrontiers, dotAtOrBelowFrontier, sameRuleOwner, type RuleSyncState, type RuleSyncFrontier, type RuleSyncDrop, type RuleSyncLandingReport, type RuleOwner, type QuarantinedRuleAdd, PERMISSION_RULE_WRITER, writerOf, foldDelta, addDotsOf, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, type PermissionRuleWriter, type WritablePermissionRuleStore, type RuleWriteDelta, type RuleAddDelta, type RuleDeleteDelta, type RuleSyncJoinDelta, type RawRuleSyncState, type RedemptionAuthorization, } from "./core/permission-rule-store.js";
170
+ export { removePersistedRule, applyTombstones, sameScope, isValidConsentScope, isValidDurableScope, InMemoryPermissionRuleStore, EMPTY_RULE_STORE, type PermissionRuleStore, type PermissionRuleStoreProvider, type StoredAllowRules, type RemoveResult, type PutResult, joinRuleStates, screenRuleSyncState, collectBelowFrontier, ruleSyncVector, joinFrontiers, dotAtOrBelowFrontier, sameRuleOwner, type RuleSyncState, type RuleSyncFrontier, type RuleSyncDrop, type RuleSyncLandingReport, type RuleOwner, type QuarantinedRuleAdd, PERMISSION_RULE_WRITER, writerOf, foldDelta, addDotsOf, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, type PermissionRuleWriter, type WritablePermissionRuleStore, type RuleWriteDelta, type RuleAddDelta, type RuleDeleteDelta, type RuleSyncJoinDelta, type RawRuleSyncState, type RedemptionAuthorization, } from "./core/permission-rule-store.js";
171
171
  export { syncPermissionRules, parseRuleSyncResponse, PERMISSION_RULE_SYNC_PATH, LOCAL_OWNER_UNSYNCABLE_CODE, type PermissionRuleSyncTransport, type PermissionRuleSyncResult, type RuleSyncRequestBody, type RuleSyncResponseBody, } from "./core/permission-rule-sync.js";
172
+ export { InMemorySessionRuleOverlay, type SessionRuleOverlay, type SessionRuleOverlayAdd, type SessionRuleOverlayApplyResult, } from "./core/permission-rule-session.js";
172
173
  export { createOrgRuleOverlay, orgRuleVerdictFor, effectivePermissionRules, orgRuleStatePersistenceOf, ORG_UNAVAILABLE_DECISION_REASON, ORG_RULE_DECISION_REASON, ORG_ADJUDICATION_TIMEOUT_MS, type OrgPermissionRule, type OrgRuleSnapshot, type OrgRuleSnapshotProvider, type OrgRuleStatePersistence, type PersistedOrgRuleState, type OrgRuleOverlay, type OrgOverlayResolution, type OrgOverlayStatus, type EffectivePermissionRule, } from "./core/permission-rule-org.js";
173
174
  export { RULE_SYNC_DROP_CODES, type RuleSyncDropReason, type RuleQuarantineReason } from "./core/governance-codes.js";
174
175
  export { prepareCardApproval, confirmRuleApproval, type ConfirmResult, type ConfirmRefusalReason, precheckEditedRuleText, type EditedRuleTextPrecheck, redeemRuleTicket, redeemRuleBatch, prepareCcImport, prepareStarterBatch, mintRuleTicket, STARTER_RULES, InMemoryRuleApprovalRecordStore, ruleOffersOfRecord, type RuleTicket, type RuleCandidate, type RuleApprovalKind, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleOffer2, type StaleRuleApprovalRecord, type RuleConsentDeps, type RedeemResult, type RedeemedBatchMember, type CcImportLayer, type ImportedSettingsLayer, type ImportPreview, } from "./core/permission-rule-consent.js";
package/dist/index.js CHANGED
@@ -127,8 +127,9 @@ export { AUTO_MODE_ARMING_RECIPE_VERSION, autoModeArmingRecipeOf, sanitizeAutoMo
127
127
  export { rebuildAutoModeDecider, } from "./core/auto-mode-rebuild.js";
128
128
  export { createPermissionRulePolicy, validatePermissionRules, parsePermissionRule, wildcardMatch, isNamespacedCoveringRuleName, namespacedRuleNameCovers, } from "./core/permission-rules.js";
129
129
  export { parseAllowRuleText, formatAllowRuleText, ruleAdmitsCommand, findAdmittingRule, suggestRulesForCommand, scopeCoversCwd, pathWithinRoot, isRuleLive, renderUntrustedCommandText, BARE_INTERPRETER_NAMES, MAX_RULE_TEXT_CHARS, } from "./core/permission-rule-model.js";
130
- export { removePersistedRule, applyTombstones, sameScope, InMemoryPermissionRuleStore, EMPTY_RULE_STORE, joinRuleStates, screenRuleSyncState, collectBelowFrontier, ruleSyncVector, joinFrontiers, dotAtOrBelowFrontier, sameRuleOwner, PERMISSION_RULE_WRITER, writerOf, foldDelta, addDotsOf, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, } from "./core/permission-rule-store.js";
130
+ export { removePersistedRule, applyTombstones, sameScope, isValidConsentScope, isValidDurableScope, InMemoryPermissionRuleStore, EMPTY_RULE_STORE, joinRuleStates, screenRuleSyncState, collectBelowFrontier, ruleSyncVector, joinFrontiers, dotAtOrBelowFrontier, sameRuleOwner, PERMISSION_RULE_WRITER, writerOf, foldDelta, addDotsOf, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, } from "./core/permission-rule-store.js";
131
131
  export { syncPermissionRules, parseRuleSyncResponse, PERMISSION_RULE_SYNC_PATH, LOCAL_OWNER_UNSYNCABLE_CODE, } from "./core/permission-rule-sync.js";
132
+ export { InMemorySessionRuleOverlay, } from "./core/permission-rule-session.js";
132
133
  export { createOrgRuleOverlay, orgRuleVerdictFor, effectivePermissionRules, orgRuleStatePersistenceOf, ORG_UNAVAILABLE_DECISION_REASON, ORG_RULE_DECISION_REASON, ORG_ADJUDICATION_TIMEOUT_MS, } from "./core/permission-rule-org.js";
133
134
  export { RULE_SYNC_DROP_CODES } from "./core/governance-codes.js";
134
135
  export { prepareCardApproval, confirmRuleApproval, precheckEditedRuleText, redeemRuleTicket, redeemRuleBatch, prepareCcImport, prepareStarterBatch, mintRuleTicket, STARTER_RULES, InMemoryRuleApprovalRecordStore, ruleOffersOfRecord, } from "./core/permission-rule-consent.js";
@@ -3,6 +3,7 @@ import { defineTool, errorResult } from "../core/tools.js";
3
3
  import { governanceBaselineError, governanceBaselineProblem } from "./governance-baseline-validity.js";
4
4
  import { redactSecrets, redactHostLeaks, boundedRedactedSummary } from "../core/untrusted-egress.js";
5
5
  import { withDelegationProvenance } from "../core/tool-policy.js";
6
+ import { LAUNCH_RECEIPT_OWN_WORDS_CLAUSE, launchReceiptNoQuoteClause } from "../agents/launch-receipt-contract.js";
6
7
  import { startWorkflow } from "./workflow.js";
7
8
  import { buildWorkflowPrimitives } from "./workflow-primitives.js";
8
9
  import { parseWorkflowMeta, splitWorkflowMeta, workflowScriptReadsClockOrRandom } from "./workflow-meta.js";
@@ -630,8 +631,7 @@ export async function createRunWorkflowTool(d) {
630
631
  task_id: runId,
631
632
  status: "started",
632
633
  ...(persistedScriptPath !== undefined ? { scriptPath: persistedScriptPath } : {}),
633
- handling: "This tool result is internal metadata — never quote or paste any part of it (the ids above, and scriptPath when present) into a user-facing reply. " +
634
- "In your own words, briefly tell the user what you launched; do not echo this result.",
634
+ handling: `${launchReceiptNoQuoteClause(" (the ids above, and scriptPath when present)")} ${LAUNCH_RECEIPT_OWN_WORDS_CLAUSE}`,
635
635
  note: (() => {
636
636
  const pollExpr = d.taskRegistry ? `TaskOutput({ task_id: "${runId}" })` : undefined;
637
637
  const blockingPollExpr = d.taskRegistry ? `TaskOutput({ task_id: "${runId}", block: true })` : undefined;