@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,98 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.19.0 — 2026-09-16
4
+
5
+ ### Fix — a refusal's note seat is filled by ONE rule, so an empty rejection reason cannot mean two different things (#812 / B-123; **narrowing** on the durable-resume path; @server @cli @test)
6
+ - **Before.** The `note` on a `human_refused` settlement is the batch boundary: absent = the decider said "no" and nothing else, so every never-started sibling of the same assistant batch settles `gate.batch_halted`; present = a reasoned deny that stops only the call it named. Two paths filled that seat and they folded empty text differently. The synchronous approver's `{ allow: false, reason: "" }` was folded to absent in the resolver (`tool-policy.ts`), so the batch halted. The durable decide's `{ decision: "deny", reason: "" }` was kept verbatim as `note: ""` (`resume-apply.ts` filtered only `undefined`), so the record read "reasoned" and every sibling RAN — while the model-facing continuation for that same decide, which tests the reason for truthiness, told it the call `was DENIED` with no reason at all. One deny, one reason text, opposite fates for the rest of the batch depending on which path carried it.
7
+ - **After.** A person's settlement has ONE composer, `settledByPerson` in `gate-outcome.ts` (beside the record it builds and its screen), and it applies the note seat's own rule, `decidersNote`: empty text is a decider who attached nothing. Both paths compose there — the resolver's human arms and the durable decide lane's person arm — and the resolver's live text now quotes the same folded note it records, so the words the model reads and the record a consumer reads cannot disagree. `maybeHumanRejectionHalt` is untouched: it still reads absence, and nothing else, as the boundary. Contract sentence `settlement.note` on the mint; registry row `settledByPerson` in `gate:single-mint` (a second inline composition of the record is red).
8
+ - **Narrowing (durable resume only).** A decide carrying `reason: ""` now halts the rest of the batch instead of letting the siblings run — it is read as the bare refusal the live path always read it as. Nothing widens: no deny becomes an allow, and a decide with any non-empty reason (whitespace included) behaves exactly as before. `reason: undefined` is unchanged on both paths.
9
+ - **Whitespace is unchanged and pinned, not promised.** `" "` / `"\n\n"` are a note today on BOTH paths (siblings run). That is pinned as current behaviour; nothing in the record's contract requires a note to carry content, and narrowing it would be its own decision with its own consumers to warn.
10
+ - Pins: `test/backlog812-settlement-note-fold.test.ts` (the same five reasons down both paths, compared to each OTHER rather than to a remembered number; the boundary read directly; the fold; the declaration-count pin).
11
+
12
+ ### Security / narrowing — a write spelled RELATIVELY from inside a write-protected directory is now judged the same as the absolute spelling (#815; found by #804's adversarial round; every deployment)
13
+ - **Before.** The write-protection judge (`compileWriteProtection` / the gate's fourth tighten) was purely lexical over the string in the tool's arguments, and never read the run's live tracked cwd. So `cd ~/.ssh` followed by a bare `Write("authorized_keys")` carried no guarded segment at all and produced **zero ask**, while the same file spelled absolutely asked. The hole is not one family's: it applied to **every `segment` row of the always-on table** — `.ssh`, `.git`, `.gnupg`, `.vscode`, `.idea`, `.husky`, `.cargo`, `.devcontainer`, `.yarn`, `.mvn`, `.claude`, `.sema` — and a bare deployment (no opt-in deny policy wired) had no second line behind it.
14
+ - **After.** A relative spelling is ANCHORED against the call's live tracked cwd — the same ref the fs hand tools resolve with, read at judgment time, handed by the gate — before the table is read. Two spellings of one file get one verdict. This is the law RB-108 already set for the path policies (its measured example was `cd .git/hooks` + `Write("pre-commit")`); the write-protection table shipped later and had never joined it.
15
+ - **Scope of the change.** Anchoring only; the join is the shared `joinForFamily` mint, with no normalization, so the two-view reading and the data-root passthrough are byte-identical on an absolute target. The passthrough now also covers relative spellings from inside the data root (a memory write from `cd <dataRoot>/memory` is exempt, as its absolute twin already was).
16
+ - **Seat (additive, absence reading preserved).** `WriteProtectionMatcher.matchPath(path, cwd?)` and `ToolGateInput.writeProtectionCheck(toolName, args, face?, cwd?)` take an OPTIONAL trailing cwd. **Absent ⇒ the spelling is judged exactly as given, i.e. today's lexical reading byte for byte** — a host driving `runToolGate` without `trackedCwd` sees no change, and an existing caller of `compileWriteProtection` keeps its arity.
17
+ - **Reach.** A deployment can now see an ask where a relative spelling previously produced none. That is a narrowing in the safe direction (one explicit look, clearable by an approver or a persisted allow rule); nothing that asked before stops asking.
18
+ - Pins: `test/backlog804-agent-config-write-tier.test.ts` (`#815` block — `.ssh` / `.git/hooks` / `.gnupg` / `.claude` judged identically on both spellings; the absent-cwd reading; an absolute target unmoved by the cwd in both directions; the passthrough on both spellings; end-to-end through `runToolGate` with and without `trackedCwd`).
19
+
20
+ ### Write protection is layered by what the file IS: agent configuration FILES deny, the configuration DIRECTORY asks (#804; @server @cli @client-core @test; deployment operators)
21
+ - **Before.** The opt-in write deny set (`RECOMMENDED_SENSITIVE_PATTERNS`) carried the agent configuration DIRECTORIES whole — `.claude*` and `.sema` as segment entries — so every path beneath them was deny severity. A person who told the agent to edit a file in their own library under `~/.claude/projects/<slug>/memory/*.md` got a hard refusal they could not lift: a deny outranks an ask under the deny > ask > allow fold, so the always-on ask table's `.claude` / `.sema` rows — which an approver or a persisted allow rule CAN clear — never got to answer. Measured on a live deployment (three refused Write/Edit calls on a file the user had named).
22
+ - **After — the law.** The class is split by what the file is, not by which paths were reported. An agent's own configuration FILE stays at deny severity (writing it re-authorizes the agent or hands the host a command line to launch); the configuration DIRECTORY around it holds the person's own working material — memory libraries, agent and skill definitions, project notes — and is guarded one severity down by `WRITE_PROTECTED_DEFAULT_TABLE`'s `.claude` / `.sema` segment rows, which are default-on and need no opt-in. Every write there still stops for one explicit look; it is now a look a person can answer.
23
+ - **After — one table, two judges.** The deny set's agent-configuration block is DERIVED from the read face's `agent-config` tier (`READ_FACE_BUILTIN_DENY_TABLE`, filtered) rather than spelled a second time, so the two faces cannot drift about which files are an agent's own configuration. Contract sentence `sensitive_paths.agent_config_tier_is_shared` (on `agentConfigDenyPatterns`): **the write deny set's agent-configuration block IS the read face's `agent-config` tier, row for row; the configuration DIRECTORIES (`.claude`, `.sema`) are on neither face's deny side — their write guard is the ask-side write-protection table.** A set-EQUALITY pin (not a sample) reds the moment either side gains or loses a row.
24
+ - **After — the ask tier is the SAME canonical judge, not only the lexical table.** `createSensitivePathPolicy` now answers TWO severities from one judge: `patterns` (deny) and a new **`askPatterns`** seat, consulted only where the deny set misses. Contract sentence `sensitive_paths.two_severities_one_judge` (on `createSensitivePathPolicy`): **a deny is a refusal no approver and no persisted allow rule can lift; an ask is one explicit look either of them may clear; lowering a path's severity never lowers its REACH** — both tiers see the real target (symlinks resolved, relative spellings resolved against the live cwd), which the synchronous write-protection TABLE deliberately does not. Without this, demoting the config directories to the lexical table alone would have traded a severity for a reach: a symlink whose spelling is innocent walks past a lexical judge (measured on the branch).
25
+ - **New export `RECOMMENDED_SENSITIVE_ASK_PATTERNS`** (additive; `readonly string[]` = `[".claude", ".sema"]`). **`askPatterns` ABSENT ⇒ this set** — a deployment that already wires this guard keeps the canonical reach it had over those directories **without changing a line**, at the severity a person can clear. `askPatterns: []` is the explicit no-tier posture and the only way to have none; a degenerate entry (`"/"`) throws at wiring time exactly as a degenerate deny pattern does. The data-root passthrough applies to both tiers, so the memory library is never asked about — alias included.
26
+ - **WIDENED (deny → ask), the whole point of the ticket.** Removed from the deny set: `.claude*`, `.sema`. Every path under a `.claude` / `.sema` directory that is NOT one of the configuration files below moves from a hard refusal to an approvable ask — `.claude/projects/**`, `.claude/agents/**`, `.claude/skills/**`, `.sema/projects/**`, `.sema/agents/**`, `.sema/skills/**`, and anything else under those directories. The two directories move to `askPatterns`' default set, so they keep their CANONICAL reach at the new severity. `.claude.json` / `.sema.json` are unaffected: `.claude.*` / `.sema.*` still deny them.
27
+ - **NARROWED (ask → deny), the two hook directories.** `.claude/hooks` and `.sema/hooks` join the deny set as WRITE-FACE rows of their own (hand-kept beside the derived block, absent from the read tier — reading a script is harmless, writing it is the escalation). A hook script is the command line the host runs on its next tool call, so editing one hands the host a command without touching the settings file that names it: the `.git/hooks` argument, one ecosystem over. Consequence for a deployment that wires the recommended set: a write anywhere under those two directories is a refusal no approver and no persisted allow rule can lift, where the directory relaxation above would otherwise have made a blanket `Write(~/.claude/**)` rule clear it. Two segments each, so the rest of the configuration directory is untouched and stays at ask severity.
28
+ - **NARROWED (allow → deny), eight patterns.** Taking the whole shared tier brings eight rows the write set did not carry. A write to any of these is now a refusal where it used to be an ordinary write (an approvable ask, or no friction at all):
29
+ - `.ai-agent/.env` — the engine host's data-root key file under the default root;
30
+ - `.sema/engine-data/.env` — the same file under the shell's layout;
31
+ - `.codex/config.toml` — `[mcp_servers.<name>] env` takes literal values;
32
+ - `.cursor/mcp.json` — MCP server definitions (`.cursor/rules` stays writable);
33
+ - `.continue/config.json` and `.continue/config.yaml` — `apiKey` fields, both serializations;
34
+ - `.aider.conf.yml` — `openai-api-key` / `anthropic-api-key` keys;
35
+ - `.gemini/settings.json` — `mcpServers` env.
36
+ Each is an agent configuration file whose `env` / `mcpServers` block is a credential or a command line the host launches — the argument the already-present `.mcp.json` row makes, applied to the same class by the same table.
37
+ - **Deployment operators.** This changes the RECOMMENDED set only. A deployment that sets `SENSITIVE_WRITE_PATTERNS` (or otherwise passes its own `patterns` to `createSensitivePathPolicy`) does NOT inherit the two deny-set changes — but it DOES get the ask tier: `askPatterns` absent = `RECOMMENDED_SENSITIVE_ASK_PATTERNS` (`.claude`, `.sema`), so a symlink alias or a relative spelling into those directories that was silently allowed before now asks once (on a headless lane: refuses). Pass `askPatterns: []` to opt out. The deny tier still ships no implicit default list.
38
+ - **Unchanged.** The data-root passthrough (HRD-PRM-10) and its exemption asymmetry; the `!canon.ok ⇒ deny` fail-closed arm for a target the guard could not resolve; the READ face in every respect (its table, its tiers, its verdicts — pinned as a negative control); the export `RECOMMENDED_SENSITIVE_PATTERNS`, whose name and type (`readonly string[]`) are byte-identical in shape — only its contents moved; `.sema-notifier-build`, the one directory of the brand namespace that keeps deny severity (its contents are compiled into an app the shell launches).
39
+ - **Two arms the ask tier's own review turned up, both fixed in it.** (i) The policy's "nothing to judge" early exit keyed on the DENY list alone, so an ASK-ONLY deployment (`patterns: []` with an ask set) silently got no protection — it now exits only when BOTH tiers are empty. (ii) The ask message named the model-authored path, and an ask's text is the auto-mode classifier's `askMessage`, so a newline-bearing filename could forge a turn inside the sentence the call is judged by; the ask now names the matched PATTERN only (the deny arm still names the path — a refusal reaches no classifier, and the approval card shows the real arguments either way).
40
+ - **Known gap, declared (#817).** The always-on write-protection TABLE — the floor a deployment gets without wiring this guard at all — is a synchronous LEXICAL judge with no canonical reach: a symlink whose spelling is innocent produces no ask there, and always has (`notes.txt -> .bashrc`, measured, unchanged by this ticket). That is a property of the whole table, not of these two rows; unifying the sync-lexical tighten seat with the async-canonical policy seat is a design question, filed rather than patched here.
41
+ - **@server (the one consumer of the recommended set).** No code change is required: `askPatterns` absent reads as the recommended directory set, so a deployment that wires `RECOMMENDED_SENSITIVE_PATTERNS` recovers the canonical reach automatically. Pass `askPatterns: []` to decline the tier, or your own list to replace it.
42
+ - Pins: `test/backlog804-agent-config-write-tier.test.ts` (the reported path and its class; the configuration files on both brand spellings plus the seven third-party rows; the data-root passthrough; a persisted allow rule silencing the ask and failing to touch the deny; the headless lane on both severities; the read face as a negative control; the derivation set-equality; and the ask tier — a symlink alias onto a config directory asks while the same alias onto a settings file still denies, `askPatterns: []` as the explicit no-tier posture with the remaining lexical gap pinned honestly, the passthrough through an alias, a replacement set, and the loud degenerate entry). Re-cut: `backlog549-sema-config-root-guard` (the config FILES keep their pins; the directory assertions say which half they mean; the passthrough-asymmetry fixtures now site their root beneath `.ssh`, a segment row still on the list), `p4-sensitive-patterns-completeness` (the coverage table follows the list — near-misses are now mostly a NON-config file in the very same directory, which is the law under test).
43
+
44
+ ### The `<skills>` disclosure block's budget is a DECLARED seat, sized to the model's window (#806; ruling C-R41; @server @cli @client-core @test)
45
+ - **Before.** The `<skills>` listing was capped at a flat 8 000 UTF-8 bytes, hard-coded, with no way for a deployment to say anything about it — and the per-skill description cap (1 536) was equally fixed. The three degradation tiers (full / priority-ordered descriptions / name-only + overflow count) were already in place and are unchanged.
46
+ - **After.** Two optional `TaskSpec` seats, CC 2.1.259's (`Jar`/`yTe`): `skillListingBudgetFraction` (absent ⇒ `0.01`, domain `(0, 1]`) and `skillListingMaxDescChars` (absent ⇒ `1536`, positive integer). The budget is `(contextTokens ?? contextWindow ?? 200 000) × 4 × fraction` bytes. Both are read at the config door, before session acquisition; a value outside its domain REFUSES the prepare with `config.skill_listing_budget_invalid` rather than folding back to the default.
47
+ - **Narrowing (behaviour face, no key required).** A model declaring a window SMALLER than 200 000 tokens now gets a proportionally smaller listing: 128 000 tokens ⇒ 5 120 bytes, 32 000 ⇒ 1 280, where all of them previously got 8 000. Every skill is still listed; the lower-priority ones (declaration order) lose their descriptions first and the `Skill` tool still serves all of them. At 200 000 tokens and above the default resolves to exactly the old 8 000, so the default render is byte-for-byte unchanged — pinned against the published 7.18.0 dist.
48
+ - **The fraction is ONE-WAY in this engine (`@contract skills.listing_budget_ceiling`).** The block is the body of the `skills_listing` turn attachment, and a bundle over `ATTACHMENT_BYTE_CAP` is clipped; a clipped frame never satisfies the intact-survival commit predicate, so it would replay at every boundary forever. The resolved budget is therefore clamped to 8 000 bytes: a raised fraction lowers nothing and raises nothing, a lowered one takes effect exactly. When the clamp costs a skill its description, `skills.listing_truncated` carries `requestedBudgetBytes` beside `budgetBytes` so both numbers are legible.
49
+ - **ONE RULER FOR EVERY FRAME — the drift frame is measured too.** The boundary's "New skills are now available" frame rendered every added line at full length with no block budget at all, and the removal arm listed every departed name however many there were. A drift that added ten 1 536-character descriptions produced a ~15 KB body (and a single 10 000-character description under a wide `skillListingMaxDescChars` produced a 10 KB one): over `ATTACHMENT_BYTE_CAP`, so the bundle CLIPPED it, so the intact-survival commit predicate could never match, so the same frame was re-rendered and re-clipped at every boundary for the life of the task. Both arms and the joiners are now priced against the SAME resolved budget the initial block uses, and the add arm degrades through the same three tiers (full → name-only in declaration-rank order → an honest overflow count). The frame's size is now the declared budget or, when even the two arms' floors do not fit it, a small constant — never a function of the roster. Stated cost: a departed name the removal arm cannot fit is not announced (the count line says how many), against a frame that would otherwise replay forever.
50
+ - **New notice `skills.listing_truncated`** (closed-set addition to `ENGINE_NOTICE_CODES`; audience `operator`): emitted by the lane that DELIVERS a listing frame — the leg's first-frame producer, or the turn boundary a drift (or a resume leg's initial listing) rides — once per delivered frame the budget actually degraded. `detail: { budgetBytes, requestedBudgetBytes, descMaxChars, total, kept, nameOnly[], dropped[], sessionId }` — every count is read off the frame that shipped, so `total` is the whole roster on an initial listing and the newly announced set on a drift frame. Three consequences of announcing at the delivery points rather than at the config door (#819): a deployment that turned the listing off (`attachments.skillsListing: false`) is no longer told that a listing it does not get would not fit; a session running many tasks hears the fact per delivered listing instead of per prepared task; and a **durable-resume leg**, whose first-frame producer never runs, is no longer silent about a listing the budget degraded on its way in (found by the adversarial round).
51
+ - **Performance.** The overflow (tier C) leg recomputed a prefix sum — and re-sliced the name array — on every candidate, making it quadratic in the roster size. One prefix-sum pass replaces it, byte-identically: 2 000 / 10 000 / 20 000 skills now render in 17 / 64 / 133 ms against 407 / 6 645 / 24 499 ms before.
52
+ - Pins: `test/backlog806-skill-listing-budget.test.ts` (six byte/sha fixtures against the published 7.18.0 dist; the budget boundary at exactly-equal and one byte under; the ceiling clamp and the renderer's re-clamp; eight + six bad-value refusals; the disclosure's silence and its detail; the multi-turn no-replay run; a source census that every render site is handed a budget; the tier-C rewrite against a naive reference plus its timing; the drift frame under both arms at 20 / 2 000 / 20 000 skills, the wide-description-cap construct, the removal flood, a durable-resume seed, and the delivered frame's own counts against the notice's).
53
+
54
+ ### One rejected-value describer, not two (#809; no wire face)
55
+ - The config-doors phase carried a second local `describeRejectedValue` built on `JSON.stringify`, beside the shared one every other config seat uses. Same three defects: the rejected value narrated its own refusal through `toJSON` (an illegal object described itself as a legal word), `NaN` and `Infinity` both serialised to `null`, and a circular value degraded to `a object value`. The local copy is deleted and the door imports the shared describer; codes and sentence structure are unchanged.
56
+ - Pins: `test/backlog809-shared-rejected-value-describer.test.ts` (the three value shapes through the attachments door; one description across four seats; a source census that `src/` declares exactly one describer).
57
+
58
+ ### The out-of-root read approval card names the ONE directory that would end the asking (#803; additive; @server @cli @client-core @test)
59
+ - **Before.** A read outside the session's declared directories raised a MANDATED ask — a persisted allow rule cannot clear it, so the card offered no "don't ask me again" either. Everything a person could do about it was say yes, and say yes again on the next call. The configuration that WOULD end it existed (`TaskSpec.additionalReadDirectories`, the read-only half of the `--add-dir` parity) and the option's label minter existed (`formatOutOfRootReadApprovalOption`); the ask simply never said which directory to name. Two contract sentences stated the gap as settled — `ReversibilityVerdict.mandated` ("no clearing channel in this version") and `AskRequest.ruleOffersAbsence`'s `"mandated"` arm ("must not point at a directory GRANT either").
60
+ - **After.** The ask carries `readRootCandidate: { dir, clearsThisAsk: true }` (new export `ReadRootGrantCandidate`) when the read boundary can name one directory whose addition to the session's read containment would make the same call run with no card at all. The same member rides `ReversibilityVerdict`, so a deployment's own `reversibilityProbe` can speak it; the gate validates it at the seam and a malformed value costs the OFFER only, never the ask. The two sentences above now say what is true.
61
+ - **Internal, no behaviour face.** The structural containment gate (`classifyOutOfRootReadGate`) asked the walk to mint a grant candidate and then discarded it — its answer has two members and neither is the candidate — so every out-of-root read paid for a second full walk that nothing read. It now asks for no candidate; the offer is minted by the verdict face, whose caller can put it on a card.
62
+ - **@contract `shell.read_boundary.grant_candidate_is_the_grant`.** The string a card DISPLAYS is byte for byte the string a deployment ADDS. A surface may shorten it for the eye and must send this spelling back when the person accepts. Pinned end to end: the directory read off the card, handed back verbatim as `additionalReadDirectories`, turns the same command into a zero-ask call.
63
+ - **One rule, not a table of arms.** The candidate rides the SAME arm as the read walk's containment sentence and is dropped wherever that sentence is dropped. So everything that would make `clearsThisAsk` a lie refuses by construction: a sensitive-path deny row and an unresolvable operand return before that arm; an unexpanded glob, a recursive walk and a `..`-traversing spelling are tested on it; a shape refusal (`tail -f <out-of-root>`) takes the sentence and the candidate together. A heredoc, a line break, a substitution and an argument-as-program read (`python3 -c …`) are refused whole by the grammar gate, which never reaches the walk at all.
64
+ - **Deliberately narrow.** The candidate is the deepest directory that PROPERLY contains every out-of-root path the walk resolved, folded lexically (this file is zero-I/O by contract, so an operand that IS a directory contributes its parent — `ls /etc/ssl` ⇒ `/etc`, one level wider than the reference implementation's stat-then-dirname step; the card shows what it grants, so a person grants what they see). Refused rather than widened when the result would be a filesystem root, or would contain the declared home or one of the declared roots. A backgrounded call and an ask another layer already raised get the mandate and NOT the offer — widening the roots would leave both standing.
65
+ - **The offer is withdrawn when a layer ABOVE the tighten is also asking** (adversarial round 1, measured hole). The persisted-rule and org lanes run AFTER the tighten that names the directory, and either can leave an ask standing that no grant answers: with a person's own standing `Bash(cat:*)` ask row, `cat <out-of-root>` carried the offer and the same call after declaring the directory was still asked about. The withdrawal is ONE rule at the ONE derivation that already answers "why is this an ask" — the ask-ORIGIN ladder: a disclosure speaking for the tighten alone survives only the two rungs the tighten produces (`shell_gate_tighten` / `safety_tighten`), and every rung above (a standing `ask` row, an unreadable rule store, an org verdict, a hook, the denial-limit fallback) takes it with them. The three origin-stamp sites now share one named act, `stampAskOrigin`.
66
+ - **The claim is MEASURED, not argued** (adversarial round 2, measured hole). The first form derived the offer from a conjunction of guards, and `cd /opt/data && cat id_rsa` passed every one — no glob, no unresolvable operand, no deny row, because a BARE operand outside the roots is not walked at all — while the grant brought the file into range of the deny judge and left the ask standing. The walk is pure and zero-I/O, so it now simply runs AGAIN with the proposed root in place and offers the directory only when that walk has no objection left. The guard list is gone: the replay sees exactly what the next call will see.
67
+ - **The probe seat enforces its declared shape** (adversarial round 2). `readRootCandidate.dir` is documented as an absolute, lexically folded directory; the validator checked length and control characters but not that. A deployment-supplied probe could put `../../..` on a card, which resolves to the filesystem root where the deployment applies it. Absolute / already-folded / not-a-family-root are now enforced family-aware with the same primitives the boundary compares with, and a value that fails is dropped rather than repaired.
68
+ - **The common-directory search is LINEAR** (adversarial round 3, measured hole). Climbing one directory at a time re-folded the whole remaining path on every step: one 32 KB command naming a 16 000-segment operand cost **4.9 s** of synchronous work — on the walk that runs BEFORE the gate arms its probe deadline, so the deadline could not interrupt it. The paths are already folded, so the answer is their common prefix cut at a separator; the family-aware comparator still confirms the result, and a prefix it cannot confirm contains both paths yields no candidate. Same answer, **9 ms** at that depth, which is the cost the same command had before this seat existed.
69
+ - **A Win32 ALIAS of another directory is never named** (adversarial round 4, measured hole ×2). Win32 trims a trailing dot or space from every path component, so `x. ` and `x` are one directory to the OS and two strings to a lexical comparator — a fact this repo already stated in its UNC key minter and now reads from one predicate (`hasWin32AliasedComponent`). Two ways it bit: an alias of the DRIVE ROOT reached the card (`C:\.. ` passes absolute + folded + not-a-root and resolves to `C:\` wherever the OS strip runs), and a byte cut BETWEEN two aliased spellings (`…\reports.` beside `…\reports`) landed one level above the directory they share, offering the parent's other children along with what was read. Both boundaries now withhold; POSIX is untouched (a trailing dot there is an ordinary filename character) and the UNC anchor keeps its exemption (a host's trailing dot is DNS, not the client-side strip).
70
+ - **A device namespace is never named** (adversarial round 5). The validator accepted `\\.\C:\Windows`, which the read boundary's own namespace screen refuses outright and which does not even contain itself — a deployment-supplied probe could advertise a grant the boundary can never match. It now consults that one screen; the screen's other verdict (a spelling it would rewrite) needs no test, because every such spelling is one the fold check already changed.
71
+ - **Reach: zero decision moved.** Advisory display metadata, read by nothing in the engine; the ask's prose, the rule-offer door, the shadowed-rule disclosure and the deny tier are all byte-unchanged, and the deny tier is judged by pattern rather than by root so no grant reaches it. WRITE face and the DURABLE PARK row are out of scope in this version — a parked out-of-root ask renders the confirmation alone, as before.
72
+ - Pins: `test/backlog803-read-root-candidate.test.ts` (28: the arm table in both directions, both probe seats, the closed loop for every offered command on both seats, the deny tier under the widest possible grant, the gate's carry and its refusal to merge onto someone else's ask, the validator's refuse-don't-repair posture, and an end-to-end negative control over the whole `AskRequest` member list).
73
+
74
+ ### Fix — adapter truthfulness: a stream that carried text and then ended without its finish frame is a torn stream, not a clean `stop` (#805 adversarial rounds 5–6; every run; @server @cli @test)
75
+ - **Before.** All THREE stream parsers marked only the EMPTY torn stream as an error — openai (no content, no `finish_reason`), anthropic (no content, no `message_delta.stop_reason`) and the Responses adapter (no content, no terminal `response.*` frame); a body that closed cleanly after a `No` delta with no finish frame finalised as a clean `stop` with no `partialFinalized` — indistinguishable downstream from a complete answer. The auto-mode classifier's bare-answer arm read such a cut `No` (of a `No, this must be blocked`) as ALLOW and executed the gated tool (reproduced through the real openai brain).
76
+ - **After.** All three parsers stamp `partialFinalized: true` and the CC-verbatim `Connection closed mid-response. The response above may be incomplete.` on content-without-finish — the same marker and the same words the design/124 mid-stream promotion stamps when the body reader throws (one fact, one word; the sentence is now single-sourced in `stream-shared.ts`). Consumers already in place act on it: the loop's bounded continue nudge (`partialFinalized && stopReason === "stop"`), and the classifier's tag-only rule (the cut reply is re-asked, never a bare verdict).
77
+ - **Reach (behaviour face, every run).** A gateway that ends a stream with `[DONE]`/EOF but never sends `finish_reason` (openai form), `stop_reason` (anthropic form) or a terminal `response.completed`/`.incomplete`/`.failed` frame (Responses form) now reads as incomplete on every turn — the conforming shape sends one, and this is the fail-closed direction (an incomplete turn is marked incomplete; a complete turn is untouched). The mid-stream promotion path (body throw) is unchanged: the parser's own finalize under `truncated: true` leaves the stamp to the facade, so the warning is never doubled.
78
+ - **Round 6 (an independent review of the rebased tree), two shapes on the SAME wire.** (i) Round 5 had said "both parsers" and patched two of the three: on the Responses wire a cut `No` still finalised as a clean `stop` and the gated tool RAN. (ii) The Responses wire can also declare a turn unfinished WITHOUT dropping the connection — `response.incomplete` whose `incomplete_details.reason` is absent, null, or a word this client does not map (`max_output_tokens` ⇒ `length` and `content_filter` ⇒ the refusal arm are the two it does) — and that terminal SETS the status the first arm keys on, so it read as a clean `stop` too. The provider's own verdict outranks the absence of its metadata: same `partialFinalized` marker, its own honest sentence (`The response above may be incomplete: the endpoint reported status "incomplete" with no reason this client recognises.` — nothing was dropped, so the torn-stream words would misdescribe it). The SAME terminal with nothing delivered joins the empty-torn arm instead: `stopReason: "error"` with the existing `[stream_torn]` sentence naming which shape it was (no new error code — the outcome is the same, the response was lost), which is what the two mapped reasons already do (`max_output_tokens` ⇒ `length_empty`, `content_filter` ⇒ the refusal arm).
79
+ - Pins: `test/backlog805-adapter-torn-stream.test.ts` (all three brains, torn vs whole; the four reasonless `incomplete` shapes with content and the empty one without, `length` / `content_filter` / `completed` as controls; the real openai brain and the real Responses brain through the Runner as the classifier — the cut `No` is re-asked and the block stands, the whole `No` allows).
80
+
81
+ ### The auto-mode classifier on single-model deployments — the seat learns from the reply, the reader accepts a bare answer, the card names the model (#805; ruling C-R44; @server @cli @test; deployment operators)
82
+ - **Before.** A catalog entry with no `reasoning`/`compat` declaration was read as "a model that never thinks" (`thinkingOffExpressible` answered `true` for a falsy `reasoning`), so the classifier ran with `reasoning:"off"` and a 256-token cap while the wire carried NO thinking key at all — the gateway's default governed, and three of four live gateways think by default. A provider error (`stopReason:"error"`) was read as a tagless reply. The parse-failure card never said which model, nothing said which model stood in for the missing `classifier` role, and the rung deadline was a sema-invented 15 s where CC runs 60 s.
83
+ - **After — seat.** `thinkingOffSpelled(model)` (renamed from `thinkingOffExpressible`, **BREAKING** on the export face; no sibling repo imports it) is true exactly when the wire will CARRY a disable key; a falsy `reasoning` spells nothing and reads `false`. Off spelled ⇒ `off` + 256 (unchanged); otherwise `low` + 256 + 2048 (CC 2.1.259 `MU`: spell off, or leave thinking to the provider and pay 2048). One rule, the special arm gone.
84
+ - **After — re-ask.** A first attempt whose reply is MISSING a verdict (empty at the cap, or text with no answer in it) is asked ONCE more on the THINK seat (`low` + 256 + `AUTO_MODE_CLASSIFIER_THINK_BUDGET_TOKENS` 8192, CC's stage-2 room), unless the run's signal is already aborted. A CONFLICTING reply (two `<block>` answers) is never re-asked; an empty reply with a non-cap stop stays CC's refusal arm. `parse_error` carries its form (`AutoModeParseFailure`: `missing` | `conflict`; a new closed set, internal). Each attempt's user message ends with CC 2.1.259's own suffix (`CLASSIFIER_ATTEMPT_SUFFIX.first` = the fast-mode "your ENTIRE response MUST begin with <block>", `.reask` = the stage-2 "use <thinking> first") — live 09-15 the first suffix took a thinking-off Qwen 3.8 from 70% tagged replies to 90% at 8 output tokens.
85
+ - **After — reader.** `parseAutoModeResponse` strips `<think>…</think>` beside `<thinking>…</thinking>`, and when NO `<block>` tag is present reads a reply that IS a bare answer — the whole thinking-stripped text, nothing else: `no` / `No.` / `**no**` / `block: no` / `Answer: yes` / `Verdict = NO` (a closed spelling set; a question followed by its answer, two words, a `?`, or prose with a bare `no` in it is still `parse_error`). Divergence D-805-1 (CC reads the tag only), narrowed from the ruling's "any yes/no" on live evidence: the last-line form read a prose `no` under a soft-block conclusion as ALLOW. A TAG-ONLY reply — a `length` stop, a stream cut (`partialFinalized`), a tool-calling reply (`toolUse` stop or a tool-call block; the classifier has no tools) — never reaches the bare arm: the leg judges it with `parseAutoModeResponse(text, { bareAnswer: false })` (additive optional parameter) and hands the decider an empty text unless a `<block>` tag ruled, so a cut `No` is re-asked, never allowed (the adapters now mark every such cut — see the adapter-truthfulness fix above).
86
+ - **After — attribution.** A reply with `stopReason:"error"` (other than the cap cut `length_empty`) or `"aborted"` is thrown by the leg and becomes `unavailable`/`error` (ladder cause `error`, the "temporarily unavailable" card), as the sibling side-channel legs already read it.
87
+ - **After — the card.** `classifierParseFailureDenyMessage(attribution?)` replaces the constant `CLASSIFIER_PARSE_FAILURE_DENY_MESSAGE` (**BREAKING**, export face): with a ladder wired the tail reads `— the classifier model "<modelUsed>" replied without a verdict.`; the CC stem is unchanged and stays a prefix. Divergence D-805-2.
88
+ - **After — role default.** No `classifier` role that NAMES a model (`roleSpecNamesModel`, exported from `roles.ts`: a string ref, a Model object, `{model}`, `{select}`; a preset-only `{thinking}` does not) ⇒ with tiers declared and a main model on a tier, the classifier is the nearest tier strictly BELOW the main's (over the Runner's expanded tier keys; the main's tier = the lowest its route sits on; a different route required), else the main itself; a main bound to no tier keeps the static `flash` row; no tiers keeps the `summarize → default` chain. New notice `classifier.role_defaulted` (operator, once per run, after the route pre-flight; `detail: { model, reason ∈ tier_below_main|tier_table|role_fallback|main_only|role_unresolvable, thinkingOffSpelled }`; a role naming a model the catalog cannot resolve falls back to the main model as the old leg did, now disclosed as `role_unresolvable`). Divergence D-805-4. The `roles.side_channels_deployment_only` sentence is narrowed for this one seat: the classifier default follows the run's main model.
89
+ - **After — loud.** New notice `classifier.contract_broken` (operator, once per run per ladder RUNG — keyed by position and id, since two rungs may share a wire id; `detail.rung`): the same rung answered `AUTO_MODE_CLASSIFIER_CONTRACT_BROKEN_AFTER` (2) consecutive classifications without a verdict (each already re-asked); a ruling from it resets the count, an `unavailable` round neither counts nor resets. Fires before the denial limit's third block hands the user a card. Both codes join `ENGINE_NOTICE_CODES` / `NOTICE_AUDIENCE` (**BREAKING** for a total table over the set; an unknown code must never be fatal to a consumer).
90
+ - **After — timeout (behaviour face, deployment operators).** `AUTO_MODE_DEFAULT_TIMEOUT_MS` 15_000 → 60_000 (CC 2.1.250 `oue` = 2.1.259 `j7e`, the per-attempt base). The deadline bounds ONE RUNG — both attempts, the transcript read, the credential resolution — so the worst-case walk is 60 s × 2 rungs = 120 s. An explicit `RunnerDeps.autoMode.timeoutMs` is unchanged. Arming recipes now MATERIALISE `timeoutMs` (a recording states its bound), and a recording that carries none — one written before this version — folds as the 15 s it ran under (`AUTO_MODE_RECORDED_ABSENT_TIMEOUT_MS`; contract `auto_mode.arming.absent_timeout_reads_recorded_default`): the wider default reaches NEW recordings only, never an ancestor's bound at redemption.
91
+ - **Contract sentence** `classifier.budget_is_room` (on `classifierRungSeat`): every classifier output cap is ROOM, never a bound the engine can vouch for — a gateway may exclude reasoning tokens from `max_tokens` (measured), so an over-long think is cut by the round-trip timeout (`unavailable`/`timeout`), not by the cap (`parse_error`), and the two are attested as two different facts.
92
+ - **After — the seat is the model that was RESOLVED (round 6).** Route identity (`id` + base URL) names the REASON the ladder folds to one rung; it never substitutes the `Model` object. Two catalog entries may match on it while carrying different headers, credential, api family or `compat` — and `compat.thinkingFormat` is what decides this seat's reasoning word — so the resolver returns the model the role chain resolved and only labels it `main_only`.
93
+ - **Not changed.** Verdict semantics of a tagged reply; the denial limit; the ladder's walk rule; the classifier system prompt (SHA-locked asset) — the suffixes ride the user message.
94
+ - Pins: `test/backlog805-classifier-robustness.test.ts` (seat predicate and seats through the real Runner; the re-ask's trigger, exceptions and suffixes; the reader's bare-answer grammar with the live false-allow counter-example; error-stop attribution; the tier-relative seat incl. a main bound to two tiers; both notices; the card). Re-pinned: `backlog661-classifier-cap-retry` (the re-ask lands on THINK; a missing text is re-asked, a conflict is not), `backlog618-*` (an undeclared model is on the `low` seat), `backlog742` (the seat is asked twice before the walk moves), `design503-parked-automode-arming` (materialised and legacy recordings). Design note and live tables: the design note lives in the internal design register (`core/design/805-classifier-robustness.md`; not part of this repository).
95
+
3
96
  ## 7.18.0 — 2026-09-15
4
97
 
5
98
  ### The WRITE face becomes a declarable containment seat — `writeFace: "open" | "roots"` (#748; @server @cli @test)
@@ -2,7 +2,7 @@ import {} from "../internal/llm.js";
2
2
  import { BrainError } from "./errors.js";
3
3
  import { startsWithInputTooLongAnchor } from "./input-too-long.js";
4
4
  import { DEGENERATE_MESSAGE, trimDegenerateTail } from "./repetition.js";
5
- import { createRepetitionPoll, parseStreamedToolArgs } from "./stream-shared.js";
5
+ import { STREAM_TORN_WARNING, createRepetitionPoll, parseStreamedToolArgs } from "./stream-shared.js";
6
6
  import { mintFallbackToolCallId } from "./tool-call-id.js";
7
7
  import { emitBrainTelemetry, reportReasoningWireFacts } from "./status-sink.js";
8
8
  import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, modelSupportsVision, sendableImages } from "./media-degrade.js";
@@ -560,7 +560,7 @@ export function createAnthropicBrain(config = {}) {
560
560
  }
561
561
  handleEvent(evt);
562
562
  },
563
- finalize() {
563
+ finalize(opts) {
564
564
  const finalContent = [];
565
565
  const toolCalls = [];
566
566
  const malformed = [];
@@ -666,6 +666,8 @@ export function createAnthropicBrain(config = {}) {
666
666
  emptyNoFinish ||
667
667
  (toolError !== undefined && noUsableContent) ||
668
668
  (truncatedEmpty && !dynamicCut && !staticReasoningCut);
669
+ const tornWithContent = !errored && opts?.truncated !== true && stopReason == null && !noUsableContent && !degenerate;
670
+ const errorMessageOut = tornWithContent ? (errorMessage ? `${errorMessage}\n${STREAM_TORN_WARNING}` : STREAM_TORN_WARNING) : errorMessage;
669
671
  const errorKind = degenerate
670
672
  ? "degenerate"
671
673
  : !safetyCut && contextWindowExceeded
@@ -680,8 +682,9 @@ export function createAnthropicBrain(config = {}) {
680
682
  usage: computeUsage(model, usageRaw),
681
683
  ...(Object.keys(usageRaw).length === 0 ? { usageMissing: true } : {}),
682
684
  timestamp: Date.now(),
683
- ...(errorMessage ? { errorMessage } : {}),
685
+ ...(errorMessageOut ? { errorMessage: errorMessageOut } : {}),
684
686
  ...(errorKind ? { errorKind } : {}),
687
+ ...(tornWithContent ? { partialFinalized: true } : {}),
685
688
  ...(rep.cut !== undefined || rep.spared.length > 0
686
689
  ? { repetition: { ...(rep.cut !== undefined ? { cut: rep.cut } : {}), ...(rep.spared.length > 0 ? { spared: rep.spared } : {}) } }
687
690
  : {}),
@@ -1,7 +1,7 @@
1
1
  import {} from "../internal/llm.js";
2
2
  import { BrainError } from "./errors.js";
3
3
  import { DEGENERATE_MESSAGE, trimDegenerateTail } from "./repetition.js";
4
- import { createRepetitionPoll, parseStreamedToolArgs } from "./stream-shared.js";
4
+ import { STREAM_TORN_WARNING, createRepetitionPoll, parseStreamedToolArgs } from "./stream-shared.js";
5
5
  import { mintFallbackToolCallId } from "./tool-call-id.js";
6
6
  import { emitBrainTelemetry } from "./status-sink.js";
7
7
  import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, imagesOmittedNoVisionNote, modelSupportsVision, sendableImages } from "./media-degrade.js";
@@ -195,6 +195,7 @@ function responsesErrorCode(code) {
195
195
  }
196
196
  const INCOMPLETE_MAX_OUTPUT_TOKENS = "max_output_tokens";
197
197
  const INCOMPLETE_CONTENT_FILTER = "content_filter";
198
+ const RESPONSE_INCOMPLETE_WARNING = 'The response above may be incomplete: the endpoint reported status "incomplete" with no reason this client recognises.';
198
199
  function closeFunctionCall(acc) {
199
200
  if (acc.closedTc)
200
201
  return acc.closedTc;
@@ -688,7 +689,8 @@ export function createOpenResponsesBrain(config = {}) {
688
689
  const staticReasoningCut = truncatedEmpty && !dynamicCut && reasoningSeen && options?.staticReasoningCutDowngrade === true;
689
690
  const safetyCut = incompleteReason === INCOMPLETE_CONTENT_FILTER;
690
691
  const refused = accumRefusal.trim().length > 0;
691
- const emptyNoFinish = noUsableContent && terminalStatus === undefined && !degenerate;
692
+ const incompleteUnmapped = terminalStatus === "incomplete" && incompleteReason !== INCOMPLETE_MAX_OUTPUT_TOKENS && incompleteReason !== INCOMPLETE_CONTENT_FILTER;
693
+ const emptyNoFinish = noUsableContent && (terminalStatus === undefined || incompleteUnmapped) && !degenerate;
692
694
  const maxTokensNote = sentMaxTokens !== undefined
693
695
  ? `effective max_output_tokens=${sentMaxTokens} (from ${sentMaxTokensLane === "options"
694
696
  ? "options.maxTokens"
@@ -708,7 +710,7 @@ export function createOpenResponsesBrain(config = {}) {
708
710
  ? `[length_empty] response truncated at max_output_tokens (${maxTokensNote}) with no answer text — the model spent the whole budget on reasoning; raise the cap, or lower the reasoning effort for this task.`
709
711
  : `[length_empty] response truncated at max_output_tokens (${maxTokensNote}) with no answer text — raise the cap`
710
712
  : emptyNoFinish
711
- ? `[stream_torn] model stream ended with no content and no terminal response event — the response was lost (a torn stream or an in-band provider error frame)${malformedFrames > 0 ? ` — ${malformedFrames} unparseable data frame(s) were dropped on this stream (first sample: ${JSON.stringify(malformedSample)})` : ""}`
713
+ ? `[stream_torn] model stream ended with no content and ${terminalStatus === undefined ? "no terminal response event" : `a terminal status "${terminalStatus}" with no reason this client recognises (incomplete_details.reason=${incompleteReason ?? "absent"})`} — the response was lost (a torn stream or an in-band provider error frame)${malformedFrames > 0 ? ` — ${malformedFrames} unparseable data frame(s) were dropped on this stream (first sample: ${JSON.stringify(malformedSample)})` : ""}`
712
714
  : undefined));
713
715
  const errored = degenerate ||
714
716
  safetyCut ||
@@ -716,6 +718,9 @@ export function createOpenResponsesBrain(config = {}) {
716
718
  emptyNoFinish ||
717
719
  (toolError !== undefined && noUsableContent) ||
718
720
  (truncatedEmpty && !dynamicCut && !staticReasoningCut);
721
+ const tornWithContent = !errored && opts?.truncated !== true && (terminalStatus === undefined || incompleteUnmapped) && !noUsableContent && !degenerate;
722
+ const partialWarning = terminalStatus === undefined ? STREAM_TORN_WARNING : RESPONSE_INCOMPLETE_WARNING;
723
+ const errorMessageOut = tornWithContent ? (errorMessage ? `${errorMessage}\n${partialWarning}` : partialWarning) : errorMessage;
719
724
  const errorKind = degenerate
720
725
  ? "degenerate"
721
726
  : !safetyCut && !refused && toolError === undefined && truncatedEmpty && !dynamicCut && !staticReasoningCut
@@ -732,8 +737,9 @@ export function createOpenResponsesBrain(config = {}) {
732
737
  ...(usageRaw === undefined ? { usageMissing: true } : {}),
733
738
  timestamp: Date.now(),
734
739
  ...(responseId !== undefined ? { responseId } : {}),
735
- ...(errorMessage ? { errorMessage } : {}),
740
+ ...(errorMessageOut ? { errorMessage: errorMessageOut } : {}),
736
741
  ...(errorKind ? { errorKind } : {}),
742
+ ...(tornWithContent ? { partialFinalized: true } : {}),
737
743
  ...(rep.cut !== undefined || rep.spared.length > 0
738
744
  ? { repetition: { ...(rep.cut !== undefined ? { cut: rep.cut } : {}), ...(rep.spared.length > 0 ? { spared: rep.spared } : {}) } }
739
745
  : {}),
@@ -1,7 +1,7 @@
1
1
  import {} from "../internal/llm.js";
2
2
  import { repairTextToolCalls } from "./tool-call-repair.js";
3
3
  import { DEGENERATE_MESSAGE, trimDegenerateTail } from "./repetition.js";
4
- import { createRepetitionPoll, parseStreamedToolArgs } from "./stream-shared.js";
4
+ import { STREAM_TORN_WARNING, createRepetitionPoll, parseStreamedToolArgs } from "./stream-shared.js";
5
5
  import { mintFallbackToolCallId } from "./tool-call-id.js";
6
6
  import { emitBrainTelemetry } from "./status-sink.js";
7
7
  import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, imagesOmittedNoVisionNote, modelSupportsVision, sendableImages } from "./media-degrade.js";
@@ -619,6 +619,8 @@ export function createOpenAIBrain(config = {}) {
619
619
  emptyNoFinish ||
620
620
  (toolError !== undefined && noUsableContent) ||
621
621
  (truncatedEmpty && !dynamicCut);
622
+ const tornWithContent = !errored && opts?.truncated !== true && finishReason == null && !noUsableContent && !degenerate;
623
+ const errorMessageOut = tornWithContent ? (errorMessage ? `${errorMessage}\n${STREAM_TORN_WARNING}` : STREAM_TORN_WARNING) : errorMessage;
622
624
  const errorKind = degenerate
623
625
  ? "degenerate"
624
626
  : !safetyCut && !refused && toolError === undefined && truncatedEmpty && !dynamicCut
@@ -631,8 +633,9 @@ export function createOpenAIBrain(config = {}) {
631
633
  usage: computeUsage(model, usageRaw),
632
634
  ...(usageRaw === undefined ? { usageMissing: true } : {}),
633
635
  timestamp: Date.now(),
634
- ...(errorMessage ? { errorMessage } : {}),
636
+ ...(errorMessageOut ? { errorMessage: errorMessageOut } : {}),
635
637
  ...(errorKind ? { errorKind } : {}),
638
+ ...(tornWithContent ? { partialFinalized: true } : {}),
636
639
  ...(rep.cut !== undefined || rep.spared.length > 0
637
640
  ? { repetition: { ...(rep.cut !== undefined ? { cut: rep.cut } : {}), ...(rep.spared.length > 0 ? { spared: rep.spared } : {}) } }
638
641
  : {}),
@@ -117,25 +117,25 @@ export interface OffCapabilityModel {
117
117
  * "none" tier), on an endpoint that takes an effort value at all (`supportsReasoningEffort` not
118
118
  * false) — else `undefined`: there is nothing to send. The effort formats have no disable key, and an
119
119
  * effort VALUE is never off (it enables reasoning at that tier), so off on those wires is spelled only
120
- * when the deployment says how. The two appliers and {@link thinkingOffExpressible} read this ONE
120
+ * when the deployment says how. The two appliers and {@link thinkingOffSpelled} read this ONE
121
121
  * function, so what the wire carries and what the capability table claims cannot disagree.
122
122
  */
123
123
  export declare function declaredOffSpelling(model: OffCapabilityModel): string | undefined;
124
124
  /**
125
- * Whether this model's wire can SAY "off". A model that does not reason at all (`reasoning` falsy)
126
- * never thinks, so off is trivially expressible. Otherwise, per wire: the anthropic budget form spells
127
- * `{type:"disabled"}` (the ADAPTIVE form rejects it — those models think on every request); deepseek
128
- * `{type:"disabled"}`, the binary enable keys at `false`, openrouter/together `reasoning:{enabled:false}`
129
- * all say it; the effort-only wires (stock openai completions, the Responses lane) say it only through
130
- * a deployment-declared off spelling ({@link declaredOffSpelling}).
125
+ * Whether the wire will CARRY a disable key for this model — off is SPELLED, never assumed. Per wire: the
126
+ * anthropic budget form spells `{type:"disabled"}` (the ADAPTIVE form rejects it — those models think on
127
+ * every request); deepseek `{type:"disabled"}`, the binary enable keys at `false`, openrouter/together
128
+ * `reasoning:{enabled:false}` all spell it; the effort-only wires (stock openai completions, the Responses
129
+ * lane) spell it only through a deployment-declared off spelling ({@link declaredOffSpelling}).
131
130
  *
132
- * A caller that sizes an output cap for an ANSWER alone (the auto-mode classifier's block-verdict
133
- * contract) applies the cap only when this is true: a model that will think regardless cannot have its
134
- * cap derived from the answer's contract, and keeps its own budget. What this table cannot see is a
135
- * model that IGNORES its wire's disable key (a thinking-only build served through a format that has
136
- * one) — that is a per-model fact the compat declaration does not yet carry.
131
+ * A model whose `reasoning` is FALSY spells NOTHING: every applier returns before writing any key for it, so
132
+ * the request is the gateway's own default — and a default-on gateway thinks regardless (three of four live
133
+ * endpoints, 2026-09-15). This arm used to answer `true` ("a non-reasoning model never thinks"): the engine
134
+ * may size a request by what it SENT, never by what it hopes a gateway does when told nothing. What this
135
+ * table cannot see is a gateway that IGNORES the key it was sent — the classifier's re-ask on a no-verdict
136
+ * reply is the seat that learns that from the reply.
137
137
  */
138
- export declare function thinkingOffExpressible(model: OffCapabilityModel): boolean;
138
+ export declare function thinkingOffSpelled(model: OffCapabilityModel): boolean;
139
139
  /**
140
140
  * OPTIONAL per-request facts for {@link resolveReasoning} — what the wire's budget arm knows at
141
141
  * request build that a per-leg eager resolution cannot: the resolved output cap and whether it is a
@@ -30,9 +30,9 @@ export function declaredOffSpelling(model) {
30
30
  const mapped = model.thinkingLevelMap?.off;
31
31
  return typeof mapped === "string" && mapped !== "" ? mapped : undefined;
32
32
  }
33
- export function thinkingOffExpressible(model) {
33
+ export function thinkingOffSpelled(model) {
34
34
  if (!model.reasoning)
35
- return true;
35
+ return false;
36
36
  const compat = (model.compat ?? {});
37
37
  if (model.api === "anthropic-messages")
38
38
  return compat.thinkingMode !== "adaptive";
@@ -1,4 +1,5 @@
1
1
  import { createAssistantMessageEventStream, } from "../internal/llm.js";
2
+ import { STREAM_TORN_WARNING } from "./stream-shared.js";
2
3
  import { FLOOR_OUTPUT_TOKENS, parseContextOverflow, planOutputCapAdjustment } from "./context-overflow.js";
3
4
  import { BrainError, classifyConnectFailure, classifyHttp, describeNetworkError, namesTheFailure, readProviderRequestId } from "./errors.js";
4
5
  import { classifyInputTooLong } from "./input-too-long.js";
@@ -650,7 +651,7 @@ export function runStreamingBrain(args) {
650
651
  midstreamWarning =
651
652
  failure.kind === "stalled"
652
653
  ? "Response stalled mid-stream. The response above may be incomplete."
653
- : "Connection closed mid-response. The response above may be incomplete.";
654
+ : STREAM_TORN_WARNING;
654
655
  parser.finalize({ truncated: true });
655
656
  return;
656
657
  }
@@ -24,6 +24,9 @@ import { type RepetitionEvent } from "./repetition.js";
24
24
  * means "do not emit a `toolcall_end` for this call"; callers must NOT cache that outcome — finalize retries
25
25
  * the parse against the complete accumulation.
26
26
  */
27
+ /** The CC-verbatim warning stamped beside `partialFinalized` when a stream ended before its close — by the
28
+ * mid-stream promotion (the body threw) and by the parsers' own finalize (content arrived, no finish frame). */
29
+ export declare const STREAM_TORN_WARNING = "Connection closed mid-response. The response above may be incomplete.";
27
30
  export declare function parseStreamedToolArgs(json: string): Record<string, unknown> | undefined;
28
31
  /** Repetition telemetry for ONE stream — the cut event plus the allowance-spared hits. */
29
32
  export interface RepetitionPoll {
@@ -1,4 +1,5 @@
1
1
  import { inspectDegenerate } from "./repetition.js";
2
+ export const STREAM_TORN_WARNING = "Connection closed mid-response. The response above may be incomplete.";
2
3
  export function parseStreamedToolArgs(json) {
3
4
  if (!json)
4
5
  return {};
@@ -133,6 +133,22 @@ export interface AskOriginFacts {
133
133
  export declare function askOriginOf(decision: Extract<PermissionResult, {
134
134
  action: "ask";
135
135
  }>, facts: AskOriginFacts): AskOrigin;
136
+ /**
137
+ * Stamp the ask's origin word — and withdraw the disclosures that speak for the TIGHTEN alone when some
138
+ * layer above it is the reason this call is being asked about. Today that is one member: the read
139
+ * boundary's directory offer, whose claim holds only while the tighten is the whole reason. Every higher
140
+ * rung is a layer with a reason of its own — a standing `ask` row, an unreadable rule store, an org
141
+ * verdict, a hook, the denial-limit fallback — which no directory grant answers, so the ask would stand
142
+ * after the grant and the offer would have promised a clearing it cannot deliver (measured: `cat
143
+ * <out-of-root>` under a standing `Bash(cat:*)` ask rule offered the directory and kept asking after it).
144
+ * It lives at the one derivation rather than the three stamp sites: the withdrawal's input IS the origin
145
+ * word, so a site that stamped without it would be a second, quieter answer to one question.
146
+ */
147
+ export declare function stampAskOrigin<T extends Extract<PermissionResult, {
148
+ action: "ask";
149
+ }>>(decision: T, facts: AskOriginFacts): T & {
150
+ origin: AskOrigin;
151
+ };
136
152
  /**
137
153
  * Which origins IMPLY the `requiresRealApproval` bit, and WHERE that implication is verified — the
138
154
  * consistency table {@link askOriginOf} reads. An ask of a must-carry origin exists so that no automatic
@@ -15,6 +15,14 @@ export function askOriginOf(decision, facts) {
15
15
  }
16
16
  return origin;
17
17
  }
18
+ const TIGHTEN_ONLY_ORIGINS = new Set(["shell_gate_tighten", "safety_tighten"]);
19
+ export function stampAskOrigin(decision, facts) {
20
+ const origin = askOriginOf(decision, facts);
21
+ if (decision.probeReadRootCandidate === undefined || TIGHTEN_ONLY_ORIGINS.has(origin))
22
+ return { ...decision, origin };
23
+ const { probeReadRootCandidate: _withdrawn, ...kept } = decision;
24
+ return { ...kept, origin };
25
+ }
18
26
  function originLadder(decision, facts) {
19
27
  if (facts.contentQuestion)
20
28
  return "content_question";
@@ -180,5 +180,11 @@ export type AutoModeArmingFold = {
180
180
  * nevertheless declare the same epoch — that is a deployment telling us its settings did not move
181
181
  * while its own data says otherwise, and picking either side would be a guess about which one is the
182
182
  * policy in force.
183
+ *
184
+ * @contract auto_mode.arming.absent_timeout_reads_recorded_default — a parked arming's absent `timeoutMs` is
185
+ * read as the default in force when it was recorded (`AUTO_MODE_RECORDED_ABSENT_TIMEOUT_MS`, 15_000 ms), not
186
+ * the default in force when it is redeemed: persisted data is read under the contract it was written under,
187
+ * so a default that moves can never widen an ancestor's bound at redemption; every recipe minted since
188
+ * materialises the knob, so absence dates the recording.
183
189
  */
184
190
  export declare function foldAutoModeArming(recorded: unknown, current: unknown): AutoModeArmingFold;
@@ -1,4 +1,4 @@
1
- import { AUTO_MODE_DEFAULT_TIMEOUT_MS, AUTO_MODE_DEFAULT_WINDOW_MAX_CHARS, AUTO_MODE_DEFAULT_WINDOW_MAX_ENTRIES, AUTO_MODE_DEFAULTS_SENTINEL, AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS, AUTO_MODE_DENIAL_LIMIT_DEFAULTS, } from "./auto-mode-defaults.js";
1
+ import { AUTO_MODE_DEFAULT_TIMEOUT_MS, AUTO_MODE_RECORDED_ABSENT_TIMEOUT_MS, AUTO_MODE_DEFAULT_WINDOW_MAX_CHARS, AUTO_MODE_DEFAULT_WINDOW_MAX_ENTRIES, AUTO_MODE_DEFAULTS_SENTINEL, AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS, AUTO_MODE_DENIAL_LIMIT_DEFAULTS, } from "./auto-mode-defaults.js";
2
2
  export const AUTO_MODE_ARMING_RECIPE_VERSION = 2;
3
3
  const isPlainRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
4
4
  function canonicalStrings(v) {
@@ -143,7 +143,7 @@ export function autoModeArmingRecipeOf(face, bind) {
143
143
  ...(face.settingsDenyRules !== undefined ? { settingsDenyRules: face.settingsDenyRules } : {}),
144
144
  ...(face.sessionContext !== undefined ? { sessionContext: face.sessionContext } : {}),
145
145
  ...(face.window !== undefined ? { window: face.window } : {}),
146
- ...(face.timeoutMs !== undefined ? { timeoutMs: face.timeoutMs } : {}),
146
+ timeoutMs: face.timeoutMs ?? AUTO_MODE_DEFAULT_TIMEOUT_MS,
147
147
  ...(face.denialLimit !== undefined ? { denialLimit: face.denialLimit } : {}),
148
148
  ...(face.settingsEpoch !== undefined ? { settingsEpoch: face.settingsEpoch } : {}),
149
149
  ...(face.crossSessionMessagesRule !== undefined ? { crossSessionMessagesRule: face.crossSessionMessagesRule } : {}),
@@ -208,8 +208,8 @@ export function foldAutoModeArming(recorded, current) {
208
208
  "(the strictest decidable answer: the inherited ask flows the original chain to a human)",
209
209
  };
210
210
  }
211
- const minKnob = (a, b, fallback) => Math.min(a ?? fallback, b ?? fallback);
212
- const timeoutMs = minKnob(rec.timeoutMs, cur.timeoutMs, AUTO_MODE_DEFAULT_TIMEOUT_MS);
211
+ const recordedTimeoutMs = rec.timeoutMs ?? AUTO_MODE_RECORDED_ABSENT_TIMEOUT_MS;
212
+ const timeoutMs = Math.min(recordedTimeoutMs, cur.timeoutMs ?? AUTO_MODE_DEFAULT_TIMEOUT_MS);
213
213
  const denialLimit = tightenDenialLimit(rec.denialLimit, cur.denialLimit);
214
214
  const recordedDenial = tightenDenialLimit(rec.denialLimit, rec.denialLimit);
215
215
  return {
@@ -221,7 +221,7 @@ export function foldAutoModeArming(recorded, current) {
221
221
  denialLimit,
222
222
  ...(cur.settingsEpoch !== undefined ? { settingsEpoch: cur.settingsEpoch } : {}),
223
223
  },
224
- tightened: timeoutMs < (rec.timeoutMs ?? AUTO_MODE_DEFAULT_TIMEOUT_MS) ||
224
+ tightened: timeoutMs < recordedTimeoutMs ||
225
225
  denialLimit.maxConsecutive < recordedDenial.maxConsecutive ||
226
226
  denialLimit.maxTotal < recordedDenial.maxTotal ||
227
227
  denialLimit.autoDenyAfterMs !== recordedDenial.autoDenyAfterMs,
@@ -1,5 +1,19 @@
1
- /** Hard cap on one classification round-trip when `AutoModeDeciderOptions.timeoutMs` is omitted. */
2
- export declare const AUTO_MODE_DEFAULT_TIMEOUT_MS = 15000;
1
+ /**
2
+ * Hard cap on one classification RUNG (both attempts) when `AutoModeDeciderOptions.timeoutMs` is omitted — CC
3
+ * 2.1.259's per-attempt classifier base (`j7e = 60000`; 2.1.250 `oue`, the same number), which upstream
4
+ * stretches to 120 s with prompt size and sema leaves to the deployment knob. The 15 s that stood here was
5
+ * never upstream's: live 2026-09-15 a thinking-by-default gateway answers in 5–165 s (Qwen 3.8 p50 11–24 s,
6
+ * kimi and GLM p50 ~10 s), so 15 s turned most classifications into `unavailable (timed out)`. Worst-case
7
+ * walk = this × rungs (2) = 120 s.
8
+ */
9
+ export declare const AUTO_MODE_DEFAULT_TIMEOUT_MS = 60000;
10
+ /**
11
+ * What an arming RECORDING that carries no `timeoutMs` meant when it was written: the default of its day. Every
12
+ * recipe minted since the deadline moved to 60 s MATERIALISES its bound, so an absent recorded knob dates the
13
+ * recording — the fold reads it as the 15 s it ran under, never as the current default
14
+ * (`auto_mode.arming.absent_timeout_reads_recorded_default`, on the fold).
15
+ */
16
+ export declare const AUTO_MODE_RECORDED_ABSENT_TIMEOUT_MS = 15000;
3
17
  /** Newest transcript entries in the classify window when `AutoModeWindowOptions.maxEntries` is omitted. */
4
18
  export declare const AUTO_MODE_DEFAULT_WINDOW_MAX_ENTRIES = 40;
5
19
  /** Per-entry excerpt cap when `AutoModeWindowOptions.maxCharsPerEntry` is omitted. */
@@ -17,25 +31,39 @@ export declare const AUTO_MODE_DEFAULT_WINDOW_MAX_CHARS = 2000;
17
31
  * parses as a block (the parser tolerates a missing tail), a truncated allow does not exist (it is
18
32
  * seven tokens), and the cap's job is to make a model that ignores the "no preamble" instruction —
19
33
  * or that reasons in-text — cost a bounded, quickly-detected contract failure instead of a slow one.
20
- * Paired with `reasoning:"off"` at the call: the cap hosts the ANSWER, so it applies only where the
21
- * wire can say off (`thinkingOffExpressible`); a model that thinks regardless keeps its own budget.
34
+ * Paired with `reasoning:"off"` at the call: the cap hosts the ANSWER, so it stands alone only where the
35
+ * wire SPELLS off (`thinkingOffSpelled`); every other seat adds a thinking allowance to it.
22
36
  */
23
37
  export declare const AUTO_MODE_CLASSIFIER_MAX_TOKENS = 256;
24
38
  /**
25
- * The output-budget ALLOWANCE for a classifier request that must think: added to the verdict cap when the
26
- * seat asks for `reasoning:"low"` instead of `"off"` — a model whose wire cannot say off (`reasoning:true`
27
- * with no off spelling on an effort-only wire, or the anthropic adaptive family), or an unknown model whose
28
- * capped off-request came back EMPTY at the cap (it reasoned anyway). The seat then pays for the lowest
29
- * reasoning tier plus the verdict, never for an open-ended budget.
39
+ * The output-budget ALLOWANCE for a classifier request whose thinking the wire could not turn OFF: added to
40
+ * the verdict cap when the seat asks for `reasoning:"low"` instead of `"off"` — a model whose wire has no off
41
+ * spelling (`reasoning:true` on an effort-only wire with no declared off, the anthropic adaptive family) and a
42
+ * model that declares NO reasoning (the wire then carries nothing and the request is the gateway's default,
43
+ * which on a default-on gateway is a think). The seat pays for the least the wire can grade plus the verdict,
44
+ * never for an open-ended budget.
30
45
  *
31
- * 2048 is anchored twice: CC 2.1.250 `E4` adds exactly this many tokens to the verdict cap for a model in
32
- * its `rejects_disabled_thinking` family (`BHe`); and it is 2 × `MIN_THINKING_TOKENS` (1024), so on the
33
- * anthropic BUDGET path a HARD cap of 256 + 2048 clears the cap-wins skip (`budgetCapSkipsThinking`) and
34
- * hosts a legal `low` budget (`1024 ≤ budget_tokens < max_tokens`) without the brain having to raise a cap
35
- * it refuses to raise. A model that spends more than this on thinking still answers nothing — that reply
36
- * is the contract failure it always was.
46
+ * 2048 is anchored twice: CC 2.1.259 `MU` adds exactly this many tokens to the verdict cap for a model in
47
+ * its `rejects_disabled_thinking` family (2.1.250 `E4`/`BHe`); and it is 2 × `MIN_THINKING_TOKENS` (1024), so
48
+ * on the anthropic BUDGET path a HARD cap of 256 + 2048 clears the cap-wins skip (`budgetCapSkipsThinking`)
49
+ * and hosts a legal `low` budget (`1024 ≤ budget_tokens < max_tokens`) without the brain having to raise a
50
+ * cap it refuses to raise. Live 2026-09-15: deepseek's default think fits it ~90–100% of the time; the tail
51
+ * is the re-ask seat's ({@link AUTO_MODE_CLASSIFIER_THINK_BUDGET_TOKENS}). What a cap can and cannot promise is
52
+ * the seat's contract (`classifier.budget_is_room`, on `classifierRungSeat`).
37
53
  */
38
54
  export declare const AUTO_MODE_CLASSIFIER_LOW_REASONING_BUDGET_TOKENS = 2048;
55
+ /**
56
+ * The RE-ASK seat's allowance — the room a rung gets ONCE after its first attempt produced no verdict: CC
57
+ * 2.1.259's stage-2 classifier cap (`max_tokens: 8192 + bn`). Live 2026-09-15 the thinking tail that overruns
58
+ * 2048 (deepseek 1–2 of 20, some past 4096; Qwen up to 6k) sits under it. One re-ask, never a loop.
59
+ */
60
+ export declare const AUTO_MODE_CLASSIFIER_THINK_BUDGET_TOKENS = 8192;
61
+ /**
62
+ * How many CONSECUTIVE no-verdict classifications on ONE model mint `classifier.contract_broken` (once per run
63
+ * per model): one is a fluke, two is a pattern, and at three the denial limit already hands the user a card —
64
+ * the operator line lands before that one.
65
+ */
66
+ export declare const AUTO_MODE_CLASSIFIER_CONTRACT_BROKEN_AFTER = 2;
39
67
  /** The sentinel a deployment puts INSIDE a paired rule list to splice the CC default rules back in at
40
68
  * that position (CC `XYt = "$defaults"`). It lives here, beside the other defaults, because the #503
41
69
  * recipe canonicalizer needs its VALUE and must not load the assembly face (and its SHA-locked assets)
@@ -1,8 +1,11 @@
1
- export const AUTO_MODE_DEFAULT_TIMEOUT_MS = 15_000;
1
+ export const AUTO_MODE_DEFAULT_TIMEOUT_MS = 60_000;
2
+ export const AUTO_MODE_RECORDED_ABSENT_TIMEOUT_MS = 15_000;
2
3
  export const AUTO_MODE_DEFAULT_WINDOW_MAX_ENTRIES = 40;
3
4
  export const AUTO_MODE_DEFAULT_WINDOW_MAX_CHARS = 2_000;
4
5
  export const AUTO_MODE_CLASSIFIER_MAX_TOKENS = 256;
5
6
  export const AUTO_MODE_CLASSIFIER_LOW_REASONING_BUDGET_TOKENS = 2048;
7
+ export const AUTO_MODE_CLASSIFIER_THINK_BUDGET_TOKENS = 8192;
8
+ export const AUTO_MODE_CLASSIFIER_CONTRACT_BROKEN_AFTER = 2;
6
9
  export const AUTO_MODE_DEFAULTS_SENTINEL = "$defaults";
7
10
  export const AUTO_MODE_DENIAL_LIMIT_DEFAULTS = Object.freeze({ maxConsecutive: 3, maxTotal: 20 });
8
11
  export const AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS = 120_000;