@sema-agent/core 7.18.0 → 7.19.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 (72) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/dist/brain/anthropic.js +6 -3
  3. package/dist/brain/open-responses.js +10 -4
  4. package/dist/brain/openai.js +5 -2
  5. package/dist/brain/reasoning.d.ts +13 -13
  6. package/dist/brain/reasoning.js +2 -2
  7. package/dist/brain/stream-engine.js +2 -1
  8. package/dist/brain/stream-shared.d.ts +3 -0
  9. package/dist/brain/stream-shared.js +1 -0
  10. package/dist/core/ask-origin.d.ts +16 -0
  11. package/dist/core/ask-origin.js +8 -0
  12. package/dist/core/auto-mode-arming.d.ts +6 -0
  13. package/dist/core/auto-mode-arming.js +5 -5
  14. package/dist/core/auto-mode-defaults.d.ts +43 -15
  15. package/dist/core/auto-mode-defaults.js +4 -1
  16. package/dist/core/auto-mode.d.ts +43 -14
  17. package/dist/core/auto-mode.js +79 -10
  18. package/dist/core/checkpoint-store.d.ts +7 -0
  19. package/dist/core/checkpoint-store.js +2 -2
  20. package/dist/core/engine-notice.d.ts +30 -0
  21. package/dist/core/gate-fold.js +5 -1
  22. package/dist/core/gate-lanes.js +7 -7
  23. package/dist/core/gate-outcome.d.ts +25 -0
  24. package/dist/core/gate-outcome.js +7 -0
  25. package/dist/core/governance-codes.d.ts +1 -1
  26. package/dist/core/governance-codes.js +6 -0
  27. package/dist/core/hooks.d.ts +1 -1
  28. package/dist/core/read-root-candidate.d.ts +12 -0
  29. package/dist/core/read-root-candidate.js +24 -0
  30. package/dist/core/roles.d.ts +9 -1
  31. package/dist/core/roles.js +4 -0
  32. package/dist/core/runner/classifier-ladder.d.ts +149 -11
  33. package/dist/core/runner/classifier-ladder.js +159 -10
  34. package/dist/core/runner/contracts.d.ts +2 -0
  35. package/dist/core/runner/denial-limit-arms.js +2 -2
  36. package/dist/core/runner/prepare-ask-lane.js +1 -0
  37. package/dist/core/runner/prepare-caps-and-workflow.js +20 -31
  38. package/dist/core/runner/prepare-config-doors.d.ts +6 -0
  39. package/dist/core/runner/prepare-config-doors.js +8 -10
  40. package/dist/core/runner/prepare-project-context.d.ts +5 -1
  41. package/dist/core/runner/prepare-project-context.js +3 -2
  42. package/dist/core/runner/prepare-task.js +1 -1
  43. package/dist/core/runner/resume-apply.js +3 -3
  44. package/dist/core/runner/run-leg.js +10 -4
  45. package/dist/core/runner/run-turn-boundary.js +12 -4
  46. package/dist/core/runner/synthetic-tools.d.ts +67 -29
  47. package/dist/core/runner/synthetic-tools.js +51 -22
  48. package/dist/core/runner/turn-attachments.d.ts +43 -12
  49. package/dist/core/runner/turn-attachments.js +58 -14
  50. package/dist/core/sensitive-path-policy.d.ts +58 -17
  51. package/dist/core/sensitive-path-policy.js +33 -5
  52. package/dist/core/skill-listing-budget.d.ts +112 -0
  53. package/dist/core/skill-listing-budget.js +58 -0
  54. package/dist/core/task-spec.d.ts +26 -0
  55. package/dist/core/tool-policy.d.ts +19 -4
  56. package/dist/core/tool-policy.js +6 -7
  57. package/dist/core/tool-spec.d.ts +37 -4
  58. package/dist/core/types.d.ts +1 -1
  59. package/dist/core/write-protect.d.ts +8 -3
  60. package/dist/core/write-protect.js +8 -5
  61. package/dist/index.d.ts +4 -4
  62. package/dist/index.js +3 -3
  63. package/dist/tools/fs/bash-readonly-classifier.d.ts +27 -0
  64. package/dist/tools/fs/bash-readonly-classifier.js +59 -6
  65. package/dist/tools/fs/fs-bash.js +7 -3
  66. package/dist/tools/fs/read-deny.d.ts +4 -3
  67. package/dist/tools/fs/safety.d.ts +15 -0
  68. package/dist/tools/fs/safety.js +7 -0
  69. package/package.json +1 -1
  70. package/test/export-surface.snapshot.json +15 -5
  71. package/wire-changes/core@7.18.0.json +1273 -0
  72. package/wire-changes/core@7.19.0.json +457 -0
@@ -10,6 +10,18 @@ export declare const AUTO_MODE_UNAVAILABLE_CAUSES: readonly ["error", "timeout"]
10
10
  export type AutoModeUnavailableCause = (typeof AUTO_MODE_UNAVAILABLE_CAUSES)[number];
11
11
  /** Whether a value is a member of the unavailable-cause set — the screen a row reader applies to a stored word. */
12
12
  export declare function isAutoModeUnavailableCause(v: unknown): v is AutoModeUnavailableCause;
13
+ /**
14
+ * HOW a reply failed to carry a verdict — the closed set behind `parse_error.form`. `missing` = the reader found
15
+ * NO answer (empty text, prose, an answer only inside a thinking block); `conflict` = it found TWO distinct
16
+ * `<block>` answers (raw or thinking-stripped). The split exists for the classify leg's re-ask: a missing
17
+ * answer is asked for again (CC 2.1.259 `Vrr` re-asks an unparseable reply), a conflict is not — it carries a
18
+ * `yes`, and a second attempt that answered `no` would erase it. Not on any wire face; the trace frame and the
19
+ * gate record keep the one word `parse_error`.
20
+ */
21
+ export declare const AUTO_MODE_PARSE_FAILURES: readonly ["missing", "conflict"];
22
+ export type AutoModeParseFailure = (typeof AUTO_MODE_PARSE_FAILURES)[number];
23
+ /** Whether a value is a member of the parse-failure set. */
24
+ export declare function isAutoModeParseFailure(v: unknown): v is AutoModeParseFailure;
13
25
  /**
14
26
  * WHY a classification moved DOWN the candidate ladder — the closed set behind
15
27
  * {@link AutoModeClassifierRound}'s `fallback.cause`, i.e. the ways a candidate produces NO RULING
@@ -52,10 +64,13 @@ export type AutoModeVerdict = ({
52
64
  cause: AutoModeUnavailableCause;
53
65
  } & AutoModeRoundAttributed)
54
66
  /** The model responded but not in the `<block>…` contract shape. Handled as a BLOCK with the CC
55
- * parse-failure sentence ({@link CLASSIFIER_PARSE_FAILURE_DENY_MESSAGE}), counted by the denial limit. */
67
+ * parse-failure sentence ({@link classifierParseFailureDenyMessage}), counted by the denial limit. `form`
68
+ * says WHICH way the reply failed ({@link AutoModeParseFailure}): the classify leg re-asks a `missing`
69
+ * reply once and never a `conflict`. */
56
70
  | ({
57
71
  kind: "parse_error";
58
72
  raw: string;
73
+ form: AutoModeParseFailure;
59
74
  } & AutoModeRoundAttributed);
60
75
  /** The member every verdict arm shares: WHICH MODEL this classification asked and which one answered. */
61
76
  export interface AutoModeRoundAttributed {
@@ -121,23 +136,35 @@ export declare function rungDidNotRule(v: AutoModeVerdict): AutoModeFallbackCaus
121
136
  * NOT move keeps the CC text byte for byte.
122
137
  */
123
138
  export declare function classifierUnavailableDenyMessage(toolName: string, cause: AutoModeUnavailableCause, attribution?: AutoModeClassifierRound): string;
124
- /** The model-facing deny text for a `parse_error` verdict — CC 2.1.250 `Ure` over the R3t stem: the classifier
125
- * answered, but not with a verdict, and auto mode blocks what it could not evaluate. */
126
- export declare const CLASSIFIER_PARSE_FAILURE_DENY_MESSAGE = "Auto mode could not evaluate this action and is blocking it for safety \u2014 the classifier's reply carried no verdict.";
127
139
  /**
128
- * Parse the classifier's output contract — the CC 2.1.250 verdict reader (`wCe`, its thinking strip
129
- * `xCe`, its reason reader `yCe`) ported as is:
140
+ * The model-facing deny text for a `parse_error` verdict — CC 2.1.259 `i7t` stem ("Auto mode could not evaluate
141
+ * this action and is blocking it for safety"; 2.1.250 `Ure`): the classifier answered, but not with a verdict,
142
+ * and auto mode blocks what it could not evaluate. With a candidate ladder wired the tail NAMES the model whose
143
+ * reply carried no verdict (`attribution.modelUsed`) — a declared departure from upstream, whose card names no
144
+ * model: the person reading this card is the one who can change the catalog, and on a single-model deployment
145
+ * the name is the whole diagnosis. Without attribution the sentence is unchanged. The stem is a stable prefix
146
+ * either way; one writer for both deny sites.
147
+ */
148
+ export declare function classifierParseFailureDenyMessage(attribution?: AutoModeClassifierRound): string;
149
+ /**
150
+ * Parse the classifier's output contract — the CC 2.1.259 verdict reader (`MTt`, its thinking strip `QTt`, its
151
+ * reason reader `TTt`; unchanged from 2.1.250 `wCe`/`xCe`/`yCe`), plus ONE arm of sema's own:
130
152
  * blocked: `<block>yes</block><category>Rule Name</category><reason>[Rule Name] …</reason>`
131
153
  * allowed: `<block>no</block>`
132
154
  *
133
155
  * The verdict is the set of `<block>yes|no` hits, NOT a prefix match:
134
156
  * 1. CONFLICT, read on the RAW text (thinking included): `<block>(yes|no)\b` collected over the whole
135
- * response — more than one distinct answer ⇒ no verdict (`parse_error`). A scratch-space "yes"
136
- * contradicting a final "no" is a contradiction, not an allow.
137
- * 2. HITS, read on the text with `<thinking>` blocks removed (closed ones anywhere, then an unclosed
138
- * trailing one — a reasoning model's scratch space is not part of the verdict): `<block>(yes|no)\b`
139
- * with the closing tag OPTIONAL, at ANY position. Zero hits ⇒ `parse_error` (a reply that only
140
- * answered inside its thinking has no verdict); the one distinct answer ⇒ `no` = allow, `yes` = block.
157
+ * response — more than one distinct answer ⇒ no verdict (`parse_error`, form `conflict`). A scratch-space
158
+ * "yes" contradicting a final "no" is a contradiction, not an allow.
159
+ * 2. HITS, read on the text with thinking blocks removed ({@link stripThinking}): `<block>(yes|no)\b` with
160
+ * the closing tag OPTIONAL, at ANY position. Two distinct answers ⇒ `conflict`; the one distinct answer ⇒
161
+ * `no` = allow, `yes` = block.
162
+ * 3. BARE ANSWER (sema, ruled 2026-09-15 — small open models drop the tag; recorded as a divergence; a caller
163
+ * that knows the reply is not a complete answer passes `bareAnswer: false` and this arm is closed): zero
164
+ * hits AND the whole thinking-stripped text is an answer by {@link bareAnswerOf} ⇒ that word is the
165
+ * verdict (a bare `yes` blocks with empty category/reason) — unless a `<block>` answer inside the
166
+ * thinking disagrees with it (`conflict`). Zero hits and anything else — prose, a question with its
167
+ * answer, a reply that only answered inside its thinking — ⇒ `parse_error`, form `missing`.
141
168
  * So a preamble before the tag, prose after `<block>no`, a missing `</block>` and a reply that repeats
142
169
  * its answer all read as the answer they carry; the tag must be spelled `<block>yes` / `<block>no` with
143
170
  * no whitespace inside (the word-boundary form — `<block>yesterday` is not a hit) and case does not matter.
@@ -149,11 +176,13 @@ export declare const CLASSIFIER_PARSE_FAILURE_DENY_MESSAGE = "Auto mode could no
149
176
  * line from the tool name in that case. Downgrading a stated block to `parse_error` would lose the
150
177
  * classifier's explicit verdict on the exact calls it flagged.
151
178
  *
152
- * `parse_error` is fail-closed: the gate handles it as a BLOCK (CC 2.1.250's own outcome for a reply with no
179
+ * `parse_error` is fail-closed: the gate handles it as a BLOCK (CC 2.1.259's own outcome for a reply with no
153
180
  * verdict) that the denial limit counts like any other — never a silent allow, and never a question handed
154
181
  * to a person on the classifier's behalf.
155
182
  */
156
- export declare function parseAutoModeResponse(text: string): AutoModeVerdict;
183
+ export declare function parseAutoModeResponse(text: string, options?: {
184
+ bareAnswer?: boolean;
185
+ }): AutoModeVerdict;
157
186
  /** What the decider hands the deployment's `classify` hook (the assembled prompt is the hook's job
158
187
  * when it owns assembly; when using {@link buildAutoModePrompt} the decider passes its output). */
159
188
  export interface AutoModeClassifyInput {
@@ -5,6 +5,11 @@ const AUTO_MODE_UNAVAILABLE_CAUSE_SET = new Set(AUTO_MODE_UNAVAILABLE_CAUSES);
5
5
  export function isAutoModeUnavailableCause(v) {
6
6
  return AUTO_MODE_UNAVAILABLE_CAUSE_SET.has(v);
7
7
  }
8
+ export const AUTO_MODE_PARSE_FAILURES = ["missing", "conflict"];
9
+ const AUTO_MODE_PARSE_FAILURE_SET = new Set(AUTO_MODE_PARSE_FAILURES);
10
+ export function isAutoModeParseFailure(v) {
11
+ return AUTO_MODE_PARSE_FAILURE_SET.has(v);
12
+ }
8
13
  export const AUTO_MODE_FALLBACK_CAUSES = ["error", "timeout", "parse_error"];
9
14
  const AUTO_MODE_FALLBACK_CAUSE_SET = new Set(AUTO_MODE_FALLBACK_CAUSES);
10
15
  export function isAutoModeFallbackCause(v) {
@@ -64,19 +69,83 @@ export function classifierUnavailableDenyMessage(toolName, cause, attribution) {
64
69
  "If it keeps failing, continue with other tasks that don't require this action and come back to it later. " +
65
70
  "Note: reading files, searching code, and other read-only operations do not require the classifier and can still be used.");
66
71
  }
67
- export const CLASSIFIER_PARSE_FAILURE_DENY_MESSAGE = "Auto mode could not evaluate this action and is blocking it for safety — the classifier's reply carried no verdict.";
68
- export function parseAutoModeResponse(text) {
72
+ export function classifierParseFailureDenyMessage(attribution) {
73
+ const stem = "Auto mode could not evaluate this action and is blocking it for safety";
74
+ return attribution === undefined ? `${stem} — the classifier's reply carried no verdict.` : `${stem} — the classifier model "${attribution.modelUsed}" replied without a verdict.`;
75
+ }
76
+ function stripThinking(text) {
77
+ return text.replace(/<(thinking|think)>[\s\S]*?<\/\1>/gi, "").replace(/<(thinking|think)>[\s\S]*$/i, "");
78
+ }
79
+ const BARE_ANSWER_WRAPPERS = [
80
+ ["**", "**"],
81
+ ["__", "__"],
82
+ ["*", "*"],
83
+ ["_", "_"],
84
+ ["`", "`"],
85
+ ['"', '"'],
86
+ ["'", "'"],
87
+ ["«", "»"],
88
+ ];
89
+ function unwrapBareAnswer(text) {
90
+ let s = text;
91
+ for (;;) {
92
+ const before = s;
93
+ s = s.trim().replace(/[.!]+$/, "").trim();
94
+ for (const [open, close] of BARE_ANSWER_WRAPPERS) {
95
+ if (s.length > open.length + close.length && s.startsWith(open) && s.endsWith(close)) {
96
+ s = s.slice(open.length, s.length - close.length);
97
+ break;
98
+ }
99
+ }
100
+ if (s === before)
101
+ return s;
102
+ }
103
+ }
104
+ function bareAnswerOf(text) {
105
+ const visible = text.replace(/[\u200b-\u200d\ufeff]/g, "").trim();
106
+ if (visible.includes("```") || visible.includes("\n"))
107
+ return undefined;
108
+ let s = unwrapBareAnswer(visible);
109
+ const labelled = labelledAnswer(s);
110
+ if (labelled !== undefined)
111
+ s = unwrapBareAnswer(labelled);
112
+ const word = /^(yes|no)$/i.exec(s)?.[1]?.toLowerCase();
113
+ return word === "yes" || word === "no" ? word : undefined;
114
+ }
115
+ function labelledAnswer(s) {
116
+ const label = "(?:final[ \\t]+)?(?:block|answer|verdict|decision)";
117
+ for (const [open, close] of [["", ""], ...BARE_ANSWER_WRAPPERS]) {
118
+ const re = new RegExp(`^${escapeRe(open)}${label}${escapeRe(close)}[ \\t]*[:=][ \\t]*(.*)$`, "i");
119
+ const m = re.exec(s);
120
+ if (m !== null)
121
+ return m[1];
122
+ }
123
+ return undefined;
124
+ }
125
+ function escapeRe(s) {
126
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
127
+ }
128
+ export function parseAutoModeResponse(text, options) {
69
129
  const rawAnswers = new Set([...text.matchAll(/<block>(yes|no)\b/gi)].map((m) => m[1].toLowerCase()));
70
130
  if (rawAnswers.size > 1)
71
- return { kind: "parse_error", raw: text };
72
- const stripped = text.replace(/<thinking>[\s\S]*?<\/thinking>/g, "").replace(/<thinking>[\s\S]*$/, "");
131
+ return { kind: "parse_error", raw: text, form: "conflict" };
132
+ const stripped = stripThinking(text);
73
133
  const hits = [...stripped.matchAll(/<block>(yes|no)\b(<\/block>)?/gi)];
74
- if (hits.length === 0)
75
- return { kind: "parse_error", raw: text };
76
- const answers = new Set(hits.map((m) => m[1].toLowerCase()));
77
- if (answers.size !== 1)
78
- return { kind: "parse_error", raw: text };
79
- if (!answers.has("yes"))
134
+ let answer;
135
+ if (hits.length > 0) {
136
+ const answers = new Set(hits.map((m) => m[1].toLowerCase()));
137
+ if (answers.size !== 1)
138
+ return { kind: "parse_error", raw: text, form: "conflict" };
139
+ answer = answers.has("yes") ? "yes" : "no";
140
+ }
141
+ else {
142
+ answer = options?.bareAnswer === false ? undefined : bareAnswerOf(stripped);
143
+ if (answer === undefined)
144
+ return { kind: "parse_error", raw: text, form: "missing" };
145
+ if (rawAnswers.size > 0 && !rawAnswers.has(answer))
146
+ return { kind: "parse_error", raw: text, form: "conflict" };
147
+ }
148
+ if (answer === "no")
80
149
  return { kind: "allow" };
81
150
  const category = /<category>([\s\S]*?)<\/category>/i.exec(stripped)?.[1]?.trim() ?? "";
82
151
  const reason = /<reason>([\s\S]*?)<\/reason>/.exec(stripped)?.[1]?.trim() ?? "";
@@ -369,6 +369,13 @@ export interface PendingSteerInput {
369
369
  inputId?: string;
370
370
  priority?: SystemInjectionPriority;
371
371
  }
372
+ /** True ONLY for a PLAIN data record (not null / array / class-instance / proxy-with-odd-prototype) — the only
373
+ * shape we safely digest. Adversarial args (a proxy, a getter that throws) must never crash the mint, so this +
374
+ * {@link safeDataValue}/{@link safeKeys} make {@link buildRiskDescriptor} fully TOTAL. */
375
+ export declare function isPlainRecord(x: unknown): x is Record<string, unknown>;
376
+ /** Read `obj[key]` as a DATA property only — NEVER invokes a getter/accessor (so a throwing getter can't escape
377
+ * AND a non-deterministic getter can't vary the digest); a trapped descriptor read ⇒ undefined. */
378
+ export declare function safeDataValue(obj: Record<string, unknown>, key: string): unknown;
372
379
  /**
373
380
  * design/80 §D-E: build the DETERMINISTIC, REDACTED {@link RiskDescriptor} for an escalation checkpoint at
374
381
  * MINT. Pure — a function ONLY of (`toolName`, `args`, the D-2 `safety` axis, the `shellGated` flag); it reads
@@ -73,7 +73,7 @@ export const MAX_PENDING_STEER_CHARS = 16_000;
73
73
  export const PENDING_STEER_QUEUE_BYTE_BUDGET_BYTES = 48_000;
74
74
  export const MAX_PENDING_STEER_ENTRIES = Math.floor(PENDING_STEER_QUEUE_BYTE_BUDGET_BYTES / MAX_PENDING_STEER_CHARS);
75
75
  export const PENDING_STEER_FROZEN_FIELDS = ["text", "trusted", "actor", "seq", "inputId", "priority"];
76
- function isPlainRecord(x) {
76
+ export function isPlainRecord(x) {
77
77
  try {
78
78
  if (x === null || typeof x !== "object" || Array.isArray(x))
79
79
  return false;
@@ -84,7 +84,7 @@ function isPlainRecord(x) {
84
84
  return false;
85
85
  }
86
86
  }
87
- function safeDataValue(obj, key) {
87
+ export function safeDataValue(obj, key) {
88
88
  try {
89
89
  const d = Object.getOwnPropertyDescriptor(obj, key);
90
90
  return d && "value" in d ? d.value : undefined;
@@ -23,6 +23,22 @@ export interface EngineNotice {
23
23
  * - `"config.tool_face_undeclared"` / `"config.tool_face_invalid"` (design/388 §2.7) — an MCP server's
24
24
  * `toolFaces` overlay names a tool the listing does not carry / does not fit the tool's materialized
25
25
  * schema (the tool mounts bare); once per leg per tool; `detail: { server, tool, problem?, sessionId, runId }`.
26
+ * - `"skills.listing_truncated"` — the `<skills>` disclosure block did not fit its byte budget, so
27
+ * at least one skill is listed without its description (`nameOnly`) or, in the pathological case,
28
+ * not listed at all (`dropped`); the `Skill` tool still serves every one of them. Audience
29
+ * `operator` — raising `TaskSpec.skillListingBudgetFraction`, lowering `skillListingMaxDescChars`
30
+ * or declaring fewer skills are all deployment acts. `detail: { budgetBytes,
31
+ * requestedBudgetBytes, descMaxChars, total, kept, nameOnly: [name], dropped: [name], sessionId }`
32
+ * — `requestedBudgetBytes` is what the declared fraction asked for BEFORE the delivery lane's
33
+ * structural clamp, and differs from `budgetBytes` only when that clamp bound, so a listing
34
+ * shortened by the lane is distinguishable from one shortened by the deployment's own fraction.
35
+ * Announced by the lane that DELIVERS a listing frame — the leg's first-frame producer, or the
36
+ * turn boundary that a drift (or a resume leg's initial listing) rides — once per delivered frame
37
+ * the budget actually degraded, and never for one that fit. Every count is read off that frame, so
38
+ * `total` is the whole roster on an initial listing and the newly announced set on a drift frame.
39
+ * A run whose deployment turned the listing off (`attachments.skillsListing: false`) emits nothing,
40
+ * since nothing is rendered. There is NO dedup key: each notice is one delivered frame`s cost, and
41
+ * a frame is delivered (and committed) once.
26
42
  * - `"tool_result.offload_put_failed"` (#167) — a clear-with-offload persist's fire-and-forget put
27
43
  * failed; THIS attempt stored nothing (the failure arm reports, it never re-inserts under the
28
44
  * ref) — an earlier attempt of the same idempotent re-put may already have stored the row, so
@@ -118,6 +134,20 @@ export interface EngineNotice {
118
134
  * `detail: { from, to, cause, sessionId?, runId? }` — `cause` is the `AutoModeFallbackCause` word, the
119
135
  * two ids are the rungs. Dedup key: (code, from, cause) PER RUN; a second CAUSE on the same rung is
120
136
  * its own line.
137
+ * - `"classifier.role_defaulted"` (`src/core/runner/classifier-ladder.ts`, the seat resolver) — no
138
+ * `classifier` role names a model, so another model STANDS IN for the seat. `detail: { model, reason,
139
+ * thinkingOffSpelled, sessionId?, runId? }` — `reason` ∈ `tier_below_main` (the nearest directly-bound
140
+ * tier below the run's main model) / `tier_table` (the static `flash` row, a main bound to no tier) /
141
+ * `role_fallback` (the `summarize → default` chain, no tiers) / `main_only` (the main model is the only
142
+ * model there is) / `role_unresolvable` (the role named a model the catalog cannot resolve; the main
143
+ * stands in); `thinkingOffSpelled` = whether that model's wire can carry a disable key. Minted
144
+ * AFTER the route pre-flight, so it names the seat actually used. Once per run.
145
+ * - `"classifier.contract_broken"` (same file, the round observer) — ONE model answered
146
+ * `AUTO_MODE_CLASSIFIER_CONTRACT_BROKEN_AFTER` CONSECUTIVE classifications without a verdict (each already
147
+ * re-asked once by the leg). `detail: { model, rung, consecutive, sessionId?, runId? }` — `rung` is the
148
+ * ladder position (0 the seat, 1 the model behind it), since two rungs may share an id. A ruling from
149
+ * that rung resets its count; an `unavailable` round neither counts nor resets. Once per run per rung,
150
+ * and by construction before the denial limit's third block hands the user its card.
121
151
  * - `"route.base_url_changed_key_unchanged"` (key↔URL pairing) — `Runner.swapDeps` moved a
122
152
  * same-name entry's `baseUrl` while its Model-visible credential half (auth-bearing headers)
123
153
  * did not change: legal (a provider changing domains), but worth one loud line — if the
@@ -1,5 +1,6 @@
1
1
  import { inlineUntrusted } from "./untrusted-text.js";
2
2
  import { PROBE_REASON_MAX, normalizeProbeCause } from "./checkpoint-store.js";
3
+ import { normalizeReadRootCandidate } from "./read-root-candidate.js";
3
4
  import { traceHookCrash } from "./runner/gate-exit.js";
4
5
  function withProbeTimeout(p, ms, signal) {
5
6
  if (ms === undefined && signal === undefined)
@@ -80,6 +81,7 @@ export async function runGateFold(pass) {
80
81
  let probeReason;
81
82
  let probeCause;
82
83
  let probeMandated;
84
+ let probeReadRootCandidate;
83
85
  if (input.irreversibility === "maybe" && input.reversibilityProbe) {
84
86
  let reversible = false;
85
87
  const probeArgs = pass.policyRewrite !== undefined ? pass.policyRewrite : pass.currentInput;
@@ -105,6 +107,7 @@ export async function runGateFold(pass) {
105
107
  probeCause = normalizeProbeCause(verdict?.cause);
106
108
  if (verdict?.mandated === true)
107
109
  probeMandated = true;
110
+ probeReadRootCandidate = normalizeReadRootCandidate(verdict?.readRootCandidate);
108
111
  }
109
112
  }
110
113
  catch (err) {
@@ -123,6 +126,7 @@ export async function runGateFold(pass) {
123
126
  ...(probeReason !== undefined ? { probeReason } : {}),
124
127
  ...(probeCause !== undefined ? { probeCause } : {}),
125
128
  ...(probeMandated === true ? { probeMandated: true } : {}),
129
+ ...(probeReadRootCandidate !== undefined ? { probeReadRootCandidate } : {}),
126
130
  };
127
131
  pass.tightenedBy = input.shellGated === true ? "shell_gate" : "safety";
128
132
  }
@@ -139,7 +143,7 @@ export async function runGateFold(pass) {
139
143
  pass.tightenedBy = "safety";
140
144
  }
141
145
  if (pass.decision.action === "allow" && input.writeProtectionCheck !== undefined) {
142
- const writeProtectedHit = input.writeProtectionCheck(toolName, pass.policyRewrite !== undefined ? pass.policyRewrite : pass.currentInput, pass.req.face);
146
+ const writeProtectedHit = input.writeProtectionCheck(toolName, pass.policyRewrite !== undefined ? pass.policyRewrite : pass.currentInput, pass.req.face, input.trackedCwd?.());
143
147
  if (writeProtectedHit !== null) {
144
148
  pass.decision = {
145
149
  action: "ask",
@@ -1,6 +1,6 @@
1
1
  import { attestClassifierDenyCause, classifierDenyCauseOf, decisionText, describeThrown } from "./tool-policy.js";
2
- import { askOriginOf, classifierMayAnswer, persistedRuleMandateOf, probeBoundaryMandate, safetyAxisOf } from "./ask-origin.js";
3
- import { CLASSIFIER_PARSE_FAILURE_DENY_MESSAGE, classifierUnavailableDenyMessage, denialLimitFallbackMessage, unarmedWindow } from "./auto-mode.js";
2
+ import { classifierMayAnswer, persistedRuleMandateOf, probeBoundaryMandate, safetyAxisOf, stampAskOrigin } from "./ask-origin.js";
3
+ import { classifierParseFailureDenyMessage, classifierUnavailableDenyMessage, denialLimitFallbackMessage, unarmedWindow } from "./auto-mode.js";
4
4
  import { inlineUntrusted } from "./untrusted-text.js";
5
5
  import { isRuleBehavior } from "./permission-rule-model.js";
6
6
  import { ASK_USER_QUESTION_TOOL_NAME } from "./ask-question.js";
@@ -346,7 +346,7 @@ export async function runGateLanes(pass) {
346
346
  if (pass.decision.action === "ask") {
347
347
  const { ruleStoreUnreadable: _selfDeclared, ...unstamped } = pass.decision;
348
348
  const stamped = { ...unstamped, ruleEvidence: mintRuleEvidence(personalEvidence), ...(laneCoverage !== undefined ? { segmentCoverage: laneCoverage } : {}), ...(originFacts.ruleStore !== undefined ? { ruleStoreUnreadable: originFacts.ruleStore } : {}) };
349
- pass.decision = { ...stamped, origin: askOriginOf(stamped, originFacts) };
349
+ pass.decision = stampAskOrigin(stamped, originFacts);
350
350
  }
351
351
  if (allowLayerMayClear() && pass.decision.action === "ask" && persistedRuleMandate === undefined && pass.decision.matchedAskRule === undefined && pass.req.toolName === COMMAND_RULE_TOOL) {
352
352
  const commandParam = catalogRuleFaceOf(COMMAND_RULE_TOOL)?.primaryParams[0] ?? "command";
@@ -387,7 +387,7 @@ export async function runGateLanes(pass) {
387
387
  }, "unavailable", pass.req);
388
388
  }
389
389
  else {
390
- const reason = verdict.kind === "block" ? (verdict.reason ? inlineUntrusted(verdict.reason) : "") : CLASSIFIER_PARSE_FAILURE_DENY_MESSAGE;
390
+ const reason = verdict.kind === "block" ? (verdict.reason ? inlineUntrusted(verdict.reason) : "") : classifierParseFailureDenyMessage(verdict.classifier);
391
391
  const category = verdict.kind === "block" && verdict.category ? inlineUntrusted(verdict.category) : "";
392
392
  const tracked = input.autoMode.denialTracking?.recordBlock();
393
393
  if (tracked?.limitReached === true) {
@@ -398,7 +398,7 @@ export async function runGateLanes(pass) {
398
398
  requiresRealApproval: true,
399
399
  denialLimitFallback: tracked.fallback,
400
400
  };
401
- pass.decision = { ...fallbackAsk, origin: askOriginOf(fallbackAsk, originFacts) };
401
+ pass.decision = stampAskOrigin(fallbackAsk, originFacts);
402
402
  }
403
403
  else {
404
404
  const denied = {
@@ -658,7 +658,7 @@ export async function runGateLanes(pass) {
658
658
  const { ruleStoreUnreadable: _selfDeclaredOnEdit, ...recheckUnstamped } = recheck;
659
659
  const editAskSnapshot = { ...recheckUnstamped, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) };
660
660
  markedThisRound = undefined;
661
- const editAskDecision = { ...editAskSnapshot, ...(editRuleStore !== undefined ? { ruleStoreUnreadable: editRuleStore } : {}), origin: askOriginOf(editAskSnapshot, askOriginFacts(editOrg.org, editRuleStore)) };
661
+ const editAskDecision = stampAskOrigin({ ...editAskSnapshot, ...(editRuleStore !== undefined ? { ruleStoreUnreadable: editRuleStore } : {}) }, askOriginFacts(editOrg.org, editRuleStore));
662
662
  const editAskReq = { toolName, args: editArgs, toolCallId, ...(pass.req.face !== undefined ? { face: pass.req.face } : {}) };
663
663
  const rr = await (callSignal !== undefined ? resolveAsk(editAskDecision, editAskReq, callSignal) : resolveAsk(editAskDecision, editAskReq));
664
664
  ledger.fromResolved(rr, editAskDecision, toolName);
@@ -677,7 +677,7 @@ export async function runGateLanes(pass) {
677
677
  editRewrittenSinceHuman = false;
678
678
  }
679
679
  if (editDenied === undefined && editRewrittenSinceHuman && input.writeProtectionCheck !== undefined) {
680
- const editHit = input.writeProtectionCheck(toolName, editArgs, pass.req.face);
680
+ const editHit = input.writeProtectionCheck(toolName, editArgs, pass.req.face, input.trackedCwd?.());
681
681
  if (editHit !== null) {
682
682
  editDenied = {
683
683
  action: "deny",
@@ -93,6 +93,31 @@ export type Settlement = {
93
93
  };
94
94
  when: number;
95
95
  };
96
+ /** The person arm of {@link Settlement} — the shape the two human words inhabit. Named so a site that
97
+ * both composes one and quotes its note can read the seat off the record instead of re-deriving it. */
98
+ export type PersonSettlement = Extract<Settlement, {
99
+ kind: "human_allowed" | "human_refused";
100
+ }>;
101
+ /**
102
+ * A decider's NOTE, normalised — the one rule for "did the decider say anything": empty text is a
103
+ * decider who attached nothing and reads as absent wherever the note travels. Whitespace-only text is
104
+ * not empty by this rule and stays a note: that half is PINNED CURRENT BEHAVIOUR, not a promise —
105
+ * nothing here requires a note to carry content, and narrowing it is a separate decision with its own
106
+ * consumers to warn.
107
+ */
108
+ export declare function decidersNote(text: string | undefined): string | undefined;
109
+ /**
110
+ * THE mint for a person's settlement: the synchronous resolver's human arms and the durable decide
111
+ * lane's person arm compose their record here, so one refusal plus one reason text cannot end as two
112
+ * different records depending on which path carried it.
113
+ *
114
+ * @contract settlement.note — the `note` seat is filled exactly when the decider attached a reason, and
115
+ * its absence is how the record says the refusal was bare (the runner reads that absence, and nothing
116
+ * else, as the issuing batch's control-flow boundary). Empty text folds to absence HERE, once, for
117
+ * every path that fills the seat ({@link decidersNote}); a site that hand-builds the record instead is
118
+ * a second answer to the same question, which is how one path came to halt a batch the other let run.
119
+ */
120
+ export declare function settledByPerson(kind: PersonSettlement["kind"], approver: string | undefined, note?: string): PersonSettlement;
96
121
  /** Which settlement kinds are REFUSALS (the ask ended as a deny). Total over the closed set — the fence
97
122
  * below refuses a member without a row — so invariant I2 is a table lookup, not a list in a condition. */
98
123
  export declare const SETTLEMENT_IS_REFUSAL: {
@@ -18,6 +18,13 @@ const SETTLEMENT_KIND_SET = new Set(SETTLEMENT_KINDS);
18
18
  export function isSettlementKind(v) {
19
19
  return SETTLEMENT_KIND_SET.has(v);
20
20
  }
21
+ export function decidersNote(text) {
22
+ return text === "" ? undefined : text;
23
+ }
24
+ export function settledByPerson(kind, approver, note) {
25
+ const given = decidersNote(note);
26
+ return { kind, who: { party: "person", ...(approver !== undefined ? { approver } : {}) }, when: Date.now(), ...(given !== undefined ? { note: given } : {}) };
27
+ }
21
28
  export const SETTLEMENT_IS_REFUSAL = {
22
29
  human_allowed: false,
23
30
  human_refused: true,
@@ -104,7 +104,7 @@ export type NoticeAudience = "user" | "operator";
104
104
  * src/ for notice mint shapes and names any code that is minted but unregistered, or registered but
105
105
  * no longer minted.
106
106
  */
107
- export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_swapped", "config.read_face_deployment_clamped", "config.write_face_swapped", "config.write_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.artifact_host_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "classifier.fallback", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.classifier_unroutable", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "task.interrupt_unconsumed", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
107
+ export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_swapped", "config.read_face_deployment_clamped", "config.write_face_swapped", "config.write_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.artifact_host_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "classifier.fallback", "classifier.role_defaulted", "classifier.contract_broken", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.classifier_unroutable", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "task.interrupt_unconsumed", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "skills.listing_truncated", "tool_result.offload_put_failed"];
108
108
  /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
109
109
  * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
110
110
  * sink is a supported shape, and narrowing that field would break it. */
@@ -119,6 +119,8 @@ export const ENGINE_NOTICE_CODES = [
119
119
  "peer.idle_subscription",
120
120
  "classifier.denial_limit",
121
121
  "classifier.fallback",
122
+ "classifier.role_defaulted",
123
+ "classifier.contract_broken",
122
124
  "checkpoint.execution_outcome_unrecorded",
123
125
  "delegation.transcript_integrity",
124
126
  "delegation.ask_unresolvable",
@@ -151,6 +153,7 @@ export const ENGINE_NOTICE_CODES = [
151
153
  "task.interrupt_unconsumed",
152
154
  "memory.capture_opted_out",
153
155
  "memory.capture_optout_unpersisted",
156
+ "skills.listing_truncated",
154
157
  "tool_result.offload_put_failed",
155
158
  ];
156
159
  const NOTICE_AUDIENCE_TABLE = {
@@ -164,6 +167,8 @@ const NOTICE_AUDIENCE_TABLE = {
164
167
  "task.user_followup_undrained": "user",
165
168
  "classifier.denial_limit": "user",
166
169
  "classifier.fallback": "operator",
170
+ "classifier.role_defaulted": "operator",
171
+ "classifier.contract_broken": "operator",
167
172
  "task.turn_interrupted": "user",
168
173
  "steering.parked_input_blocked": "user",
169
174
  "task.halt_unconsumed": "user",
@@ -209,6 +214,7 @@ const NOTICE_AUDIENCE_TABLE = {
209
214
  "route.fallback_to_primary": "operator",
210
215
  "route.classifier_unroutable": "operator",
211
216
  "route.base_url_changed_key_unchanged": "operator",
217
+ "skills.listing_truncated": "operator",
212
218
  "tool_result.offload_put_failed": "operator",
213
219
  };
214
220
  export const NOTICE_AUDIENCE = NOTICE_AUDIENCE_TABLE;
@@ -1354,7 +1354,7 @@ export interface ToolGateInput {
1354
1354
  * (the write-protection tighten below). Absent ⇒ the deployment replaced the table with `[]` (or
1355
1355
  * the caller runs the gate without one) and the decision path is byte-identical.
1356
1356
  */
1357
- writeProtectionCheck?: (toolName: string, args: unknown, face?: import("./tool-registry.js").ToolCallLike["face"]) => import("./write-protect.js").WriteProtectedHit | null;
1357
+ writeProtectionCheck?: (toolName: string, args: unknown, face?: import("./tool-registry.js").ToolCallLike["face"], cwd?: string) => import("./write-protect.js").WriteProtectedHit | null;
1358
1358
  /**
1359
1359
  * design/143 批2 (A, CC 2.1.207 auto mode): when present, a surviving `ask` is routed to the
1360
1360
  * small-model policy CLASSIFIER before any human/durable resolution:
@@ -0,0 +1,12 @@
1
+ /** The display bound on a grant candidate's directory: generous next to a real `PATH_MAX`, tight enough
2
+ * that the seat cannot become an unbounded prose channel onto an approval card. */
3
+ export declare const READ_ROOT_CANDIDATE_DIR_MAX = 1024;
4
+ /**
5
+ * The validator for a probe's {@link import("./tool-spec.js").ReversibilityVerdict.readRootCandidate}: the
6
+ * errand `normalizeProbeCause` does for the cause, fail-closed on the disclosure alone — a malformed value
7
+ * yields `undefined`, the ask stands, the card carries no offer. It REFUSES where the cause NEUTRALIZES,
8
+ * per `shell.read_boundary.grant_candidate_is_the_grant`: the cause is prose, so folding a separator out of
9
+ * it keeps it true, while this string IS the grant, and a card showing one spelling while the deployment
10
+ * applies another is the failure the seat prevents.
11
+ */
12
+ export declare function normalizeReadRootCandidate(value: unknown): import("./tool-spec.js").ReadRootGrantCandidate | undefined;
@@ -0,0 +1,24 @@
1
+ import { isPlainRecord, safeDataValue } from "./checkpoint-store.js";
2
+ import { hasWin32AliasedComponent, isAbsolutePathForm, normalizeAbsPathLexically, pathFamilyOf, win32NamespaceScreen } from "../tools/fs/safety.js";
3
+ export const READ_ROOT_CANDIDATE_DIR_MAX = 1024;
4
+ export function normalizeReadRootCandidate(value) {
5
+ if (!isPlainRecord(value))
6
+ return undefined;
7
+ if (safeDataValue(value, "clearsThisAsk") !== true)
8
+ return undefined;
9
+ const dir = safeDataValue(value, "dir");
10
+ if (typeof dir !== "string" || dir.length === 0 || dir.length > READ_ROOT_CANDIDATE_DIR_MAX)
11
+ return undefined;
12
+ if (/[\u0000-\u001f\u007f\u2028\u2029]/.test(dir))
13
+ return undefined;
14
+ if (!isAbsolutePathForm(dir))
15
+ return undefined;
16
+ const folded = normalizeAbsPathLexically(dir);
17
+ if (folded !== dir || normalizeAbsPathLexically(`${dir}/..`) === dir)
18
+ return undefined;
19
+ if (hasWin32AliasedComponent(dir))
20
+ return undefined;
21
+ if (!win32NamespaceScreen(dir, pathFamilyOf({ root: dir })).ok)
22
+ return undefined;
23
+ return { dir, clearsThisAsk: true };
24
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ThinkingLevel } from "../internal/harness-types.js";
2
2
  import type { Model } from "../internal/llm.js";
3
- import type { ModelRef, ModelRole, ModelRoles } from "./types.js";
3
+ import type { ModelRef, ModelRole, ModelRoles, RoleSpec } from "./types.js";
4
4
  export type { ModelRole, RoleSpec, ModelRoles } from "./types.js";
5
5
  /** Resolve a string ModelRef against the catalog (a Model object passes through). */
6
6
  export declare function resolveModel(ref: ModelRef, models?: Record<string, Model>): Model;
@@ -87,6 +87,14 @@ export declare function parseModelMention(text: string, allowedNames: Iterable<s
87
87
  * (即使 catalog 恰有裸模型名 `pro`/`flash` 也不入档位路由——INERT 靠配置成立,不靠键名运气)。
88
88
  */
89
89
  export declare const ROLE_TIER_DEFAULTS: Readonly<Partial<Record<ModelRole, string>>>;
90
+ /**
91
+ * Whether a RoleSpec NAMES a model — a string ref, a Model object, or a config object carrying `model` or
92
+ * `select` — as opposed to a preset-only object (`{ thinking }`, `{ systemPrompt }`) that shapes the role's
93
+ * request but leaves its model to the defaults. The classifier seat's own default (the tier below the run's
94
+ * main model, `classifier-ladder.ts`) applies exactly when this is false: a `{select}` keeps its selection, a
95
+ * preset-only role does not disable the relative pick. One reader, the same parts {@link specParts} reads.
96
+ */
97
+ export declare function roleSpecNamesModel(spec: RoleSpec | undefined): boolean;
90
98
  export interface ResolvedRole {
91
99
  model: Model;
92
100
  /** The effective thinking level: explicit `spec.thinking` wins, else the role's default. */
@@ -130,6 +130,10 @@ function specParts(s) {
130
130
  const cfg = s;
131
131
  return { model: cfg.model, select: cfg.select, thinking: cfg.thinking, systemPrompt: cfg.systemPrompt };
132
132
  }
133
+ export function roleSpecNamesModel(spec) {
134
+ const parts = specParts(spec);
135
+ return parts.model !== undefined || parts.select !== undefined;
136
+ }
133
137
  function toConfig(s) {
134
138
  if (s == null) {
135
139
  return undefined;