@intentic/sandbox-contract 1.223.0 → 1.225.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 (165) hide show
  1. package/README.md +14 -14
  2. package/dist/agent-catalog.js +3 -3
  3. package/dist/agent-catalog.js.map +1 -1
  4. package/dist/chores/chores.js +43 -43
  5. package/dist/chores/chores.js.map +1 -1
  6. package/dist/chores/extension-update.js +2 -2
  7. package/dist/chores/extension-update.js.map +1 -1
  8. package/dist/chores/fix-deps.js +1 -1
  9. package/dist/chores/fix-deps.js.map +1 -1
  10. package/dist/chores/probes.js +1 -1
  11. package/dist/chores/probes.js.map +1 -1
  12. package/dist/chores/prompt.d.ts.map +1 -1
  13. package/dist/chores/prompt.js +3 -3
  14. package/dist/chores/prompt.js.map +1 -1
  15. package/dist/chores/verdict.js +2 -2
  16. package/dist/chores/verdict.js.map +1 -1
  17. package/dist/contracts/capabilities.contract.d.ts +41 -0
  18. package/dist/contracts/capabilities.contract.d.ts.map +1 -1
  19. package/dist/contracts/exit.contract.d.ts +80 -0
  20. package/dist/contracts/exit.contract.d.ts.map +1 -0
  21. package/dist/contracts/exit.contract.js +13 -0
  22. package/dist/contracts/exit.contract.js.map +1 -0
  23. package/dist/contracts/ports.contract.d.ts +11 -0
  24. package/dist/contracts/ports.contract.d.ts.map +1 -1
  25. package/dist/contracts/settings.contract.d.ts +12 -0
  26. package/dist/contracts/settings.contract.d.ts.map +1 -1
  27. package/dist/events.d.ts +4 -4
  28. package/dist/events.d.ts.map +1 -1
  29. package/dist/events.js +5 -5
  30. package/dist/events.js.map +1 -1
  31. package/dist/fast-tier.d.ts +9 -0
  32. package/dist/fast-tier.d.ts.map +1 -0
  33. package/dist/fast-tier.js +19 -0
  34. package/dist/fast-tier.js.map +1 -0
  35. package/dist/history-state.js +3 -3
  36. package/dist/history-state.js.map +1 -1
  37. package/dist/index.d.ts +214 -68
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +5 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/model-order.d.ts +1 -0
  42. package/dist/model-order.d.ts.map +1 -1
  43. package/dist/model-order.js +5 -0
  44. package/dist/model-order.js.map +1 -1
  45. package/dist/output-fields.d.ts.map +1 -1
  46. package/dist/output-fields.js +2 -2
  47. package/dist/output-fields.js.map +1 -1
  48. package/dist/prompt-complexity.d.ts +19 -0
  49. package/dist/prompt-complexity.d.ts.map +1 -0
  50. package/dist/prompt-complexity.js +83 -0
  51. package/dist/prompt-complexity.js.map +1 -0
  52. package/dist/publish-drafts.js +2 -2
  53. package/dist/publish-drafts.js.map +1 -1
  54. package/dist/schemas.d.ts +236 -0
  55. package/dist/schemas.d.ts.map +1 -1
  56. package/dist/schemas.js +116 -2
  57. package/dist/schemas.js.map +1 -1
  58. package/dist/workflow-faults.js +3 -3
  59. package/dist/workflow-faults.js.map +1 -1
  60. package/dist/workspace-state.d.ts +20 -20
  61. package/dist/workspace-state.d.ts.map +1 -1
  62. package/dist/workspace-state.js +20 -20
  63. package/dist/workspace-state.js.map +1 -1
  64. package/package.json +5 -5
  65. package/src/agent-catalog.test.ts +25 -25
  66. package/src/agent-catalog.ts +81 -81
  67. package/src/agent-run-model.test.ts +3 -3
  68. package/src/agent-run-model.ts +8 -8
  69. package/src/capability-env.ts +3 -3
  70. package/src/capability-ledger.test.ts +13 -13
  71. package/src/capability-secrets.ts +5 -5
  72. package/src/chores/chores.test.ts +1 -1
  73. package/src/chores/chores.ts +109 -109
  74. package/src/chores/digest.test.ts +1 -1
  75. package/src/chores/digest.ts +3 -3
  76. package/src/chores/extension-update.ts +5 -5
  77. package/src/chores/fix-deps.ts +4 -4
  78. package/src/chores/probes.test.ts +6 -6
  79. package/src/chores/probes.ts +17 -17
  80. package/src/chores/prompt.ts +9 -9
  81. package/src/chores/stack.test.ts +3 -3
  82. package/src/chores/stack.ts +17 -17
  83. package/src/chores/verdict.test.ts +20 -20
  84. package/src/chores/verdict.ts +25 -25
  85. package/src/contract-lock.test.ts +1 -1
  86. package/src/contract-lock.ts +7 -7
  87. package/src/contracts/activity.contract.ts +1 -1
  88. package/src/contracts/agent.contract.ts +2 -2
  89. package/src/contracts/agents.contract.ts +11 -11
  90. package/src/contracts/automations.contract.ts +5 -5
  91. package/src/contracts/capabilities.contract.ts +7 -7
  92. package/src/contracts/chores.contract.ts +3 -3
  93. package/src/contracts/ci.contract.ts +2 -2
  94. package/src/contracts/claude.contract.ts +3 -3
  95. package/src/contracts/drafts.contract.ts +1 -1
  96. package/src/contracts/endpoints.contract.ts +2 -2
  97. package/src/contracts/exit.contract.ts +42 -0
  98. package/src/contracts/extensions.contract.ts +8 -8
  99. package/src/contracts/git.contract.ts +6 -6
  100. package/src/contracts/grok.contract.ts +4 -4
  101. package/src/contracts/host.contract.ts +5 -5
  102. package/src/contracts/intentic.contract.ts +2 -2
  103. package/src/contracts/logs.contract.ts +1 -1
  104. package/src/contracts/loops.contract.ts +8 -8
  105. package/src/contracts/personas.contract.ts +8 -8
  106. package/src/contracts/ports.contract.ts +1 -1
  107. package/src/contracts/prepush.contract.ts +2 -2
  108. package/src/contracts/providers.contract.ts +4 -4
  109. package/src/contracts/public.contract.ts +1 -1
  110. package/src/contracts/push.contract.ts +2 -2
  111. package/src/contracts/secrets.contract.ts +2 -2
  112. package/src/contracts/sessions.contract.ts +2 -2
  113. package/src/contracts/settings.contract.ts +3 -3
  114. package/src/contracts/share.contract.ts +1 -1
  115. package/src/contracts/skills.contract.ts +2 -2
  116. package/src/contracts/system.contract.ts +9 -9
  117. package/src/contracts/usage.contract.ts +1 -1
  118. package/src/contracts/vpn.contract.ts +5 -5
  119. package/src/contracts/workflows.contract.ts +12 -12
  120. package/src/contracts/workspace.contract.ts +13 -13
  121. package/src/conversation-ids.ts +8 -8
  122. package/src/events.test.ts +3 -3
  123. package/src/events.ts +142 -142
  124. package/src/fast-tier.test.ts +88 -0
  125. package/src/fast-tier.ts +72 -0
  126. package/src/history-state.ts +15 -15
  127. package/src/host-protocol.ts +7 -7
  128. package/src/hostnames.test.ts +1 -1
  129. package/src/hostnames.ts +15 -15
  130. package/src/index.ts +13 -8
  131. package/src/listener-protocol.ts +12 -12
  132. package/src/model-order.test.ts +11 -11
  133. package/src/model-order.ts +52 -30
  134. package/src/output-fields.ts +11 -11
  135. package/src/path-refs.test.ts +4 -4
  136. package/src/path-refs.ts +6 -6
  137. package/src/prompt-complexity.test.ts +160 -0
  138. package/src/prompt-complexity.ts +271 -0
  139. package/src/publish-drafts.ts +8 -8
  140. package/src/quick-model.test.ts +11 -11
  141. package/src/quick-model.ts +15 -15
  142. package/src/routes.test.ts +11 -5
  143. package/src/routes.ts +19 -19
  144. package/src/runtime-state.test.ts +1 -1
  145. package/src/runtime-state.ts +22 -22
  146. package/src/schemas.test.ts +8 -8
  147. package/src/schemas.ts +1238 -921
  148. package/src/search-globs.test.ts +2 -2
  149. package/src/search-globs.ts +6 -6
  150. package/src/session-names.ts +5 -5
  151. package/src/share-paths.test.ts +1 -1
  152. package/src/share-paths.ts +7 -7
  153. package/src/sse.ts +1 -1
  154. package/src/state-portability.ts +8 -8
  155. package/src/terminal-protocol.ts +3 -3
  156. package/src/title.test.ts +9 -9
  157. package/src/title.ts +21 -21
  158. package/src/tunnel-ids.test.ts +3 -3
  159. package/src/tunnel-ids.ts +7 -7
  160. package/src/versions.test.ts +3 -3
  161. package/src/versions.ts +6 -6
  162. package/src/workflow-faults.test.ts +6 -6
  163. package/src/workflow-faults.ts +14 -14
  164. package/src/workspace-state.test.ts +32 -32
  165. package/src/workspace-state.ts +139 -139
@@ -0,0 +1,271 @@
1
+ /* HOW HARD A TURN LOOKS BEFORE ANYTHING HAS BEEN SPENT ON IT, the judge behind automatic tier selection.
2
+ *
3
+ * The job is narrow on purpose: decide whether this turn could have run on the cheap rung of the provider the
4
+ * user is already on. Nothing here picks a model, nothing here reads a catalog, and nothing here calls
5
+ * anything. It is a pure function over the turn's own words and shape, so the daemon and the composer can both
6
+ * ask it and get the same answer, which is the same reason quick-model.ts lives in the contract rather than in
7
+ * either of them.
8
+ *
9
+ * IT CAN ONLY EVER ROUTE DOWN. The standard tier is not a setting: it is whatever the user already picked. So
10
+ * the question this file answers is never "which of two models" but "may we substitute something cheaper for
11
+ * the one they chose", and every ambiguous answer is NO. That asymmetry is the whole safety argument, and it is
12
+ * why there is no fail-up branch to get wrong: failing up means doing exactly what was asked.
13
+ *
14
+ * WHY RULES AND NOT A MODEL. Two findings decide this. Across the routing literature nothing sophisticated
15
+ * beats a simple predictor over decent features (a tuned kNN and a linear head tie, and both beat graph and
16
+ * attention routers costing 13-14x more), and a router that spends an LLM call to save an LLM call has spent
17
+ * the saving. So the layer is deliberately narrow, transparent, and free. See docs/model-routing-design.md.
18
+ *
19
+ * WHY EVERY RULE IS NAMED. A verdict carries the rules that fired, not just a number. That is what lets a
20
+ * screen say WHY a turn was downgraded, what makes a bad call reportable rather than mysterious, and what makes
21
+ * the shadow ledger analysable later: a score alone tells you a threshold was crossed, the rules tell you which
22
+ * feature is doing the work. The interpretable-router literature (Routesplain, COLM 2026) reaches the same
23
+ * conclusion from the accuracy side, but the operational one is enough on its own. */
24
+
25
+ // The named features. A verdict lists these, the shadow ledger stores them, and a screen renders them, so they
26
+ // are a vocabulary rather than debug strings: renaming one is a breaking change to what past rows mean.
27
+ export type ComplexityRule =
28
+ // Gates. Any one of these ends the question: the turn is standard and no score is computed.
29
+ | "images"
30
+ | "plan-mode"
31
+ | "unattended"
32
+ // Escalating rules. Any one forces standard. Order between them cannot matter, which is the point.
33
+ | "code-block"
34
+ | "stack-trace"
35
+ | "hard-words"
36
+ | "multi-step"
37
+ | "cross-cutting"
38
+ | "long-prompt"
39
+ | "many-attachments"
40
+ // Graded features. These only move the score.
41
+ | "medium-prompt"
42
+ | "attachment"
43
+ | "editor-context"
44
+ | "paths"
45
+ | "many-verbs"
46
+ | "after-hard-turn"
47
+ | "short-prompt"
48
+ | "easy-words"
49
+ | "bare-question"
50
+ | "no-workspace-reference";
51
+
52
+ export type ComplexityTier = "fast" | "standard";
53
+
54
+ /* Everything the judge is allowed to know. Deliberately primitive: counts, flags and the prompt itself, no
55
+ * objects owned by either side, so the daemon's turn and the composer's draft can each build one without
56
+ * agreeing about anything else. The same "compress route state into cheap primitive fields" shape the serving
57
+ * literature converges on. */
58
+ export interface ComplexityInput {
59
+ readonly prompt: string;
60
+ // Uploaded files plus @-mentioned workspace paths; the daemon resolves both into one list, so one count.
61
+ readonly attachments: number;
62
+ // Any attachment the model will read as an image. A cheap rung reading a screenshot is the worst cell in
63
+ // the matrix: it is the tier most likely to misread it and the turn least likely to notice.
64
+ readonly hasImages: boolean;
65
+ // The opt-in editor chip: the user pointed at a file and a selection, so the turn is about real code.
66
+ readonly editorContext: boolean;
67
+ // A surface started this, not a person at a composer (AgentTurn.unattended).
68
+ readonly unattended: boolean;
69
+ // The turn opens in plan mode: it is being asked to think before it acts, which is the request itself.
70
+ readonly planMode: boolean;
71
+ /* THE TURN BEFORE THIS ONE, IN THIS CONVERSATION, WAS JUDGED STANDARD.
72
+ *
73
+ * The most important field, and the one a prompt-only judge cannot derive. "now do the same for the other
74
+ * file" is nine easy words carrying the whole weight of the task before it, and a judge reading only the
75
+ * words will downgrade it every time. The routing work closest to this product (SWE-Router, over SWE-bench)
76
+ * states the general form: prompt-only routers inherit an information-theoretic error floor because the
77
+ * difficulty lives in the trajectory rather than in the request. Their answer is a 7B value model reading
78
+ * partial trajectories; ours is one boolean, which costs nothing and catches the case that actually bites.
79
+ *
80
+ * IT RAISES THE BAR, IT DOES NOT LOCK THE DOOR, and that is a deliberate softening of the rule this was
81
+ * designed as. A hard "once standard, always standard" lock reads well and is nearly useless: opening
82
+ * messages are substantive, so almost every conversation would take standard on turn one and never be
83
+ * eligible again, which is a mechanism that saves nothing while carrying all of the risk. As a weight it
84
+ * still stops the deceptive follow-up (which scores near the base and cannot afford the penalty) while a
85
+ * genuinely trivial aside inside a hard conversation still gets through, and that aside is a real and
86
+ * common turn.
87
+ *
88
+ * The JUDGEMENT, not what ran: a turn judged fast that ran standard anyway (nothing cheaper in the catalog,
89
+ * or the feature switched off) says nothing about the difficulty of the work, and reading it as escalation
90
+ * would make the sandbox's configuration leak into its opinion about a sentence. */
91
+ readonly afterHardTurn: boolean;
92
+ }
93
+
94
+ export interface ComplexityVerdict {
95
+ readonly tier: ComplexityTier;
96
+ // 0..1, rounded to three places so ledger rows compare exactly and do not carry float noise. 1 whenever a
97
+ // gate or an escalating rule fired: those do not produce a degree of difficulty, they produce an answer.
98
+ readonly score: number;
99
+ // Every rule that fired, in declaration order. Empty is legal and means "nothing distinctive": the score is
100
+ // the base, which sits above the fast ceiling, so an unremarkable turn stays on the user's own pick.
101
+ readonly rules: readonly ComplexityRule[];
102
+ }
103
+
104
+ /* WHERE AN UNREMARKABLE TURN STARTS, and it starts ABOVE the fast ceiling on purpose: a prompt that matches no
105
+ * rule at all is medium, not simple. That is the reference keyword-router convention and it is the conservative
106
+ * reading of silence, which is the only safe one when the downside of a wrong downgrade is a retry, an
107
+ * escalation, and a user who stops trusting the feature. */
108
+ const BASE_SCORE = 0.5;
109
+
110
+ // Fast iff the score lands at or below this. Exported because the shadow ledger's whole purpose is to let this
111
+ // be re-fitted against real traffic rather than argued about, and a reader of a stored score needs the ceiling
112
+ // that score was judged against.
113
+ export const FAST_CEILING = 0.25;
114
+
115
+ // Characters, not tokens: nothing here can tokenize, and for a threshold the constant cancels. ~600 chars is
116
+ // where a request stops being a sentence and starts being a brief; ~2400 is where it is carrying pasted
117
+ // material it has not fenced.
118
+ const MEDIUM_PROMPT_CHARS = 600;
119
+ const LONG_PROMPT_CHARS = 2400;
120
+ const SHORT_PROMPT_CHARS = 140;
121
+ // Three files in is a job about a shape rather than about a file, whatever the words say.
122
+ const MANY_ATTACHMENTS = 3;
123
+ // Three or more distinct imperatives is a list of jobs wearing the grammar of one.
124
+ const MANY_VERBS = 3;
125
+
126
+ /* THE WORD LISTS. Kept short and boring, because a hand-written lexicon is the part of a router that rots: it
127
+ * is inflexible by construction and every addition is a guess about traffic nobody has measured yet. It exists
128
+ * to catch the unambiguous ends of the distribution and to hand everything else to the score.
129
+ *
130
+ * Both lists are matched on word boundaries and case-insensitively. `easy` only ever lowers a score; `hard`
131
+ * forces standard outright, which is the asymmetry the rest of this file is built on. */
132
+ const EASY_WORDS =
133
+ /\b(?:what(?:'s| is| are)|explain|describe|summari[sz]e|list|show me|where(?:'s| is| are)|rename|typo|reword|reformat|format this|tidy|define|translate|spell|abbreviat)/i;
134
+
135
+ const HARD_WORDS =
136
+ /\b(?:refactor|redesign|architect|architecture|migrat|root cause|debug|investigat|diagnos|optimi[sz]|race condition|deadlock|memory leak|regression|security|threat model|benchmark|profil|audit|design a|plan (?:a|the|out)|why (?:does|is|are|did|would|can't|cannot))/i;
137
+
138
+ // "Do this, and also that." The strongest cheap signal of a job that is several jobs, and the one an easy-
139
+ // sounding sentence hides behind most often.
140
+ const MULTI_STEP = /\b(?:and then|after that|once (?:that|you)|followed by|as well as|then also)\b/i;
141
+
142
+ // A job whose subject is the shape of the codebase rather than a place in it. A cheap rung asked to be
143
+ // consistent across twenty files is being asked the one thing it is worst at.
144
+ const CROSS_CUTTING =
145
+ /\b(?:across (?:the|all|every)|every(?: single)? (?:file|module|package|component|usage|call ?site)|all (?:the|of the) (?:files|usages|call ?sites|places)|everywhere|throughout the|codebase-wide|repo-wide)\b/i;
146
+
147
+ // Fenced code, a unified diff, or an inline patch. Pasted code is not proof of difficulty by itself, but it is
148
+ // proof the turn is about real code rather than about a word, and the cheap rung's failures there are silent.
149
+ const CODE_BLOCK = /```|^diff --git |^@@ .* @@|^[+-]{3} [ab]\//m;
150
+
151
+ // A thrown error the user has pasted in. Debugging from a trace is the canonical case where the expensive tier
152
+ // earns its price, and it is trivially detectable.
153
+ const STACK_TRACE =
154
+ /(?:^|\n)\s*(?:at [\w$.<>]+ \(|Traceback \(most recent call last\)|Caused by:|panic:|thread '.*' panicked|Unhandled|[A-Z]\w*(?:Error|Exception): )/;
155
+
156
+ // A workspace path or an @-mention in the prose. Distinguishes "explain closures" from "explain what this file
157
+ // does" without claiming either is hard.
158
+ const PATH_LIKE =
159
+ /(?:^|\s)(?:@[\w./-]+|[\w-]+\/[\w./-]+\.[a-z]{1,5}\b|\b[\w-]+\.(?:ts|tsx|js|jsx|vue|py|go|rs|java|rb|css|scss|json|ya?ml|md|sql|sh)\b)/i;
160
+
161
+ // One sentence, ending in a question mark, with no second clause. Knowledge, not work.
162
+ const BARE_QUESTION = /^[^.!?]{0,200}\?\s*$/;
163
+
164
+ // Imperatives that start a request. Counted, not matched: one is a task, three is a list.
165
+ const VERBS =
166
+ /\b(?:add|remove|delete|fix|write|create|make|update|change|move|rename|split|merge|extract|inline|wire|hook|test|check|run|build|deploy|document|implement|replace|convert|handle|support|expose|log|render|validate|parse|sort|filter|cache)\b/gi;
167
+
168
+ // Bullets and numbered steps, which is a checklist however casually it is written.
169
+ const LIST_LINES = /^\s*(?:[-*+]\s|\d+[.)]\s)/gm;
170
+
171
+ // Every rule that ends the question on its own, paired with the test that fires it. Split from the graded
172
+ // features below because the two are read differently: these are answers, those are evidence.
173
+ const forcing = (input: ComplexityInput, text: string): ComplexityRule[] => {
174
+ const rules: ComplexityRule[] = [];
175
+ // Gates first, cheapest and least arguable. They are about the turn's SITUATION rather than its words.
176
+ if (input.hasImages) {
177
+ rules.push("images");
178
+ }
179
+ if (input.planMode) {
180
+ rules.push("plan-mode");
181
+ }
182
+ /* An unattended run is billed whole and nobody is watching it fail. The settings this repo already ships
183
+ * make the same call in the other direction: agentRunModels resolves to NOTHING when empty precisely
184
+ * because "nothing here can judge whether a job is worth the frontier tier". This file does judge, but not
185
+ * for the runs where a wrong guess costs a whole session with a worktree in it. */
186
+ if (input.unattended) {
187
+ rules.push("unattended");
188
+ }
189
+ // Then the words. Every one of these is a claim that the turn is about real code doing something real.
190
+ if (CODE_BLOCK.test(text)) {
191
+ rules.push("code-block");
192
+ }
193
+ if (STACK_TRACE.test(text)) {
194
+ rules.push("stack-trace");
195
+ }
196
+ if (HARD_WORDS.test(text)) {
197
+ rules.push("hard-words");
198
+ }
199
+ if (MULTI_STEP.test(text) || (text.match(LIST_LINES)?.length ?? 0) >= 2) {
200
+ rules.push("multi-step");
201
+ }
202
+ if (CROSS_CUTTING.test(text)) {
203
+ rules.push("cross-cutting");
204
+ }
205
+ if (text.length > LONG_PROMPT_CHARS) {
206
+ rules.push("long-prompt");
207
+ }
208
+ if (input.attachments >= MANY_ATTACHMENTS) {
209
+ rules.push("many-attachments");
210
+ }
211
+ return rules;
212
+ };
213
+
214
+ /* The graded half: what nudges an otherwise ordinary request either way. Weights are a starting shape fitted to
215
+ * nothing, which is exactly why the mechanism ships in shadow first — see docs/model-routing-design.md §4. They
216
+ * are not a claim, they are a hypothesis with a ledger under it.
217
+ *
218
+ * ONE PROPERTY IS NOT A HYPOTHESIS AND MUST SURVIVE ANY REFIT: the two ABSENCE features (`short-prompt`,
219
+ * `no-workspace-reference`) are too light to reach the ceiling together. Absence of complexity is not evidence
220
+ * of simplicity — "fix the bug" is four words naming no file and is not a cheap turn — so a downgrade always
221
+ * requires something POSITIVE to have been said, which in practice means `easy-words` or `bare-question`
222
+ * carrying it. Weighted the obvious way instead, the judge downgraded every short vague request in the
223
+ * product, which is the single worst population to be wrong about. */
224
+ const GRADED: readonly { readonly rule: ComplexityRule; readonly weight: number; readonly of: (input: ComplexityInput, text: string) => boolean }[] =
225
+ [
226
+ { rule: "medium-prompt", weight: +0.2, of: (_input, text) => text.length > MEDIUM_PROMPT_CHARS },
227
+ { rule: "attachment", weight: +0.15, of: (input) => input.attachments > 0 },
228
+ { rule: "editor-context", weight: +0.1, of: (input) => input.editorContext },
229
+ // Enough on its own to hold an easy-worded question at standard: "explain what this file does" is a
230
+ // question about real code in this repo, and the cheap rung's failures on real code are the silent kind.
231
+ { rule: "paths", weight: +0.15, of: (_input, text) => PATH_LIKE.test(text) },
232
+ {
233
+ rule: "many-verbs",
234
+ weight: +0.15,
235
+ of: (_input, text) => new Set((text.match(VERBS) ?? []).map((verb) => verb.toLowerCase())).size >= MANY_VERBS,
236
+ },
237
+ // The heaviest single weight, because it is the only feature that can see past the words. See
238
+ // ComplexityInput.afterHardTurn for why it is a weight rather than the gate it was designed as.
239
+ { rule: "after-hard-turn", weight: +0.25, of: (input) => input.afterHardTurn },
240
+ // The two absence features, deliberately light; see the note above the list.
241
+ { rule: "short-prompt", weight: -0.1, of: (_input, text) => text.length <= SHORT_PROMPT_CHARS },
242
+ { rule: "easy-words", weight: -0.25, of: (_input, text) => EASY_WORDS.test(text) },
243
+ { rule: "bare-question", weight: -0.15, of: (_input, text) => BARE_QUESTION.test(text) },
244
+ {
245
+ rule: "no-workspace-reference",
246
+ weight: -0.1,
247
+ of: (input, text) => input.attachments === 0 && !input.editorContext && !PATH_LIKE.test(text),
248
+ },
249
+ ];
250
+
251
+ // Three places, so a stored score is a value rather than a float artefact and two rows written by the same
252
+ // rules compare equal.
253
+ const round3 = (value: number): number => Math.round(value * 1000) / 1000;
254
+
255
+ /* THE VERDICT. Gates and escalating rules first, and if any fired the answer is standard at score 1 — no
256
+ * partial credit, no weight to tune, and no way for rule ORDER to change the outcome. That monotone-escalation
257
+ * property is worth more than the accuracy it costs: it means adding a rule tomorrow can only ever move turns
258
+ * up a tier, never silently move a different set of turns down.
259
+ *
260
+ * Only what survives all of that gets scored, which keeps the graded layer doing the one job it is good at:
261
+ * separating "explain this" from "wire this up" among requests that look alike. */
262
+ export const judgeComplexity = (input: ComplexityInput): ComplexityVerdict => {
263
+ const text = input.prompt.trim();
264
+ const forced = forcing(input, text);
265
+ if (forced.length > 0) {
266
+ return { tier: "standard", score: 1, rules: forced };
267
+ }
268
+ const hits = GRADED.filter((feature) => feature.of(input, text));
269
+ const score = Math.min(1, Math.max(0, BASE_SCORE + hits.reduce((total, feature) => total + feature.weight, 0)));
270
+ return { tier: score <= FAST_CEILING ? "fast" : "standard", score: round3(score), rules: hits.map((feature) => feature.rule) };
271
+ };
@@ -1,16 +1,16 @@
1
- /* HOW AN APPROVED POST GETS SENT the shared half, written once because the daemon acts on it, the app draws
1
+ /* HOW AN APPROVED POST GETS SENT, the shared half, written once because the daemon acts on it, the app draws
2
2
  * it, and the two have to agree on the same seconds.
3
3
  *
4
4
  * THERE IS NO PUBLISHER AUTOMATION ANY MORE, and its absence is the design. Publishing used to be a scheduled
5
5
  * automation: a cron waking every few minutes, running a shell guard over the drafts directory, almost always
6
- * finding nothing a job whose entire job was to ask "yet?" forever. It also made the approve button
6
+ * finding nothing, a job whose entire job was to ask "yet?" forever. It also made the approve button
7
7
  * conditional on a row in a list nobody had asked for: delete the automation and approvals silently went
8
8
  * nowhere, with the button still there and still saying yes. The daemon owns publishing now
9
9
  * (drafts-publisher.ts). It knows the moment a draft comes due because it is the process that wrote the draft,
10
10
  * so it sleeps until exactly then and costs nothing in between.
11
11
  *
12
12
  * APPROVAL IS NOT "SEND NOW", IT IS "SEND UNLESS I STOP YOU". A post is public and permanent the instant it
13
- * lands, and the gap between realising and clicking is about two seconds so an approved draft carrying no
13
+ * lands, and the gap between realising and clicking is about two seconds, so an approved draft carrying no
14
14
  * date of its own is dated HOLD into the future, and the queue counts it down in the open. The hold is stored
15
15
  * as an ordinary scheduledAt rather than as a new state, which is what keeps it honest: it survives a restart,
16
16
  * it reads as one number in the same place a scheduled post's date is already read, and calling it off is the
@@ -21,20 +21,20 @@
21
21
  export const APPROVAL_HOLD_MS = 60_000;
22
22
 
23
23
  /* WHO CAN BE SENT BY CODE ALONE. A connector reached through a real API with a stored credential is a request
24
- * the daemon can make itself no model, no browser, no turn and it either got a 200 or it did not. A
24
+ * the daemon can make itself, no model, no browser, no turn, and it either got a 200 or it did not. A
25
25
  * connector that IS a logged-in browser session (reddit, x) has no such door: posting there means driving a
26
26
  * page whose markup moves under you, past dialogs and rate screens nobody can enumerate in advance, which is
27
27
  * precisely the work an agent turn exists to absorb.
28
28
  *
29
29
  * So the split follows what the platform actually offers rather than what would be cheaper, and it is stated
30
- * here rather than guessed at a call site because being wrong in the optimistic direction means a post that
30
+ * here rather than guessed at a call site, because being wrong in the optimistic direction means a post that
31
31
  * silently never goes out. A platform absent from this set is published by an agent turn, which always works
32
32
  * and merely costs more. */
33
33
  export const DIRECT_PUBLISH_PLATFORMS: ReadonlySet<string> = new Set(["discord"]);
34
34
 
35
35
  /* WHAT THE PUBLISH TURN IS TOLD, for the drafts no API can carry. It NAMES the drafts rather than saying "go
36
36
  * and look": the daemon has already decided what is due, and a turn that re-derives that decision can disagree
37
- * with it sending something the owner pulled back a second ago, or skipping something it judged not ready.
37
+ * with it, sending something the owner pulled back a second ago, or skipping something it judged not ready.
38
38
  * The turn's job is the part only it can do, which is working the platform's own UI.
39
39
  *
40
40
  * It still writes the outcome back into the file, because the file is where the queue reads it, and a post
@@ -47,7 +47,7 @@ export const publishTurnPrompt = (drafts: readonly { readonly id: string; readon
47
47
  ``,
48
48
  `Take them ONE AT A TIME, and for each:`,
49
49
  `1. Read the file. Set "status":"posting" BEFORE you act, so a turn that dies here cannot double-post.`,
50
- `2. Post exactly its "content" with its "title", "target" and "media" using that platform's skill.`,
50
+ `2. Post exactly its "content": with its "title", "target" and "media", using that platform's skill.`,
51
51
  ` A "target" that is a URL means this draft is a REPLY to whatever is at it: open that exact URL and`,
52
52
  ` reply where it lands. On reddit a comment permalink (.../comments/<post>/<slug>/<comment>/) has to`,
53
53
  ` nest under that comment rather than becoming a new top-level comment on the thread.`,
@@ -55,5 +55,5 @@ export const publishTurnPrompt = (drafts: readonly { readonly id: string; readon
55
55
  ` If it failed, set "status":"failed" plus an "error" saying what went wrong in plain words the owner`,
56
56
  ` can act on, then move to the next draft instead of retrying in a loop.`,
57
57
  ``,
58
- `Never rewrite the content the owner approved these exact words. Never touch a draft not listed above.`,
58
+ `Never rewrite the content: the owner approved these exact words. Never touch a draft not listed above.`,
59
59
  ].join(`\n`);
@@ -1,8 +1,8 @@
1
1
  import { expect, test } from "vitest";
2
2
  import { type QuickModelChoice, type QuickModelSource, quickModelKey, resolveQuickModels } from "./quick-model.js";
3
3
 
4
- /* Which models a small automatic helper spends, and in which order. The rule answers two surfaces at once
5
- * the daemon walks it, the browser names its head in the settings row so what these tests pin is that a
4
+ /* Which models a small automatic helper spends, and in which order. The rule answers two surfaces at once:
5
+ * the daemon walks it, the browser names its head in the settings row, so what these tests pin is that a
6
6
  * sandbox's connections alone decide it, with no stored id to go stale, and that there is always a rung
7
7
  * underneath the first one whenever the sandbox has another account to reach for. */
8
8
 
@@ -14,7 +14,7 @@ const KIMI: QuickModelSource = { provider: `kimi`, ready: true, models: [`kimi-k
14
14
 
15
15
  const offline = (source: QuickModelSource): QuickModelSource => ({ ...source, ready: false });
16
16
 
17
- // The model that answers when nothing goes wrong the head of the chain, which is what most of what follows is
17
+ // The model that answers when nothing goes wrong: the head of the chain, which is what most of what follows is
18
18
  // about and what every surface naming the spend up front reads.
19
19
  const head = (sources: readonly QuickModelSource[], pinned: readonly string[]): QuickModelChoice | undefined =>
20
20
  resolveQuickModels(sources, pinned)[0];
@@ -24,12 +24,12 @@ test("reaches for the efficient rung of the one connected provider, never its fl
24
24
  });
25
25
 
26
26
  test("spends the FREE channel over the subscription when both offer the same rung", () => {
27
- // Both publish a cheap-tier row, so nothing separates them on capability and one of them costs the user
27
+ // Both publish a cheap-tier row, so nothing separates them on capability, and one of them costs the user
28
28
  // nothing while the other eats headroom they watch. A background helper should not quietly bill the Claude plan.
29
29
  expect(head([CLAUDE, GOOGLE], [])).toEqual({ provider: `gemini`, model: `gemini-3-flash-lite` });
30
30
  });
31
31
 
32
- test("puts tier ahead of cost a free frontier model is still the wrong tool for a commit message", () => {
32
+ test("puts tier ahead of cost: a free frontier model is still the wrong tool for a commit message", () => {
33
33
  // Google connected but publishing only its Pro line. Ordering on price first would seat a flagship here,
34
34
  // which is the exact outcome the feature exists to avoid.
35
35
  const proOnly: QuickModelSource = { provider: `gemini`, ready: true, models: [`gemini-3-pro`] };
@@ -90,7 +90,7 @@ test("skips a connected provider whose catalog has not loaded yet", () => {
90
90
  expect(head([unloaded], [])).toBeUndefined();
91
91
  });
92
92
 
93
- /* THE CHAIN what the daemon walks when the model at the top of it refuses. A spent allowance is the ordinary
93
+ /* THE CHAIN: what the daemon walks when the model at the top of it refuses. A spent allowance is the ordinary
94
94
  * case, not the exotic one: the account a helper shares with the chat runs out mid-afternoon, and the whole
95
95
  * point of the list is that the click still lands on the next rung down. */
96
96
 
@@ -111,7 +111,7 @@ test("drops a pin whose provider went away and keeps the rest of the order intac
111
111
 
112
112
  test("stops at the end of a pinned list rather than reaching for an account the user left out", () => {
113
113
  // Google and Kimi are connected and cheaper. The user wrote down one model, so one model is what this may
114
- // spend a pin exists precisely to keep a helper off the accounts it does not name.
114
+ // spend: a pin exists precisely to keep a helper off the accounts it does not name.
115
115
  expect(resolveQuickModels([CLAUDE, GOOGLE, KIMI], [`claude:claude-haiku-4-5`])).toEqual([{ provider: `claude`, model: `claude-haiku-4-5` }]);
116
116
  });
117
117
 
@@ -122,7 +122,7 @@ test("names each model once, however many times the list repeats it", () => {
122
122
  ]);
123
123
  });
124
124
 
125
- test("Auto is a ladder too every connected provider's cheap rung, best first", () => {
125
+ test("Auto is a ladder too, every connected provider's cheap rung, best first", () => {
126
126
  expect(resolveQuickModels([CLAUDE, GOOGLE, KIMI], [])).toEqual([
127
127
  { provider: `gemini`, model: `gemini-3-flash-lite` },
128
128
  { provider: `claude`, model: `claude-haiku-4-5-20251001` },
@@ -135,11 +135,11 @@ test("Auto is a ladder too — every connected provider's cheap rung, best first
135
135
  * dropped would print one model's name in the settings row and spend a different account entirely. */
136
136
  const OLLAMA: QuickModelSource = { provider: `endpoint/ollama`, ready: true, models: [`qwen3-coder`, `gemma3-27b`] };
137
137
 
138
- test("honours a pin on a configured endpoint the whole id, not the half before its slash", () => {
138
+ test("honours a pin on a configured endpoint: the whole id, not the half before its slash", () => {
139
139
  expect(head([CLAUDE, OLLAMA], [`endpoint/ollama:qwen3-coder`])).toEqual({ provider: `endpoint/ollama`, model: `qwen3-coder` });
140
140
  // And it round-trips through the key shape the picker mints, which is where the slash-not-colon rule earns
141
141
  // itself: parsePinned splits on the FIRST colon, so an `endpoint:ollama` id would have parsed the provider
142
- // as "endpoint" and the model as "ollama:qwen3-coder" a pin that silently resolves to nothing.
142
+ // as "endpoint" and the model as "ollama:qwen3-coder": a pin that silently resolves to nothing.
143
143
  expect(quickModelKey({ provider: `endpoint/ollama`, model: `qwen3-coder` })).toBe(`endpoint/ollama:qwen3-coder`);
144
144
  });
145
145
 
@@ -152,7 +152,7 @@ test("leaves Auto to the providers whose price is known, rather than reaching fo
152
152
 
153
153
  test("still answers from an endpoint when it is the only thing configured", () => {
154
154
  // No tier word in either id, so the shared id-derived ordering decides between them exactly as it does for
155
- // Kimi above the point here is that a sandbox whose only model API is its owner's still gets an answer
155
+ // Kimi above: the point here is that a sandbox whose only model API is its owner's still gets an answer
156
156
  // rather than the disabled "nothing connected" button.
157
157
  expect(head([offline(CLAUDE), OLLAMA], [])).toEqual({ provider: `endpoint/ollama`, model: `qwen3-coder` });
158
158
  });
@@ -2,7 +2,7 @@ import { ACCESS_COST, accessFor, modelsFor, PROVIDERS } from "./agent-catalog.js
2
2
  import { compareCheapestFirst, familyOf, tierRankOf } from "./model-order.js";
3
3
  import type { AgentProvider } from "./schemas.js";
4
4
 
5
- /* THE QUICK MODEL the cheap, fast model a small automatic job spends instead of the frontier model the chat
5
+ /* THE QUICK MODEL, the cheap, fast model a small automatic job spends instead of the frontier model the chat
6
6
  * runs on. Today that is the commit message written when an agent's work lands; anything else of that shape (a
7
7
  * branch name, a PR description) reads the same answer, which is the reason this is a `quickModel` setting
8
8
  * rather than a commit-message one.
@@ -11,11 +11,11 @@ import type { AgentProvider } from "./schemas.js";
11
11
  * failure: the account it names spends its allowance on the chat all morning, and every job for the rest of the
12
12
  * day fails on a limit while three other connected providers sit idle. So the setting is a LIST
13
13
  * read top to bottom, the resolver hands back the whole ladder, and the daemon walks it until one answers.
14
- * Nothing here decides WHICH failures are worth stepping over that is the daemon's, since only it has run
15
- * the call this side only says what the running order is.
14
+ * Nothing here decides WHICH failures are worth stepping over, that is the daemon's, since only it has run
15
+ * the call, this side only says what the running order is.
16
16
  *
17
17
  * The rule lives in the contract because BOTH sides need the same answer for different jobs: the daemon runs
18
- * the model, and the browser has to NAME it in the settings row's "Auto (…)" label before anything has been
18
+ * the model, and the browser has to NAME it, in the settings row's "Auto (…)" label, before anything has been
19
19
  * run. Two implementations would drift precisely where it matters most, since a label promising Haiku while the
20
20
  * daemon bills Opus is worse than no label.
21
21
  *
@@ -27,14 +27,14 @@ import type { AgentProvider } from "./schemas.js";
27
27
 
28
28
  /* One provider's standing in the decision: whether a turn on it can be sent at all, and what its catalog holds.
29
29
  *
30
- * ACP agents are deliberately not expressible here an ACP row's model id is empty because the agent owns its
30
+ * ACP agents are deliberately not expressible here, an ACP row's model id is empty because the agent owns its
31
31
  * own model, so there is no cheap rung to point it at. `endpoint/<id>` providers ARE, and have to be: their
32
32
  * models appear in the same picker the settings row builds its options from, so a pin naming one has to hold
33
33
  * rather than fall silently back to Auto and spend an account the user was deliberately steering away from. */
34
34
  export interface QuickModelSource {
35
35
  // AgentProvider, not NativeProvider: an endpoint's id is user-created and cannot be in a fixed union. Auto's
36
- // ranking degrades gracefully for one costOf falls to the metered rung and an id with no tier word is
37
- // UNRANKED, which is genuine last place so an endpoint effectively only wins Auto when nothing else is
36
+ // ranking degrades gracefully for one, costOf falls to the metered rung and an id with no tier word is
37
+ // UNRANKED, which is genuine last place, so an endpoint effectively only wins Auto when nothing else is
38
38
  // connected, while a PIN on one holds. Both are the right answers: what a turn on someone's own model server
39
39
  // costs is not a fact this repo can know, so it is not one Auto should be asserting.
40
40
  readonly provider: AgentProvider;
@@ -67,13 +67,13 @@ export const parsePinned = (pinned: string): QuickModelChoice | undefined => {
67
67
 
68
68
  /* A pin as a person reads it: the catalog's own label for the id, or the id itself for one the static catalog
69
69
  * has not caught up with (the picker offers a custom-id escape hatch, so this is a real case rather than a
70
- * defensive branch). Beside parsePinned because the two are always wanted together by any surface that has to
70
+ * defensive branch). Beside parsePinned because the two are always wanted together, by any surface that has to
71
71
  * name what a click is about to spend BEFORE it spends it, and the two loudest of those are extensions that
72
72
  * share no other code with each other. */
73
73
  export const pinnedModelLabel = (choice: QuickModelChoice): string =>
74
74
  modelsFor(choice.provider).find((option) => option.value === choice.model)?.label ?? choice.model;
75
75
 
76
- // The cheapest row a provider publishes its whole catalog read from the cheap end. Undefined for a catalog
76
+ // The cheapest row a provider publishes, its whole catalog read from the cheap end. Undefined for a catalog
77
77
  // that hasn't loaded yet, which is a real state: every provider serves a floor, but only once something has
78
78
  // asked it.
79
79
  const cheapestOf = (source: QuickModelSource): string | undefined => source.models.toSorted(compareCheapestFirst)[0];
@@ -83,28 +83,28 @@ const cheapestOf = (source: QuickModelSource): string | undefined => source.mode
83
83
  // so the row is the provider's base line rather than its budget one.
84
84
  const tierOf = (model: string): number => tierRankOf(familyOf(model));
85
85
 
86
- // PROVIDERS order, as the final tiebreak. Arbitrary, but the SAME arbitrary answer on every read the property
86
+ // PROVIDERS order, as the final tiebreak. Arbitrary, but the SAME arbitrary answer on every read, the property
87
87
  // compareUnrankedModelIds exists to guarantee, and the one a default actually needs. An endpoint is in no fixed
88
88
  // list, so it reads -1 and leads the tiebreak; unreachable in practice, since it can never tie on cost.
89
89
  const providerOrder = (provider: AgentProvider): number => PROVIDERS.findIndex((entry) => entry.value === provider);
90
90
 
91
91
  // How much a call on this provider costs at the margin. Every native provider declares an access kind; an
92
- // endpoint declares none, and takes the metered rung the conservative reading of a model API whose bill this
92
+ // endpoint declares none, and takes the metered rung, the conservative reading of a model API whose bill this
93
93
  // repo cannot see, which keeps Auto from reaching for someone's paid gateway on its own initiative.
94
94
  const costOf = (provider: AgentProvider): number => {
95
95
  const access = accessFor(provider);
96
96
  return access === undefined ? ACCESS_COST.key : ACCESS_COST[access.kind];
97
97
  };
98
98
 
99
- /* AUTO every connected provider's cheapest row, best-first, as a ladder rather than a winner.
99
+ /* AUTO, every connected provider's cheapest row, best-first, as a ladder rather than a winner.
100
100
  *
101
101
  * Ranked on TIER FIRST, then cost. That order is the point of the feature: the helper exists to not be the
102
102
  * frontier model, so a free flagship is still the wrong tool, while a free Haiku-class row and a subscription
103
103
  * Haiku-class row differ only in whose quota they spend. Cost then breaks that tie towards the channel the user
104
- * is not paying per token for and against the one they are.
104
+ * is not paying per token for, and against the one they are.
105
105
  *
106
106
  * The whole ladder, not just its head, because the same ranking that picks the best answer also states the best
107
- * SECOND answer and a sandbox with three accounts connected should not lose its commit messages for six hours
107
+ * SECOND answer, and a sandbox with three accounts connected should not lose its commit messages for six hours
108
108
  * because one of them is spent. */
109
109
  const autoLadder = (sources: readonly QuickModelSource[]): readonly QuickModelChoice[] =>
110
110
  sources
@@ -144,7 +144,7 @@ export const resolveQuickModels = (sources: readonly QuickModelSource[], pinned:
144
144
  const choice = parsePinned(key);
145
145
  return choice === undefined || !ready.has(choice.provider) ? [] : [choice];
146
146
  });
147
- // The same model twice would spend two attempts proving the same account is out a real state, since the
147
+ // The same model twice would spend two attempts proving the same account is out, a real state, since the
148
148
  // list is edited by hand and Auto's ladder can rank a provider the user has also pinned.
149
149
  const chain = [...new Map(requested.map((choice) => [quickModelKey(choice), choice])).values()];
150
150
  return chain.length > 0 ? chain : autoLadder(sources);
@@ -87,14 +87,14 @@ describe(`routeShapes`, () => {
87
87
  expect(after).not.toBe(before);
88
88
  });
89
89
 
90
- it(`is blind to the order fields are declared in a reordered object is not a wire change`, () => {
90
+ it(`is blind to the order fields are declared in: a reordered object is not a wire change`, () => {
91
91
  const one = routeShapes(shaped(z.object({ a: z.string(), b: z.number() })))[`vpn.list`];
92
92
  const other = routeShapes(shaped(z.object({ b: z.number(), a: z.string() })))[`vpn.list`];
93
93
  expect(other).toBe(one);
94
94
  });
95
95
 
96
96
  it(`reads a defaulted field differently on the way in than on the way out`, () => {
97
- // `.default()` makes a field optional going in and required coming out the same declaration, two
97
+ // `.default()` makes a field optional going in and required coming out: the same declaration, two
98
98
  // wire shapes. Reading both directions the same way would call them identical.
99
99
  const one = z.object({ a: z.string().default(`x`) });
100
100
  const asOutput = routeShapes(shaped(one))[`vpn.list`];
@@ -114,7 +114,7 @@ describe(`routeShapes`, () => {
114
114
 
115
115
  it(`omits a route whose shape cannot be expressed rather than failing the walk`, () => {
116
116
  // An oRPC event iterator wraps its output in an opaque type with no schema underneath. The route keeps
117
- // its NAME (it is still advertised as existing) and simply carries no shape which reads downstream as
117
+ // its NAME (it is still advertised as existing) and simply carries no shape, which reads downstream as
118
118
  // "assume compatible". The two neighbours still get fingerprinted, which is the part that matters: one
119
119
  // unexpressable route must not cost the whole walk.
120
120
  const withStream = {
@@ -135,12 +135,18 @@ describe(`routeShapes`, () => {
135
135
  describe(`the real sandbox contract`, () => {
136
136
  it(`fingerprints all but the streaming routes`, () => {
137
137
  const unshaped = SANDBOX_ROUTE_NAMES.filter((name) => !(name in SANDBOX_ROUTE_SHAPES));
138
- // oRPC wraps an event iterator's output in an opaque type with no schema under it, so these seven
138
+ // oRPC wraps an event iterator's output in an opaque type with no schema under it, so these ten
139
139
  // cannot be fingerprinted and are assumed compatible. Named rather than counted: a NEW entry here is
140
140
  // a route that quietly lost its shape check, which is worth failing a test over.
141
141
  expect(unshaped.toSorted()).toEqual([
142
142
  `agent.attach`,
143
143
  `capabilities.add`,
144
+ // The three geo-exit moves, streaming for the same reason vpn.connect does: bringing an exit up
145
+ // pulls a catalog, dials, and then verifies the address it landed on, which is tens of seconds on
146
+ // the free providers and can fail with something the user has to read at each step.
147
+ `exit.rotate`,
148
+ `exit.start`,
149
+ `exit.use`,
144
150
  `intentic.applyEvents`,
145
151
  `intentic.run`,
146
152
  `system.events`,
@@ -151,7 +157,7 @@ describe(`the real sandbox contract`, () => {
151
157
 
152
158
  it(`fingerprints every other route exactly once`, () => {
153
159
  expect(Object.keys(SANDBOX_ROUTE_SHAPES).every((name) => SANDBOX_ROUTE_NAMES.includes(name))).toBe(true);
154
- expect(Object.keys(SANDBOX_ROUTE_SHAPES).length).toBe(SANDBOX_ROUTE_NAMES.length - 7);
160
+ expect(Object.keys(SANDBOX_ROUTE_SHAPES).length).toBe(SANDBOX_ROUTE_NAMES.length - 10);
155
161
  });
156
162
 
157
163
  it(`derives a route table with no duplicate names`, () => {