@sema-agent/core 5.54.0 → 5.55.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 +94 -0
  2. package/dist/agents/cumulative-stats.d.ts +26 -0
  3. package/dist/agents/cumulative-stats.js +56 -0
  4. package/dist/agents/observer.d.ts +11 -7
  5. package/dist/agents/observer.js +2 -4
  6. package/dist/agents/verify.d.ts +27 -3
  7. package/dist/agents/verify.js +7 -2
  8. package/dist/core/governance-codes.js +14 -0
  9. package/dist/core/hooks.js +1 -1
  10. package/dist/core/lsp-diagnostics.d.ts +19 -17
  11. package/dist/core/lsp-diagnostics.js +11 -5
  12. package/dist/core/mcp.d.ts +46 -0
  13. package/dist/core/mcp.js +132 -6
  14. package/dist/core/memory-engine/consolidation.d.ts +378 -0
  15. package/dist/core/memory-engine/consolidation.js +342 -0
  16. package/dist/core/memory-engine/dual-root.js +3 -0
  17. package/dist/core/memory-engine/engine.d.ts +237 -4
  18. package/dist/core/memory-engine/engine.js +1111 -4
  19. package/dist/core/memory-engine/export-bundle.js +9 -0
  20. package/dist/core/memory-engine/file-backend.js +27 -1
  21. package/dist/core/memory-engine/frontmatter.d.ts +20 -1
  22. package/dist/core/memory-engine/frontmatter.js +111 -0
  23. package/dist/core/memory-engine/index.d.ts +4 -2
  24. package/dist/core/memory-engine/index.js +3 -1
  25. package/dist/core/memory-engine/memory-backend-contract.js +131 -0
  26. package/dist/core/memory-engine/sync-client.js +26 -0
  27. package/dist/core/memory-engine/tools.d.ts +9 -0
  28. package/dist/core/memory-engine/tools.js +57 -13
  29. package/dist/core/memory-engine/types.d.ts +99 -0
  30. package/dist/core/memory-recall.js +4 -3
  31. package/dist/core/memory.d.ts +33 -3
  32. package/dist/core/memory.js +6 -4
  33. package/dist/core/permission-rules.d.ts +22 -0
  34. package/dist/core/permission-rules.js +60 -6
  35. package/dist/core/reminder-disclosure.d.ts +29 -4
  36. package/dist/core/reminder-disclosure.js +60 -12
  37. package/dist/core/runner/prepare-memory.js +7 -2
  38. package/dist/core/runner/prepare-task.d.ts +31 -1
  39. package/dist/core/runner/prepare-task.js +31 -14
  40. package/dist/core/runner/runtask.d.ts +8 -1
  41. package/dist/core/runner/runtask.js +12 -10
  42. package/dist/core/runner/session-rule-policy.js +5 -3
  43. package/dist/core/runner/synthetic-tools.js +4 -2
  44. package/dist/core/runner/turn-attachments.d.ts +16 -6
  45. package/dist/core/runner/turn-attachments.js +34 -20
  46. package/dist/core/tool-policy.d.ts +18 -0
  47. package/dist/core/tool-policy.js +19 -8
  48. package/dist/core/types.d.ts +89 -6
  49. package/dist/core/untrusted-egress.js +12 -2
  50. package/dist/core/untrusted-text.d.ts +189 -3
  51. package/dist/core/untrusted-text.js +416 -6
  52. package/dist/engine/loop/types.d.ts +7 -0
  53. package/dist/index.d.ts +1 -1
  54. package/dist/index.js +1 -1
  55. package/dist/prompts/default.d.ts +12 -2
  56. package/dist/tools/fs/index.d.ts +3 -1
  57. package/package.json +1 -1
  58. package/test/export-surface.snapshot.json +28 -1
@@ -1,5 +1,5 @@
1
1
  import { PRESENT_PLAN_TOOL_NAME } from "../present-plan-tool.js";
2
- import { defuseFenceMarkers, delimitUntrusted, sanitizeUntrustedText } from "../untrusted-text.js";
2
+ import { delimitUntrustedWithClip, sanitizeUntrustedText, SHELLED_BODY_ENVELOPE_TAGS } from "../untrusted-text.js";
3
3
  import { protocolOf } from "../protocol-table.js";
4
4
  import { buildSkillsBlock, skillListingLine } from "./synthetic-tools.js";
5
5
  import { TOOL_SEARCH_NAME as TOOL_SEARCH_TOOL_NAME } from "./tool-disclosure.js";
@@ -20,6 +20,11 @@ export const CHANGED_FILES_MTIME_EPS_MS = 2000;
20
20
  export const ATTACHMENT_BYTE_CAP = 8 * 1024;
21
21
  const PROJECTION_ITEMS_MAX = 50;
22
22
  const PROJECTION_CONTENT_MAX = 80;
23
+ const ATTACHMENT_TAGS_DEFAULT = [...SHELLED_BODY_ENVELOPE_TAGS];
24
+ const ATTACHMENT_TAGS_SKILLS_OWNER = SHELLED_BODY_ENVELOPE_TAGS.filter((t) => t !== "skills");
25
+ export function attachmentEnvelopeTags(source) {
26
+ return source === "skills_listing" ? ATTACHMENT_TAGS_SKILLS_OWNER : ATTACHMENT_TAGS_DEFAULT;
27
+ }
23
28
  export const INSTRUCTIONS_CHANGE_BYTE_CAP = 512;
24
29
  export function createAttachmentState() {
25
30
  return {
@@ -358,8 +363,9 @@ export function renderBudgetUsd(used, total) {
358
363
  return `USD budget: $${used}/$${total}; $${total - used} remaining`;
359
364
  }
360
365
  export function renderOrphanedBackgroundTasks(tasks) {
366
+ const safe = (t) => sanitizeUntrustedText(t, SHELLED_BODY_ENVELOPE_TAGS);
361
367
  return (`The container was restarted. The following background tasks were running and are now stopped:\n` +
362
- tasks.map((t) => `- ${t.description || "(no description)"} (task ${t.id})`).join("\n") +
368
+ tasks.map((t) => `- ${safe(t.description || "(no description)")} (task ${safe(t.id)})`).join("\n") +
363
369
  `\nRe-create them if still needed.`);
364
370
  }
365
371
  function renderChangedFiles(paths) {
@@ -449,7 +455,7 @@ export const AGENT_TOOLS_NOTE_DEFAULT = "All tools";
449
455
  export const AGENT_CONCURRENCY_NOTE = "When you launch multiple agents for independent work, send them in a single message with multiple tool uses so they run concurrently.";
450
456
  export const AMBIENT_CONTEXT_NOTE = "This is ambient context — do not narrate it to the user unless they ask or it is directly relevant to their request.";
451
457
  export function agentListingInitialHeader(toolName) {
452
- return `Available agent types for the ${toolName} tool:`;
458
+ return `Available agent types for the ${sanitizeUntrustedText(toolName, SHELLED_BODY_ENVELOPE_TAGS)} tool:`;
453
459
  }
454
460
  export function replayAnnouncedListing(texts, headers) {
455
461
  let announced;
@@ -492,13 +498,17 @@ export function replayAnnouncedListing(texts, headers) {
492
498
  return announced;
493
499
  }
494
500
  export const AGENT_LISTING_REMOVED_HEADER = "The following agent types are no longer available:";
501
+ function listingIdentity(name) {
502
+ return sanitizeUntrustedText(name, SHELLED_BODY_ENVELOPE_TAGS);
503
+ }
495
504
  export function agentListingDeltaHeader(toolName) {
496
- return `New agent types are now available for the ${toolName} tool:`;
505
+ return `New agent types are now available for the ${sanitizeUntrustedText(toolName, SHELLED_BODY_ENVELOPE_TAGS)} tool:`;
497
506
  }
498
507
  export function renderAgentListingDelta(state, entries, toolName, models) {
508
+ const safe = (t) => sanitizeUntrustedText(t, SHELLED_BODY_ENVELOPE_TAGS);
499
509
  const line = (e) => {
500
- const base = e.description ? `- ${e.name}: ${e.description}` : `- ${e.name}`;
501
- return `${base} (Tools: ${e.tools ?? AGENT_TOOLS_NOTE_DEFAULT})`;
510
+ const base = e.description ? `- ${safe(e.name)}: ${safe(e.description)}` : `- ${safe(e.name)}`;
511
+ return `${base} (Tools: ${safe(e.tools ?? AGENT_TOOLS_NOTE_DEFAULT)})`;
502
512
  };
503
513
  const announced = state.announcedAgentTypes;
504
514
  if (announced === undefined) {
@@ -511,18 +521,22 @@ export function renderAgentListingDelta(state, entries, toolName, models) {
511
521
  blocks.push(modelsAvailableLine(models));
512
522
  return blocks.join("\n\n");
513
523
  }
514
- const added = entries.filter((e) => !announced.has(e.name)).toSorted((a, b) => a.name.localeCompare(b.name));
515
- const removed = [...announced.keys()].filter((n) => !entries.some((e) => e.name === n)).sort();
524
+ const announcedIds = new Set([...announced.keys()].map(listingIdentity));
525
+ const added = entries.filter((e) => !announcedIds.has(listingIdentity(e.name))).toSorted((a, b) => a.name.localeCompare(b.name));
526
+ const removed = [...announced.keys()]
527
+ .filter((n) => !entries.some((e) => listingIdentity(e.name) === listingIdentity(n)))
528
+ .sort();
516
529
  const modelsDrifted = state.announcedModels !== undefined &&
517
530
  models !== undefined &&
518
- (state.announcedModels.length !== models.length || state.announcedModels.some((m, i) => m !== models[i]));
531
+ (state.announcedModels.length !== models.length ||
532
+ state.announcedModels.some((m, i) => listingIdentity(m) !== listingIdentity(models[i])));
519
533
  if (added.length === 0 && removed.length === 0 && !modelsDrifted)
520
534
  return undefined;
521
535
  const blocks = [];
522
536
  if (added.length > 0)
523
537
  blocks.push(`${agentListingDeltaHeader(toolName)}\n${added.map(line).join("\n")}`);
524
538
  if (removed.length > 0) {
525
- blocks.push(`${AGENT_LISTING_REMOVED_HEADER}\n${removed.map((n) => `- ${n}`).join("\n")}`);
539
+ blocks.push(`${AGENT_LISTING_REMOVED_HEADER}\n${removed.map((n) => `- ${safe(n)}`).join("\n")}`);
526
540
  blocks.push(AMBIENT_CONTEXT_NOTE);
527
541
  }
528
542
  if (modelsDrifted)
@@ -531,7 +545,8 @@ export function renderAgentListingDelta(state, entries, toolName, models) {
531
545
  }
532
546
  export const MODELS_AVAILABLE_PREFIX = "Models available for the 'model' parameter: ";
533
547
  function modelsAvailableLine(models) {
534
- return `${MODELS_AVAILABLE_PREFIX}${models.length > 0 ? models.join(", ") : "(none)"}`;
548
+ const named = models.map((m) => sanitizeUntrustedText(m, SHELLED_BODY_ENVELOPE_TAGS));
549
+ return `${MODELS_AVAILABLE_PREFIX}${named.length > 0 ? named.join(", ") : "(none)"}`;
535
550
  }
536
551
  export function replayAnnouncedModels(texts) {
537
552
  let last;
@@ -563,15 +578,16 @@ export function renderSkillsListingDelta(state, entries) {
563
578
  return undefined;
564
579
  return buildSkillsBlock(entries);
565
580
  }
566
- const added = entries.filter((e) => !announced.has(e.name));
567
- const removed = [...announced.keys()].filter((n) => !entries.some((e) => e.name === n));
581
+ const announcedIds = new Set([...announced.keys()].map(listingIdentity));
582
+ const added = entries.filter((e) => !announcedIds.has(listingIdentity(e.name)));
583
+ const removed = [...announced.keys()].filter((n) => !entries.some((e) => listingIdentity(e.name) === listingIdentity(n)));
568
584
  if (added.length === 0 && removed.length === 0)
569
585
  return undefined;
570
586
  const blocks = [];
571
587
  if (added.length > 0)
572
588
  blocks.push(`${SKILLS_LISTING_DELTA_HEADER}\n${added.map((e) => skillListingLine(e)).join("\n")}`);
573
589
  if (removed.length > 0) {
574
- blocks.push(`${SKILLS_LISTING_REMOVED_HEADER}\n${removed.map((n) => `- ${n}`).join("\n")}`);
590
+ blocks.push(`${SKILLS_LISTING_REMOVED_HEADER}\n${removed.map((n) => `- ${sanitizeUntrustedText(n, SHELLED_BODY_ENVELOPE_TAGS)}`).join("\n")}`);
575
591
  blocks.push(AMBIENT_CONTEXT_NOTE);
576
592
  }
577
593
  return blocks.join("\n\n");
@@ -581,12 +597,10 @@ export function commitSkillsListing(state, entries) {
581
597
  }
582
598
  export const MCP_INSTRUCTIONS_MAX_CHARS = 8 * 1024;
583
599
  export function fenceMcpServerInstructions(server, text) {
584
- const neutralized = defuseFenceMarkers(sanitizeUntrustedText(text));
585
- const clipped = [...neutralized].length > MCP_INSTRUCTIONS_MAX_CHARS;
586
- const fenced = delimitUntrusted(`MCP server "${server}" instructions`, neutralized, MCP_INSTRUCTIONS_MAX_CHARS);
587
- return clipped
588
- ? `${fenced}\n(Truncated by the agent runtime: this server's instructions exceeded ${MCP_INSTRUCTIONS_MAX_CHARS} characters.)`
589
- : fenced;
600
+ const fence = delimitUntrustedWithClip(`MCP server "${server}" instructions`, text, MCP_INSTRUCTIONS_MAX_CHARS);
601
+ return fence.clipped
602
+ ? `${fence.text}\n(Truncated by the agent runtime: this server's instructions exceeded ${MCP_INSTRUCTIONS_MAX_CHARS} characters.)`
603
+ : fence.text;
590
604
  }
591
605
  export function renderMcpInstructionsDelta(added, removed) {
592
606
  const blocks = [];
@@ -383,6 +383,24 @@ export interface ToolPolicyProjection {
383
383
  */
384
384
  readonly requiresLiveRemainder: boolean;
385
385
  }
386
+ /**
387
+ * The entries of a tool-NAME list whose reach is a set of names rather than one name — the MCP
388
+ * covering spellings (`mcp__<server>`, `mcp__<server>__<glob>`) that CC's rule matcher resolves and
389
+ * an exact-membership test silently cannot. Every name-keyed lane in this file consults these
390
+ * ALONGSIDE its exact set: a covering spelling can never equal a minted tool name (`*` is outside the
391
+ * minted charset and a minted name always carries a tool segment), so leaving it in the exact set too
392
+ * costs nothing and keeps the projection/audit faces reporting the operator's own spelling.
393
+ *
394
+ * The list is usually empty, which is why every call site tests it before scanning.
395
+ *
396
+ * Exported for the OTHER name-keyed lane in this engine — the persisted session/ancestor rules in
397
+ * `session-rule-policy.ts`. Not part of the public API (`src/index.ts` re-exports by name and does not
398
+ * list these): all four publishers of {@link ToolPolicyNameSets} must resolve a covering entry the same
399
+ * way, because the prepare-time audit's exemption for these spellings speaks for all of them at once.
400
+ */
401
+ export declare function mcpCoveringEntries(entries: readonly string[] | undefined): readonly string[];
402
+ /** Does any covering entry reach `toolName`? See {@link mcpCoveringEntries}. */
403
+ export declare function mcpCoveringHit(covering: readonly string[], toolName: string): boolean;
386
404
  /**
387
405
  * Execute a persisted {@link ToolPolicyProjection} against a call (F-012 L1): returns the first
388
406
  * component's deny, or `undefined` when the projection has no opinion (it is deny-only by
@@ -5,7 +5,7 @@ import { join, normalize as normalizePath, posix as posixPath, sep, win32 as win
5
5
  import { BASH_READONLY_DEFAULT_ALLOW, parseLeadingCommandName } from "../tools/fs/index.js";
6
6
  import { boundInputHashOf } from "./canonical-json.js";
7
7
  import { delimitUntrusted, inlineUntrusted, REVIEWER_NOTE_MAX_BODY } from "./untrusted-text.js";
8
- import { parsePermissionRule } from "./permission-rules.js";
8
+ import { isMcpCoveringRuleName, mcpRuleNameCovers, parsePermissionRule } from "./permission-rules.js";
9
9
  import { isAbsolutePathForm, isWinFormPath, writeTargetPath } from "../tools/fs/safety.js";
10
10
  const DECISION_REASONS = ["rule", "mode", "hook", "safety", "classifier", "persisted_rule", "sandbox", "org_rule", "org_unavailable"];
11
11
  const DECISION_REASON_SET = new Set(DECISION_REASONS);
@@ -36,16 +36,22 @@ export const ASK_EVIDENCE_ABSENCE_VALUES = ["not_wired", "not_adjudicated", "una
36
36
  export function decisionText(d) {
37
37
  return d.message;
38
38
  }
39
+ export function mcpCoveringEntries(entries) {
40
+ return (entries ?? []).filter(isMcpCoveringRuleName);
41
+ }
42
+ export function mcpCoveringHit(covering, toolName) {
43
+ return covering.length > 0 && covering.some((e) => mcpRuleNameCovers(e, toolName));
44
+ }
39
45
  export function checkToolPolicyProjection(projection, req) {
40
46
  for (const c of projection.components) {
41
47
  if (c.kind === "tool_deny") {
42
- if (c.names.includes(req.toolName)) {
48
+ if (c.names.includes(req.toolName) || mcpCoveringHit(mcpCoveringEntries(c.names), req.toolName)) {
43
49
  return { action: "deny", message: `tool "${req.toolName}" is denied by a frozen inherited policy projection` };
44
50
  }
45
51
  continue;
46
52
  }
47
53
  if (c.kind === "tool_allowlist") {
48
- if (!c.names.includes(req.toolName)) {
54
+ if (!c.names.includes(req.toolName) && !mcpCoveringHit(mcpCoveringEntries(c.names), req.toolName)) {
49
55
  return { action: "deny", message: `tool "${req.toolName}" is not in a frozen inherited policy projection's allowlist` };
50
56
  }
51
57
  continue;
@@ -218,6 +224,8 @@ export function createAllowDenyPolicy(opts) {
218
224
  opts = { ...opts, ...(screenedAllow ? { allow: screenedAllow } : {}), ...(screenedDeny ? { deny: screenedDeny } : {}) };
219
225
  const allow = opts.allow ? new Set(opts.allow) : undefined;
220
226
  const deny = new Set(opts.deny ?? []);
227
+ const denyCovering = mcpCoveringEntries(opts.deny);
228
+ const allowCovering = mcpCoveringEntries(opts.allow);
221
229
  return {
222
230
  projection: {
223
231
  components: [
@@ -229,10 +237,10 @@ export function createAllowDenyPolicy(opts) {
229
237
  nameSets: [{ ...(opts.allow ? { allow: [...opts.allow] } : {}), ...(opts.deny ? { deny: [...opts.deny] } : {}) }],
230
238
  check(req) {
231
239
  const toolName = req.toolName;
232
- if (deny.has(toolName)) {
240
+ if (deny.has(toolName) || mcpCoveringHit(denyCovering, toolName)) {
233
241
  return { action: "deny", message: `tool "${req.toolName}" is denied by policy` };
234
242
  }
235
- if (allow && !allow.has(toolName)) {
243
+ if (allow && !allow.has(toolName) && !mcpCoveringHit(allowCovering, toolName)) {
236
244
  return { action: "deny", message: `tool "${req.toolName}" is not in the allowlist` };
237
245
  }
238
246
  return ALLOW;
@@ -249,6 +257,9 @@ export function createApprovalPolicy(opts) {
249
257
  const need = new Set(opts.requireApproval);
250
258
  const deny = new Set(opts.deny ?? []);
251
259
  const auto = new Set(opts.autoAllow ?? []);
260
+ const needCovering = mcpCoveringEntries(opts.requireApproval);
261
+ const denyCovering = mcpCoveringEntries(opts.deny);
262
+ const autoCovering = mcpCoveringEntries(opts.autoAllow);
252
263
  return {
253
264
  projection: {
254
265
  components: [
@@ -266,10 +277,10 @@ export function createApprovalPolicy(opts) {
266
277
  ],
267
278
  async check(req, signal) {
268
279
  const toolName = req.toolName;
269
- if (deny.has(toolName)) {
280
+ if (deny.has(toolName) || mcpCoveringHit(denyCovering, toolName)) {
270
281
  return { action: "deny", message: `tool "${req.toolName}" is denied by policy` };
271
282
  }
272
- if (need.has(toolName)) {
283
+ if (need.has(toolName) || mcpCoveringHit(needCovering, toolName)) {
273
284
  if (signal?.aborted) {
274
285
  return withCoreMintedResolution({ action: "deny", message: `approval aborted for "${req.toolName}" (task ended)`, settledBy: "aborted" }, "task_aborted", req);
275
286
  }
@@ -303,7 +314,7 @@ export function createApprovalPolicy(opts) {
303
314
  }
304
315
  return withCoreMintedResolution({ action: "deny", message: `approval denied for "${req.toolName}"`, settledBy: "human" }, "human_refused", req);
305
316
  }
306
- if (opts.denyByDefault && !auto.has(toolName)) {
317
+ if (opts.denyByDefault && !auto.has(toolName) && !mcpCoveringHit(autoCovering, toolName)) {
307
318
  return { action: "deny", message: `tool "${req.toolName}" requires explicit allow` };
308
319
  }
309
320
  return ALLOW;
@@ -3342,12 +3342,27 @@ export interface TaskResult {
3342
3342
  }>;
3343
3343
  /** design/319 (B ticket, G9② observation seat) — reminder-disclosure trigger counts for the
3344
3344
  * leg, keyed `<outlet>.<form>`: outlets `read` / `notebook` / `pdf` / `mcp` / `webFetch` /
3345
- * `webSearch`; forms `bare` (bare-form trailer appended), `marked` (marked-form trailer —
3346
- * never throttled), `bare_throttled` (a bare trailer suppressed by the 60s per-key window),
3347
- * `defused` (an MCP/web segment's exact-mark bytes were rewritten the lane's one sanctioned
3348
- * byte change, always paired with a `marked` disclosure). Present only when ≥1 key is
3349
- * non-zero. This is the D-4/D-6 re-ruling data (defuse/trailer widening to Read/Bash/Grep):
3350
- * a reading, never a gate. */
3345
+ * `webSearch`; forms
3346
+ * · `bare` (bare-form trailer appended reminder-shaped text without this session's mark),
3347
+ * · `marked` (marked-form trailernever throttled),
3348
+ * · `bare_throttled` (a bare trailer suppressed by the 60s per-key window),
3349
+ * · `defused` (an MCP/web segment's exact-mark bytes were rewritten the lane's one sanctioned
3350
+ * byte change, always paired with a `marked` disclosure),
3351
+ * · `envelope` (text shaped like one of the engine's OTHER authority envelopes — the DISCLOSED
3352
+ * subset is `task-notification` / `new-diagnostics` / `user_memory` / `skills`; `scope` is
3353
+ * fenced but not disclosed, since `<scope>…</scope>` is also an ordinary build-file element.
3354
+ * That family carries no mark, so its sentence is positional rather than byte-testable. It
3355
+ * rides ON the reminder copy when both families hit, so `envelope` can be bumped alongside
3356
+ * `marked`/`bare` on one result),
3357
+ * · `envelope_throttled` (an envelope sentence suppressed by its OWN 60s window — the two
3358
+ * families throttle independently, so one may emit while the other reports suppression),
3359
+ * · `mark_echo` (observation ONLY, never a disclosure: the session's mark VALUE appeared in
3360
+ * external bytes with no reminder-shaped tag around it, so the tag-grammar detector cannot
3361
+ * reach it. Nothing is rewritten and no trailer is appended; it is an upper bound on model
3362
+ * exposure for the lanes that do not defuse).
3363
+ * Present only when ≥1 key is non-zero. The map is OPEN by type — fold by key rather than
3364
+ * switching on a closed set. This is the D-4/D-6 re-ruling data (defuse/trailer widening to
3365
+ * Read/Bash/Grep): a reading, never a gate. */
3351
3366
  reminderDisclosures?: Record<string, number>;
3352
3367
  };
3353
3368
  /**
@@ -3667,6 +3682,36 @@ export type TaskEvent = ({
3667
3682
  * carry no string discriminator under either name.
3668
3683
  */
3669
3684
  errorCode?: string;
3685
+ /**
3686
+ * WHICH call the gate was holding, when this frame is a park-contamination frame — the tool call
3687
+ * id of the gated call. Three conditions, ALL required: `errorCode === "gate.parked"`, the run is
3688
+ * tearing down behind a committed park, and that park binds a tool call at all.
3689
+ *
3690
+ * **WHERE IT COMES FROM — never lifted from the result**, and deliberately unlike its neighbour
3691
+ * {@link errorCode}: that one IS the result's own `details.code`, i.e. tool-authored, because a
3692
+ * tool classifying its own failure is bounded self-description. This field is an assertion about a
3693
+ * DIFFERENT call, so a tool able to author it could point an approval UI at a call nobody is
3694
+ * holding. It is therefore engine-minted — read from the run's committed park holder and handed to
3695
+ * the frame projection as a parameter, the same posture (and the same reason) as {@link settledBy}.
3696
+ * `errorCode` only participates in selecting WHETHER the id is placed; it never supplies the
3697
+ * value, and on its own it is not enough — the run must also be aborting behind a real park, which
3698
+ * is why a tool declaring `gate.parked` on its own failure cannot conjure this field.
3699
+ *
3700
+ * Why it is on the frame: a durable gate parks ONE call and the loop then short-circuits every
3701
+ * sibling in the batch with an identical "Operation aborted" body, so `gate.parked` alone says
3702
+ * "something parked this batch" without saying WHAT. A consumer wanting to render "waiting on
3703
+ * <the gated call>" beside the collateral frames otherwise has to re-derive the pair from batch
3704
+ * adjacency and timing — an inference that is wrong exactly when it matters (a batch with more
3705
+ * than one ask candidate, a reordered stream).
3706
+ *
3707
+ * ONLY EVER STRUCTURALLY PROVEN, never guessed: the id is read off the committed checkpoint's
3708
+ * pending tool call, so it is the same value the checkpoint parks on and the same value the
3709
+ * decide/resume lane answers about. It is therefore ABSENT — not approximated — for the park
3710
+ * kinds that hold no call (a resource-limit slice, a plan review), and absent for every abort
3711
+ * that is not a gate park at all (user interrupt, timeout, walltime): those frames are
3712
+ * byte-unchanged. Absence means "core cannot prove a causal call", never "there wasn't one".
3713
+ */
3714
+ gatedCallId?: string;
3670
3715
  /**
3671
3716
  * WHAT ENDED THE APPROVAL this call was waiting on, when this frame closes a gated call — `"human"`
3672
3717
  * / `"timeout"` / `"aborted"`, the vocabulary of
@@ -4889,6 +4934,27 @@ export interface EngineNotice {
4889
4934
  * tier is NOT auto-downgraded (declaration-制 — observation reports, it never re-adjudicates);
4890
4935
  * `detail: { handle }`.
4891
4936
  *
4937
+ * - `"memory.consolidation_recommended"` (design/339 §2.2/§6.2) — the engine-minted per-scope
4938
+ * session count crossed the consolidation thresholds (time gate open ∧ enough distinct
4939
+ * sessions); minted at most once per crossing (a completed run re-arms the edge), NEVER when
4940
+ * the deployment leaves consolidation off. ADVISORY: the host owns the verbs, nothing runs
4941
+ * automatically; `detail: { scope, sessionsSince, sessionId? }`.
4942
+ * - `"memory.consolidation_committed"` (design/339 §6.2) — a consolidation plan reached
4943
+ * `completed`: products landed, superseded targets left the default read face (retained as
4944
+ * evidence), intents settled; `detail: { planId, scope, products, superseded, intents }` —
4945
+ * counts and engine-minted ids only, zero content.
4946
+ * - `"memory.consolidation_conflict"` (design/339 §6.2) — a plan parked CONFLICT: at least one
4947
+ * target did not reach its planned state (a concurrent write, a patch the store declined, or a
4948
+ * target the re-judgment could no longer act on — the plan's audit rows carry the per-target
4949
+ * reason). The REFUSED targets were not overwritten; patches the plan had already applied
4950
+ * stand (discard rolls nothing back — the receipt's applied-vs-conflict counts say how many).
4951
+ * Affected intents stay pending and wait for the host valve
4952
+ * (`resolveConsolidationPlan` retry/discard); `detail: { planId, scope }`.
4953
+ * - `"memory.consolidation_refused"` (design/339 §6.2) — the notice dialect of a consolidation
4954
+ * verb's coded structured refusal (the verb itself throws
4955
+ * {@link import("../core/memory-engine/consolidation.js").ConsolidationRefusedError});
4956
+ * `detail: { refusalCode, scope? }`.
4957
+ *
4892
4958
  * Deliberately NOT a notice family: brain retry/reconnect liveness (a rate limit, a 5xx, a
4893
4959
  * transient network failure being retried). Those are per-attempt liveness frames with their own
4894
4960
  * frequency semantics and ride the wire `status` channel ({@link BrainStatus}), whose sink the
@@ -5110,6 +5176,23 @@ export interface RunnerDeps {
5110
5176
  * never truthiness).
5111
5177
  */
5112
5178
  memoryProvenance?: "off" | "carry";
5179
+ /**
5180
+ * design/339 §6.1 — the v3 memory-consolidation switch, threaded verbatim to the engine seat
5181
+ * ({@link import("../core/memory-engine/engine.js").MemoryEngineOptions.consolidation} — same
5182
+ * transport as {@link memoryProvenance}). ABSENT = OFF (the shipped default): no gate state is
5183
+ * ever written, no recommendation is ever minted, and the consolidation verbs on an engine the
5184
+ * host constructs over the same store refuse coded. PRESENT = enabled: the engine counts
5185
+ * distinct terminal-harvest sessions per scope and surfaces the advisory
5186
+ * `memory.consolidation_recommended` notice when the configured thresholds cross — EXECUTION
5187
+ * stays host-owned (the four verbs are engine host API; nothing in a task can trigger a run).
5188
+ * `provenance: "off"` beside this refuses loudly at prepare
5189
+ * (`config.memory_consolidation_provenance_off` — the fold law must be able to mint);
5190
+ * `multiNode: true` without a lease refuses (`config.memory_consolidation_lease_required`);
5191
+ * every other bad value refuses under `config.memory_consolidation` (#123). DEPLOYMENT seat
5192
+ * ONLY (no TaskSpec twin, not in the governed workflow whitelist), same law as
5193
+ * {@link memoryProvenance}.
5194
+ */
5195
+ memoryConsolidation?: import("../core/memory-engine/consolidation.js").MemoryConsolidationOptions;
5113
5196
  /**
5114
5197
  * design/199 件A — the DEPLOYMENT's read-face declaration
5115
5198
  * ({@link import("../tools/fs/read-face.js").ReadFace}; see {@link TaskSpec.readFace} for the
@@ -1,4 +1,4 @@
1
- import { delimitUntrusted } from "./untrusted-text.js";
1
+ import { delimitUntrusted, neutralizeForFence } from "./untrusted-text.js";
2
2
  import { scrubSecrets, SECRET_PASSES, runRedactionPasses } from "./arg-summary.js";
3
3
  import { sliceHeadSafe } from "./surrogate-safe-slice.js";
4
4
  export { summarizeRedactions } from "./arg-summary.js";
@@ -98,5 +98,15 @@ export function boundedRedactedSummary(value, max) {
98
98
  return boundedString(value, max, redactSecrets);
99
99
  }
100
100
  export function untrustedEgressForHuman(value, opts) {
101
- return delimitUntrusted(opts.label, boundedString(value, opts.max, redactHostLeaks), opts.max);
101
+ const source = boundedString(value, Number.POSITIVE_INFINITY, redactHostLeaks);
102
+ let budget = opts.max;
103
+ let bounded = boundedString(source, budget, (s) => s);
104
+ for (let i = 0; i < 4 && budget > 0; i++) {
105
+ const overflow = [...neutralizeForFence(bounded)].length - opts.max;
106
+ if (overflow <= 0)
107
+ break;
108
+ budget = Math.max(0, budget - overflow);
109
+ bounded = boundedString(source, budget, (s) => s);
110
+ }
111
+ return delimitUntrusted(opts.label, bounded, opts.max);
102
112
  }
@@ -16,6 +16,144 @@
16
16
  * own (instructions and data share one channel). The real boundary is decorrelation + reading the objective
17
17
  * artifact (the diff / working tree) rather than the worker's self-report (design/53 §3, design/54 §3).
18
18
  */
19
+ /**
20
+ * One row of {@link ENGINE_ENVELOPES}.
21
+ *
22
+ * WHY A TABLE EXISTS. design/319 spent its whole threat model on a single tag and three independent
23
+ * texts stated the supporting premise as "this harness mints no other tag" — a MODULE-level
24
+ * enumeration (`turn-attachments` really does mint only one) promoted to a HARNESS-level claim. The
25
+ * engine in fact authors several model-facing envelopes, guarded by two different escape families
26
+ * living in two different modules, and the two envelopes added most recently (`skills`, `scope`)
27
+ * reached the model with NO guard at all — not because anyone argued they were safe, but because
28
+ * nothing in the tree listed the set an author was joining. A guard family without a census is a
29
+ * guard family that silently loses members; this table is that census, and the accompanying
30
+ * literal-tag test keeps it honest (a new closing-tag literal in `src/` fails until it is classified
31
+ * here).
32
+ *
33
+ * The same discipline as the `ToolSpec` completeness table: the registry is the declaration, the
34
+ * test is the gate, and a NEW envelope is a two-line edit rather than an invisible omission.
35
+ */
36
+ export interface EngineEnvelope {
37
+ /** The tag NAME as the engine spells it, without angle brackets. */
38
+ readonly tag: string;
39
+ /**
40
+ * - `authority` — the engine authors it as model-facing HARNESS speech; seeing the tag is what
41
+ * makes the model read the content as system information, so a forgery is an authority claim.
42
+ * - `framing` — the engine wraps UNTRUSTED content in it as a labeled data frame; a forgery
43
+ * escapes the frame (or closes it early) rather than claiming harness authority.
44
+ * - `not-an-envelope` — a tag-shaped literal in `src/` that is not an engine-minted model-facing
45
+ * envelope at all (a MODEL-output grammar the engine parses, a prompt-assembly placeholder,
46
+ * rendering metadata, or copy that merely TALKS about an envelope). Listed so the census test
47
+ * has a home for every literal in the tree and no reader has to re-derive the classification.
48
+ */
49
+ readonly kind: "authority" | "framing" | "not-an-envelope";
50
+ /** Where the engine authors (or, for `not-an-envelope`, spells) it. */
51
+ readonly mint: string;
52
+ /** How untrusted bytes reaching this envelope's own body/attributes are defused at that site. */
53
+ readonly guard: string;
54
+ /**
55
+ * True ⇒ neutralized inside every FENCED/inline untrusted projection ({@link delimitUntrusted},
56
+ * {@link inlineUntrusted}). Those lanes already rewrite bytes by contract, so a forged authority
57
+ * envelope arriving through them is defused rather than merely fenced.
58
+ */
59
+ readonly fenced: boolean;
60
+ /**
61
+ * True ⇒ DISCLOSED (never rewritten) when it appears in VERBATIM external data — the design/319
62
+ * trailer pipeline (reminder-disclosure.ts). Verbatim lanes are byte-frozen by the G3 veto (the
63
+ * Read↔Edit `old_string` quote-back loop), so the only available judgment bit is an appended,
64
+ * engine-minted note. Deliberately NOT set for tags whose spelling is common in ordinary data
65
+ * (`scope` is a Maven POM element; `summary` is HTML) — a trailer that fires on every build file
66
+ * teaches the model to ignore trailers.
67
+ */
68
+ readonly disclosed: boolean;
69
+ }
70
+ /**
71
+ * The census. Rows are grouped by {@link EngineEnvelope.kind}; within a group, by module.
72
+ *
73
+ * KNOWN CEILING (stated, not hidden): the accompanying test enumerates CLOSING-TAG LITERALS in
74
+ * `src/`, so an envelope whose tag name is INTERPOLATED (`<${tag}>`) is invisible to it. Those are
75
+ * enumerated here by hand — `teammate-message`, the observer digest's four event tags, and the
76
+ * dynamic `<{slug}-activity>` wrapper — and each already carries its own escape at its mint site.
77
+ */
78
+ export declare const ENGINE_ENVELOPES: readonly EngineEnvelope[];
79
+ /** The registry row for a tag name, or `undefined` when the tag is not in the census. */
80
+ export declare function engineEnvelope(tag: string): EngineEnvelope | undefined;
81
+ /** Registry-derived: the envelopes that read as HARNESS AUTHORITY on sight. */
82
+ export declare const ENGINE_AUTHORITY_ENVELOPE_TAGS: readonly string[];
83
+ /**
84
+ * Registry-derived: the tags {@link delimitUntrusted} / {@link inlineUntrusted} neutralize on top of
85
+ * the always-on reminder tag. These lanes carry EXTERNAL/worker bytes into a labeled data frame and
86
+ * already rewrite them by contract, so an authority envelope forged inside them is defused rather
87
+ * than merely fenced — the "forgery can be neutralized, never trusted" direction.
88
+ *
89
+ * Deliberately NOT applied inside {@link sanitizeUntrustedText} itself: that primitive is also used
90
+ * by mint sites over an ALREADY-ASSEMBLED body (the boundary/first-frame reminder wrap runs it over
91
+ * a body that may legitimately contain the engine's own `<skills>` block), so widening the primitive
92
+ * would have the engine defuse its OWN envelopes. Containment belongs to the fence, not the
93
+ * primitive.
94
+ */
95
+ export declare const FENCED_LANE_ENVELOPE_TAGS: readonly string[];
96
+ /**
97
+ * The same family as {@link FENCED_LANE_ENVELOPE_TAGS}, as a MUTABLE array for every body that will be
98
+ * SHELLED in a marked `<system-reminder>` — the attachment sink, the listing/diagnostics producers,
99
+ * the durable-resume orphan notice, the post-tool-batch hook relay, the git frame.
100
+ *
101
+ * These are not fences. They render deployment/server/model-supplied strings into a body the run loop
102
+ * then wraps in engine authority, so a forged envelope inside one is laundered by the wrapper. The
103
+ * wrapper itself cannot blanket-neutralize the family — it also shells the one body that legitimately
104
+ * IS an envelope (`buildSkillsBlock`'s `<skills>` fence) — so containment is expressed as OWNERSHIP:
105
+ * this full set for every body that owns nothing, minus its own tag for the one that does (see
106
+ * `attachmentEnvelopeTags` in turn-attachments.ts).
107
+ *
108
+ * Allocated ONCE so the sanitizer's memoized break-out regex is keyed by a stable value.
109
+ */
110
+ export declare const SHELLED_BODY_ENVELOPE_TAGS: string[];
111
+ /**
112
+ * Registry-derived: the tags the design/319 disclosure pipeline reports when they appear in VERBATIM
113
+ * external data (see {@link scanEnvelopeShaped}). `system-reminder` is absent on purpose — the mark
114
+ * pipeline scans it with a stronger (marked vs bare) verdict of its own.
115
+ */
116
+ export declare const DISCLOSED_ENVELOPE_TAGS: readonly string[];
117
+ /** The result of one envelope-shaped scan of an external projection. */
118
+ export interface EnvelopeShapedScan {
119
+ /** ≥1 disclosed-family envelope tag (open or close) is present. */
120
+ hit: boolean;
121
+ /** The REGISTRY spellings that matched, de-duplicated, in registry order. Registry-owned strings —
122
+ * never the matched bytes — so a caller may render them into trusted copy safely. */
123
+ tags: string[];
124
+ }
125
+ /**
126
+ * The envelope-family sibling of {@link scanReminderShaped}: detect text shaped like one of the
127
+ * engine's OTHER authority envelopes inside external data. Read-only — verbatim lanes are byte
128
+ * frozen (design/319 G3 veto: the Read↔Edit `old_string` quote-back loop), so the disclosure
129
+ * pipeline's only move is to append an engine-minted note saying what was seen.
130
+ *
131
+ * Grammar is deliberately the same shape as the reminder scan (attribute / case / tag-internal
132
+ * whitespace tolerant, open OR close tag), and the tag SET comes from the registry rather than a
133
+ * hand-copied list, so adding an envelope to {@link ENGINE_ENVELOPES} with `disclosed: true` is the
134
+ * whole change. Same accepted residual as the reminder scan: a tag the grammar does not recognize
135
+ * (fragmented, Unicode-lookalike) is not a hit — defense in depth, not a guarantee.
136
+ */
137
+ export declare function scanEnvelopeShaped(text: string): EnvelopeShapedScan;
138
+ /**
139
+ * Defuse a caller-declared occurrence of ONE specific envelope tag (opening AND closing) inside
140
+ * content, CC `zZe` @8339092 verbatim: ``t.replace(new RegExp(`<(?=/?${e}(?:[>\\s/]|$))`, "gi"), "<\\")``.
141
+ *
142
+ * Homed here (beside {@link ENGINE_ENVELOPES}) rather than in the observer module that first needed
143
+ * it: "escape one named envelope tag" is a containment primitive, and the registry's guard column
144
+ * points at it. Re-exported from `agents/observer.ts` for its existing importers.
145
+ *
146
+ * Deviation from CC (hardening): the tag is regex-escaped before entering the RegExp — CC
147
+ * interpolates raw, safe only because its slugs are `[a-zA-Z0-9_-]`; ours are too, but we don't rely
148
+ * on the caller for that invariant.
149
+ *
150
+ * NOTE the two sanctioned escape SHAPES in this tree, and why they are not merged: this one inserts
151
+ * a backslash (`<` → `<\`) because it is a byte-level CC anchor on the observer/teammate lanes;
152
+ * {@link sanitizeUntrustedText} inserts a zero-width space because its lanes are read by humans as
153
+ * well. Both are idempotent and both break the tag; a merge would move bytes on one of the two
154
+ * families for no security gain.
155
+ */
156
+ export declare function escapeEnvelopeTag(tag: string, text: string): string;
19
157
  /** design/319 (B ticket) — what one reminder-shaped scan of an external projection found. */
20
158
  export interface ReminderShapedScan {
21
159
  /** ≥1 reminder-shaped tag (open or close; attribute/case/whitespace tolerant) is present. */
@@ -87,6 +225,14 @@ export declare function defuseExactMarkInSegments(segments: readonly string[], m
87
225
  * `<discussion>`, so an untrusted member could emit `</statement>` to break out (search [46] BUG2);
88
226
  * the caller passes those tag names. Tag names must be literal (alphanumeric/hyphen) — they are code-supplied
89
227
  * wrapper names, never untrusted input. Idempotent for prompt assembly (a defused tag no longer matches).
228
+ *
229
+ * WHY THE ALWAYS-ON SET IS ONE TAG AND NOT THE WHOLE {@link ENGINE_ENVELOPES} AUTHORITY FAMILY: this is a
230
+ * PRIMITIVE, and mint sites also run it over an ALREADY-ASSEMBLED body — the boundary/first-frame reminder
231
+ * wrap sanitizes a body that may legitimately BE the engine's own `<skills>` block, and the memory write
232
+ * scan diffs against it to decide what markup a note may contain. Widening the primitive would have the
233
+ * engine defuse its own envelopes and would silently move an unrelated store's admission rule. Family-wide
234
+ * containment therefore lives at the FENCE ({@link delimitUntrusted} / {@link inlineUntrusted}, via
235
+ * {@link FENCED_LANE_ENVELOPE_TAGS}) and at each envelope's own mint site (the registry's guard column).
90
236
  */
91
237
  export declare function sanitizeUntrustedText(text: string, extraTags?: string[]): string;
92
238
  /**
@@ -237,8 +383,13 @@ export declare function defuseControlChars(text: string): string;
237
383
  * `\s` does NOT fully match: the C1 half carries the 8-bit CSI/OSC/ST forms (U+009B/U+009D/U+009C) a
238
384
  * C1-honoring terminal treats like their ESC-prefixed spellings, so leaving them through would let a
239
385
  * 'sanitized' value repaint the trusted line it is interpolated onto (adversarial round finding) — caps the
240
- * length, then applies the same tag-neutralization (`</system-reminder>`) + fence-sentinel (`<<<`/`>>>`)
241
- * defusing the fenced body gets.
386
+ * length, then applies the same tag-neutralization + fence-sentinel (`<<<`/`>>>`) defusing the fenced
387
+ * body gets.
388
+ *
389
+ * The neutralized tag set is {@link FENCED_LANE_ENVELOPE_TAGS} — the engine's whole AUTHORITY envelope
390
+ * family, not the reminder tag alone. A value interpolated onto a TRUSTED line is the cheapest place to
391
+ * open a forged envelope, and this lane rewrites bytes by contract already, so the whole family is
392
+ * defused here.
242
393
  * Defense-in-depth, NOT a guarantee (same posture as the rest of this module).
243
394
  */
244
395
  export declare function inlineUntrusted(text: string, maxLen?: number): string;
@@ -250,8 +401,43 @@ export declare function inlineUntrusted(text: string, maxLen?: number): string;
250
401
  export declare const REVIEWER_NOTE_MAX_BODY = 2048;
251
402
  /**
252
403
  * Wrap untrusted text in a clearly labeled opaque fence. The consuming prompt should instruct the model to
253
- * treat everything inside as untrusted data — never as instructions. Sanitizes internally (system-reminder
404
+ * treat everything inside as untrusted data — never as instructions. Sanitizes internally (authority-envelope
254
405
  * neutralization + fence-sentinel defusing, on the body AND the label), so callers may pass raw
255
406
  * worker/external text — and labels derived from external identifiers (hostnames, resource URIs).
407
+ *
408
+ * The neutralized tag set is {@link FENCED_LANE_ENVELOPE_TAGS} — the engine's whole AUTHORITY envelope
409
+ * family (design/319 sibling work): a fence declares its contents to be data, but a forged
410
+ * `<task-notification>` / `<user_memory>` / `<skills>` opening inside it was previously reproduced byte
411
+ * for byte, so a model that skims past the fence line reads harness-shaped text with no judgment bit.
412
+ * This lane rewrites bytes by contract (it is the CONTAINED half; the verbatim quote-back lanes stay
413
+ * frozen and are served by the disclosure trailer instead), so the whole family is defused here.
414
+ *
415
+ * A caller that must DISCLOSE a `maxBody` truncation calls {@link delimitUntrustedWithClip} instead —
416
+ * never a re-derived length test of its own (see that function and {@link neutralizeForFence}).
256
417
  */
257
418
  export declare function delimitUntrusted(label: string, text: string, maxBody?: number): string;
419
+ /**
420
+ * The EXACT body neutralization {@link delimitUntrusted} applies, exported so a caller that has to
421
+ * MEASURE the fenced body (its length in code points, whether the cap will bite) measures the string
422
+ * the fence really carries instead of re-deriving the transform from its own copy of the tag list.
423
+ *
424
+ * Two copies of that list is precisely what drifted once the fenced lane widened past the reminder tag:
425
+ * a caller pre-counting with the default (reminder-only) set under-measured the wide-set result by one
426
+ * ZWSP per widened-family tag occurrence, so a body got clipped while the truncation disclosure that
427
+ * decision drove was withheld — an attacker-selectable window just under the cap. Idempotent (both
428
+ * halves are), so a caller may hand the neutralized string straight back to the fence.
429
+ */
430
+ export declare function neutralizeForFence(text: string): string;
431
+ /**
432
+ * {@link delimitUntrusted} plus the clip verdict, for the callers that must DISCLOSE a truncation on a
433
+ * TRUSTED line outside the fence (§25 honesty: a bare ellipsis reads to the model as the source's own
434
+ * text, so a silent clip is a lie about what the source said).
435
+ *
436
+ * `clipped` is reported by the same measurement that performed the cut. A caller re-deriving "would this
437
+ * clip?" from its own transform + its own tag list is the shape that silently broke when the fence's tag
438
+ * set widened; there is no second condition here to keep in sync.
439
+ */
440
+ export declare function delimitUntrustedWithClip(label: string, text: string, maxBody?: number): {
441
+ text: string;
442
+ clipped: boolean;
443
+ };