@sema-agent/core 7.13.0 → 7.15.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 (102) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/dist/agents/subagent.d.ts +4 -9
  3. package/dist/agents/subagent.js +16 -12
  4. package/dist/agents/teacher.js +1 -1
  5. package/dist/brain/reasoning.d.ts +23 -0
  6. package/dist/brain/reasoning.js +35 -4
  7. package/dist/brain/stream-engine.js +35 -17
  8. package/dist/brain/timeout.d.ts +25 -4
  9. package/dist/brain/timeout.js +1 -1
  10. package/dist/core/ask-origin.d.ts +30 -11
  11. package/dist/core/ask-origin.js +10 -5
  12. package/dist/core/auto-mode.d.ts +3 -3
  13. package/dist/core/checkpoint-seat.d.ts +18 -0
  14. package/dist/core/checkpoint-seat.js +6 -0
  15. package/dist/core/checkpoint-store.d.ts +23 -31
  16. package/dist/core/checkpoint-store.js +7 -4
  17. package/dist/core/engine-notice.d.ts +3 -1
  18. package/dist/core/env-knob-announce.d.ts +16 -0
  19. package/dist/core/env-knob-announce.js +14 -0
  20. package/dist/core/gate-fold.js +2 -0
  21. package/dist/core/gate-lanes.js +21 -12
  22. package/dist/core/gate-outcome.d.ts +42 -3
  23. package/dist/core/gate-outcome.js +22 -0
  24. package/dist/core/governance-codes.js +1 -1
  25. package/dist/core/hooks.d.ts +9 -19
  26. package/dist/core/hooks.js +6 -4
  27. package/dist/core/mcp.d.ts +3 -2
  28. package/dist/core/mcp.js +5 -11
  29. package/dist/core/memory-engine/engine.d.ts +2 -2
  30. package/dist/core/runner/advertised-writable-dirs.d.ts +74 -0
  31. package/dist/core/runner/advertised-writable-dirs.js +48 -0
  32. package/dist/core/runner/assemble-result.d.ts +5 -0
  33. package/dist/core/runner/contracts.d.ts +2 -2
  34. package/dist/core/runner/denial-limit-arms.d.ts +7 -9
  35. package/dist/core/runner/denial-limit-arms.js +8 -10
  36. package/dist/core/runner/gate-exit.d.ts +15 -3
  37. package/dist/core/runner/gate-exit.js +6 -4
  38. package/dist/core/runner/permission-rule-lanes.d.ts +3 -1
  39. package/dist/core/runner/permission-rule-lanes.js +1 -1
  40. package/dist/core/runner/prepare-caps-and-workflow.d.ts +34 -4
  41. package/dist/core/runner/prepare-caps-and-workflow.js +47 -12
  42. package/dist/core/runner/prepare-defer-classify.d.ts +1 -1
  43. package/dist/core/runner/prepare-defer-classify.js +12 -10
  44. package/dist/core/runner/prepare-gate-stations.d.ts +4 -5
  45. package/dist/core/runner/prepare-gate-stations.js +2 -2
  46. package/dist/core/runner/prepare-hands-readface.d.ts +18 -12
  47. package/dist/core/runner/prepare-hands-readface.js +32 -43
  48. package/dist/core/runner/prepare-inherited-gate.d.ts +7 -5
  49. package/dist/core/runner/prepare-inherited-gate.js +1 -1
  50. package/dist/core/runner/prepare-memory.d.ts +31 -34
  51. package/dist/core/runner/prepare-memory.js +73 -51
  52. package/dist/core/runner/prepare-park-ask.d.ts +5 -6
  53. package/dist/core/runner/prepare-park-ask.js +3 -3
  54. package/dist/core/runner/prepare-policy-chain.d.ts +2 -2
  55. package/dist/core/runner/prepare-policy-chain.js +7 -4
  56. package/dist/core/runner/prepare-prompt-inputs.d.ts +4 -0
  57. package/dist/core/runner/prepare-prompt-inputs.js +2 -2
  58. package/dist/core/runner/prepare-question-face.d.ts +10 -8
  59. package/dist/core/runner/prepare-question-face.js +1 -3
  60. package/dist/core/runner/prepare-safety-scan.js +1 -1
  61. package/dist/core/runner/prepare-task.js +60 -53
  62. package/dist/core/runner/prepare-wiring-manifest.d.ts +2 -2
  63. package/dist/core/runner/prepare-wiring-manifest.js +1 -1
  64. package/dist/core/runner/run-compaction-machinery.js +2 -0
  65. package/dist/core/runner/run-harness-handlers.js +3 -1
  66. package/dist/core/runner/stream-settle-backstop.js +1 -1
  67. package/dist/core/runner/tool-defer-gate.d.ts +86 -0
  68. package/dist/core/runner/tool-defer-gate.js +57 -0
  69. package/dist/core/runner/tool-disclosure.d.ts +0 -36
  70. package/dist/core/runner/tool-disclosure.js +0 -43
  71. package/dist/core/runner-deps.d.ts +16 -9
  72. package/dist/core/runtime-caps.d.ts +21 -0
  73. package/dist/core/runtime-caps.js +5 -1
  74. package/dist/core/task-event.d.ts +11 -2
  75. package/dist/core/task-registry-shared.js +8 -6
  76. package/dist/core/task-result.d.ts +15 -0
  77. package/dist/core/task-spec.d.ts +8 -2
  78. package/dist/core/terminal-cause.d.ts +6 -2
  79. package/dist/core/tool-policy.d.ts +34 -28
  80. package/dist/core/tool-policy.js +29 -5
  81. package/dist/core/tool-roster.js +2 -0
  82. package/dist/core/tool-spec.d.ts +20 -16
  83. package/dist/core/types.d.ts +3 -2
  84. package/dist/core/types.js +1 -0
  85. package/dist/core/wiring-manifest.d.ts +16 -9
  86. package/dist/core/wiring-manifest.js +8 -3
  87. package/dist/index.d.ts +7 -6
  88. package/dist/index.js +5 -4
  89. package/dist/orchestration/run-workflow-tool.d.ts +8 -8
  90. package/dist/orchestration/run-workflow-tool.js +1 -1
  91. package/dist/orchestration/workflow-primitives.d.ts +4 -3
  92. package/dist/orchestration/workflow-primitives.js +3 -3
  93. package/dist/orchestration/workflow-types.d.ts +14 -0
  94. package/dist/orchestration/workflow.d.ts +41 -4
  95. package/dist/orchestration/workflow.js +23 -5
  96. package/dist/tools/fs/fs-bash.d.ts +41 -0
  97. package/dist/tools/fs/fs-bash.js +86 -29
  98. package/dist/tools/fs/fs-shared.js +9 -3
  99. package/dist/tools/fs/read-deny.d.ts +24 -8
  100. package/dist/tools/fs/read-deny.js +20 -1
  101. package/package.json +2 -1
  102. package/test/export-surface.snapshot.json +29 -1
@@ -1,4 +1,4 @@
1
- import { decisionText, describeThrown } from "./tool-policy.js";
1
+ import { attestClassifierDenyCause, classifierDenyCauseOf, decisionText, describeThrown } from "./tool-policy.js";
2
2
  import { askOriginOf, classifierMayAnswer, persistedRuleMandateOf, probeBoundaryMandate } from "./ask-origin.js";
3
3
  import { CLASSIFIER_PARSE_FAILURE_DENY_MESSAGE, classifierUnavailableDenyMessage, denialLimitFallbackMessage, unarmedWindow } from "./auto-mode.js";
4
4
  import { inlineUntrusted } from "./untrusted-text.js";
@@ -220,6 +220,7 @@ export async function runGateLanes(pass) {
220
220
  if (behavior === "deny") {
221
221
  orgTightenCount += 1;
222
222
  pass.deniedBy = "org";
223
+ pass.denyCause = undefined;
223
224
  return { decision: { action: "deny", message: `an organization policy rule (${rule}) denies this call`, decisionReason: ORG_RULE_DECISION_REASON }, org: undefined };
224
225
  }
225
226
  if (decided.action === "deny")
@@ -242,6 +243,7 @@ export async function runGateLanes(pass) {
242
243
  };
243
244
  const orgAnswer = await applyOrgLayer(pass.decision, pass.policyRewrite !== undefined ? pass.policyRewrite : pass.currentInput);
244
245
  pass.decision = orgAnswer.decision;
246
+ const ruleStoreUnreadableOf = (lane) => lane?.unreadable === true ? (lane.reason !== undefined ? "call" : "store") : undefined;
245
247
  const readPersistedRules = async (req) => {
246
248
  let pendingHit;
247
249
  try {
@@ -259,6 +261,8 @@ export async function runGateLanes(pass) {
259
261
  pass.decision = tightened.decision;
260
262
  if (tightened.tightened === "deny")
261
263
  pass.deniedBy = "persisted_rule";
264
+ if (tightened.tightened === "deny")
265
+ pass.denyCause = undefined;
262
266
  }
263
267
  if (pass.decision.action === "ask") {
264
268
  pass.decision = { ...pass.decision };
@@ -338,9 +342,10 @@ export async function runGateLanes(pass) {
338
342
  };
339
343
  }
340
344
  }
341
- const originFacts = askOriginFacts(orgAnswer.org, laneAnswer?.unreadable === true ? "unavailable" : undefined);
345
+ const originFacts = askOriginFacts(orgAnswer.org, ruleStoreUnreadableOf(laneAnswer));
342
346
  if (pass.decision.action === "ask") {
343
- const stamped = { ...pass.decision, ruleEvidence: mintRuleEvidence(personalEvidence), ...(laneCoverage !== undefined ? { segmentCoverage: laneCoverage } : {}) };
347
+ const { ruleStoreUnreadable: _selfDeclared, ...unstamped } = pass.decision;
348
+ const stamped = { ...unstamped, ruleEvidence: mintRuleEvidence(personalEvidence), ...(laneCoverage !== undefined ? { segmentCoverage: laneCoverage } : {}), ...(originFacts.ruleStore !== undefined ? { ruleStoreUnreadable: originFacts.ruleStore } : {}) };
344
349
  pass.decision = { ...stamped, origin: askOriginOf(stamped, originFacts) };
345
350
  }
346
351
  if (allowLayerMayClear() && pass.decision.action === "ask" && persistedRuleMandate === undefined && pass.decision.matchedAskRule === undefined && pass.req.toolName === COMMAND_RULE_TOOL) {
@@ -374,12 +379,11 @@ export async function runGateLanes(pass) {
374
379
  };
375
380
  }
376
381
  else if (verdict.kind === "unavailable") {
377
- pass.decision = {
382
+ pass.decision = attestClassifierDenyCause({
378
383
  action: "deny",
379
384
  message: classifierUnavailableDenyMessage(toolName, verdict.cause),
380
385
  decisionReason: "classifier",
381
- classifierUnavailable: { cause: verdict.cause },
382
- };
386
+ }, "unavailable", pass.req);
383
387
  }
384
388
  else {
385
389
  const reason = verdict.kind === "block" ? (verdict.reason ? inlineUntrusted(verdict.reason) : "") : CLASSIFIER_PARSE_FAILURE_DENY_MESSAGE;
@@ -396,15 +400,18 @@ export async function runGateLanes(pass) {
396
400
  pass.decision = { ...fallbackAsk, origin: askOriginOf(fallbackAsk, originFacts) };
397
401
  }
398
402
  else {
399
- pass.decision = {
403
+ const denied = {
400
404
  action: "deny",
401
405
  message: verdict.kind === "block" ? `auto-mode classifier blocked this call${reason ? `: ${reason}` : category ? `: [${category}]` : ""}` : reason,
402
406
  decisionReason: "classifier",
403
407
  };
408
+ pass.decision = verdict.kind === "parse_error" ? attestClassifierDenyCause(denied, "parse_error", pass.req) : denied;
404
409
  }
405
410
  }
406
- if (pass.decision.action === "deny")
411
+ if (pass.decision.action === "deny") {
407
412
  pass.deniedBy = "classifier";
413
+ pass.denyCause = classifierDenyCauseOf(pass.decision, pass.req);
414
+ }
408
415
  }
409
416
  if (input.sandboxAdmission !== undefined &&
410
417
  pass.decision.action === "ask" &&
@@ -446,7 +453,7 @@ export async function runGateLanes(pass) {
446
453
  ...(callSignal !== undefined ? { signal: callSignal } : {}),
447
454
  ...(d.action === "ask" && d.denialLimitFallback !== undefined ? { denialLimitFallback: unarmedWindow(d.denialLimitFallback) } : {}),
448
455
  ...(d.action === "ask" && d.origin !== undefined ? { origin: d.origin } : {}),
449
- ...(d.action === "ask" && d.classifierUnavailable !== undefined ? { classifierUnavailable: { cause: d.classifierUnavailable.cause } } : {}),
456
+ ...(d.action === "ask" && d.ruleStoreUnreadable !== undefined ? { ruleStoreUnreadable: d.ruleStoreUnreadable } : {}),
450
457
  });
451
458
  if (suspendAsk && pass.decision.action === "ask") {
452
459
  const parkArgs = [pass.req, pass.currentInput, safety, undefined, realApprovalOf(pass.decision), pass.decision.action === "ask" ? pass.decision.persistedRuleShadowed : undefined, pass.decision.action === "ask" ? pass.decision.decisionReason : undefined, pass.decision.action === "ask" ? pass.decision.probeReason : undefined, pass.decision.action === "ask" ? pass.decision.probeCause : undefined, pass.decision.action === "ask" ? pass.decision.segmentCoverage : undefined, pass.decision.action === "ask" ? pass.decision.matchedAskRule : undefined, pass.decision.action === "ask" ? pass.decision.probeMandated : undefined];
@@ -614,6 +621,7 @@ export async function runGateLanes(pass) {
614
621
  if (recheck.action === "deny" && refusedBy !== undefined) {
615
622
  editDenied = recheck;
616
623
  pass.deniedBy = refusedBy;
624
+ pass.denyCause = ledger.causeOf(recheck);
617
625
  break;
618
626
  }
619
627
  }
@@ -630,7 +638,7 @@ export async function runGateLanes(pass) {
630
638
  let editRuleStore;
631
639
  if (input.persistedRules !== undefined && toolName !== ASK_USER_QUESTION_TOOL_NAME) {
632
640
  const editLane = await readPersistedRules({ toolName, args: editArgs, toolCallId, ...(pass.req.face !== undefined ? { face: pass.req.face } : {}) });
633
- editRuleStore = editLane.unreadable === true ? "unavailable" : undefined;
641
+ editRuleStore = ruleStoreUnreadableOf(editLane);
634
642
  const editTightened = applyPersistedTightening(recheck, editLane);
635
643
  recheck = editTightened.decision;
636
644
  if (editTightened.tightened === "deny")
@@ -651,9 +659,10 @@ export async function runGateLanes(pass) {
651
659
  editArgs = recheck.updatedInput;
652
660
  editRewrittenSinceHuman = true;
653
661
  }
654
- const editAskSnapshot = { ...recheck, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) };
662
+ const { ruleStoreUnreadable: _selfDeclaredOnEdit, ...recheckUnstamped } = recheck;
663
+ const editAskSnapshot = { ...recheckUnstamped, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) };
655
664
  markedThisRound = undefined;
656
- const editAskDecision = { ...editAskSnapshot, origin: askOriginOf(editAskSnapshot, askOriginFacts(editOrg.org, editRuleStore)) };
665
+ const editAskDecision = { ...editAskSnapshot, ...(editRuleStore !== undefined ? { ruleStoreUnreadable: editRuleStore } : {}), origin: askOriginOf(editAskSnapshot, askOriginFacts(editOrg.org, editRuleStore)) };
657
666
  const editAskReq = { toolName, args: editArgs, toolCallId, ...(pass.req.face !== undefined ? { face: pass.req.face } : {}) };
658
667
  const rr = await (callSignal !== undefined ? resolveAsk(editAskDecision, editAskReq, callSignal) : resolveAsk(editAskDecision, editAskReq));
659
668
  ledger.fromResolved(rr, editAskDecision, toolName);
@@ -153,12 +153,49 @@ export declare const DENIED_BY_MAY_VETO: {
153
153
  };
154
154
  /** Compile-time fence: `never` while every deny layer has a veto row. */
155
155
  export type VetoTableCoversEveryDeniedBy = AssertAllKeysHandled<Exclude<DeniedBy, keyof typeof DENIED_BY_MAY_VETO>>;
156
- /** The final disposition of a gate pass. */
156
+ /**
157
+ * The FORM of an auto-mode classifier deny — the one structural position a consumer branches on when a
158
+ * deny came through the classifier station. The classifier ends a round three ways, and until this
159
+ * member the wire carried the three as one layer word plus prose:
160
+ * - `unavailable` — the classifier could not run (the model leg threw / rejected / ran past the cap):
161
+ * a fail-closed refusal that SAYS SO — the call may simply be retried later (CC 2.1.250 `x1t`);
162
+ * - `parse_error` — the classifier answered outside its verdict contract: blocked for safety, counted by
163
+ * the denial limit like a ruling (CC 2.1.250 `Ure`);
164
+ * - ABSENT beside `deniedBy: "classifier"` — the classifier's own RULING (a `<block>yes` verdict).
165
+ * The WHY behind an `unavailable` (error vs timeout) is not a branching fact: the deny sentence spells it
166
+ * and the `auto_mode.classified` trace frame carries it (`cause: "error" | "timeout"`).
167
+ */
168
+ export declare const CLASSIFIER_DENY_CAUSES: readonly ["unavailable", "parse_error"];
169
+ export type ClassifierDenyCause = (typeof CLASSIFIER_DENY_CAUSES)[number];
170
+ /** Membership test for {@link ClassifierDenyCause}. */
171
+ export declare function isClassifierDenyCause(v: unknown): v is ClassifierDenyCause;
172
+ /** Which deny layers a classifier cause may stand beside: the gate's own classifier station (`classifier`),
173
+ * and an ANCESTOR's frozen classifier reached through the inherited policy chain, whose deny the child's
174
+ * gate attributes to the chain (`policy`, station row 14) — the cause is attested by the station that
175
+ * minted the deny, so the word beside it is whichever layer word the child's ledger assigns. */
176
+ export declare const DENIED_BY_MAY_CARRY_CAUSE: {
177
+ readonly policy: true;
178
+ readonly hook: false;
179
+ readonly org: false;
180
+ readonly persisted_rule: false;
181
+ readonly classifier: true;
182
+ readonly plan_mode: false;
183
+ readonly compliance: false;
184
+ readonly write_protection: false;
185
+ readonly ask_resolution: false;
186
+ };
187
+ /** Compile-time fence: `never` while every deny layer has a cause row. */
188
+ export type CauseTableCoversEveryDeniedBy = AssertAllKeysHandled<Exclude<DeniedBy, keyof typeof DENIED_BY_MAY_CARRY_CAUSE>>;
189
+ /** The final disposition of a gate pass. `cause` is present exactly when the deny came out of an auto-mode
190
+ * classifier round that did not RULE ({@link ClassifierDenyCause}); a ruling block and every non-classifier
191
+ * deny carry none. Invariant I5 ({@link screenGateOutcome}): a cause stands only beside a layer word in
192
+ * {@link DENIED_BY_MAY_CARRY_CAUSE}. */
157
193
  export type GateDisposition = {
158
194
  kind: "allowed";
159
195
  } | {
160
196
  kind: "denied";
161
197
  deniedBy: DeniedBy;
198
+ cause?: ClassifierDenyCause;
162
199
  };
163
200
  /**
164
201
  * The WHOLE record of one tool-gate pass: the final disposition, plus — when this pass settled an ask —
@@ -173,7 +210,9 @@ export type GateDisposition = {
173
210
  * I3 `settlement.kind === "human_allowed"` ∧ `disposition.kind === "denied"` ⇒ `deniedBy` is a veto
174
211
  * layer ({@link DENIED_BY_MAY_VETO}) — a person approved, a later re-check refused; the approver stays
175
212
  * on the settlement (it is not the approver's refusal);
176
- * I4 `disposition.kind === "allowed"` ⇒ `settlement` absent or `human_allowed`.
213
+ * I4 `disposition.kind === "allowed"` ⇒ `settlement` absent or `human_allowed`;
214
+ * I5 `disposition.cause` present ⇒ a member of {@link CLASSIFIER_DENY_CAUSES} beside a layer word
215
+ * {@link DENIED_BY_MAY_CARRY_CAUSE} admits (the classifier station, or the inherited chain's `policy`).
177
216
  * Nothing here is read out of an ABSENCE: an ordinary allow with no ask is `{ disposition: { kind: "allowed" } }`,
178
217
  * a direct policy deny is `{ disposition: { kind: "denied", deniedBy: "policy" } }`, and neither carries a
179
218
  * settlement because neither settled one.
@@ -184,7 +223,7 @@ export interface GateOutcome {
184
223
  origin?: AskOrigin;
185
224
  }
186
225
  /**
187
- * The one invariant screen for a {@link GateOutcome} (I1–I4 above, plus the shape of each member). Runs
226
+ * The one invariant screen for a {@link GateOutcome} (I1–I5 above, plus the shape of each member). Runs
188
227
  * where an outcome enters a wire face — the engine's settlement sideband before the `tool_end` frame, and
189
228
  * the decide lane before a durable row is resolved. Returns the defects as sentences (an empty list is a
190
229
  * clean record); the caller decides the fail-loud form (report + withhold, or refuse), never silently
@@ -47,6 +47,22 @@ export const DENIED_BY_MAY_VETO = {
47
47
  write_protection: true,
48
48
  ask_resolution: false,
49
49
  };
50
+ export const CLASSIFIER_DENY_CAUSES = ["unavailable", "parse_error"];
51
+ const CLASSIFIER_DENY_CAUSE_SET = new Set(CLASSIFIER_DENY_CAUSES);
52
+ export function isClassifierDenyCause(v) {
53
+ return CLASSIFIER_DENY_CAUSE_SET.has(v);
54
+ }
55
+ export const DENIED_BY_MAY_CARRY_CAUSE = {
56
+ policy: true,
57
+ hook: false,
58
+ org: false,
59
+ persisted_rule: false,
60
+ classifier: true,
61
+ plan_mode: false,
62
+ compliance: false,
63
+ write_protection: false,
64
+ ask_resolution: false,
65
+ };
50
66
  export function screenGateOutcome(g) {
51
67
  const defects = [];
52
68
  const d = g.disposition;
@@ -67,5 +83,11 @@ export function screenGateOutcome(g) {
67
83
  }
68
84
  if (d.kind === "allowed" && s !== undefined && s.kind !== "human_allowed")
69
85
  defects.push(`I4: an allowed disposition cannot carry a "${s.kind}" settlement`);
86
+ if (d.kind === "denied" && d.cause !== undefined) {
87
+ if (!isClassifierDenyCause(d.cause))
88
+ defects.push(`disposition.cause "${String(d.cause)}" is outside the closed set`);
89
+ else if (isDeniedBy(d.deniedBy) && !DENIED_BY_MAY_CARRY_CAUSE[d.deniedBy])
90
+ defects.push(`I5: a classifier cause cannot stand beside deniedBy "${d.deniedBy}"`);
91
+ }
70
92
  return defects;
71
93
  }
@@ -164,7 +164,7 @@ const NOTICE_AUDIENCE_TABLE = {
164
164
  "config.durable_gate_unavailable": "user",
165
165
  "memory.capture_opted_out": "user",
166
166
  "memory.capture_optout_unpersisted": "user",
167
- "memory.consolidation_withheld": "user",
167
+ "memory.consolidation_withheld": "operator",
168
168
  "delegation.ask_unresolvable": "user",
169
169
  "config.autocompact_window_clamped": "operator",
170
170
  "config.env_timeout_discarded": "operator",
@@ -5,7 +5,7 @@ import type { GateOutcome } from "./gate-outcome.js";
5
5
  export { normalizeOrgGateVerdict, normalizePersistedRuleHit } from "./gate-lanes.js";
6
6
  export { cloneObserverInput } from "./runner/gate-exit.js";
7
7
  import { type AskClass } from "./ask-class.js";
8
- import { type AutoModeDenialTracker } from "./auto-mode.js";
8
+ import type { AutoModeDenialTracker } from "./auto-mode.js";
9
9
  export { formatHookFeedback } from "./reminder-mint.js";
10
10
  import type { WiringLegKind } from "./wiring-manifest.js";
11
11
  /**
@@ -411,12 +411,6 @@ export interface PermissionDeniedPayload {
411
411
  * settled none.
412
412
  */
413
413
  gate: GateOutcome;
414
- /** #661 (additive): present ⇔ this deny is the auto-mode classifier's UNAVAILABILITY (`deniedBy: "classifier"`
415
- * with the verdict's cause word) — the structured half of the deny text's "the classifier is temporarily
416
- * unavailable" sentence. A classifier BLOCK or parse-failure deny carries no member here. */
417
- classifierUnavailable?: {
418
- readonly cause: import("./auto-mode.js").AutoModeUnavailableCause;
419
- };
420
414
  /** {@link HookSeatSignal} — this invocation's own abort signal. On an OBSERVATION seat the deny has
421
415
  * already happened and nothing this callback does can change it, so the signal says exactly one
422
416
  * thing: stop reading, nobody is waiting for your answer any more. */
@@ -1332,9 +1326,10 @@ export interface ToolGateInput {
1332
1326
  */
1333
1327
  onHookError?: (err: unknown) => void;
1334
1328
  /**
1335
- * design/134 R5: true when this tool's `irreversibility` tier was installed by the COARSE
1336
- * `TaskSpec.shellGate` doctrine (design/80 D-E `shellGatedBash`/`shellGatedMonitor`), not an explicit
1337
- * per-tool mark attributes a tighten-deny to `source:"shellGate"` instead of `"safety"`.
1329
+ * design/134 R5: true when this tool's `irreversibility` tier was installed by the ENGINE'S SHELL SEAT
1330
+ * (the read-boundary judge under every doctrine, plus the `TaskSpec.shellGate` doctrine's residual-risk
1331
+ * classifierdesign/80 D-E; the seat's `shellGatedTools` membership), not an explicit per-tool mark —
1332
+ * attributes a tighten-deny to `source:"shellGate"` instead of `"safety"`.
1338
1333
  */
1339
1334
  shellGated?: boolean;
1340
1335
  /**
@@ -1676,12 +1671,9 @@ export interface AskCarry {
1676
1671
  /** The surviving ask's engine-stamped origin word ({@link import("./ask-origin.js").AskOrigin}), persisted
1677
1672
  * on the row so a durable card is rendered by the same word the synchronous card is. */
1678
1673
  readonly origin?: import("./ask-origin.js").AskOrigin;
1679
- /** #616: the surviving ask's classifier-unavailable fact (the classifier was consulted on this ask and could
1680
- * not run `cause` is the verdict's word), persisted on the row so a durable card says why it is asking
1681
- * exactly as the synchronous card does. Absent ⇒ the classifier answered, was not eligible, or was not wired. */
1682
- readonly classifierUnavailable?: {
1683
- readonly cause: import("./auto-mode.js").AutoModeUnavailableCause;
1684
- };
1674
+ /** #688 C3: the mechanism behind an `origin: "rule_store_unavailable"` (`store` / `call`), persisted on the row
1675
+ * so a durable card says the same thing the synchronous card does. */
1676
+ readonly ruleStoreUnreadable?: import("./ask-origin.js").RuleStoreUnreadable;
1685
1677
  }
1686
1678
  /** What a durable row persists of an {@link AskCarry}: the fallback member COPIED (the row never aliases
1687
1679
  * the gate's object; its window is already `0` — the gate's carry mint is the one writer of that
@@ -1690,9 +1682,7 @@ export interface AskCarry {
1690
1682
  export declare function askCarryRowMembers(carry: AskCarry | undefined): {
1691
1683
  denialLimitFallback?: import("./auto-mode.js").DenialLimitFallback;
1692
1684
  origin?: import("./ask-origin.js").AskOrigin;
1693
- classifierUnavailable?: {
1694
- readonly cause: import("./auto-mode.js").AutoModeUnavailableCause;
1695
- };
1685
+ ruleStoreUnreadable?: import("./ask-origin.js").RuleStoreUnreadable;
1696
1686
  };
1697
1687
  /**
1698
1688
  * The design/37 **two-phase tool gate** — the single chokepoint that makes the load-bearing invariant
@@ -5,8 +5,7 @@ import { runGateLanes } from "./gate-lanes.js";
5
5
  export { normalizeOrgGateVerdict, normalizePersistedRuleHit } from "./gate-lanes.js";
6
6
  export { cloneObserverInput } from "./runner/gate-exit.js";
7
7
  import { brandPolicyAskClass } from "./ask-class.js";
8
- import { isAskOrigin } from "./ask-origin.js";
9
- import { isAutoModeUnavailableCause } from "./auto-mode.js";
8
+ import { isAskOrigin, isRuleStoreUnreadable } from "./ask-origin.js";
10
9
  import { inlineUntrusted } from "./untrusted-text.js";
11
10
  export { formatHookFeedback } from "./reminder-mint.js";
12
11
  import { createSafeNotifier } from "./safe-notify.js";
@@ -235,10 +234,12 @@ export function createPreToolUseConstraintPolicy(preToolUse, env, onCrash, timeo
235
234
  export function askCarryRowMembers(carry) {
236
235
  if (carry === undefined)
237
236
  return {};
237
+ const origin = carry.origin;
238
+ const ruleStoreUnreadable = carry.ruleStoreUnreadable;
238
239
  return {
239
240
  ...(carry.denialLimitFallback !== undefined ? { denialLimitFallback: { ...carry.denialLimitFallback } } : {}),
240
- ...(isAskOrigin(carry.origin) ? { origin: carry.origin } : {}),
241
- ...(isAutoModeUnavailableCause(carry.classifierUnavailable?.cause) ? { classifierUnavailable: { cause: carry.classifierUnavailable.cause } } : {}),
241
+ ...(isAskOrigin(origin) ? { origin } : {}),
242
+ ...(isRuleStoreUnreadable(ruleStoreUnreadable) ? { ruleStoreUnreadable } : {}),
242
243
  };
243
244
  }
244
245
  export async function runToolGate(input) {
@@ -308,6 +309,7 @@ export async function runToolGate(input) {
308
309
  hookAsk: undefined,
309
310
  parkFailed: undefined,
310
311
  deniedBy: undefined,
312
+ denyCause: undefined,
311
313
  decision: { action: "allow" },
312
314
  tightenedBy: undefined,
313
315
  policyRewrite: undefined,
@@ -330,8 +330,9 @@ export declare const MCP_TOOL_TIMEOUT_DEFAULT_MS = 100000000;
330
330
  */
331
331
  export declare const MCP_ENV_MS_MIN = 1000;
332
332
  export declare const MCP_ENV_MS_MAX = 2147483647;
333
- /** Test seam: the announcement de-dupes per process, so a test asserting the line must be able to clear
334
- * the ledger. Never called by production code. */
333
+ /** Test seam kept under the name its callers already use; the ledger it clears is the shared one
334
+ * ({@link __resetEnvKnobAnnouncements} — the MCP knobs are not a family of their own, they are
335
+ * process-level env knobs like the output-length pair). Never called by production code. */
335
336
  export declare function __resetMcpEnvAnnouncements(): void;
336
337
  /** Resolve the per-call tool timeout in ms (env `MCP_TOOL_TIMEOUT`, else the large default). Exported for tests. */
337
338
  export declare function mcpToolTimeoutMs(): number;
package/dist/core/mcp.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { redactSecrets } from "./untrusted-egress.js";
2
+ import { announceEnvKnob, __resetEnvKnobAnnouncements } from "./env-knob-announce.js";
2
3
  import { MCP_NAMESPACE } from "./protocol-table.js";
3
4
  import { findNamespacePrefixCollision, mintNamespacePrefix, mintNamespacedToolName, normalizeNameSegment } from "./protocol-naming.js";
4
5
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
@@ -38,7 +39,7 @@ function mcpMaxOutputTokens() {
38
39
  return MCP_OUTPUT_TOKENS_DEFAULT;
39
40
  const n = parseWholeNumber(raw);
40
41
  if (n === undefined || n <= 0) {
41
- announceMcpEnvKnob(`MAX_MCP_OUTPUT_TOKENS=${rawEnv} was ignored — it is not a positive whole number of tokens. Using ${MCP_OUTPUT_TOKENS_DEFAULT} instead.`);
42
+ announceEnvKnob(`MAX_MCP_OUTPUT_TOKENS=${rawEnv} was ignored — it is not a positive whole number of tokens. Using ${MCP_OUTPUT_TOKENS_DEFAULT} instead.`);
42
43
  return MCP_OUTPUT_TOKENS_DEFAULT;
43
44
  }
44
45
  return n;
@@ -132,15 +133,8 @@ function parseWholeNumber(raw) {
132
133
  return parseInt(raw.replace(ENV_GROUP_SEPARATORS_RE, ""), 10);
133
134
  return undefined;
134
135
  }
135
- const announcedMcpEnvKnobs = new Set();
136
- function announceMcpEnvKnob(line) {
137
- if (announcedMcpEnvKnobs.has(line))
138
- return;
139
- announcedMcpEnvKnobs.add(line);
140
- console.warn(line);
141
- }
142
136
  export function __resetMcpEnvAnnouncements() {
143
- announcedMcpEnvKnobs.clear();
137
+ __resetEnvKnobAnnouncements();
144
138
  }
145
139
  function parseEnvMs(name) {
146
140
  const rawEnv = process.env[name];
@@ -151,12 +145,12 @@ function parseEnvMs(name) {
151
145
  return undefined;
152
146
  const n = parseWholeNumber(raw);
153
147
  if (n === undefined || n <= 0) {
154
- announceMcpEnvKnob(`${name}=${rawEnv} was ignored — it is not a positive whole number of milliseconds. Using the built-in default instead.`);
148
+ announceEnvKnob(`${name}=${rawEnv} was ignored — it is not a positive whole number of milliseconds. Using the built-in default instead.`);
155
149
  return undefined;
156
150
  }
157
151
  const clamped = Math.min(Math.max(n, MCP_ENV_MS_MIN), MCP_ENV_MS_MAX);
158
152
  if (clamped !== n) {
159
- announceMcpEnvKnob(`${name}=${rawEnv} is outside the range this runtime can honor (${MCP_ENV_MS_MIN}..${MCP_ENV_MS_MAX} ms). Using ${clamped}ms instead.`);
153
+ announceEnvKnob(`${name}=${rawEnv} is outside the range this runtime can honor (${MCP_ENV_MS_MIN}..${MCP_ENV_MS_MAX} ms). Using ${clamped}ms instead.`);
160
154
  }
161
155
  return clamped;
162
156
  }
@@ -1663,8 +1663,8 @@ export declare class MemoryEngine {
1663
1663
  * @returns the rejection to report, or `undefined` when the index is clean/absent.
1664
1664
  */
1665
1665
  private gateDerivedIndex;
1666
- /** Names of read-only sibling scope dirs directly under the writable root (excluded from scans). */
1667
- private readonlyDirNamesUnderRoot;
1666
+ /** Names of read-only sibling scope dirs directly under the writable root (excluded from scans; public since #691 — the runner refuses to admit a writable root that contains another scope's directory to the file tools' allowlist). */
1667
+ readonlyDirNamesUnderRoot(handle: MemorySessionHandle): Set<string>;
1668
1668
  /** Projection write: reads the target to skip a no-op, then writes — the same check-then-use shape
1669
1669
  * as the containment restores, so it takes the same no-follow write. A link at a projection path
1670
1670
  * is never a supported layout (the harvest scan refuses symlinked entry files outright), and this
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The advertised-writable-directory seat — ONE rule for every directory the prompt tells the model
3
+ * it may write with the file tools:
4
+ *
5
+ * a directory the prompt advertises as writable is a directory the root fence ADMITS; the same
6
+ * admission decides the hole and the teaching, so a directory the fence cannot admit is neither
7
+ * opened nor taught, and the skip is announced.
8
+ *
9
+ * Two advertisers share it: the deployment's scratchpad (`TaskSpec.envFacts.scratchpadDir`, spoken
10
+ * by the `# Environment` block) and the memory engine's write root (spoken by the `# Memory`
11
+ * instruction, "write to it directly with the Write tool"). Before this seat each advertiser judged
12
+ * on its own: the scratchpad opened its hole in the hands phase, the memory instruction checked only
13
+ * that a `Write` tool was mounted — and the fence refused the very path the instruction named
14
+ * (`path_not_in_root`), a contradictory gate that taught the model to route around the tools.
15
+ *
16
+ * Admission = fail-closed canonicalization through the SAME ExecutionEnv the fence judges with (a
17
+ * raw or dangling entry is never admitted — the ancestor-canonicalize fallback would let a symlinked
18
+ * prefix carry writes outside every root). An advertiser that OWNS its directory's creation may ask
19
+ * for materialization on a DEFINITIVE `not_found` (the exclusive-create marker, so a concurrently
20
+ * planted path is never written through); an advertiser whose directory is created elsewhere passes
21
+ * no marker and an absent directory narrows.
22
+ *
23
+ * Reverse check (the seat's own invariant): nothing enters `roots` except through `admit`, and every
24
+ * caller of `admit` is an advertiser deciding its advertisement off the returned verdict — so there is
25
+ * no directory the fence opens that the prompt does not advertise, and none the prompt advertises
26
+ * that the fence refuses.
27
+ */
28
+ import type { ExecutionEnv } from "../../internal/harness.js";
29
+ export interface AdvertisedWritableDir {
30
+ /** The directory as the advertisement spells it. */
31
+ path: string;
32
+ /** Who advertises it — names the skip line and the trace. */
33
+ advertiser: "scratchpad" | "memory";
34
+ /**
35
+ * How the directory comes to exist when the env reports it absent. The advertiser that owns the
36
+ * directory's creation supplies the marker file name for the atomic exclusive-create
37
+ * materialization (parent dirs created like writeFile; a marker that already exists — planted
38
+ * concurrently, symlink or not — is never written through; an env without an exclusive primitive
39
+ * narrows instead of emulating one with an overwriting write). An advertiser whose directory is
40
+ * created elsewhere (the memory engine materializes its own root) passes nothing — absent narrows.
41
+ */
42
+ materializeMarker?: string;
43
+ /**
44
+ * The canonical spelling the advertiser's OWN gates key on, when it has one (the memory engine keys its
45
+ * write gate and its harvest boundary on `MemorySessionHandle.writableRoot`). A fence that resolves the
46
+ * directory to a different canonical path would admit writes those gates never see — such a mismatch is
47
+ * a skip, announced with both spellings, never an admission.
48
+ */
49
+ expectCanonical?: string;
50
+ }
51
+ /** The skip announcement — the one loud outlet for a directory that was advertised but could not be admitted. */
52
+ export type AdvertisedWritableDirSkipped = (dir: AdvertisedWritableDir, reason: string) => void;
53
+ export declare class AdvertisedWritableDirs {
54
+ private readonly env;
55
+ private readonly onSkipped;
56
+ private readonly admitted;
57
+ private readonly verdicts;
58
+ constructor(env: ExecutionEnv, onSkipped: AdvertisedWritableDirSkipped);
59
+ /** The admitted roots, CANONICAL, in admission order, deduplicated. The hands phase folds this list into the
60
+ * write-capable containment allowlist before the toolkit compiles; it is not a live view after that. */
61
+ get roots(): readonly string[];
62
+ /** What became of an advertiser's directory: admitted, skipped (announced), or never asked (the advertiser
63
+ * had nothing to advertise, or decided not to). The prompt side reads this to withhold an advertisement. */
64
+ verdictOf(advertiser: AdvertisedWritableDir["advertiser"]): "admitted" | "skipped" | "not-asked";
65
+ /**
66
+ * Admit one advertised directory. TRUE ⇔ the fence will admit writes under it (its canonical form is on
67
+ * {@link roots}); FALSE ⇔ it was skipped (announced through the seat's outlet) and the advertiser must not
68
+ * advertise it. The env's answers are Result-shaped, so the judgment itself never throws; the skip outlet is
69
+ * the caller's own sink (`deps.onError` in the driver) and is deliberately not guarded here — every phase
70
+ * lets that sink's own exceptions propagate, and a seat that swallowed them would hide the deployment's bug.
71
+ */
72
+ admit(dir: AdvertisedWritableDir): Promise<boolean>;
73
+ private judge;
74
+ }
@@ -0,0 +1,48 @@
1
+ export class AdvertisedWritableDirs {
2
+ env;
3
+ onSkipped;
4
+ admitted = [];
5
+ verdicts = new Map();
6
+ constructor(env, onSkipped) {
7
+ this.env = env;
8
+ this.onSkipped = onSkipped;
9
+ }
10
+ get roots() {
11
+ return this.admitted;
12
+ }
13
+ verdictOf(advertiser) {
14
+ return this.verdicts.get(advertiser) ?? "not-asked";
15
+ }
16
+ async admit(dir) {
17
+ const ok = await this.judge(dir);
18
+ this.verdicts.set(dir.advertiser, ok ? "admitted" : "skipped");
19
+ return ok;
20
+ }
21
+ async judge(dir) {
22
+ if (!dir.path || !dir.path.trim()) {
23
+ this.onSkipped(dir, "empty path");
24
+ return false;
25
+ }
26
+ let c = await this.env.canonicalPath(dir.path);
27
+ if (!c.ok && c.error.code === "not_found" && dir.materializeMarker !== undefined) {
28
+ const marker = await this.env.joinPath([dir.path, dir.materializeMarker]);
29
+ const exclusiveCreate = this.env.writeFileExclusive?.bind(this.env);
30
+ if (marker.ok && exclusiveCreate) {
31
+ const w = await exclusiveCreate(marker.value, "");
32
+ if (w.ok || w.error.code === "already_exists")
33
+ c = await this.env.canonicalPath(dir.path);
34
+ }
35
+ }
36
+ if (!c.ok) {
37
+ this.onSkipped(dir, `${c.error.code}: ${c.error.message}`);
38
+ return false;
39
+ }
40
+ if (dir.expectCanonical !== undefined && c.value !== dir.expectCanonical) {
41
+ this.onSkipped(dir, `the execution environment resolves it to ${c.value} while the advertiser's gates key on ${dir.expectCanonical} — a fence hole those gates cannot see is not opened`);
42
+ return false;
43
+ }
44
+ if (!this.admitted.includes(c.value))
45
+ this.admitted.push(c.value);
46
+ return true;
47
+ }
48
+ }
@@ -18,6 +18,11 @@ export interface Stats {
18
18
  * it fails the type check instead of silently reporting a total short by its own leg. */
19
19
  totalInputTokens: number;
20
20
  costMicroUsd: number;
21
+ /** #692 C-b — present (`true`) when at least one model round of this leg reported NO usage frame (the brain's
22
+ * own `usageMissing` mark on the assistant message: an aborted / errored / usage-less provider reply). The
23
+ * numeric fields stay required and stay what was COUNTED — with this key present they are a LOWER BOUND,
24
+ * not a measurement ("0 is unknown, not free"). Absent ⇔ every round reported usage. */
25
+ usageMissing?: true;
21
26
  nested?: NestedUsage;
22
27
  /** Post-task memory-consolidation usage (design/41); set after the result is assembled. Kept out of
23
28
  * the budget gate (`overBudget` reads `costMicroUsd`/`tokens`, never this). */
@@ -43,7 +43,7 @@ import type { SystemInjectionPriority, TaskNotificationPayload } from "../task-n
43
43
  import type { OnAsk, PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "../tool-policy.js";
44
44
  import type { RemoteExecutionEnv, SnapshotId, WorkspaceHandle } from "../remote-env.js";
45
45
  import type { ToolDisclosureManifest } from "../trace.js";
46
- import type { AgentDefinition, Brain, CompactOutcome, ModelRoles, NestedUsageAccum, RunnerDeps, TaskEvent, TaskResult, TaskSpec, TaskStream, ToolActivity, ToolEffect } from "../types.js";
46
+ import type { AgentDefinition, Brain, CompactOutcome, ModelRoles, NestedUsageAccum, RunnerDeps, ShellGateDoctrine, TaskEvent, TaskResult, TaskSpec, TaskStream, ToolActivity, ToolEffect } from "../types.js";
47
47
  import type { WiringManifest } from "../wiring-manifest.js";
48
48
  import type { ActiveSkillFrame } from "./active-skill-scope.js";
49
49
  import type { BudgetAxis } from "./assemble-result.js";
@@ -1189,7 +1189,7 @@ export interface InheritedGate {
1189
1189
  rules: SessionPermissionRules;
1190
1190
  }>;
1191
1191
  /** The chain's effective shell-gate doctrine — the child folds it by max-rank with its own spec. */
1192
- shellGate?: "off" | "always" | "classify";
1192
+ shellGate?: ShellGateDoctrine;
1193
1193
  /**
1194
1194
  * The chain's AUTO-MODE INTENT (`TaskSpec.autoModeRequested`, session-wide like a permission mode):
1195
1195
  * emitted when the spawning task carried the intent itself or inherited it, so an engine-spawned
@@ -1,8 +1,8 @@
1
- import { type AutoModeDecider, type AutoModeDenialLimitOptions, type AutoModeDenialTracker, type AutoModeUnavailableCause, type DenialLimitFallback, type DenialLimitFallbackFace, type UnarmedDenialLimitFallback } from "../auto-mode.js";
1
+ import { type AutoModeDecider, type AutoModeDenialLimitOptions, type AutoModeDenialTracker, type DenialLimitFallback, type DenialLimitFallbackFace, type UnarmedDenialLimitFallback } from "../auto-mode.js";
2
2
  import { type AutoModeArmingRecipe } from "../auto-mode-arming.js";
3
- import type { PermissionResult, ResolvedAsk, ToolCallRequest } from "../tool-policy.js";
3
+ import { type PermissionResult, type ResolvedAsk, type ToolCallRequest } from "../tool-policy.js";
4
4
  import { type EngineNotice } from "../types.js";
5
- import { type AskOrigin } from "../ask-origin.js";
5
+ import { type AskOrigin, type RuleStoreUnreadable } from "../ask-origin.js";
6
6
  import type { Prepared } from "./contracts.js";
7
7
  /**
8
8
  * #548 — the classifier DENIAL-LIMIT arms of the tool gate's inherited (delegation) lane, extracted
@@ -83,14 +83,12 @@ export type InheritedAsk = Extract<PermissionResult, {
83
83
  }>;
84
84
  /** The members of a surviving ask that ride onto the approval request with a COMPUTED value — what the
85
85
  * four request mint stations (the gate's own and the three inherited-lane ones) spread after the seats
86
- * they spell themselves: the ask's origin word, its denial-limit fallback and a policy-declared
87
- * classifier-unavailable fact (#616; the engine's own fact rides the DENY since #661). */
86
+ * they spell themselves: the ask's origin word and its denial-limit fallback. */
88
87
  export interface AskRequestCarry {
89
88
  origin?: AskOrigin;
89
+ /** #688 C3: rides only from the gate's OWN station (the inherited stations' facts never read a rule store). */
90
+ ruleStoreUnreadable?: RuleStoreUnreadable;
90
91
  denialLimitFallback?: DenialLimitFallback;
91
- classifierUnavailable?: {
92
- readonly cause: AutoModeUnavailableCause;
93
- };
94
92
  }
95
93
  /** The GATE's own mint station: both members are read off the surviving decision, which is the gate's
96
94
  * OWN snapshot (its stamp point spreads the policy's object and stamps the word onto the copy), so the
@@ -124,7 +122,7 @@ export type InheritedClassifierJudgment<A extends InheritedAsk> =
124
122
  * stateful accessor cannot answer the exclusion with one word and the card with another); on the
125
123
  * bound branch it is derived from the re-spoken ask this station minted (its own object). */
126
124
  /** `ask` is the incoming ask unchanged (the caller's own object); an UNAVAILABLE round never reaches this arm —
127
- * it is a `deny` carrying `classifierUnavailable: { cause }` (#661). */
125
+ * it is a `deny` whose attested cause reads `unavailable` (#661). */
128
126
  | {
129
127
  kind: "resolve";
130
128
  ask: A;