@sema-agent/core 7.9.2 → 7.11.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 (86) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/dist/agents/child-model-seat.d.ts +71 -14
  3. package/dist/agents/child-model-seat.js +23 -7
  4. package/dist/agents/subagent.d.ts +2 -2
  5. package/dist/agents/subagent.js +8 -5
  6. package/dist/agents/teacher.js +2 -2
  7. package/dist/core/ask-unresolvable-notice.d.ts +52 -0
  8. package/dist/core/ask-unresolvable-notice.js +25 -0
  9. package/dist/core/auto-mode-defaults.d.ts +15 -3
  10. package/dist/core/auto-mode-defaults.js +1 -0
  11. package/dist/core/auto-mode.d.ts +86 -23
  12. package/dist/core/auto-mode.js +43 -12
  13. package/dist/core/checkpoint-store.d.ts +14 -0
  14. package/dist/core/checkpoint-store.js +2 -1
  15. package/dist/core/engine-notice.d.ts +28 -7
  16. package/dist/core/gate-fold.js +1 -0
  17. package/dist/core/gate-lanes.d.ts +6 -1
  18. package/dist/core/gate-lanes.js +60 -18
  19. package/dist/core/governance-codes.d.ts +1 -1
  20. package/dist/core/governance-codes.js +4 -0
  21. package/dist/core/hooks.d.ts +37 -1
  22. package/dist/core/hooks.js +2 -0
  23. package/dist/core/permission-rule-model.d.ts +53 -16
  24. package/dist/core/permission-rule-model.js +59 -21
  25. package/dist/core/permission-rules.d.ts +6 -4
  26. package/dist/core/permission-rules.js +14 -14
  27. package/dist/core/persisted-rule-arms.js +4 -3
  28. package/dist/core/read-only-shell-table.d.ts +87 -0
  29. package/dist/core/read-only-shell-table.js +485 -0
  30. package/dist/core/read-only-shell.d.ts +42 -0
  31. package/dist/core/read-only-shell.js +316 -0
  32. package/dist/core/roles.d.ts +3 -2
  33. package/dist/core/runner/contracts.d.ts +26 -2
  34. package/dist/core/runner/denial-limit-arms.d.ts +14 -3
  35. package/dist/core/runner/denial-limit-arms.js +15 -5
  36. package/dist/core/runner/gate-exit.d.ts +5 -0
  37. package/dist/core/runner/permission-rule-lanes.d.ts +7 -1
  38. package/dist/core/runner/permission-rule-lanes.js +9 -3
  39. package/dist/core/runner/prepare-caps-and-workflow.d.ts +1 -1
  40. package/dist/core/runner/prepare-caps-and-workflow.js +35 -14
  41. package/dist/core/runner/prepare-gate-stations.d.ts +3 -2
  42. package/dist/core/runner/prepare-gate-stations.js +12 -0
  43. package/dist/core/runner/prepare-policy-chain.js +7 -6
  44. package/dist/core/runner/prepare-task.js +1 -1
  45. package/dist/core/runner/prepare-turn-wiring.js +1 -1
  46. package/dist/core/runner/prepare-wiring-manifest.d.ts +1 -1
  47. package/dist/core/runner/prepare-wiring-manifest.js +8 -1
  48. package/dist/core/runner/runtask.d.ts +33 -32
  49. package/dist/core/runner/runtask.js +63 -33
  50. package/dist/core/runner-deps.d.ts +9 -2
  51. package/dist/core/shell-lexer.d.ts +18 -0
  52. package/dist/core/shell-lexer.js +17 -10
  53. package/dist/core/shell-wrapper-table.js +8 -5
  54. package/dist/core/swappable-deps.d.ts +90 -0
  55. package/dist/core/swappable-deps.js +55 -0
  56. package/dist/core/tool-policy.d.ts +30 -1
  57. package/dist/core/tool-policy.js +6 -2
  58. package/dist/core/tools.d.ts +28 -7
  59. package/dist/core/tools.js +44 -4
  60. package/dist/core/trace.d.ts +15 -0
  61. package/dist/core/wiring-manifest.d.ts +15 -1
  62. package/dist/core/wiring-manifest.js +10 -2
  63. package/dist/core/workflow-journal-store.d.ts +21 -2
  64. package/dist/core/workflow-journal-store.js +1 -1
  65. package/dist/engine/execution-env/node-execution-env.d.ts +2 -0
  66. package/dist/engine/execution-env/node-execution-env.js +2 -1
  67. package/dist/engine/harness/agent-harness.d.ts +3 -1
  68. package/dist/engine/harness/agent-harness.js +1 -1
  69. package/dist/engine/harness/types.d.ts +15 -2
  70. package/dist/index.d.ts +8 -4
  71. package/dist/index.js +7 -3
  72. package/dist/orchestration/run-workflow-tool.d.ts +22 -2
  73. package/dist/orchestration/run-workflow-tool.js +14 -1
  74. package/dist/orchestration/workflow-governance.d.ts +3 -2
  75. package/dist/orchestration/workflow-observe.d.ts +1 -1
  76. package/dist/orchestration/workflow-observe.js +2 -0
  77. package/dist/orchestration/workflow-primitives.d.ts +4 -1
  78. package/dist/orchestration/workflow-primitives.js +1 -6
  79. package/dist/orchestration/workflow-types.d.ts +37 -2
  80. package/dist/orchestration/workflow-types.js +16 -0
  81. package/dist/orchestration/workflow.d.ts +53 -6
  82. package/dist/orchestration/workflow.js +319 -51
  83. package/dist/prompt-assembly/turn-snapshot.d.ts +4 -2
  84. package/dist/stores/file/workflow-journal-store.js +10 -3
  85. package/package.json +1 -1
  86. package/test/export-surface.snapshot.json +85 -5
@@ -13,11 +13,13 @@ const ALL_OPS = [...CONNECTOR_OPS, ...REDIRECT_OPS];
13
13
  export const MAX_SHELL_READ_CHARS = 10_000;
14
14
  export function readShellCommand(source) {
15
15
  if (source.length > MAX_SHELL_READ_CHARS)
16
- return { segments: [{ argv: [], peeled: [], unreadable: `the command is longer than ${MAX_SHELL_READ_CHARS} characters — not read` }], connectors: [], grouped: false };
16
+ return { segments: [{ argv: [], redirections: [], peeled: [], unreadable: `the command is longer than ${MAX_SHELL_READ_CHARS} characters — not read` }], connectors: [], grouped: false, backgrounded: false, strayRedirection: false };
17
17
  const segments = [];
18
18
  const connectors = [];
19
19
  let grouped = false;
20
- let seg = { words: [] };
20
+ let backgrounded = false;
21
+ let strayRedirection = false;
22
+ let seg = { words: [], redirections: [] };
21
23
  let word = freshWord();
22
24
  let pendingRedirect;
23
25
  let heredocs = [];
@@ -40,6 +42,7 @@ export function readShellCommand(source) {
40
42
  badDelimiter = true;
41
43
  heredocs.push({ delimiter: word.text, quoted: word.raw !== word.text, dash: pendingRedirect === "<<-" });
42
44
  }
45
+ seg.redirections.push({ op: pendingRedirect, target: publicWord(word) });
43
46
  pendingRedirect = undefined;
44
47
  }
45
48
  else {
@@ -56,17 +59,19 @@ export function readShellCommand(source) {
56
59
  let built = buildSegment(seg);
57
60
  if (built === "keyworded")
58
61
  grouped = true;
62
+ if ((built === "empty" || built === "keyworded") && seg.redirections.length > 0)
63
+ strayRedirection = true;
59
64
  if (built === "empty" || built === "keyworded") {
60
65
  const continuation = connector === "\n" && lastConnector !== undefined && lastConnector !== ";" && lastConnector !== "&" && lastConnector !== "\n";
61
66
  const legal = built === "keyworded" || afterBoundary || connector === undefined || connector === "\n" || lastConnector === "\n";
62
67
  if (!legal)
63
- built = { argv: [], peeled: [], unreadable: `an empty command before \`${connector}\` — a syntax error` };
68
+ built = { argv: [], redirections: seg.redirections, peeled: [], unreadable: `an empty command before \`${connector}\` — a syntax error` };
64
69
  else {
65
70
  if (connector !== undefined && connector !== "\n" && segments.length > 0)
66
71
  grouped = true;
67
72
  if (connector !== undefined && !continuation)
68
73
  lastConnector = connector;
69
- seg = { words: [] };
74
+ seg = { words: [], redirections: [] };
70
75
  afterBoundary = false;
71
76
  return;
72
77
  }
@@ -76,7 +81,7 @@ export function readShellCommand(source) {
76
81
  segments.push(built);
77
82
  if (connector !== undefined)
78
83
  lastConnector = connector;
79
- seg = { words: [] };
84
+ seg = { words: [], redirections: [] };
80
85
  afterBoundary = false;
81
86
  };
82
87
  const boundary = () => { finishSegment(undefined); grouped = true; afterBoundary = true; lastConnector = undefined; };
@@ -187,6 +192,8 @@ export function readShellCommand(source) {
187
192
  i += op.length;
188
193
  continue;
189
194
  }
195
+ if (op === "&")
196
+ backgrounded = true;
190
197
  finishSegment(op);
191
198
  i += op.length;
192
199
  continue;
@@ -271,7 +278,7 @@ export function readShellCommand(source) {
271
278
  seg.unreadable ??= `the connector \`${lastConnector}\` has no command after it — a syntax error`;
272
279
  finishSegment(undefined);
273
280
  }
274
- return { segments, connectors, grouped };
281
+ return { segments, connectors, grouped, backgrounded, strayRedirection };
275
282
  }
276
283
  const publicWord = ({ text, raw, expands }) => ({ text, raw, expands });
277
284
  const SUBSTITUTION_UNREAD = "a command substitution runs a program this reader does not read";
@@ -281,8 +288,8 @@ export function isFullyReadable(shape) {
281
288
  }
282
289
  function buildSegment(seg) {
283
290
  if (seg.unreadable !== undefined)
284
- return { argv: seg.words.map(publicWord), peeled: [], unreadable: seg.unreadable };
285
- const carried = () => (seg.substitution === true ? { argv: [], peeled: [], peelUnreadable: SUBSTITUTION_UNREAD } : keyworded ? "keyworded" : "empty");
291
+ return { argv: seg.words.map(publicWord), redirections: seg.redirections, peeled: [], unreadable: seg.unreadable };
292
+ const carried = () => (seg.substitution === true ? { argv: [], redirections: seg.redirections, peeled: [], peelUnreadable: SUBSTITUTION_UNREAD } : keyworded ? "keyworded" : "empty");
286
293
  let keyworded = false;
287
294
  let k0 = 0;
288
295
  while (k0 < seg.words.length && seg.words[k0].expands === false && (PREFIX_KEYWORDS.has(seg.words[k0].text) || CLOSING_KEYWORDS.has(seg.words[k0].text))) {
@@ -298,7 +305,7 @@ function buildSegment(seg) {
298
305
  if (head.expands === false && (head.text === "[[" || head.text === "["))
299
306
  return carried();
300
307
  if (head.expands === false && OPAQUE_KEYWORDS.has(head.text))
301
- return { argv: words.map(publicWord), peeled: [], unreadable: `a \`${head.text}\` control structure — its commands need a syntax tree` };
308
+ return { argv: words.map(publicWord), redirections: seg.redirections, peeled: [], unreadable: `a \`${head.text}\` control structure — its commands need a syntax tree` };
302
309
  const argv = words.map(publicWord);
303
310
  const peeled = [];
304
311
  let peelUnreadable;
@@ -338,7 +345,7 @@ function buildSegment(seg) {
338
345
  }
339
346
  if (seg.substitution === true || [argv, ...peeled].some((c) => c[0]?.expands === false && evaluatesOperand(c)))
340
347
  peelUnreadable ??= SUBSTITUTION_UNREAD;
341
- return { argv, peeled, ...(peelUnreadable !== undefined ? { peelUnreadable } : {}) };
348
+ return { argv, redirections: seg.redirections, peeled, ...(peelUnreadable !== undefined ? { peelUnreadable } : {}) };
342
349
  }
343
350
  function unwrap(words) {
344
351
  const head = words[0];
@@ -50,9 +50,12 @@ export function evaluatesOperand(run) {
50
50
  return run.some((w) => namesArrayElement(w.text) || namesArrayElement(w.raw) || (integer && /^[A-Za-z_][A-Za-z0-9_]*\+?=.*[A-Za-z_$]/.test(w.text)));
51
51
  }
52
52
  function namesArrayElement(word) {
53
- const open = word.indexOf("[");
54
- if (open <= 0 || !/[A-Za-z0-9_]/.test(word[open - 1]))
55
- return false;
56
- const close = word.indexOf("]", open);
57
- return close > 0 && /[A-Za-z_$]/.test(word.slice(open + 1, close));
53
+ for (let open = word.indexOf("["); open > 0; open = word.indexOf("[", open + 1)) {
54
+ if (!/[A-Za-z0-9_]/.test(word[open - 1]))
55
+ continue;
56
+ const close = word.indexOf("]", open);
57
+ if (close > 0 && /[A-Za-z_$]/.test(word.slice(open + 1, close)))
58
+ return true;
59
+ }
60
+ return false;
58
61
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The deployment seats a Runner may HOT-SWAP after construction — the vocabulary behind the ONE door,
3
+ * `Runner.swapDeps` (runtask.ts).
4
+ *
5
+ * WHY ONE DOOR. Each hot-swappable seat used to be a verb of its own (`swapModels` was the first), and a
6
+ * verb per seat restates the same three laws every time — validate the candidate BEFORE anything is
7
+ * written, write the deps object ONCE (a swap is atomic: a refused candidate leaves the current deps
8
+ * byte-identical), announce the change under a notice code — while the question "which seats may be
9
+ * swapped at all" was answered by the list of verbs, i.e. by nothing a compiler could see. With one door
10
+ * the laws are stated once, the swappable set is THIS closed list, and every seat carries its rows in
11
+ * the tables beside it: the compiler refuses a member without its rows ({@link AssertAllKeysHandled}),
12
+ * the door refuses a key outside the set at run time (never a silent ignore — a hot-config driver
13
+ * handing `{ brain }` must learn that the brain is not a swappable seat, not watch nothing happen).
14
+ *
15
+ * WHAT A SWAP MEANS — the natural snapshot, one sentence for every seat: a running task reads the deps
16
+ * object it was handed at its prepare and holds that reference; a swap replaces the Runner's object, so
17
+ * every LATER prepare reads the new seat and every in-flight leg keeps the value it prepared under.
18
+ * Nothing is re-routed, re-priced or re-fenced mid-run; the divergence window is exactly the in-flight
19
+ * legs' lifetime, by design (the `swapModels` precedent, now the rule for the whole set).
20
+ *
21
+ * KEY PRESENCE IS THE INSTRUCTION: a key present on the candidate is REPLACED (an explicit `undefined`
22
+ * CLEARS an optional seat back to its engine default — `readFace: undefined` ⇒ the deployment declares
23
+ * no read face, `tiers: undefined` ⇒ no tier bindings); a key absent is KEPT. `Object.hasOwn`, never
24
+ * truthiness, so the two spellings cannot be confused. An EMPTY candidate is refused: a swap that names
25
+ * no seat is a caller defect, not a no-op.
26
+ */
27
+ import type { AssertAllKeysHandled } from "./ask-origin.js";
28
+ import type { RunnerDeps } from "./runner-deps.js";
29
+ /**
30
+ * The closed set of hot-swappable deployment seats.
31
+ * - `models` — the model catalog generation (the same-name redirect, re-pricing and retirement rules
32
+ * of the former `swapModels`: a string ref resolves against the new catalog at the next
33
+ * prepare; a `Model` object a leg already holds is untouched).
34
+ * - `tiers` — the tier bindings, a BINDING OVER a catalog: it may only be swapped together with
35
+ * `models` (the door refuses `tiers` alone — re-expanding bindings over an already
36
+ * expanded catalog would keep the retired tier words as ordinary keys, which is exactly
37
+ * the stale-generation shape the swap exists to end). Omitted beside `models` ⇒ the
38
+ * current bindings are re-applied over the new catalog; present (including `undefined`)
39
+ * ⇒ replaced.
40
+ * - `readFace` — the deployment's read-face declaration ({@link RunnerDeps.readFace}); screened by the
41
+ * same value gate every prepare applies (`assertReadFaceValue`: `"open"` | `"roots"`
42
+ * | absent, anything else refused loudly). The RESOLVED face of a leg is still the
43
+ * resolver's business (task seat, governance, the read-only mount); the swap changes the
44
+ * deployment seat that resolution reads.
45
+ */
46
+ export declare const SWAPPABLE_DEP_SEATS: readonly ["models", "tiers", "readFace"];
47
+ export type SwappableDepSeat = (typeof SWAPPABLE_DEP_SEATS)[number];
48
+ /** The candidate a swap takes: the swappable seats of {@link RunnerDeps}, each optional (presence = instruction). */
49
+ export type SwappableDeps = Pick<RunnerDeps, SwappableDepSeat>;
50
+ /** Whether a key names a swappable seat. */
51
+ export declare function isSwappableDepSeat(k: unknown): k is SwappableDepSeat;
52
+ /**
53
+ * Which notice a seat's successful swap is announced under — the disposition table over the set (a seat
54
+ * with no row does not compile: {@link SwapNoticeTableCoversEverySeat}). `models` and `tiers` share the
55
+ * catalog-generation line (one swap, one line, both counts); the read face has its own.
56
+ */
57
+ export declare const SWAP_SEAT_NOTICE: {
58
+ readonly models: "config.models_swapped";
59
+ readonly tiers: "config.models_swapped";
60
+ readonly readFace: "config.read_face_swapped";
61
+ };
62
+ /** The fence over the notice table: `never` while every seat has a row. */
63
+ export type SwapNoticeTableCoversEverySeat = AssertAllKeysHandled<Exclude<SwappableDepSeat, keyof typeof SWAP_SEAT_NOTICE>>;
64
+ /** What the screen hands back: the seats present (set order) and the ONE read of each present seat's value,
65
+ * copied at screen time — the door writes from THIS object and never re-reads the caller's. */
66
+ export interface ScreenedSwappableDeps {
67
+ readonly seats: readonly SwappableDepSeat[];
68
+ /** A fresh plain object: the screened value of every present seat (`models`/`tiers` shallow-copied,
69
+ * `readFace` the screened word; an explicit `undefined` is kept as a PRESENT own key so the door can
70
+ * tell "clear this seat" from "not named"). */
71
+ readonly candidate: SwappableDeps;
72
+ }
73
+ /**
74
+ * Screen a swap candidate BEFORE anything is written: the object shape, the key set (every own key must
75
+ * be a member of {@link SWAPPABLE_DEP_SEATS}; at least one), the `tiers`-rides-with-`models` rule, and
76
+ * each present seat's own value gate. Returns the seats present (set order) and a SNAPSHOT of their values
77
+ * — the one read. Throws on the first defect; the caller has written nothing yet, so the current deps stay
78
+ * in force byte-identical.
79
+ *
80
+ * ONE READ, BY CONSTRUCTION: every seat value is read off the caller's object exactly once, here, and the
81
+ * screened copy is what the door writes and announces. A getter-backed or concurrently-mutated candidate
82
+ * therefore cannot present a legal value to the screen and a different one to the write (or to the
83
+ * announcement, which runs AFTER the write — a throw there would have left a half-announced swap in
84
+ * force). The prepare doors read their deps seats the same way.
85
+ *
86
+ * The per-seat gate is the SAME one the seat's prepare-time reader applies (`readFace` ⇒
87
+ * `assertReadFaceValue` with the deployment-seat name a prepare door uses — one value, one loudness, one
88
+ * seat name on every leg), so a value the door admits is a value every later prepare admits.
89
+ */
90
+ export declare function screenSwappableDeps(next: unknown): ScreenedSwappableDeps;
@@ -0,0 +1,55 @@
1
+ import { assertReadFaceValue } from "../tools/fs/read-face.js";
2
+ export const SWAPPABLE_DEP_SEATS = ["models", "tiers", "readFace"];
3
+ const SWAPPABLE_DEP_SEAT_SET = new Set(SWAPPABLE_DEP_SEATS);
4
+ export function isSwappableDepSeat(k) {
5
+ return SWAPPABLE_DEP_SEAT_SET.has(k);
6
+ }
7
+ export const SWAP_SEAT_NOTICE = {
8
+ models: "config.models_swapped",
9
+ tiers: "config.models_swapped",
10
+ readFace: "config.read_face_swapped",
11
+ };
12
+ export function screenSwappableDeps(next) {
13
+ if (next === null || typeof next !== "object" || Array.isArray(next)) {
14
+ throw new Error(`swapDeps: the candidate must be a plain object naming the seats to swap (got ${next === null ? "null" : Array.isArray(next) ? "array" : typeof next}) — the current deps stay in force`);
15
+ }
16
+ const keys = Object.keys(next);
17
+ const unknown = keys.filter((k) => !isSwappableDepSeat(k));
18
+ if (unknown.length > 0) {
19
+ throw new Error(`swapDeps: ${unknown.map((k) => JSON.stringify(k)).join(", ")} is not a hot-swappable seat (the closed set is ${SWAPPABLE_DEP_SEATS.join(" | ")}) — the current deps stay in force`);
20
+ }
21
+ const present = SWAPPABLE_DEP_SEATS.filter((seat) => Object.hasOwn(next, seat));
22
+ if (present.length === 0) {
23
+ throw new Error(`swapDeps: the candidate names no seat (an empty swap is a caller defect, not a no-op) — the current deps stay in force`);
24
+ }
25
+ const raw = next;
26
+ const candidate = {};
27
+ for (const seat of present) {
28
+ const v = raw[seat];
29
+ switch (seat) {
30
+ case "models":
31
+ if (v === null || typeof v !== "object" || Array.isArray(v)) {
32
+ throw new Error(`swapDeps: models must be a plain Record<string, Model> (got ${v === null ? "null" : Array.isArray(v) ? "array" : typeof v}) — the current generation stays in force`);
33
+ }
34
+ candidate.models = { ...v };
35
+ break;
36
+ case "tiers":
37
+ if (!present.includes("models")) {
38
+ throw new Error(`swapDeps: tiers is a binding over models — pass the catalog it binds in the same swap (\`{ models, tiers }\`); the current generation stays in force`);
39
+ }
40
+ if (v !== undefined && (v === null || typeof v !== "object" || Array.isArray(v))) {
41
+ throw new Error(`swapDeps: tiers must be a plain Record<string, ModelRef> or undefined (got ${v === null ? "null" : Array.isArray(v) ? "array" : typeof v}) — the current generation stays in force`);
42
+ }
43
+ candidate.tiers = v === undefined ? undefined : { ...v };
44
+ break;
45
+ case "readFace":
46
+ candidate.readFace = assertReadFaceValue(v, "readFace (deployment seat)");
47
+ break;
48
+ default: {
49
+ const _exhaustive = seat;
50
+ throw new Error(`swapDeps: unhandled seat ${String(_exhaustive)}`);
51
+ }
52
+ }
53
+ }
54
+ return { seats: present, candidate: candidate };
55
+ }
@@ -99,7 +99,10 @@ export interface ToolCallRequest {
99
99
  * - `"org_unavailable"` — an org-governed deployment could not adjudicate against a snapshot, so the
100
100
  * whole decision boundary failed closed (see `ORG_UNAVAILABLE_DECISION_REASON`, the single
101
101
  * spelling this word is minted from). */
102
- declare const DECISION_REASONS: readonly ["rule", "mode", "hook", "safety", "classifier", "persisted_rule", "sandbox", "org_rule", "org_unavailable"];
102
+ /** `read_only` (#619): the shell tool's command was PROVABLY read-only by the engine's transcribed upstream
103
+ * tables (`readOnlyShellVerdict`) and the allow layer let it run without a question — after every
104
+ * deny/ask lane and the person's own allow rules, never over a mandated ask. */
105
+ declare const DECISION_REASONS: readonly ["rule", "mode", "hook", "safety", "classifier", "persisted_rule", "sandbox", "org_rule", "org_unavailable", "read_only"];
103
106
  export type DecisionReason = (typeof DECISION_REASONS)[number];
104
107
  /** Read the engine-attested settlement off a funneled decision FOR the named call (the gate's exit is the
105
108
  * one consumer): an attestation bound to a different call is a replayed object and answers absence.
@@ -204,6 +207,23 @@ export type PermissionResult = {
204
207
  * same safe direction as `matchedAskRule`); it cannot state a window of its own, because the
205
208
  * route it would be a window for has not been chosen yet. */
206
209
  denialLimitFallback?: import("./auto-mode.js").UnarmedDenialLimitFallback;
210
+ /** #616 (additive): the auto-mode classifier was CONSULTED on this ask and could not run — the ask
211
+ * flows the original chain exactly as it would have (routing, origin, bit and members unchanged),
212
+ * carrying the station FACT beside them so a card can say "asked because the classifier was
213
+ * unavailable (timeout)" instead of reading as ordinary hesitation. `cause` is the verdict's own
214
+ * word ({@link import("./auto-mode.js").AutoModeUnavailableCause}: `error` / `timeout` /
215
+ * `breaker_open`). A FACT, not an origin: CC 2.1.250 marks the same condition as a denial KIND on
216
+ * the outcome (`automode-unavailable`) beside the decision's provenance, never in place of it, and
217
+ * this engine's divergence (the ask reaches a person instead of being denied) does not move the
218
+ * fact onto the origin axis either. ENGINE-STAMPED at the classifier stations (the gate's own and
219
+ * the inherited-lane arms) only when the verdict was `unavailable`; `parse_error` stamps nothing
220
+ * (the classifier ran and answered outside its contract — a different sentence). Display metadata:
221
+ * nothing reads it to decide anything, so a policy that self-declares it can only put its own
222
+ * sentence on its own card. Carried onto the approval request and the durable row by the carry
223
+ * stations. */
224
+ classifierUnavailable?: {
225
+ readonly cause: import("./auto-mode.js").AutoModeUnavailableCause;
226
+ };
207
227
  /** #144 disclosure (additive): a persisted allow rule MATCHED this call but could not clear the
208
228
  * ask, because the ask is MANDATED (operator shellGate:"always", or the tool's own
209
229
  * egress/irreversibility marks) rather than a classifier's hesitation — "allow rules silence
@@ -1191,6 +1211,15 @@ export interface AskRequest {
1191
1211
  * that forwards a decision's member here does not compile, which is the point: the window is the
1192
1212
  * route's fact, and forwarding it silently is how a configured window becomes a wait with none. */
1193
1213
  readonly denialLimitFallback?: import("./auto-mode.js").DenialLimitFallback;
1214
+ /** #616 (additive) — present ⇔ the auto-mode classifier was consulted on this ask and could not run (see
1215
+ * the {@link PermissionResult} ask-arm member of the same name): the card's "asked because the
1216
+ * classifier was unavailable" fact, with the verdict's own cause word. Absent on every ask the classifier
1217
+ * answered, was not eligible for, or was not wired for — read presence, never absence. Filled by the
1218
+ * carry stations from the decision, never a caller/worker-settable field; the durable park row carries
1219
+ * the same member (`PendingAction.tool_approval.classifierUnavailable`). */
1220
+ readonly classifierUnavailable?: {
1221
+ readonly cause: import("./auto-mode.js").AutoModeUnavailableCause;
1222
+ };
1194
1223
  /** (additive) WHICH AUTHORITY raised this ask — the wire twin of the {@link PermissionResult} ask-arm
1195
1224
  * member of the same name, one word from the closed {@link import("./ask-origin.js").AskOrigin} set.
1196
1225
  * The gate's own mint station copies the engine-stamped word; the three inherited-lane stations
@@ -10,7 +10,7 @@ import { delimitUntrusted, inlineUntrusted, REVIEWER_NOTE_MAX_BODY } from "./unt
10
10
  import { isNamespacedCoveringRuleName, namespacedRuleNameCovers, parsePermissionRule } from "./permission-rules.js";
11
11
  import { protocolOf } from "./protocol-table.js";
12
12
  import { isAbsolutePathForm, isWinFormPath, writeTargetPath } from "../tools/fs/safety.js";
13
- const DECISION_REASONS = ["rule", "mode", "hook", "safety", "classifier", "persisted_rule", "sandbox", "org_rule", "org_unavailable"];
13
+ const DECISION_REASONS = ["rule", "mode", "hook", "safety", "classifier", "persisted_rule", "sandbox", "org_rule", "org_unavailable", "read_only"];
14
14
  const DECISION_REASON_SET = new Set(DECISION_REASONS);
15
15
  function settledByNobody(kind) {
16
16
  return { kind, who: { party: "none" }, when: Date.now() };
@@ -410,6 +410,7 @@ export function combinePolicies(...policies) {
410
410
  let ruleAskText;
411
411
  let probeMandateSeen = false;
412
412
  let fallbackSeen;
413
+ let classifierUnavailableSeen;
413
414
  for (const p of policies) {
414
415
  const d = refuseOutOfContractDecision(await p.check(current, signal));
415
416
  if (d.action === "deny") {
@@ -440,13 +441,16 @@ export function combinePolicies(...policies) {
440
441
  probeMandateSeen = true;
441
442
  if (d.action === "ask" && d.denialLimitFallback !== undefined && fallbackSeen === undefined)
442
443
  fallbackSeen = d.denialLimitFallback;
444
+ if (d.action === "ask" && d.classifierUnavailable !== undefined && classifierUnavailableSeen === undefined)
445
+ classifierUnavailableSeen = d.classifierUnavailable;
443
446
  }
444
447
  if (asked) {
445
448
  const merged = rewrite?.updatedInput;
446
449
  const withRuleAsk = ruleAskText !== undefined && asked.matchedAskRule === undefined ? { ...asked, matchedAskRule: ruleAskText } : asked;
447
450
  const withMark = probeMandateSeen && withRuleAsk.probeMandated !== true ? { ...withRuleAsk, probeMandated: true } : withRuleAsk;
448
451
  const withFallback = fallbackSeen !== undefined && withMark.denialLimitFallback === undefined ? { ...withMark, denialLimitFallback: fallbackSeen, requiresRealApproval: true } : withMark;
449
- return merged !== undefined ? { ...withFallback, updatedInput: merged } : withFallback;
452
+ const withFact = classifierUnavailableSeen !== undefined && withFallback.classifierUnavailable === undefined ? { ...withFallback, classifierUnavailable: { cause: classifierUnavailableSeen.cause } } : withFallback;
453
+ return merged !== undefined ? { ...withFact, updatedInput: merged } : withFact;
450
454
  }
451
455
  const allowed = rewrite ?? ALLOW;
452
456
  if (settledAllow === undefined)
@@ -15,14 +15,34 @@ export declare function errorResult(text: string, details?: unknown): {
15
15
  isError: true;
16
16
  details?: unknown;
17
17
  };
18
- /** True iff `x` is an `AgentTool` this module's `defineTool` itself constructed see {@link DEFINE_TOOL_BRAND}. */
18
+ /** The seat's function: the mount's ctx builder and the very object being mounted (the seal compares it to the stamped one). */
19
+ type DefineToolRebind = (enrich: ToolCtxEnricher, mounted: object) => AgentTool;
20
+ /** True iff `x` is an `AgentTool` this module's `defineTool` itself constructed — see {@link DEFINE_TOOL_BRAND}.
21
+ * An OWN property read: an object that merely INHERITS the brand (`Object.create(product)`, a Proxy over it)
22
+ * is not the product — the rebind seat would rebuild the product and drop whatever the derived object
23
+ * overrode, so such objects are not recognised here and fall to the spec arm's contract (which they do not
24
+ * meet either; see {@link stampDefineToolBrand} for the one supported wrapper form). */
19
25
  export declare function isDefineToolProduct(x: unknown): x is AgentTool;
20
- /** RB-362 类修同源章点 — the ONE place the brand is stamped. `defineTool` uses it on its own product;
21
- * the only OTHER legitimate caller is a wrapper that (a) starts from a branded product and (b)
22
- * preserves the AgentTool `execute(toolCallId, rawParams, signal, onUpdate)` contract faithfully
23
- * (e.g. teacher.ts's logging wrapper). A shallow copy that does NOT re-stamp deliberately loses the
24
- * brand that is the brand's documented survival contract, not an accident. Internal only. */
25
- export declare function stampDefineToolBrand<T extends object>(tool: T): T;
26
+ /** RB-362 类修同源章点 — the ONE place the brand is stamped, and with it the rebind seat (a branded object
27
+ * ALWAYS carries one; that invariant is what lets the caller mount rebind every product without a
28
+ * "cannot enrich" arm). `defineTool` uses it on its own product; the only OTHER legitimate caller is a
29
+ * wrapper that (a) starts from a branded product, (b) preserves the AgentTool
30
+ * `execute(toolCallId, rawParams, signal, onUpdate)` contract faithfully and (c) hands over a `rebind`
31
+ * that re-wraps the REBOUND inner product the same way (e.g. teacher.ts's logging wrapper) — so a mount
32
+ * rebinding the wrapper gets a wrapper over an enriched product, not an enriched product without the
33
+ * wrapper. A shallow copy that does NOT re-stamp deliberately loses the brand — that is the brand's
34
+ * documented survival contract, not an accident. Internal only. */
35
+ export declare function stampDefineToolBrand<T extends object>(tool: T, rebind: DefineToolRebind): T;
36
+ /**
37
+ * A branded product REBOUND to a mount's ctx builder — the product's own `execute` rebuilt over its own
38
+ * spec with `enrich` applied per call (see {@link ToolCtxEnricher}; a builder the product was built with
39
+ * runs AFTER the mount's, refining the run's trusted seats, and the per-call identity is re-stamped after
40
+ * both). This is how a `defineTool()` product handed to `TaskSpec.tools` receives exactly the ctx a raw
41
+ * `ToolSpec` on the same mount receives: one mount law, whatever object shape the caller handed in.
42
+ * Throws on an unbranded object — the caller checks {@link isDefineToolProduct} first; a branded object
43
+ * without the seat cannot be constructed (both are written at the one stamp site).
44
+ */
45
+ export declare function rebindDefineToolCtx(product: AgentTool, enrich: ToolCtxEnricher): AgentTool;
26
46
  /**
27
47
  * RB-409 — a per-call ctx builder a MOUNT hands to {@link defineTool}.
28
48
  *
@@ -58,3 +78,4 @@ export interface DefineToolOptions {
58
78
  }
59
79
  /** Adapt a friendly ToolSpec into the vendored AgentTool the agent loop expects. */
60
80
  export declare function defineTool<TParams extends TSchema = TSchema>(spec: ToolSpec<TParams>, options?: DefineToolOptions): AgentTool<TParams>;
81
+ export {};
@@ -26,13 +26,46 @@ export function errorResult(text, details) {
26
26
  return details === undefined ? { content: text, isError: true } : { content: text, isError: true, details };
27
27
  }
28
28
  const DEFINE_TOOL_BRAND = Symbol("sema.core.defineTool.product");
29
+ const DEFINE_TOOL_REBIND = Symbol("sema.core.defineTool.rebind");
29
30
  export function isDefineToolProduct(x) {
30
- return typeof x === "object" && x !== null && x[DEFINE_TOOL_BRAND] === true;
31
+ return typeof x === "object" && x !== null && Object.getOwnPropertyDescriptor(x, DEFINE_TOOL_BRAND)?.value === true;
31
32
  }
32
- export function stampDefineToolBrand(tool) {
33
+ export function stampDefineToolBrand(tool, rebind) {
33
34
  Object.defineProperty(tool, DEFINE_TOOL_BRAND, { value: true, enumerable: false });
35
+ Object.defineProperty(tool, DEFINE_TOOL_REBIND, { value: sealedRebind(tool, rebind), enumerable: false });
34
36
  return tool;
35
37
  }
38
+ function sealedRebind(stamped, rebuild) {
39
+ const ownExecute = Object.getOwnPropertyDescriptor(stamped, "execute")?.value;
40
+ const stillSealed = () => {
41
+ const d = Object.getOwnPropertyDescriptor(stamped, "execute");
42
+ return d !== undefined && "value" in d && d.value === ownExecute;
43
+ };
44
+ return (enrich, mounted) => {
45
+ if (mounted !== stamped) {
46
+ throw new Error(`defineTool product ${JSON.stringify(stamped.name)}: the object being mounted is not the one this brand was stamped on (a derived object — prototype, proxy or descriptor copy — carrying a borrowed brand), so it cannot be rebound to the run's ctx without dropping its own overrides — wrap the product as a NEW object (stampDefineToolBrand with a rebind that re-wraps the rebound inner product), or author it as a raw ToolSpec`);
47
+ }
48
+ const refuse = () => {
49
+ throw new Error(`defineTool product ${JSON.stringify(stamped.name)}: its execute is not the own data property the factory wrote (replaced, or turned into an accessor, after the factory built it), so it cannot be rebound to the run's ctx without dropping the replacement — wrap the product as a NEW object (stampDefineToolBrand with a rebind that re-wraps the rebound inner product), or author it as a raw ToolSpec`);
50
+ };
51
+ if (!stillSealed())
52
+ refuse();
53
+ const rebound = rebuild(enrich, mounted);
54
+ if (!stillSealed())
55
+ refuse();
56
+ return rebound;
57
+ };
58
+ }
59
+ export function rebindDefineToolCtx(product, enrich) {
60
+ const rebind = Object.getOwnPropertyDescriptor(product, DEFINE_TOOL_REBIND)?.value;
61
+ if (typeof rebind !== "function")
62
+ throw new Error(`rebindDefineToolCtx: ${product.name} is not a defineTool product (no rebind seat)`);
63
+ return rebind(enrich, product);
64
+ }
65
+ function stampCallIdentity(enriched, toolCallId, signal) {
66
+ const own = (value) => ({ value, enumerable: true, configurable: true, writable: true });
67
+ return Object.create(Object.getPrototypeOf(enriched), { ...Object.getOwnPropertyDescriptors(enriched), toolCallId: own(toolCallId), signal: own(signal) });
68
+ }
36
69
  export function defineTool(spec, options) {
37
70
  const executionMode = spec.executionMode ?? (spec.effect === "read" ? "parallel" : "sequential");
38
71
  const tool = {
@@ -77,7 +110,8 @@ export function defineTool(spec, options) {
77
110
  let ret;
78
111
  try {
79
112
  const baseCtx = { toolCallId, signal };
80
- ret = await spec.execute(params, options?.enrichCtx ? { ...options.enrichCtx(baseCtx), toolCallId, signal } : baseCtx);
113
+ const ctx = options?.enrichCtx ? stampCallIdentity(options.enrichCtx(baseCtx), toolCallId, signal) : baseCtx;
114
+ ret = await spec.execute(params, ctx);
81
115
  }
82
116
  catch (err) {
83
117
  const wrapped = new Error(formatToolError(err));
@@ -105,6 +139,12 @@ export function defineTool(spec, options) {
105
139
  ...(spec.aliases && spec.aliases.length > 0 ? { aliases: spec.aliases } : {}),
106
140
  });
107
141
  }
108
- stampDefineToolBrand(tool);
142
+ stampDefineToolBrand(tool, (enrich) => {
143
+ const composed = options?.enrichCtx === undefined ? enrich : (base) => options.enrichCtx(enrich(base));
144
+ const rebuilt = defineTool(spec, { ...options, enrichCtx: composed });
145
+ const { [DEFINE_TOOL_BRAND]: _brand, [DEFINE_TOOL_REBIND]: _seat, ...face } = Object.getOwnPropertyDescriptors(tool);
146
+ const copy = Object.create(Object.getPrototypeOf(tool), { ...face, execute: { value: rebuilt.execute, enumerable: true, configurable: true, writable: true } });
147
+ return stampDefineToolBrand(copy, (again) => rebindDefineToolCtx(rebuilt, again));
148
+ });
109
149
  return tool;
110
150
  }
@@ -425,6 +425,21 @@ export type TraceEvent = {
425
425
  toolCallId: string;
426
426
  rules: readonly string[];
427
427
  ts: number;
428
+ } | {
429
+ /**
430
+ * #619 — the READ-ONLY reader cleared a shell call, so no person and no classifier was asked: the
431
+ * allow layer's second attribution channel beside `permission.persisted_rule_allowed`. `command` is
432
+ * the FINAL command the gate judged (a policy rewrite included) — model-authored text, carried
433
+ * verbatim as the audit fact this frame exists for (a consumer rendering it applies its own
434
+ * display sanitizer, as it does for `tool_start` args).
435
+ */
436
+ kind: "permission.read_only_allowed";
437
+ version: 1;
438
+ taskId: string;
439
+ toolName: string;
440
+ toolCallId: string;
441
+ command: string;
442
+ ts: number;
428
443
  } | {
429
444
  /**
430
445
  * design/179 — the persisted allow-rule store could not be read, so this call was adjudicated with
@@ -244,10 +244,21 @@ export interface WiringManifest {
244
244
  * classifier decider was minted for this leg), and `reason` names the first arm that failed
245
245
  * otherwise — see {@link AUTO_MODE_ARM_REASONS}. A serving layer that used to infer the mode from
246
246
  * spec shape reads it here instead.
247
+ *
248
+ * `breaker` (#616) — the SESSION-level breaker read face: the most recent one-way breaker trip recorded
249
+ * for this session on this Runner (an EARLIER leg's decider — the latch is a per-run fact and a leg-start
250
+ * manifest's own decider is closed by construction, which is why there is no `open` boolean here: it
251
+ * would read `false` on every mint; the reason vocabulary reserves `latch_open` for a mid-leg re-read
252
+ * face nothing mints today). Present ⇔ a trip was recorded (never tripped, evicted or a standalone
253
+ * prepare ⇒ absent). `{ openedAtMs, lastCause, failures, runId }` — see
254
+ * {@link import("./auto-mode.js").AutoModeBreakerTrip}. A per-leg observation like `mcp`, so it is NOT
255
+ * part of {@link configFingerprint}: legs of one assembly must fingerprint alike whether or not a
256
+ * classifier was down.
247
257
  */
248
258
  autoMode?: {
249
259
  armed: boolean;
250
260
  reason: AutoModeArmReason;
261
+ breaker?: import("./auto-mode.js").AutoModeBreakerTrip;
251
262
  };
252
263
  /**
253
264
  * EFFECTIVE half only, and ALWAYS present on an engine-minted effective manifest (the `autoMode`
@@ -336,10 +347,13 @@ export interface WiringFacts {
336
347
  restore: string;
337
348
  };
338
349
  /** Effective half only — see {@link WiringManifest.autoMode}; the static half has no leg to arm.
339
- * `armed` must agree with `reason` (`armed ⇔ reason === "armed"`); a contradicting pair is refused. */
350
+ * `armed` must agree with `reason` (`armed ⇔ reason === "armed"`); a contradicting pair is refused.
351
+ * `breaker` (#616) is the session's most recent recorded trip, or absent; a trip whose cause is outside
352
+ * the closed set or whose numbers are not finite is refused. */
340
353
  autoMode?: {
341
354
  armed: boolean;
342
355
  reason: AutoModeArmReason;
356
+ breaker?: import("./auto-mode.js").AutoModeBreakerTrip;
343
357
  };
344
358
  /** Effective half only — see {@link WiringManifest.mcp}; the static half materializes nothing.
345
359
  * Copied entry-wise onto the manifest (the caller's array is never aliased). */
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { AUTO_MODE_BREAKER_CAUSES, isAutoModeBreakerCause } from "./auto-mode.js";
2
3
  import { canonicalize } from "./canonical-json.js";
3
4
  import { resolveCheckpointStore } from "./checkpoint-store.js";
4
5
  import { isLiveQuestionFace } from "./ask-question.js";
@@ -71,7 +72,13 @@ function readAutoModeFact(fact) {
71
72
  if (!AUTO_MODE_ARM_REASONS.includes(fact.reason) || fact.armed !== (fact.reason === "armed")) {
72
73
  throw new Error(`the auto-mode wiring fact is inconsistent (armed=${String(fact.armed)}, reason=${JSON.stringify(fact.reason)}) — armed must hold exactly when reason is "armed", and reason must be one of ${AUTO_MODE_ARM_REASONS.join("|")}`);
73
74
  }
74
- return { armed: fact.armed, reason: fact.reason };
75
+ const b = fact.breaker;
76
+ if (b === undefined)
77
+ return { armed: fact.armed, reason: fact.reason };
78
+ if (!isAutoModeBreakerCause(b.lastCause) || !Number.isFinite(b.openedAtMs) || !Number.isInteger(b.failures) || b.failures < 1 || typeof b.runId !== "string" || b.runId === "") {
79
+ throw new Error(`the auto-mode breaker fact is malformed (${JSON.stringify(b)}) — lastCause must be one of ${AUTO_MODE_BREAKER_CAUSES.join("|")}, openedAtMs finite, failures a positive integer, runId a non-empty string`);
80
+ }
81
+ return { armed: fact.armed, reason: fact.reason, breaker: { openedAtMs: b.openedAtMs, lastCause: b.lastCause, failures: b.failures, runId: b.runId } };
75
82
  }
76
83
  export function deriveWiringManifest(facts) {
77
84
  if (facts.half === "static" && facts.leg !== undefined) {
@@ -133,8 +140,9 @@ export function deriveWiringManifest(facts) {
133
140
  const { leg: _leg, mcp: _mcp, tools: _tools, ...assembly } = manifest;
134
141
  const { provenance: _askSeat, ...askForHash } = assembly.ask;
135
142
  const { provenance: _questionSeat, ...questionForHash } = assembly.question;
143
+ const { breaker: _breaker, ...autoModeForHash } = assembly.autoMode ?? {};
136
144
  manifest.configFingerprint = createHash("sha256")
137
- .update(canonicalize({ ...assembly, ask: askForHash, question: questionForHash }))
145
+ .update(canonicalize({ ...assembly, ask: askForHash, question: questionForHash, ...(assembly.autoMode !== undefined ? { autoMode: autoModeForHash } : {}) }))
138
146
  .digest("hex")
139
147
  .slice(0, 16);
140
148
  }
@@ -5,12 +5,31 @@ import type { TaskResult } from "./types.js";
5
5
  * longest unchanged PREFIX of these (keyed by the deterministic {@link workflowAgentCallKey}) and runs only the
6
6
  * first changed/new call + everything after it live. Same script + same args → 100% cache hit.
7
7
  */
8
- export interface WorkflowJournalEntry {
8
+ export type WorkflowJournalEntry = {
9
9
  /** The agent's deterministic call key (`ordinal:specIdentityHash`) — the replay match key. */
10
10
  callKey: string;
11
11
  /** The agent's TaskResult, replayed verbatim when a resume's call key matches at the same ordinal. */
12
12
  result: TaskResult;
13
- }
13
+ parked?: never;
14
+ } | {
15
+ callKey: string;
16
+ result?: never;
17
+ /**
18
+ * #642 — the ordinal's leg is PARKED at a durable approval gate: the child's own paused `TaskResult`
19
+ * (its `terminal` carries the checkpoint token + gate, its `sessionId` the pinned session). A resume
20
+ * reaching this ordinal with a matching call key does NOT run the call live (the parked child's
21
+ * session is pinned under a pending checkpoint; a fresh spawn would duplicate it) — it drives the
22
+ * parked child's resume when the caller hands it a decision for the token, else re-parks. Deliberately
23
+ * a DIFFERENT key from `result`: a journal reader from before this entry kind existed finds no
24
+ * `result.terminal` and refuses the whole resume (`WorkflowJournalIncompatibleError`) instead of
25
+ * admitting a paused cause it would re-run live — one-way, loud, like every journal schema step.
26
+ */
27
+ parked: TaskResult & {
28
+ terminal: Extract<TaskResult["terminal"], {
29
+ kind: "paused";
30
+ }>;
31
+ };
32
+ };
14
33
  /** REF-D5: the resume-claim key triple — ONE shape for the interface pair and every implementation
15
34
  * (it was re-inlined seven times; a key-field rename must red every leg at once). */
16
35
  export interface ResumeClaimArgs {
@@ -65,7 +65,7 @@ export class InMemoryWorkflowJournalStore {
65
65
  return [...rec.byOrdinal.entries()].sort((a, b) => a[0] - b[0]).map(([, e]) => snapshot(e));
66
66
  }
67
67
  async append(runId, scope, entry) {
68
- if (oversizeJournalResult(JSON.stringify(entry.result)))
68
+ if (oversizeJournalResult(JSON.stringify(entry.parked !== undefined ? entry.parked : entry.result)))
69
69
  return;
70
70
  let rec = this.runs.get(runId);
71
71
  if (!rec) {
@@ -79,6 +79,8 @@ export declare function openSpoolPair(base: string): {
79
79
  };
80
80
  export declare class NodeExecutionEnv implements ExecutionEnv, BackgroundShellCapability, SchedulerCapability {
81
81
  cwd: string;
82
+ /** #644 — this adapter runs on the engine host, so the host's home IS the environment's (`ExecutionEnv.homeDir`). */
83
+ readonly homeDir: string;
82
84
  private shellPath?;
83
85
  private shellEnv?;
84
86
  private inheritEnv;