@sema-agent/core 7.18.0 → 7.20.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 (137) hide show
  1. package/CHANGELOG.md +276 -2
  2. package/dist/agents/subagent.js +12 -20
  3. package/dist/agents/teacher.js +2 -5
  4. package/dist/agents/verify.js +21 -10
  5. package/dist/brain/anthropic.js +8 -3
  6. package/dist/brain/open-responses.js +12 -4
  7. package/dist/brain/openai.js +7 -2
  8. package/dist/brain/reasoning.d.ts +13 -13
  9. package/dist/brain/reasoning.js +2 -2
  10. package/dist/brain/stream-engine.d.ts +4 -0
  11. package/dist/brain/stream-engine.js +6 -1
  12. package/dist/brain/stream-shared.d.ts +3 -0
  13. package/dist/brain/stream-shared.js +1 -0
  14. package/dist/core/ask-origin.d.ts +16 -0
  15. package/dist/core/ask-origin.js +8 -0
  16. package/dist/core/auto-mode-arming.d.ts +6 -0
  17. package/dist/core/auto-mode-arming.js +5 -5
  18. package/dist/core/auto-mode-defaults.d.ts +43 -15
  19. package/dist/core/auto-mode-defaults.js +4 -1
  20. package/dist/core/auto-mode.d.ts +74 -26
  21. package/dist/core/auto-mode.js +80 -11
  22. package/dist/core/checkpoint-store.d.ts +7 -0
  23. package/dist/core/checkpoint-store.js +2 -2
  24. package/dist/core/child-clamp-seats.d.ts +79 -0
  25. package/dist/core/child-clamp-seats.js +29 -0
  26. package/dist/core/engine-notice.d.ts +89 -41
  27. package/dist/core/fs-write-gate-policy.d.ts +17 -9
  28. package/dist/core/fs-write-gate-policy.js +51 -4
  29. package/dist/core/gate-bounded-await.d.ts +38 -0
  30. package/dist/core/gate-bounded-await.js +53 -0
  31. package/dist/core/gate-fold.js +20 -48
  32. package/dist/core/gate-lanes.js +17 -9
  33. package/dist/core/gate-outcome.d.ts +34 -6
  34. package/dist/core/gate-outcome.js +7 -0
  35. package/dist/core/governance-codes.d.ts +16 -1
  36. package/dist/core/governance-codes.js +18 -0
  37. package/dist/core/hooks.d.ts +8 -8
  38. package/dist/core/memory-engine/engine.d.ts +14 -9
  39. package/dist/core/memory-engine/engine.js +21 -8
  40. package/dist/core/memory-engine/layout.d.ts +31 -0
  41. package/dist/core/memory-engine/layout.js +68 -3
  42. package/dist/core/memory-engine/types.d.ts +24 -0
  43. package/dist/core/memory.d.ts +19 -0
  44. package/dist/core/memory.js +12 -0
  45. package/dist/core/read-root-candidate.d.ts +12 -0
  46. package/dist/core/read-root-candidate.js +24 -0
  47. package/dist/core/remote-env.d.ts +32 -0
  48. package/dist/core/remote-env.js +4 -1
  49. package/dist/core/roles.d.ts +9 -1
  50. package/dist/core/roles.js +4 -0
  51. package/dist/core/runner/advertised-writable-dirs.d.ts +59 -1
  52. package/dist/core/runner/advertised-writable-dirs.js +51 -3
  53. package/dist/core/runner/announce-once-ledger.d.ts +15 -0
  54. package/dist/core/runner/announce-once-ledger.js +8 -0
  55. package/dist/core/runner/classifier-ladder.d.ts +154 -11
  56. package/dist/core/runner/classifier-ladder.js +170 -10
  57. package/dist/core/runner/contracts.d.ts +46 -1
  58. package/dist/core/runner/denial-limit-arms.d.ts +14 -1
  59. package/dist/core/runner/denial-limit-arms.js +7 -6
  60. package/dist/core/runner/env-capability-census.d.ts +30 -0
  61. package/dist/core/runner/env-capability-census.js +38 -0
  62. package/dist/core/runner/forward-sink.d.ts +32 -0
  63. package/dist/core/runner/forward-sink.js +41 -0
  64. package/dist/core/runner/gate-exit.d.ts +4 -0
  65. package/dist/core/runner/gate-exit.js +4 -1
  66. package/dist/core/runner/git-status-frame.d.ts +0 -9
  67. package/dist/core/runner/listing-fold.d.ts +57 -0
  68. package/dist/core/runner/listing-fold.js +75 -0
  69. package/dist/core/runner/prepare-ask-lane.js +1 -0
  70. package/dist/core/runner/prepare-caps-and-workflow.js +20 -31
  71. package/dist/core/runner/prepare-config-doors.d.ts +6 -0
  72. package/dist/core/runner/prepare-config-doors.js +8 -10
  73. package/dist/core/runner/prepare-execution-env.d.ts +1 -1
  74. package/dist/core/runner/prepare-execution-env.js +2 -0
  75. package/dist/core/runner/prepare-gate-stations.d.ts +8 -0
  76. package/dist/core/runner/prepare-gate-stations.js +6 -2
  77. package/dist/core/runner/prepare-hands-readface.d.ts +4 -0
  78. package/dist/core/runner/prepare-hands-readface.js +3 -5
  79. package/dist/core/runner/prepare-listings.d.ts +3 -0
  80. package/dist/core/runner/prepare-listings.js +4 -2
  81. package/dist/core/runner/prepare-memory.js +7 -1
  82. package/dist/core/runner/prepare-policy-chain.js +5 -5
  83. package/dist/core/runner/prepare-project-context.d.ts +5 -1
  84. package/dist/core/runner/prepare-project-context.js +3 -2
  85. package/dist/core/runner/prepare-prompt-inputs.d.ts +2 -2
  86. package/dist/core/runner/prepare-run-refs.d.ts +4 -2
  87. package/dist/core/runner/prepare-run-refs.js +5 -4
  88. package/dist/core/runner/prepare-task.js +8 -6
  89. package/dist/core/runner/resume-apply.js +3 -3
  90. package/dist/core/runner/run-clock-and-content.js +3 -7
  91. package/dist/core/runner/run-harness-handlers.js +10 -10
  92. package/dist/core/runner/run-leg.js +31 -16
  93. package/dist/core/runner/run-settle-and-teardown.js +2 -5
  94. package/dist/core/runner/run-turn-boundary.js +44 -25
  95. package/dist/core/runner/runtask.js +2 -1
  96. package/dist/core/runner/synthetic-tools.d.ts +41 -29
  97. package/dist/core/runner/synthetic-tools.js +25 -40
  98. package/dist/core/runner/task-root-canonical.d.ts +45 -0
  99. package/dist/core/runner/task-root-canonical.js +18 -0
  100. package/dist/core/runner/turn-attachments.d.ts +160 -53
  101. package/dist/core/runner/turn-attachments.js +153 -51
  102. package/dist/core/safe-notify.d.ts +5 -0
  103. package/dist/core/safe-notify.js +24 -2
  104. package/dist/core/sensitive-path-policy.d.ts +84 -17
  105. package/dist/core/sensitive-path-policy.js +37 -19
  106. package/dist/core/skill-listing-budget.d.ts +106 -0
  107. package/dist/core/skill-listing-budget.js +57 -0
  108. package/dist/core/skills-directory.js +17 -10
  109. package/dist/core/task-registry.js +2 -1
  110. package/dist/core/task-spec.d.ts +29 -2
  111. package/dist/core/tool-policy.d.ts +31 -5
  112. package/dist/core/tool-policy.js +45 -32
  113. package/dist/core/tool-spec.d.ts +37 -4
  114. package/dist/core/types.d.ts +1 -1
  115. package/dist/core/write-protect.d.ts +105 -20
  116. package/dist/core/write-protect.js +89 -32
  117. package/dist/index.d.ts +8 -7
  118. package/dist/index.js +6 -5
  119. package/dist/orchestration/workflow-primitives.js +10 -20
  120. package/dist/stores/file/memory-store.d.ts +5 -2
  121. package/dist/stores/file/memory-store.js +8 -4
  122. package/dist/tools/fs/bash-readonly-classifier.d.ts +40 -2
  123. package/dist/tools/fs/bash-readonly-classifier.js +376 -41
  124. package/dist/tools/fs/fs-bash.d.ts +1 -1
  125. package/dist/tools/fs/fs-bash.js +19 -28
  126. package/dist/tools/fs/fs-shared.d.ts +22 -0
  127. package/dist/tools/fs/fs-shared.js +30 -0
  128. package/dist/tools/fs/index.d.ts +1 -1
  129. package/dist/tools/fs/index.js +1 -1
  130. package/dist/tools/fs/read-deny.d.ts +4 -3
  131. package/dist/tools/fs/safety.d.ts +15 -0
  132. package/dist/tools/fs/safety.js +7 -0
  133. package/package.json +2 -1
  134. package/test/export-surface.snapshot.json +31 -5
  135. package/wire-changes/core@7.18.0.json +1273 -0
  136. package/wire-changes/core@7.19.0.json +457 -0
  137. package/wire-changes/core@7.20.0.json +568 -0
@@ -1,20 +1,98 @@
1
1
  import { sameRouteIdentity } from "../../brain/route-adjudicator.js";
2
- import { thinkingOffExpressible } from "../../brain/reasoning.js";
3
- import { AUTO_MODE_CLASSIFIER_LOW_REASONING_BUDGET_TOKENS, AUTO_MODE_CLASSIFIER_MAX_TOKENS } from "../auto-mode-defaults.js";
4
- import { autoModeClassifierFallbackOf } from "../auto-mode.js";
2
+ import { thinkingOffSpelled } from "../../brain/reasoning.js";
3
+ import { AUTO_MODE_CLASSIFIER_CONTRACT_BROKEN_AFTER, AUTO_MODE_CLASSIFIER_LOW_REASONING_BUDGET_TOKENS, AUTO_MODE_CLASSIFIER_MAX_TOKENS, AUTO_MODE_CLASSIFIER_THINK_BUDGET_TOKENS } from "../auto-mode-defaults.js";
4
+ import { autoModeClassifierFallbackOf, parseAutoModeResponse } from "../auto-mode.js";
5
+ import { DEFAULT_TIER_ORDER, expandTiers, resolveTaskModel, roleSpecNamesModel } from "../roles.js";
5
6
  import { emitTrace } from "../trace.js";
6
7
  import { deliverEngineNotice } from "../types.js";
7
8
  export function classifierLadderRungs(knob, classifier, main) {
8
9
  if (autoModeClassifierFallbackOf(knob) === "none")
9
10
  return [classifier];
10
- let sameRoute;
11
+ return sameRouteQuietly(classifier, main) ? [classifier] : [classifier, main];
12
+ }
13
+ function sameRouteQuietly(a, b) {
14
+ try {
15
+ return sameRouteIdentity(a, b);
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ export const CLASSIFIER_ROLE_DEFAULT_REASONS = ["tier_below_main", "tier_table", "role_fallback", "main_only", "role_unresolvable"];
22
+ export function classifierSeatModel(args) {
23
+ const { main } = args;
24
+ const deps = normalizeSeatDeps(args.deps);
25
+ if (roleSpecNamesModel(deps.roles?.classifier)) {
26
+ try {
27
+ return { model: resolveTaskModel({ modelRole: "classifier" }, deps).model };
28
+ }
29
+ catch {
30
+ return { model: main, defaulted: "role_unresolvable" };
31
+ }
32
+ }
33
+ const below = tierBelowMain(deps, main);
34
+ if (below !== undefined)
35
+ return below;
11
36
  try {
12
- sameRoute = sameRouteIdentity(classifier, main);
37
+ const model = resolveTaskModel({ modelRole: "classifier" }, deps).model;
38
+ if (sameRouteQuietly(model, main))
39
+ return { model, defaulted: "main_only" };
40
+ return { model, defaulted: deps.tiers !== undefined && Object.keys(deps.tiers).length > 0 ? "tier_table" : "role_fallback" };
13
41
  }
14
42
  catch {
15
- sameRoute = false;
43
+ return { model: main, defaulted: "main_only" };
16
44
  }
17
- return sameRoute ? [classifier] : [classifier, main];
45
+ }
46
+ function normalizeSeatDeps(deps) {
47
+ if (deps.tiers === undefined)
48
+ return deps;
49
+ try {
50
+ return { ...deps, models: expandTiers(deps.models, deps.tiers) };
51
+ }
52
+ catch {
53
+ return deps;
54
+ }
55
+ }
56
+ function tierBelowMain(deps, main) {
57
+ if (deps.tiers === undefined || Object.keys(deps.tiers).length === 0 || deps.models === undefined)
58
+ return undefined;
59
+ const bound = DEFAULT_TIER_ORDER.map((tier) => deps.models?.[tier]);
60
+ let mainTier = -1;
61
+ for (let i = bound.length - 1; i >= 0; i--) {
62
+ const m = bound[i];
63
+ if (m !== undefined && sameRouteQuietly(m, main)) {
64
+ mainTier = i;
65
+ break;
66
+ }
67
+ }
68
+ if (mainTier < 0)
69
+ return undefined;
70
+ for (let i = mainTier + 1; i < bound.length; i++) {
71
+ const m = bound[i];
72
+ if (m !== undefined && !sameRouteQuietly(m, main))
73
+ return { model: m, defaulted: "tier_below_main" };
74
+ }
75
+ return { model: main, defaulted: "main_only" };
76
+ }
77
+ export function announceClassifierRoleDefaulted(args) {
78
+ const { seat } = args;
79
+ if (seat.defaulted === undefined)
80
+ return;
81
+ const spelled = thinkingOffSpelled(seat.model);
82
+ deliverEngineNotice(args.onNotice, {
83
+ code: "classifier.role_defaulted",
84
+ message: `no classifier role names a model — "${seat.model.id}" classifies this run (${seat.defaulted}); ` +
85
+ (spelled ? "its wire can turn thinking off for the verdict. " : "its wire cannot turn thinking off, so each classification carries a thinking allowance. ") +
86
+ "Name a `classifier` role, or declare the model's thinking format (`compat.thinkingFormat`) so the seat can spell off.",
87
+ detail: {
88
+ model: seat.model.id,
89
+ reason: seat.defaulted,
90
+ thinkingOffSpelled: spelled,
91
+ ...(args.sessionId !== undefined ? { sessionId: args.sessionId } : {}),
92
+ ...(args.runId !== undefined ? { runId: args.runId } : {}),
93
+ },
94
+ ...(args.sessionId !== undefined ? { sessionId: args.sessionId } : {}),
95
+ });
18
96
  }
19
97
  function classifierFallbackAnnouncer(args) {
20
98
  const announced = new Set();
@@ -41,13 +119,94 @@ function classifierFallbackAnnouncer(args) {
41
119
  });
42
120
  };
43
121
  }
122
+ function classifierContractAnnouncer(args) {
123
+ const streak = new Map();
124
+ const announced = new Set();
125
+ const noVerdict = (rung, model) => {
126
+ const key = `${rung}:${model}`;
127
+ const n = (streak.get(key) ?? 0) + 1;
128
+ streak.set(key, n);
129
+ if (n < AUTO_MODE_CLASSIFIER_CONTRACT_BROKEN_AFTER || announced.has(key))
130
+ return;
131
+ announced.add(key);
132
+ deliverEngineNotice(args.onNotice, {
133
+ code: "classifier.contract_broken",
134
+ message: `the auto-mode classifier model "${model}" has answered ${n} consecutive classifications without a verdict (each asked twice) — ` +
135
+ "every gated call it judges is denied. Declare the model's thinking format (`compat.thinkingFormat`) so the seat can turn thinking off, " +
136
+ "or name a `classifier` role whose model follows the <block> contract.",
137
+ detail: {
138
+ model,
139
+ rung,
140
+ consecutive: n,
141
+ ...(args.sessionId !== undefined ? { sessionId: args.sessionId } : {}),
142
+ ...(args.runId !== undefined ? { runId: args.runId } : {}),
143
+ },
144
+ ...(args.sessionId !== undefined ? { sessionId: args.sessionId } : {}),
145
+ });
146
+ };
147
+ return (info) => {
148
+ const record = info.classifier;
149
+ if (record === undefined)
150
+ return;
151
+ if (record.fallback?.cause === "parse_error")
152
+ noVerdict(0, record.fallback.from);
153
+ const landed = record.fallback !== undefined ? 1 : 0;
154
+ if (info.verdict === "parse_error")
155
+ noVerdict(landed, record.modelUsed);
156
+ else if (info.verdict === "allow" || info.verdict === "block")
157
+ streak.delete(`${landed}:${record.modelUsed}`);
158
+ };
159
+ }
44
160
  export const CLASSIFIER_OFF_SEAT = { reasoning: "off", maxTokens: AUTO_MODE_CLASSIFIER_MAX_TOKENS };
45
161
  export const CLASSIFIER_LOW_SEAT = { reasoning: "low", maxTokens: AUTO_MODE_CLASSIFIER_MAX_TOKENS + AUTO_MODE_CLASSIFIER_LOW_REASONING_BUDGET_TOKENS };
162
+ export const CLASSIFIER_THINK_SEAT = { reasoning: "low", maxTokens: AUTO_MODE_CLASSIFIER_MAX_TOKENS + AUTO_MODE_CLASSIFIER_THINK_BUDGET_TOKENS };
46
163
  export function classifierRungSeat(model) {
47
- return thinkingOffExpressible(model) ? CLASSIFIER_OFF_SEAT : CLASSIFIER_LOW_SEAT;
164
+ return thinkingOffSpelled(model) ? CLASSIFIER_OFF_SEAT : CLASSIFIER_LOW_SEAT;
165
+ }
166
+ export const CLASSIFIER_ATTEMPT_SUFFIX = {
167
+ first: '\nErr on the side of blocking. Your ENTIRE response MUST begin with <block>. Do NOT output any analysis, reasoning, or commentary before <block>. No "Looking at..." or similar preamble.',
168
+ reask: "\nReview the classification process and follow it carefully, making sure you deny actions that should be blocked. As a reminder, explicit (not suggestive or implicit) user confirmation is required to override blocks. Use <thinking> before responding with <block>. Think longer on ambiguous or borderline actions; keep reasoning brief for clear-cut ones.",
169
+ };
170
+ export function classifierReplyText(reply) {
171
+ return reply.content
172
+ .filter((c) => c.type === "text")
173
+ .map((c) => c.text)
174
+ .join("");
175
+ }
176
+ export function classifierReplyEmptyAtCap(reply) {
177
+ if (classifierReplyText(reply) !== "")
178
+ return false;
179
+ return reply.stopReason === "length" || (reply.stopReason === "error" && reply.errorKind === "length_empty");
180
+ }
181
+ export function classifierReplyTagOnly(reply) {
182
+ return reply.stopReason === "length" || reply.stopReason === "toolUse" || reply.partialFinalized === true || reply.content.some((c) => c.type === "toolCall");
183
+ }
184
+ export function classifierReplyVerdictText(reply) {
185
+ const text = classifierReplyText(reply);
186
+ if (!classifierReplyTagOnly(reply))
187
+ return text;
188
+ const tagged = parseAutoModeResponse(text, { bareAnswer: false });
189
+ return tagged.kind === "parse_error" && tagged.form === "missing" ? "" : text;
190
+ }
191
+ export function classifierReplyAsksAgain(reply) {
192
+ if (classifierReplyEmptyAtCap(reply))
193
+ return true;
194
+ const text = classifierReplyVerdictText(reply);
195
+ if (text === "")
196
+ return classifierReplyTagOnly(reply) && classifierReplyText(reply) !== "";
197
+ const verdict = parseAutoModeResponse(text);
198
+ return verdict.kind === "parse_error" && verdict.form === "missing";
199
+ }
200
+ export function classifierReplyFailure(reply) {
201
+ if (reply.stopReason === "aborted")
202
+ return new Error("the auto-mode classifier request was aborted");
203
+ if (reply.stopReason !== "error" || reply.errorKind === "length_empty")
204
+ return undefined;
205
+ return new Error(`the auto-mode classifier request failed: ${reply.errorMessage ?? "the model leg reported an error with no message"}`);
48
206
  }
49
207
  export function classifierRoundObserver(args) {
50
- const announce = classifierFallbackAnnouncer(args);
208
+ const announceFallback = classifierFallbackAnnouncer(args);
209
+ const countContract = classifierContractAnnouncer(args);
51
210
  return (info) => {
52
211
  emitTrace(args.tracer, () => ({
53
212
  kind: "auto_mode.classified",
@@ -60,6 +219,7 @@ export function classifierRoundObserver(args) {
60
219
  ...(info.cause !== undefined ? { cause: info.cause } : {}),
61
220
  ts: Date.now(),
62
221
  }));
63
- announce(info.classifier);
222
+ announceFallback(info.classifier);
223
+ countContract(info);
64
224
  };
65
225
  }
@@ -32,6 +32,7 @@ import type { ClearedProjectionLedger, MicroCompactMachine, OccurrenceIndex } fr
32
32
  import type { HookInvocationIdentity, Hooks, OrgGateVerdict, PermissionDeniedPayload, PostToolBatchCall, PostToolBatchResult, ToolGateInput } from "../hooks.js";
33
33
  import type { MaterializedMcp } from "../mcp.js";
34
34
  import type { MemoryEngine } from "../memory-engine/engine.js";
35
+ import type { ResolvedSkillListingBudget } from "../skill-listing-budget.js";
35
36
  import type { HarvestReport, MemorySessionHandle } from "../memory-engine/types.js";
36
37
  import type { SessionPermissionRules } from "../session-policy-store.js";
37
38
  import type { RecoveredOrphan } from "../session-reconcile.js";
@@ -172,6 +173,35 @@ export interface CommittedPause {
172
173
  scope: string;
173
174
  gatedCallId?: string;
174
175
  }
176
+ /**
177
+ * One engine frame whose commit waits for the receipt of the message carrying it.
178
+ *
179
+ * @contract frames.commit_on_receipt — an engine-minted frame that rides a user message (the git-status
180
+ * frame, an `agent_listing` or `skills_listing` frame) is recorded as announced ONLY when the run loop's
181
+ * `message_end` walk sees a persisted user entry whose text equals the carrying text it was registered
182
+ * with; every receipt with that text settles on that one entry. Until then nothing is recorded — a
183
+ * message that never reaches the transcript (a pre-append failure, a suppressed or undrained steer, a
184
+ * leg that ends first) leaves the frame unannounced, and the next boundary or leg renders it again.
185
+ * The error asymmetry is one-directional by design: a lost receipt costs a duplicate frame, never a
186
+ * fact the model was recorded as told and was not.
187
+ */
188
+ export interface FrameReceipt {
189
+ /** The carrying message's exact text (its first text block) — the receipt's match key. */
190
+ text: string;
191
+ /** Which frame this receipt settles. A boundary collector must not render a listing whose receipt is
192
+ * still pending (the frame is queued, not yet in the transcript). */
193
+ source: "git_status" | "agent_listing" | "skills_listing";
194
+ /** Runs inside the `message_end` walk (synchronous, never awaited): advances the run-local record
195
+ * and parks any session write for the next serialization point. */
196
+ commit: (entryId: string) => void;
197
+ }
198
+ /** The leg's frame-receipt ledger — see {@link FrameReceipt}. `listingSnapshotOwed` is the parked
199
+ * announced-listing snapshot write (a session append from inside the walk would race the loop's own
200
+ * next transcript append); the boundary's end and the prompt's settle flush it. */
201
+ export interface FrameReceiptLedger {
202
+ pending: FrameReceipt[];
203
+ listingSnapshotOwed: boolean;
204
+ }
175
205
  export interface Prepared {
176
206
  harness: AgentHarness;
177
207
  /** The CONCRETE built-in session (engine-internal: prepare constructs/acquires `StoredSession` itself,
@@ -1016,6 +1046,7 @@ export interface Prepared {
1016
1046
  toolName: string;
1017
1047
  seedAnnounced: boolean;
1018
1048
  models?: readonly string[];
1049
+ budget: ResolvedSkillListingBudget;
1019
1050
  };
1020
1051
  /** [c209-C] skills_listing counterpart of {@link agentListing} — the normalized skills METADATA
1021
1052
  * projection (names/descriptions/attachment paths, never bodies; the exact list `createSkillTool`
@@ -1024,6 +1055,7 @@ export interface Prepared {
1024
1055
  skillsListing?: {
1025
1056
  entries: ReadonlyArray<SkillListingEntry>;
1026
1057
  seedAnnounced: boolean;
1058
+ budget: ResolvedSkillListingBudget;
1027
1059
  };
1028
1060
  /** [c209-C] Q5 — the run loop's live mirror of the listing frames' announced NAME-SETS (updated at
1029
1061
  * every commit point: first-frame delivery, intact boundary survival, resume seed). Read by the
@@ -1036,6 +1068,9 @@ export interface Prepared {
1036
1068
  * announce-once-ledger.ts): seeded at prepare from the branch's record, advanced by the run loop
1037
1069
  * on intact delivery of the tools_delta `failedServers` arm / the mcp_dropped_tools frame. */
1038
1070
  announcedListingsRef: AnnouncedListingSnapshotInput;
1071
+ /** The frame-receipt ledger — the ONE commit point of every engine frame that rides a user message
1072
+ * (the git-status frame, the agent and skills listing frames). Always present; per leg. */
1073
+ frameReceipts: FrameReceiptLedger;
1039
1074
  /** env-tail migration — the git-status frame lane's run-local state: this leg's
1040
1075
  * resolved frame (probe outcome rendered + hashed at prepare), the announced `(kind, hash)`
1041
1076
  * mirror the checkpoint serializer reads, the trim-protection slot the request-build context
@@ -1794,7 +1829,11 @@ export interface RunInternals {
1794
1829
  * `forwardSubagentEvents: true` it ALSO forwards the child's content events (`text_delta` / `text_end` /
1795
1830
  * `reasoning_delta` / `tool_start` / `tool_end` — the subagent viewing pane, carrying the same UNTRUSTED-RAW/consumer-must-redact
1796
1831
  * contract as the main stream's tool events). Either way the child stream is NEVER merged into the parent's
1797
- * MODEL context (this is purely a render channel). Absent unless the deployment opted in.
1832
+ * MODEL context (this is purely a render channel). Absent unless the deployment opted in. CONTAINED at
1833
+ * one door: every engine site feeding this seat goes through `forwardSafely` (`forward-sink.ts`), so a
1834
+ * sink that throws — or, since an `async` function satisfies this `=> void` type, one that returns a
1835
+ * REJECTING promise — drops its frame and nothing else, never awaited
1836
+ * (`@contract runtask.forward_sink_never_faults_the_run`).
1798
1837
  */
1799
1838
  onForwardEvent?: (event: TaskEvent) => void;
1800
1839
  /**
@@ -1813,6 +1852,12 @@ export interface RunInternals {
1813
1852
  /** The run-local per-tool-call detach hub. runtask creates it and exposes
1814
1853
  * `TaskStream.detach(toolCallId)`; the hands Bash tool threads `signalFor(toolCallId)` into env.exec. */
1815
1854
  detachHub?: import("../tool-detach.js").ToolDetachHub;
1855
+ /** The run's forwarding-failure bookkeeping for {@link onForwardEvent} — ENGINE-owned and opaque,
1856
+ * minted once per run by `Runner.runTaskStream` and carried by every later COPY of this object (the
1857
+ * prepare re-spread, a placement-root rebind, a child's snapshot), which is what makes "announced once
1858
+ * per run" true across the lanes that forward. A caller value is overridden like the refs beside it;
1859
+ * absent ⇒ the door mints a per-object one, so containment never depends on the threading. */
1860
+ forwardScope?: import("./forward-sink.js").ForwardSinkScope;
1816
1861
  /**
1817
1862
  * The Runner's per-session read-file-state seats (CC parity: readFileState is
1818
1863
  * session-scoped). Always set by the Runner's own prepare call (overriding any caller value, like
@@ -105,9 +105,12 @@ export declare function inheritedAskCarry(judged: Pick<Extract<InheritedClassifi
105
105
  }>, "origin" | "fallback" | "ask">, liveApprover: boolean, tracker: AutoModeDenialTracker | undefined): AskRequestCarry;
106
106
  /** What {@link judgeInheritedClassifier} hands back to the arm that called it. */
107
107
  export type InheritedClassifierJudgment<A extends InheritedAsk> =
108
- /** The frozen classifier allowed — the arm returns its own allow shape (with or without the ask's rewrite). */
108
+ /** The frozen classifier allowed. `result` is the allow the arm RETURNS — minted at the judging station so
109
+ * the model attribution rides the same one object the deny arm's does (an arm re-minting its own
110
+ * `{action:"allow"}` drops it); it already carries the ask's rewrite when the arm declared one. */
109
111
  {
110
112
  kind: "allow";
113
+ result: PermissionResult;
111
114
  }
112
115
  /** A block below the bound — the exact deny the ancestor's own gate would have produced. */
113
116
  | {
@@ -145,6 +148,13 @@ export type InheritedClassifierJudgment<A extends InheritedAsk> =
145
148
  * `req` is the request as the classifier must see it (the arm's presented / edited args);
146
149
  * `subject` is the noun the deny names ("this call" on the wrapper arms, "the approved edit" on the
147
150
  * re-check).
151
+ *
152
+ * ONE MINT for the classification's model attribution: every decision this station hands back — the allow it
153
+ * mints from `allowRewrite` as much as either deny — carries it on the identity-keyed seat beside the one
154
+ * carrying the FORM, and the arms return the station's object instead of re-minting their own. The
155
+ * descendant's fold reads both seats at its single attestation read. A decider with no candidate ladder
156
+ * vouches for no name and the seat stays empty (absence = unknown); a block AT the bound reaches no seat at
157
+ * all, since it becomes an ask and the disposition then names that resolution.
148
158
  */
149
159
  export declare function judgeInheritedClassifier<A extends InheritedAsk>(opts: {
150
160
  autoMode: InheritedAutoMode | undefined;
@@ -152,6 +162,9 @@ export declare function judgeInheritedClassifier<A extends InheritedAsk>(opts: {
152
162
  req: ToolCallRequest;
153
163
  signal: AbortSignal;
154
164
  subject: "this call" | "the approved edit";
165
+ /** The rewrite the arm's allow would have carried (the ask's own `updatedInput`, the re-check's edited
166
+ * args) — handed IN so the allow is minted here, attested, once; absent ⇒ a bare allow. */
167
+ allowRewrite?: unknown;
155
168
  }): Promise<InheritedClassifierJudgment<A>>;
156
169
  /** The resolver's answer as the settlement reads it. */
157
170
  export type FallbackResolution = Pick<ResolvedAsk, "action" | "settlement" | "approverUnavailable">;
@@ -1,6 +1,6 @@
1
- import { CLASSIFIER_PARSE_FAILURE_DENY_MESSAGE, classifierUnavailableDenyMessage, createAutoModeDenialTracker, denialLimitFallbackMessage, denialLimitSentence, unarmedWindow } from "../auto-mode.js";
1
+ import { classifierParseFailureDenyMessage, classifierUnavailableDenyMessage, createAutoModeDenialTracker, denialLimitFallbackMessage, denialLimitSentence, unarmedWindow } from "../auto-mode.js";
2
2
  import { sanitizeAutoModeArmingRecipe, tightenDenialLimit } from "../auto-mode-arming.js";
3
- import { attestClassifierDenyCause } from "../tool-policy.js";
3
+ import { attestClassifierDenyCause, attestClassifierRound } from "../tool-policy.js";
4
4
  import { deliverEngineNotice } from "../types.js";
5
5
  import { inlineUntrusted } from "../untrusted-text.js";
6
6
  import { askOriginOf, classifierMayAnswer, probeMandatedAsk } from "../ask-origin.js";
@@ -82,17 +82,18 @@ export async function judgeInheritedClassifier(opts) {
82
82
  const verdict = await autoMode.decider
83
83
  .decide({ req, ...(ask.message !== undefined ? { askMessage: ask.message } : {}) }, opts.signal)
84
84
  .catch(() => ({ kind: "unavailable", cause: "error" }));
85
+ const attributed = (d) => (verdict.classifier !== undefined ? attestClassifierRound(d, verdict.classifier, req) : d);
85
86
  if (verdict.kind === "allow") {
86
87
  autoMode.denialTracking?.recordAllow();
87
- return { kind: "allow" };
88
+ return { kind: "allow", result: attributed({ action: "allow", ...(opts.allowRewrite !== undefined ? { updatedInput: opts.allowRewrite } : {}) }) };
88
89
  }
89
90
  if (verdict.kind === "unavailable") {
90
91
  return {
91
92
  kind: "deny",
92
- result: attestClassifierDenyCause({ action: "deny", message: classifierUnavailableDenyMessage(req.toolName, verdict.cause), decisionReason: "classifier" }, "unavailable", req),
93
+ result: attributed(attestClassifierDenyCause({ action: "deny", message: classifierUnavailableDenyMessage(req.toolName, verdict.cause), decisionReason: "classifier" }, "unavailable", req)),
93
94
  };
94
95
  }
95
- const reason = verdict.kind === "block" ? (verdict.reason ? inlineUntrusted(verdict.reason) : "") : CLASSIFIER_PARSE_FAILURE_DENY_MESSAGE;
96
+ const reason = verdict.kind === "block" ? (verdict.reason ? inlineUntrusted(verdict.reason) : "") : classifierParseFailureDenyMessage(verdict.classifier);
96
97
  const category = verdict.kind === "block" && verdict.category ? inlineUntrusted(verdict.category) : "";
97
98
  const tracked = autoMode.denialTracking?.recordBlock();
98
99
  if (tracked?.limitReached !== true) {
@@ -101,7 +102,7 @@ export async function judgeInheritedClassifier(opts) {
101
102
  message: verdict.kind === "block" ? `auto-mode classifier blocked ${opts.subject} at an inherited ancestor layer${reason ? `: ${reason}` : category ? `: [${category}]` : ""}` : `${reason} (${opts.subject}, at an inherited ancestor layer)`,
102
103
  decisionReason: "classifier",
103
104
  };
104
- return { kind: "deny", result: verdict.kind === "parse_error" ? attestClassifierDenyCause(denied, "parse_error", req) : denied };
105
+ return { kind: "deny", result: attributed(verdict.kind === "parse_error" ? attestClassifierDenyCause(denied, "parse_error", req) : denied) };
105
106
  }
106
107
  const fallback = tracked.fallback;
107
108
  const minted = { ...ask, message: denialLimitFallbackMessage(fallback, reason || category || req.toolName), decisionReason: "classifier", requiresRealApproval: true, denialLimitFallback: fallback };
@@ -0,0 +1,30 @@
1
+ import type { ExecutionEnv } from "../../internal/harness.js";
2
+ import type { EngineNotice } from "../engine-notice.js";
3
+ /** One capability key whose declared value is not a boolean, with the type it actually carries. */
4
+ export interface MalformedCapabilityDeclaration {
5
+ readonly key: string;
6
+ readonly valueType: string;
7
+ }
8
+ /**
9
+ * Every boolean capability this env DECLARES with a value that is not a boolean.
10
+ *
11
+ * The probe is the VALUE, not membership, because the value is what the readers get: a `capabilities`
12
+ * that answers `has` and `get` differently (a Proxy) would otherwise hide from the census exactly the
13
+ * value a reader goes on to act on. `undefined` is where absence lands — an absent key and an explicit
14
+ * `undefined` are the same word to every reader, and both are the documented silent reading — so one
15
+ * test covers both and the census needs no membership rule at all.
16
+ *
17
+ * TOTAL by construction: a diagnostic may not be the thing that fails a run. Every read of the adapter
18
+ * can throw on a getter or a Proxy trap — the structural probe, the block acquisition and each key —
19
+ * and a read that throws is itself a declaration nobody can use, so all three are reported rather than
20
+ * raised (`unreadable`, under the key when there is one and under `capabilities` when the block itself
21
+ * could not be had).
22
+ *
23
+ * What a snapshot cannot promise: this is taken once, at admission. An adapter whose capability values
24
+ * CHANGE afterwards can hand a later reader something this census never saw; the declaration is
25
+ * documented as a constant property of the adapter, and a mutable one is outside what any one-time
26
+ * reading covers.
27
+ */
28
+ export declare function malformedCapabilityDeclarations(env: ExecutionEnv): MalformedCapabilityDeclaration[];
29
+ /** Announce each malformed declaration once, to the operator who wired the adapter. */
30
+ export declare function announceMalformedCapabilities(env: ExecutionEnv, onNotice: ((notice: EngineNotice) => void) | undefined): void;
@@ -0,0 +1,38 @@
1
+ import { isRemoteExecutionEnv } from "../remote-env.js";
2
+ import { deliverEngineNotice } from "../engine-notice.js";
3
+ const BOOLEAN_CAPABILITY_KEYS = ["isolation", "suspendable", "canonicalPathAuthoritative"];
4
+ export function malformedCapabilityDeclarations(env) {
5
+ let caps;
6
+ try {
7
+ if (!isRemoteExecutionEnv(env))
8
+ return [];
9
+ caps = env.capabilities;
10
+ }
11
+ catch {
12
+ return [{ key: "capabilities", valueType: "unreadable" }];
13
+ }
14
+ const out = [];
15
+ for (const key of BOOLEAN_CAPABILITY_KEYS) {
16
+ let value;
17
+ try {
18
+ value = caps[key];
19
+ }
20
+ catch {
21
+ out.push({ key, valueType: "unreadable" });
22
+ continue;
23
+ }
24
+ if (value === undefined || typeof value === "boolean")
25
+ continue;
26
+ out.push({ key, valueType: value === null ? "null" : typeof value });
27
+ }
28
+ return out;
29
+ }
30
+ export function announceMalformedCapabilities(env, onNotice) {
31
+ for (const { key, valueType } of malformedCapabilityDeclarations(env)) {
32
+ deliverEngineNotice(onNotice, {
33
+ code: "config.execution_env_capability_invalid",
34
+ message: `The execution environment declares capabilities.${key} as a ${valueType}, not a boolean. What a reader makes of that is the reader's own business and differs between them — the containment readers take only the boolean, so this reads as undeclared, while the suspend reader takes any truthy value, so a non-empty string reads as TRUE. That is why a declaration of the wrong shape cannot pass quietly: the engine may act on something other than what the adapter meant. Fix the adapter — this is a wiring fault, not a supported way to leave the capability unset.`,
35
+ detail: { key, valueType },
36
+ });
37
+ }
38
+ }
@@ -0,0 +1,32 @@
1
+ import type { TaskEvent } from "../task-event.js";
2
+ import type { RunnerDeps } from "../runner-deps.js";
3
+ import { type SafeNotifier } from "../safe-notify.js";
4
+ /** One run's forwarding-failure bookkeeping: opaque to everything but this module. */
5
+ export interface ForwardSinkScope {
6
+ readonly notifier: SafeNotifier;
7
+ }
8
+ /** The two seats this module reads off a run's trusted internals — spelled structurally so a SNAPSHOT
9
+ * of them (the delegation revive lane holds one) is as valid a holder as the live object. */
10
+ export interface ForwardSinkHolder {
11
+ onForwardEvent?: (event: TaskEvent) => void;
12
+ forwardScope?: ForwardSinkScope;
13
+ }
14
+ /**
15
+ * Mint the forwarding scope for ONE run, bound to the deployment's notice seat. Called where the run's
16
+ * internals object is composed (`Runner.runTaskStream`), so every copy of that object made later — the
17
+ * prepare re-spread, a placement-root rebind, a child's snapshot — carries the same counters.
18
+ */
19
+ export declare function createForwardSinkScope(onNotice: RunnerDeps["onNotice"]): ForwardSinkScope;
20
+ /**
21
+ * Forward ONE event to the deployment's display sink, containing both failure shapes.
22
+ *
23
+ * @contract runtask.forward_sink_never_faults_the_run — a `RunInternals.onForwardEvent` that throws,
24
+ * or that returns a promise which rejects, never reaches the run's control flow: the frame is dropped,
25
+ * the failure is counted on the run's own `forwardScope`, and the FIRST one is announced once per run
26
+ * as `config.forward_sink_failed` (later ones are counted only). Delivery is never awaited — describing
27
+ * a run is not part of it, so a sink that never settles cannot hold a turn open. An absent sink is a
28
+ * no-op, and a healthy sink sees the same call it always did, with the same receiver.
29
+ */
30
+ export declare function forwardSafely(holder: ForwardSinkHolder | undefined, event: TaskEvent): void;
31
+ /** Contained forwarding failures on this scope (diagnostics; 0 on the healthy path). */
32
+ export declare function forwardSinkFailures(holder: ForwardSinkHolder | undefined): number;
@@ -0,0 +1,41 @@
1
+ import { deliverEngineNotice } from "../engine-notice.js";
2
+ import { createSafeNotifier, observeThenableRejection, safeErrorMessage } from "../safe-notify.js";
3
+ const FORWARD_SINK_SITE = "runtask.onForwardEvent";
4
+ const ORPHAN_SCOPES = new WeakMap();
5
+ function forwardSinkFailedNotice(message) {
6
+ return {
7
+ code: "config.forward_sink_failed",
8
+ message: `The subagent display sink (RunInternals.onForwardEvent) failed and the frame was dropped: ${message}. ` +
9
+ `The run is unaffected — forwarding is a render channel — and further failures of this run are counted, not repeated.`,
10
+ detail: { site: FORWARD_SINK_SITE, cause: message },
11
+ };
12
+ }
13
+ export function createForwardSinkScope(onNotice) {
14
+ return {
15
+ notifier: createSafeNotifier({
16
+ onError: (f) => deliverEngineNotice(onNotice, forwardSinkFailedNotice(safeErrorMessage(f.error))),
17
+ }),
18
+ };
19
+ }
20
+ function scopeOf(holder) {
21
+ if (holder.forwardScope !== undefined)
22
+ return holder.forwardScope;
23
+ const existing = ORPHAN_SCOPES.get(holder);
24
+ if (existing !== undefined)
25
+ return existing;
26
+ const scope = createForwardSinkScope(undefined);
27
+ ORPHAN_SCOPES.set(holder, scope);
28
+ return scope;
29
+ }
30
+ export function forwardSafely(holder, event) {
31
+ if (holder?.onForwardEvent === undefined)
32
+ return;
33
+ const { notifier } = scopeOf(holder);
34
+ notifier.notify(() => observeThenableRejection(holder.onForwardEvent?.(event), notifier, FORWARD_SINK_SITE), FORWARD_SINK_SITE);
35
+ }
36
+ export function forwardSinkFailures(holder) {
37
+ if (holder === undefined)
38
+ return 0;
39
+ const scope = holder.forwardScope ?? ORPHAN_SCOPES.get(holder);
40
+ return scope?.notifier.failuresAt(FORWARD_SINK_SITE) ?? 0;
41
+ }
@@ -92,6 +92,10 @@ export interface SettlementLedger {
92
92
  * `parse_error` round travelling through an ancestor wrapper into this fold); undefined for a ruling
93
93
  * block, a non-classifier deny, an allow or an ask. */
94
94
  causeOf(decision: PermissionResult): ClassifierDenyCause | undefined;
95
+ /** Read a decision's attested CLASSIFICATION for this call — which model an ancestor's frozen classifier
96
+ * asked and which answered, on the allow arm as much as the deny arm. Undefined when no inherited
97
+ * classifier judged the call, or when the ancestor's decider could vouch for no name. */
98
+ classifierRoundOf(decision: PermissionResult): AutoModeClassifierRound | undefined;
95
99
  /** A surviving ask supersedes whatever an earlier decision settled. */
96
100
  supersede(): void;
97
101
  }
@@ -1,5 +1,5 @@
1
1
  import { screenGateOutcome, SETTLEMENT_IS_REFUSAL } from "../gate-outcome.js";
2
- import { askUnresolvableCauseOf, classifierDenyCauseOf, decisionText, engineSettlementOf } from "../tool-policy.js";
2
+ import { askUnresolvableCauseOf, classifierDenyCauseOf, classifierRoundOf, decisionText, engineSettlementOf } from "../tool-policy.js";
3
3
  import { inlineUntrusted } from "../untrusted-text.js";
4
4
  import { formatHookFeedback } from "../reminder-mint.js";
5
5
  export function mintGateOutcome(facts) {
@@ -56,6 +56,9 @@ export function createSettlementLedger(call) {
56
56
  causeOf(decision) {
57
57
  return decision.action === "deny" ? classifierDenyCauseOf(decision, call) : undefined;
58
58
  },
59
+ classifierRoundOf(decision) {
60
+ return classifierRoundOf(decision, call);
61
+ },
59
62
  supersede() {
60
63
  settled = undefined;
61
64
  },
@@ -126,15 +126,6 @@ export interface GitStatusLaneRef {
126
126
  hash: string;
127
127
  entryId: string;
128
128
  };
129
- /** The duplicate-tolerant receipt slot: set when a frame delivery is in flight, settled by the
130
- * run loop's `message_end` walk when the CARRYING message commits (matched on the exact
131
- * engine-held message text). Commit-on-receipt, never commit-before-append — the git lane's
132
- * error asymmetry is the REVERSE of the listing lane's (a false "announced" = stale git facts
133
- * forever; a duplicate frame = a few idempotent kilobytes). */
134
- pendingReceipt?: {
135
- text: string;
136
- commit: (entryId: string) => void;
137
- };
138
129
  }
139
130
  /**
140
131
  * prepare-side probe half of the lane (H4, extracted from prepareTask): run the CC-shape snapshot
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The ONE folding engine every listing frame renders through — the `<skills>` block, the skills drift
3
+ * frame's add arm, the agent-type roster and its drift frame's add arm. A listing is an intact-class
4
+ * turn attachment (`turn-attachments.ts` `ATTACHMENT_DELIVERY_CLASS`): the delivery lane never clips
5
+ * it, so this engine is the frame's whole bound, byte-exact over the caller's `frame` (headers, fences,
6
+ * markers, joiners included).
7
+ *
8
+ * Three honesty tiers over ONE set of items, each chosen with the LATER tiers' fixed overhead already
9
+ * reserved so the final render never blows the budget it was sized against:
10
+ * A. everything fits → every item gets its full line;
11
+ * B. reserve the cap marker + EVERY item's name-only line first, then spend the remaining slack on
12
+ * full lines in PRIORITY order (`rank` ascending; absent ⇒ index), admitting each full line whose
13
+ * incremental cost still fits and skipping (not stopping at) the ones that do not — every item stays
14
+ * listed, the low-priority ones lose their detail;
15
+ * C. even all-name-only overflows → the longest name-only prefix that fits, closed by the caller's
16
+ * overflow count line.
17
+ * Pure: the same items, spec and frame always render the same bytes.
18
+ */
19
+ export interface ListingFoldSpec<T> {
20
+ /** The whole frame's byte budget, honoured as given. */
21
+ blockMaxBytes: number;
22
+ /** One item's full line (tier A, and tier B's admitted lines). */
23
+ full: (item: T) => string;
24
+ /** One item's name-only line (tier B's degraded form, tier C's listed prefix). */
25
+ nameOnly: (item: T) => string;
26
+ /** The raw name reported for an item the budget degraded (host-facing data, not prompt text). */
27
+ nameOf: (item: T) => string;
28
+ /** Priority for keeping the full line: ascending, 0 = first. Absent ⇒ the item's index. */
29
+ rank?: (item: T, index: number) => number;
30
+ /** The marker line that opens a degraded (tier B/C) body. */
31
+ capMarker: string;
32
+ /** Tier C's closing line for `n` items that could not be listed at all. */
33
+ overflowLine: (n: number) => string;
34
+ /** Renders body lines into the lane's frame; its fixed cost is priced once by rendering it empty. */
35
+ frame: (lines: readonly string[]) => string;
36
+ }
37
+ /** What one fold produced: the frame, and which items the budget degraded (display order). */
38
+ export interface ListingFoldRender {
39
+ text: string;
40
+ /** Items listed WITHOUT their full line. */
41
+ nameOnly: readonly string[];
42
+ /** Items the frame could not list at all (tier C's overflow tail). */
43
+ dropped: readonly string[];
44
+ }
45
+ export declare function foldListingWithin<T>(items: ReadonlyArray<T>, spec: ListingFoldSpec<T>): ListingFoldRender | undefined;
46
+ /** The removal arm's FLOOR — the departure fact with none of the names; constant-size, which is what
47
+ * keeps the fact from being starved by a budget the names cannot fit. */
48
+ export declare function removalArmFloor(header: string, count: number): string;
49
+ /**
50
+ * A listing frame's REMOVAL arm rendered INSIDE a byte budget: every departed name when they fit,
51
+ * otherwise the longest prefix that does, closed by an honest count line — tier C over a list whose
52
+ * lines have one form, so there is no name-only tier between them. Names are neutralized on the way
53
+ * out: the announced map holds raw names from the commit half. Cost of the bound, stated: a name that
54
+ * does not fit is never announced (the commit half advances past it), so the model can still hold an
55
+ * entry that is gone — one failed call, against a frame that would otherwise never fit its budget.
56
+ */
57
+ export declare function foldRemovalArmWithin(removed: readonly string[], header: string, maxBytes: number): string;