ytdb-slate 0.5.0 → 0.5.1

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.
package/README.md CHANGED
@@ -53,7 +53,7 @@ Slate can ride through model API outages. The opt-in `modelFailover` map in `.pi
53
53
 
54
54
  ## Action-level model routing
55
55
 
56
- Slate can pick the model and the effort level per dispatched action rather than running every action on whatever model the orchestrator happens to be on. `router.models` in `.pi/slate.json` (trusted projects only) is a closed list of `provider/id` specs; a dispatch then runs on the `model` and `effort` arguments of that action, else on the thread's own base model and a level DERIVED for the model that actually runs — the lowest level that model has a capability measurement at, never a fixed default. The list is empty by default, and empty means off: no list is enforced, nothing is seeded, and model selection is exactly what it was before the router existed. Only models Slate ships a benchmark profile for can be routed to; entries pi cannot serve, or has no profile for, are dropped with a warning naming them.
56
+ Slate can pick the model and the effort level per dispatched action rather than running every action on whatever model the orchestrator happens to be on. `router.models` in `.pi/slate.json` (trusted projects only) is a closed list of `provider/id` specs. Each action uses its `model` and `effort` arguments when present. Otherwise, it uses the thread's base model and a level DERIVED for the model it routes to. That level is the lowest one with a capability measurement, never a fixed default. The list is empty by default. An empty list disables candidate-list enforcement, base seeding, effort derivation, and the router's window, billing, and substitution paths. Per-action `model` and `effort` arguments stay active on both router states. Slate always checks an explicit effort against pi's vocabulary, and against the ladder of the model it routes to whenever it has that model's profile data. An action that omits `model` plans for the thread's pre-router pin, and a thread with no pin gives it no plan target. An action that omits `effort` resolves no level. In both cases the worker session returns to what it opened on. [`docs/model-routing.md`](docs/model-routing.md) holds the full contract, including the known cases where the model or level that runs differs. Only models Slate ships a benchmark profile for can be routed to; entries pi cannot serve, or has no profile for, are dropped with a warning naming them.
57
57
 
58
58
  What the code enforces is deliberately narrower than what the routing data advises. The dispatch guards refuse an unlisted model, a level off the target model's ladder and a level the provider rejects outright; they warn about — and mark — an unmeasured level, refusing it only when `router.allowUnmeasuredEffort` is `false`; and the context-window guard substitutes a wider listed model instead of ever blocking an action. The obligations that come from the profile data itself — keeping review and gate actions on measured levels, and honoring a REFUSE such as `anthropic/claude-fable-5`'s for zero-data-retention work — are stated in the doctrine for the orchestrator to honor; they are **not** code-enforced guards. Full semantics: the `router.*` rows in [Configuration](#configuration) and the shipped [`docs/model-routing.md`](docs/model-routing.md) — if they disagree, that document wins.
59
59
 
@@ -86,7 +86,7 @@ Optional config file: `slate.json` in the project's pi config dir (`.pi/slate.js
86
86
  | `reviewPerspectivesPath` | string | — | Project review charters, each declaring its own finding-ID prefix. The doctrine references this **path**; the orchestrator reads the file alongside the shipped review rules. |
87
87
  | `modelFailover` | object (string → string) | — (empty, failover off) | Map of `provider/id` → equal-quality alternative model; on a model API failure the affected site retries once on the mapped model (worker/orchestrator sites only after pi's own retries are exhausted; episode compression has no pi retry loop). Read once at session start — see [`docs/model-failover.md`](docs/model-failover.md). |
88
88
  | `preserveGlobalModelDefault` | boolean | `true` | Put pi's global model defaults (`defaultProvider`, `defaultModel`, `defaultThinkingLevel`) back after a Slate-initiated model switch — orchestrator failover and handoff adoption — so the switch stays session-scoped. `false` turns that restore off at both sites (the switches themselves are unaffected), leaving each one persisted in pi's global settings. Best-effort: Slate reverts only a value its own switch produced, and can stand down or give up — limits in [`docs/model-failover.md`](docs/model-failover.md). |
89
- | `router.models` | string[] | `[]` (empty — routing off) | Closed list of models (`provider/id`) an action may be routed to. **Empty or absent means the router is OFF and dispatch behaves exactly as it did before the router existed** — no model list is enforced and no thread gets a seeded base model. A listed model becomes routable only where four things intersect: your list, a benchmark profile Slate ships for that model, pi's model registry, and credentials pi actually has configured; anything that fails one of those is dropped with a warning naming it (if every entry drops, routing turns off with one summary warning). Read once at session start and resolved once per session — see [`docs/model-routing.md`](docs/model-routing.md). |
89
+ | `router.models` | string[] | `[]` (empty — routing off) | Closed list of models (`provider/id`) an action may use. Empty or absent means the router is OFF. Slate enforces no candidate list, seeds no thread base, derives no effort level, and runs no router-only window, billing, or substitution path. Per-action `model` and `effort` arguments stay active on both router states. Slate still checks explicit effort against pi's vocabulary and the routed model's ladder when it has that model's profile data. An action that omits `model` plans for the thread's pre-router pin, and a thread with no pin gives it no plan target. An action that omits `effort` resolves no level. In both cases the worker session returns to what it opened on. The known cases where the model or level that runs differs are listed in [`docs/model-routing.md`](docs/model-routing.md). A listed model becomes routable only if Slate has its benchmark profile, pi registers it, and credentials are configured. Slate drops other entries with a warning. If all entries drop, routing turns off with one summary warning. Slate reads and resolves the list once per session. See [`docs/model-routing.md`](docs/model-routing.md). |
90
90
  | `router.allowUnmeasuredEffort` | boolean | `true` | What happens when an explicitly requested effort level is on the target model's ladder but has no capability measurement in Slate's profiles: `true` dispatches it with a ⚠ notice and marks the episode `(unmeasured level)`; `false` refuses the dispatch. Either way a level that is off the model's ladder, or one the provider rejects outright, is refused, and a level Slate DERIVES for an action is always a measured one. |
91
91
 
92
92
  Example `.pi/slate.json` (the `docs/agents/...` paths are placeholders — point them at markdown files that actually exist in **your** project):
@@ -194,7 +194,9 @@ context budget, also decide per dispatch whether a worker thread still
194
194
  fits the model it is routed to, in which case the action moves to the
195
195
  widest listed model rather than being blocked.
196
196
 
197
- The feature is OFF until `router.models` lists something, and it is
197
+ Candidate routing is OFF until `router.models` lists something — the
198
+ per-action `model` and `effort` arguments, and a fallback a failover
199
+ is holding, stay active either way. The feature is
198
200
  built on a deliberate split that is worth holding onto when reasoning
199
201
  about the architecture: the code ENFORCES less than the routing data
200
202
  ADVISES. Dispatch guards refuse what is mechanically wrong — a model
@@ -89,11 +89,11 @@ Failover applies at three sites:
89
89
  model and the failed action is re-prompted once with a continuation
90
90
  nudge. The transcript is preserved; usage and cost accumulate across
91
91
  both attempts. The thread stays on the mapped model while its
92
- session is live and returns to its configured model when the session
93
- is reopened (e.g. after a pi restart). The `threads` listing marks a
94
- switched live session as `model=A ⇒B (live)` — the marker means a
95
- live failover switch happened (it is not a value comparison) and
96
- disappears when the session is reopened.
92
+ session is live, including across later model-less actions, and the
93
+ `threads` listing marks it `live=<model> (failover)`. The hold, the
94
+ marker and the retry inside the failing dispatch are cases M1, M6
95
+ and M2 of [Known cases where the model or level
96
+ differs](model-routing.md#known-cases-where-the-model-or-level-differs).
97
97
  - **Episode compression.** The compression call is retried once with
98
98
  the mapped compressor model — only if it is distinct from the
99
99
  original, resolvable, and authed. If the retry also fails, the
@@ -6,14 +6,19 @@ list of models an action may run on; every dispatch then resolves to
6
6
  one model from that list and one effort level from that model's own
7
7
  ladder — chosen to be up to the task and no more, so the bill is
8
8
  bounded by the action rather than by the session's current model.
9
- The list is empty by default: with no entries the router is OFF, no
10
- model list is enforced, no thread gets a seeded base, and a
11
- dispatch that names no model resolves exactly as it did before the
12
- router existed — the thread's pre-router pin, else the host
13
- session's model. (The per-action `model` and `effort` arguments
14
- themselves are honoured on both router states; see the accepted
15
- limitations.) This document is reference documentation, not
16
- workflow doctrine.
9
+ The list is empty by default. With no entries the router is OFF: no
10
+ model list is enforced, no thread gets a seeded base, no effort level
11
+ is derived, and the router's window, billing and substitution paths
12
+ are inert. A dispatch that names no model then plans for the thread's
13
+ pre-router pin, and for no model at all when the thread has none: a
14
+ new worker opens on the host session's model, and a reused one stays
15
+ on the model it opened on. The per-action `model` and `effort`
16
+ arguments are honoured on both router states. What a dispatch plans
17
+ is not always what it runs; the cases known to bite are collected
18
+ under [Known cases where the model or level
19
+ differs](#known-cases-where-the-model-or-level-differs).
20
+
21
+ This document is reference documentation, not workflow doctrine.
17
22
 
18
23
  A model can only be routed to if Slate ships a benchmark profile for
19
24
  it. An entry with no profile is named in a warning and excluded —
@@ -26,36 +31,44 @@ from](#where-the-numbers-come-from-and-how-stale-they-can-be).
26
31
 
27
32
  ## What routing decides, per action
28
33
 
29
- Two levers, both per dispatch — on a new thread and on a
34
+ Two route inputs are per dispatch — on a new thread and on a
30
35
  continuation alike:
31
36
 
32
37
  - **the model** — the `model` argument (`"provider/id"`), else the
33
- thread's base model (router ON), else the thread's pre-router pin
34
- or the host session's model (router OFF);
38
+ thread's base model (router ON), else the thread's pre-router pin,
39
+ else no plan target at all, in which case the session returns to
40
+ the model it opened on (router OFF; M1, M3, M7);
35
41
  - **the effort level** — the `effort` argument (one of pi's thinking
36
- levels), else a level Slate resolves for the model that actually
37
- runs (router ON) or the level the worker session opened on (router
42
+ levels), else a level Slate resolves for the model it routes to
43
+ (router ON) or the level the worker session opened on (router
38
44
  OFF). See [Effort levels](#effort-levels).
39
45
 
40
- Neither is sticky, and that is the guarantee to hold on to: an
41
- explicit `model` or `effort` governs its own action and no later
42
- one. An omitted `model` resolves to the thread's base. An omitted
43
- `effort` resolves differently on the two router states, and neither
44
- of them is "whatever the last action asked for":
46
+ The arguments themselves do not become thread defaults. An explicit
47
+ `effort` never governs a later action. An explicit `model` governs
48
+ only its own action too, outside M1, M3, M6 and M7. An omitted `effort`
49
+ is never "whatever the last action asked for":
45
50
 
46
51
  - **router on** — the thread's base effort, re-validated, or a level
47
- derived for the model that runs (see [Effort
52
+ derived for the model it routes to (see [Effort
48
53
  levels](#effort-levels)). A thread based on `luna@medium` whose
49
54
  previous action ran at `max` runs the next one at `medium`.
50
- - **router off** — nothing is resolved, so the worker session is put
51
- back to the level it opened on, which is pi's own settings default
55
+ - **router off** — the worker session is put back to the level it
56
+ opened on, which is pi's own settings default
52
57
  (`defaultThinkingLevel`, else pi's built-in default). Slate passes
53
58
  that level explicitly at every open, so it is re-clamped against
54
59
  the session's current model rather than inherited from the session
55
60
  file.
56
61
 
57
- A thread's base model and base effort are separate state, visible in
58
- the `threads` listing, and are never set from an action's own route.
62
+ The model axis carries live-session cases the effort axis does not,
63
+ M1 and M3 among them, and they predate action-level routing. The
64
+ effort axis shares only M7. All of them, with the code that
65
+ implements each, are under [Known cases where the model or level
66
+ differs](#known-cases-where-the-model-or-level-differs).
67
+
68
+ A thread's base model and base effort are separate planning state,
69
+ visible in the `threads` listing, and are never set from an action's
70
+ own route. A live failover marker, when present, identifies the
71
+ model that currently overrides that nominal base.
59
72
 
60
73
  Two things can come back instead of a normal episode:
61
74
 
@@ -68,6 +81,120 @@ Two things can come back instead of a normal episode:
68
81
  result — advisory: an evidence gap, a window substitution, a
69
82
  long-context billing cliff, a re-seeded base. The action ran.
70
83
 
84
+ ## Known cases where the model or level differs
85
+
86
+ The model and the level an action runs on are not always the ones it
87
+ asked for, or the ones its thread implies. The cases below are the
88
+ ones known to bite, each with the code that implements it. **This
89
+ list is not exhaustive.** `planRoute`, `decideModelSwitch` and
90
+ `decideEffortSwitch` in `extension/route.ts`, applied by `applyRoute`
91
+ in `extension/threads.ts`, are the authority: read them when you need
92
+ certainty rather than a map. This section is the reference for these
93
+ cases. Other sites may name or summarise them; if a summary disagrees,
94
+ this section governs, with the source functions above the ultimate
95
+ authority.
96
+
97
+ Why the MODEL can differ:
98
+
99
+ - **M1 — a live failover holds the thread.** After a successful
100
+ `modelFailover` the worker stays on its fallback, and a dispatch
101
+ that omits `model` keeps it: the revert to the session's opening
102
+ model stands down (BG16). The hold ends when the session is
103
+ disposed, and when a later non-`openOnly` route moves it — an
104
+ explicit `model` on either router state, or the thread's routed
105
+ base while the router is ON. An OFF pre-router pin is `openOnly`
106
+ and does not release it. A reopen ends it too, except in M6.
107
+ *Code:* the `failover-held` keep in `decideModelSwitch`
108
+ (`route.ts`); `applyRoute` clears the marker after a plan-driven
109
+ `setModel`, and `liveFailoverModel` reports it (`threads.ts`).
110
+ - **M2 — a failover fires inside the dispatch.** When the attempt
111
+ fails with a model-API error, Slate switches the live session to
112
+ the mapped model and re-prompts once. One episode covers both
113
+ attempts, and its `ran:` line names the model the action ended on
114
+ rather than the planned one. *Code:* the `isFailoverCandidate`
115
+ block in `runDispatchInner` (`threads.ts`) and `planFailoverSwitch`,
116
+ guard 7 (`route.ts`).
117
+ - **M3 — the revert was attempted and failed.** A dispatch that names
118
+ no model switches the session back to the model it opened on. When
119
+ that spec no longer resolves, or `setModel` throws, Slate warns and
120
+ leaves the session on the model the previous action left it on
121
+ (BG24). A PLAN-driven switch that fails is the opposite case: the
122
+ dispatch aborts, nothing runs and no episode is written. M7 is the
123
+ case where no revert is attempted at all. *Code:* `giveUp` inside
124
+ `applyRoute` (`threads.ts`).
125
+ - **M4 — the window guard substitutes** (router ON). A model that
126
+ cannot hold the thread's context is replaced by the widest listed
127
+ candidate, and only when one is STRICTLY wider than the model it
128
+ would replace. When nothing listed is wider, the action runs on the
129
+ original model and a warning says pi will compact. It never blocks
130
+ the action, and `substitutedFrom` records a replacement. *Code:*
131
+ `strictlyWider` in `planRoute`'s guard 5 (`route.ts`).
132
+ - **M5 — the thread's base was not routable** (router ON). A base
133
+ that is absent or has fallen off the list is re-seeded to a listed
134
+ candidate with a warning, so a model-less dispatch runs on the
135
+ seeded base and not on the value the record carried. *Code:* the
136
+ re-seed branch of `planRoute` with `defaultBase` (`route.ts`),
137
+ written back by `persistReseededBase` (`threads.ts`).
138
+ - **M6 — a reopened session restores a model from its file** (CQ3).
139
+ Slate passes the reopening model explicitly whenever the dispatch
140
+ resolves one, and that overrides the session file. When the
141
+ dispatch resolves no model at all and the host session has none
142
+ either, pi restores the file's last model change instead, which can
143
+ be a fallback M1 was holding. The `live=` marker is cleared on every
144
+ reopen, so in this one shape the fallback outlives its marker.
145
+ *Code:* `opts.model ? resolveModel(ctx, opts.model) : ctx.model` in
146
+ `openWorkerSession` (`worker.ts`); the marker delete in
147
+ `openWorkerFor` and the CQ3 note on `liveFailoverModel`
148
+ (`threads.ts`).
149
+ - **M7 — nothing was captured to return to.** When the worker session
150
+ reported no model as it opened, there is no revert target, and a
151
+ dispatch that names no model leaves the session where it is with no
152
+ switch attempted. The effort axis has the same keep when no opening
153
+ level was captured. *Code:* `NO_SESSION_BASELINE` and the
154
+ `no-baseline` keeps in `decideModelSwitch` and `decideEffortSwitch`,
155
+ fed by `captureSessionBaseline` (`route.ts`).
156
+
157
+ Why the LEVEL can differ. There is no counterpart to M1 here: a
158
+ dispatch that names no `effort` returns the session to the level it
159
+ opened on. M7 and E6 are known cases in which it does not.
160
+
161
+ - **E1 — pi clamps a level Slate cannot judge.** When the ladder for
162
+ the judged model is unreadable — no profile, a throwing lookup, a
163
+ non-array ladder — the guards stand down and the level goes to pi,
164
+ which clamps it to something the model offers. Slate reports
165
+ nothing, because it has no evidence to report. *Code:*
166
+ `checkEffortFor` and `guardEffort`'s `ladderKnown` (`route.ts`).
167
+ - **E2 — M4 moved the model under an explicit level.** A level that
168
+ is not valid on the substituted model is DROPPED with a warning
169
+ rather than refusing the action — a context size must never
170
+ hard-block a dispatch — and the session's opening level applies.
171
+ *Code:* the `soft` branch of `guardEffort` (`route.ts`).
172
+ - **E3 — a stored base level no longer holds.** A base effort is a
173
+ cached derivation over a table that ships with Slate. When today's
174
+ table no longer reads it as `ok`, it is silently re-derived for
175
+ that model: nobody asked for that level, so a stale cache is
176
+ Slate's to correct rather than news to report. *Code:* the stored
177
+ base-effort branch of `planRoute`, with `lowestMeasuredEffort`
178
+ (`route.ts`).
179
+ - **E4 — the action's model is not the base model.** An explicit
180
+ `model`, or M4, drops the inherited base level and derives the new
181
+ model's own lowest measured level instead. A level derived for one
182
+ model is never carried onto another. *Code:* the effort resolution
183
+ in `planRoute`, keyed on `judgedModel` (`route.ts`).
184
+ - **E5 — M2 re-clamps the level.** The failover route requests no
185
+ level, and pi re-clamps the session's current level for the
186
+ fallback model. *Code:* `session.setModel(mapped)` in the failover
187
+ block (`threads.ts`); `openWorkerSession` passes the settings
188
+ default explicitly at every open for the same reason (`worker.ts`).
189
+ - **E6 — the level was judged for one model and another model runs.**
190
+ When M1, M3 or M7 leaves the session on a model other than the plan
191
+ target, the level the plan resolved is still applied, and pi clamps
192
+ it to that model's own ladder with nothing reported.
193
+ `effortJudgedFor` names the model the judgement was about, which is
194
+ then not the model that ran. *Code:* the effort switch in
195
+ `applyRoute`, which runs after a model `keep` (`threads.ts`), and
196
+ `decideEffortSwitch` (`route.ts`).
197
+
71
198
  ## Configuration
72
199
 
73
200
  `router` lives in `.pi/slate.json` and, like the rest of that file,
@@ -196,7 +323,8 @@ The vocabulary is pi's ladder and nothing else: `off`, `minimal`,
196
323
  actually offers is per model — the shipped table records a ladder
197
324
  per model id, not a family rule.
198
325
 
199
- **An omitted `effort` never resolves to a fixed default.** In order:
326
+ **An omitted `effort` never resolves to a fixed default.** With the
327
+ router ON, in order:
200
328
 
201
329
  1. the thread's stored base effort — but only when the action runs
202
330
  on the thread's base model, and only while that stored level
@@ -205,8 +333,8 @@ per model id, not a family rule.
205
333
  the ladder, or onto the provider's rejection list) is silently
206
334
  re-derived; nobody asked for it, so a stale cache is Slate's
207
335
  problem to correct rather than news to report;
208
- 2. otherwise the **lowest measured level of the model that actually
209
- runs** — the lowest level on its ladder that carries a traced
336
+ 2. otherwise the **lowest measured level of the model it routes to**
337
+ — the lowest level on that model's ladder that carries a traced
210
338
  capability measurement. A level derived for one model is never
211
339
  carried onto another, so an explicit `model`, or a window
212
340
  substitution, re-derives it;
@@ -214,14 +342,19 @@ per model id, not a family rule.
214
342
  the level the worker session opened on applies, which is pi's own
215
343
  settings default — never a level another action left behind.
216
344
 
345
+ With the router OFF Slate resolves no level at all, so that same
346
+ opening level applies to every action that omits `effort`.
347
+
217
348
  So a higher level is only ever reached by naming it: pass `effort`
218
349
  explicitly on the dispatch. A derived level is measured by
219
350
  construction and therefore cannot trip the effort guards; only an
220
351
  explicit one can.
221
352
 
222
- An explicit level is judged against the model the action will
223
- actually run on, and the difference between a warning and a refusal
224
- is:
353
+ An explicit level is judged against the model the action routes to,
354
+ which `effortJudgedFor` names. [Known cases where the model or level
355
+ differs](#known-cases-where-the-model-or-level-differs) collects what
356
+ can move either afterwards. The difference between a warning and a
357
+ refusal is:
225
358
 
226
359
  - **evidence gap** (the level is on that model's ladder but no
227
360
  traced source reports a capability result there) — advisory. The
@@ -230,8 +363,8 @@ is:
230
363
  `router.allowUnmeasuredEffort: false` turns this one case into a
231
364
  hard refusal for the whole project;
232
365
  - **off the model's ladder** — refused, whenever the ladder is
233
- known. pi would otherwise clamp the level silently and the
234
- orchestrator would believe the action ran at a level the model
366
+ known, outside E2. pi would otherwise clamp the level silently and
367
+ the orchestrator would believe the action ran at a level the model
235
368
  never offered;
236
369
  - **rejected outright by the provider** — refused, always. Such a
237
370
  level is still on pi's ladder for the model (the table records the
@@ -300,13 +433,15 @@ model's ladder is unreadable.
300
433
  its decision to make), the same lines in the live progress output,
301
434
  and `details.ranModel` / `details.ranEffort` /
302
435
  `details.ranEffortUnmeasured` / `details.warnings` for a renderer.
303
- - **In the `threads` listing:** `base=<model>@<level>?` — what a
304
- dispatch that omits `model` will run on. The trailing `?` marks
436
+ - **In the `threads` listing:** `base=<model>@<level>?` — the nominal
437
+ plan target when a dispatch omits `model`. The trailing `?` marks
305
438
  the level, not the model, as provisional: it is a stored default,
306
- re-checked on every dispatch. Then `last=<model>@<level>` — what the thread's
307
- last action actually ran on, with `(unmeasured)` where that
308
- applies — and `live=<model> (failover)` while a failover holds the
309
- live session.
439
+ re-validated against the model's current capability data on every
440
+ dispatch and silently re-derived if it no longer holds. `last=` is
441
+ the model and level that the last action actually ran on, with
442
+ `(unmeasured)` where that applies. `live=<model> (failover)` means
443
+ a held fallback currently overrides the nominal base for the live
444
+ session.
310
445
  - **At the session level:** the resolution's own warnings, as UI
311
446
  notifications (console when no UI is attached).
312
447
  - **In the orchestrator's own system prompt, every turn:** the
@@ -519,13 +654,16 @@ concern; a green suite says nothing about it.
519
654
  dispatch guard and anything else that consults the router agree.
520
655
  A key or provider added mid-session, and an edit to
521
656
  `slate.json`, both need a new pi session.
522
- - **Router OFF is not "no effort checking".** With an empty list
523
- there is no model list, no window guard, no billing notice, no
524
- seeded base and no substitution — but an explicit `effort` is
525
- still judged against the shipped profile for the model that runs,
526
- when pi's registry can serve that model. The `model` argument
527
- itself is passed through byte-for-byte, so a malformed spec still
528
- produces pi's own error.
657
+ - **Router OFF disables candidate routing, not action arguments or
658
+ failover.** With an empty list there is no model list, window
659
+ guard, billing notice, seeded base or candidate substitution, and
660
+ Slate derives no effort level. A model-less plan targets the
661
+ thread's pre-router pin, else nothing, so the session returns to the
662
+ model it opened on outside M1, M3 and M7, and to the level it opened
663
+ on outside M7 and E6. An explicit `effort` is still judged against
664
+ the shipped profile for the plan target when pi's registry can serve
665
+ that model. The `model` argument itself is passed through
666
+ byte-for-byte, so a malformed spec still produces pi's own error.
529
667
  - **A mid-thread model switch costs the prompt cache.** A long
530
668
  thread runs overwhelmingly on cache reads; routing a small action
531
669
  to a cheaper model on a cold cache can cost more than staying on
@@ -59,6 +59,31 @@ The description becomes the squash-commit body on the default
59
59
  development branch — it is the permanent git-archaeology record for
60
60
  the change. Write it accordingly.
61
61
 
62
+ Aim to keep the final delivery commit body at or below 16,384 UTF-8
63
+ bytes, excluding the subject. This is a target, not a gate. Measure
64
+ the exact body text as UTF-8 without adding a newline: for a PR, count
65
+ the GitHub API `body` string; for a delivered commit, count the byte
66
+ sequence after the subject separator in `git cat-file commit <sha>`.
67
+ Do not use a formatted `git log` value such as `%b`, which adds an
68
+ output newline.
69
+
70
+ If the body is larger, first remove repetition and merge overlapping
71
+ material. Then record a size exception in Risks & accepted trade-offs
72
+ carrying measurements, not assurances: the overrun, by the canonical
73
+ method above; every top-level section's byte count by that same
74
+ method, largest first; and, for each section that was condensed, its
75
+ byte count before condensing. A section with no before-count is
76
+ visibly untouched, so the user weighs any claim that nothing could be
77
+ removed against the counts beside it, and can refuse the exception. Do
78
+ not remove decisions, risks, verdicts, or evidence needed to
79
+ understand or review the change only to meet the target.
80
+
81
+ By the canonical measurement above, the most recent accepted delivery
82
+ body, from the publishing-enabled path, was 20,957 bytes: 27.9% above
83
+ the target. That precedent establishes that a justified overrun can
84
+ pass; it does not establish the typical size of either delivery path
85
+ or a content-equivalent exception in the publishing-disabled path.
86
+
62
87
  ## Tracks table
63
88
 
64
89
  The description's Tracks section holds the track table; its
@@ -102,6 +127,14 @@ executed in order:
102
127
  description, including the Planned changes section, describes the
103
128
  change as implemented, folding in everything added, dropped, or
104
129
  reshaped since the draft PR was opened.
130
+ - Resolve every remaining Open Question, or record its user-approved
131
+ deferral or accepted uncertainty in Risks & accepted trade-offs. A
132
+ mention without the user's disposition is not enough.
133
+ - Measure the final PR description by the canonical method in §
134
+ Description rules. If it exceeds the target, verify that Risks &
135
+ accepted trade-offs carries the size exception's measurements: the
136
+ overrun, the per-section counts, and the before-counts of whatever
137
+ was condensed.
105
138
  - Last, re-read the whole PR description end-to-end to confirm the
106
139
  as-flipped text tells one consistent story.
107
140
 
@@ -110,10 +143,13 @@ executed in order:
110
143
  The user may wait for CI green and/or peer-review completion and ask
111
144
  the agent to fix test failures or review observations. The agent lands
112
145
  fixes as normal commits, keeps the description in sync, and presents
113
- agent-landed commits to the user as they land. Commits pushed directly
114
- by reviewers are visible in the PR UI; the agent reconciles the
115
- description with them on its next task. The user's merge act is the
116
- final approval.
146
+ agent-landed commits to the user as they land. After every post-flip
147
+ description change, and again at the final handoff for merge, repeat
148
+ the byte measurement and any over-target size exception required by §
149
+ Description rules. Commits pushed directly by reviewers are visible in
150
+ the PR UI; the agent reconciles the description with them on its next
151
+ task. The user's merge act is the final approval, including acceptance
152
+ of any recorded size exception.
117
153
 
118
154
  ## After the merge
119
155
 
@@ -108,6 +108,35 @@ explicitly hands any remaining Open Questions to the user, then
108
108
  deletes the log file. On abandonment the agent deletes the log file
109
109
  after offering its content to the user for archiving.
110
110
 
111
+ Aim to keep the final delivery commit body at or below 16,384 UTF-8
112
+ bytes, excluding the subject. This is a target, not a gate. Measure
113
+ the exact body text as UTF-8 without adding a newline: count the byte
114
+ sequence after the subject separator in `git cat-file commit <sha>`.
115
+ Do not use a formatted `git log` value such as `%b`, which adds an
116
+ output newline. For a proposed commit, apply the same rule to the
117
+ exact body text before committing.
118
+
119
+ If the body is larger, first remove repetition and merge overlapping
120
+ material. Then add a `Size exception` paragraph to the delivery commit
121
+ body carrying measurements, not assurances: the overrun, by the
122
+ canonical method above; every top-level section's byte count by that
123
+ same method, largest first; and, for each section that was condensed,
124
+ its byte count before condensing. A section with no before-count is
125
+ visibly untouched, so the user weighs any claim that nothing could be
126
+ removed against the counts beside it, and can refuse the exception. Do
127
+ not remove decisions, risks, verdicts, or evidence needed to
128
+ understand or review the change only to meet the target. Immediately
129
+ before presenting the final delivery commit to the user, measure it by
130
+ that method and obtain the user's approval of the body and of any size
131
+ exception; repeat both steps if the body changes before it lands.
132
+
133
+ By this canonical measurement, the most recent accepted delivery body
134
+ from the publishing-enabled path was 20,957 bytes, 27.9% above the
135
+ target. That precedent establishes only that a justified overrun can
136
+ pass in that path. Publishing-enabled descriptions can carry material
137
+ that this path does not, so the figure is neither a direct measurement
138
+ nor an exercised exception for publishing-disabled delivery.
139
+
111
140
  ### Under-trigger guardrail
112
141
 
113
142
  When shaping the planned-changes statement without a log, state this
@@ -101,9 +101,9 @@ export default function (pi: ExtensionAPI) {
101
101
  // One MEMOIZED model-router resolution per session (model-router.ts), reassigned
102
102
  // every session_start below for the same reason as the resolver above: the
103
103
  // candidate list is frozen at first use, and a dispatch guard built from a
104
- // different resolution than the doctrine describes would be a bug. Starts as the
105
- // off resolution — dispatch then behaves exactly as it did before the router
106
- // existed — until session_start has sanitized `router.models`.
104
+ // different resolution than the doctrine describes would be a bug. Starts at the
105
+ // shared OFF resolution — no candidate list is available — until session_start
106
+ // has sanitized `router.models`.
107
107
  let resolveModelRouterResolution: () => ModelRouterResolution = () => ROUTER_OFF;
108
108
 
109
109
  // One base-model tracker per session (base-model.ts), reassigned every
package/extension/mode.ts CHANGED
@@ -339,7 +339,7 @@ ${n}. Route every action to the cheapest model and effort that clears it. Routab
339
339
  ${rows.join("\n")}${legend === "" ? "" : `\n ${legend}.`}
340
340
  \`model\` and \`effort\` route THAT action only. Omit \`model\` for the thread's
341
341
  base${newThreadBase}; omit \`effort\` for its base
342
- level, else the FIRST measured level of the model that runs — never a higher
342
+ level, else the FIRST measured level of the model it routes to — never a higher
343
343
  one, so name the level harder work needs. Off-ladder and provider-rejected
344
344
  levels are tool errors; an unmeasured one ${gap}.
345
345
  Prices are base rates: some models bill a long-context multiplier above a
@@ -2,9 +2,9 @@
2
2
  * Model router: resolving WHICH models an action may be dispatched to, and at
3
3
  * which effort levels.
4
4
  *
5
- * The router is OFF by default and OFF-behaviour is the pre-router behaviour:
6
- * with no `router.models` configured nothing here changes a dispatch. When a
7
- * project DOES list models, this module maps that list plus pi's model registry
5
+ * The router is OFF by default: with no `router.models` configured this resolver
6
+ * returns no candidates and emits no router warning. When a project DOES list
7
+ * models, this module maps that list plus pi's model registry
8
8
  * plus the benchmark profiles in model-profiles.ts onto a small, ordered set of
9
9
  * routable candidates — and the warnings that make the gaps in that data
10
10
  * visible instead of silent.
@@ -423,9 +423,9 @@ function tierOf(profile: ModelProfile): number {
423
423
  * Resolve the routable candidate set.
424
424
  *
425
425
  * An empty model list returns ROUTER_OFF WITHOUT touching the registry or the
426
- * profile table — the fast path, since the router is off by default and that
427
- * path must stay behaviourally identical to the pre-router extension. Every
428
- * warning is emitted at most once per condition (D58), sanitized, through
426
+ * profile table — the fast path, since the router is off by default and there
427
+ * are no candidates to resolve. Every warning is emitted at most once per
428
+ * condition (D58), sanitized, through
429
429
  * `warn` (default no-op) and collected on the result.
430
430
  *
431
431
  * Hostile/raw input is handled here rather than assumed away: `models` may be
@@ -770,8 +770,11 @@ function frozenResolution(
770
770
  * decides anything — `router.allowUnmeasuredEffort` and the refusal wording
771
771
  * live in the dispatch path.
772
772
  *
773
- * With the router OFF every pair is `ok`: the list guard is inert exactly as it
774
- * was before the router existed. An omitted/empty effort is also `ok` — the
773
+ * With the router OFF this predicate reports `ok` for every pair, having no
774
+ * candidate list to judge against — a statement about THIS function only, since
775
+ * route.ts feeds it a SYNTHESISED one-candidate resolution built from its
776
+ * injected profile source, so a router-OFF explicit level can still be refused.
777
+ * An omitted/empty effort is also `ok` — the
775
778
  * ladder question only arises for a level the caller actually asked for.
776
779
  * `listedGap` distinguishes an authored evidence gap from one the table never
777
780
  * mentioned (see EffortVerdict/BG9); both are advisory.
@@ -823,9 +826,9 @@ export function checkEffort(resolution: ModelRouterResolution, spec: string, eff
823
826
  * The memo holds unconditionally (BG3). Warnings already go through a
824
827
  * try/catch, and a throw from `getInput` or from anything else in resolution is
825
828
  * caught here and cached as an OFF resolution carrying one warning: the router
826
- * failing is not a reason to keep re-running it every turn, and OFF is the
827
- * pre-router behaviour, so dispatch keeps working. There is deliberately NO
828
- * second message-level dedup layer — it made the real dedup untestable (CQ4)
829
+ * failing is not a reason to keep re-running it every turn, and an empty
830
+ * candidate result is safe for candidate-dependent guards. There is deliberately
831
+ * NO second message-level dedup layer — it made the real dedup untestable (CQ4)
829
832
  * while adding nothing the memo does not already guarantee.
830
833
  *
831
834
  * CQ7: what is frozen includes the registry and auth SNAPSHOT. A key added to
@@ -29,10 +29,10 @@
29
29
  * refused: the fall-through path must always have a valid destination, or
30
30
  * configuring a list would strand every thread that predates it.
31
31
  *
32
- * EFFORT — the level an action runs at is ALWAYS one that belongs to the model it
33
- * actually runs on: either the caller named it, in which case it is judged against
34
- * THAT model's own ladder, or it is derived from that model (its lowest MEASURED
35
- * level). A level derived for one model is never carried onto another, and a level
32
+ * EFFORT — the level the planner resolves is ALWAYS one that belongs to the model
33
+ * it routes to: either the caller named it, in which case it is judged against THAT
34
+ * model's own ladder, or it is derived from that model (its lowest MEASURED level).
35
+ * A level derived for one model is never carried onto another, and a level
36
36
  * STORED for this one is only replayed while today's table still reads it as `ok` —
37
37
  * a cached derivation must not outlive the evidence it was derived from. `effort`
38
38
  * therefore reports one and only one thing, and `effortJudgedFor` names the model
@@ -43,13 +43,14 @@
43
43
  * DIFFERENT model, and applying it produced evidence-gap warnings — or, with
44
44
  * allowUnmeasuredEffort:false, outright rejections — for a level nobody requested.
45
45
  *
46
- * WITH THE ROUTER OFF THIS MODULE IS INVISIBLE. No list, no window guard, no
47
- * billing notice, no seeded or persisted base, no tracker: the model a dispatch
48
- * runs on is the `model` ARGUMENT (honoured per action, which is the one addition)
49
- * or else the thread's PRE-ROUTER PIN — and the pin is only ever the model a NEW
50
- * worker session opens with, never a reason to switch a live one (`openOnly`).
51
- * The argument itself is passed through byte-for-byte, so a malformed spec still
52
- * produces pi's own error rather than a router opinion.
46
+ * WITH THE ROUTER OFF ITS PLANNER-OWNED MECHANISMS ARE INERT. There is no list,
47
+ * window guard, billing notice, seeded or persisted base, or tracker input. The
48
+ * plan target is the `model` ARGUMENT (honoured per action) or else the thread's
49
+ * PRE-ROUTER PIN; the pin only opens a NEW worker session and never instructs a
50
+ * live one to switch (`openOnly`). A later switch decision KEEPS a held failover
51
+ * fallback rather than applying an open-only target (decideModelSwitch). The raw
52
+ * argument still passes through byte-for-byte, so a malformed spec produces pi's
53
+ * own error rather than a router opinion.
53
54
  *
54
55
  * A FAILURE TO READ EVIDENCE IS NOT EVIDENCE OF A PROBLEM. Every injected data
55
56
  * source may be missing, throwing or malformed — a profile lookup, a ladder
@@ -68,13 +69,13 @@
68
69
  * is one that is not a STRING at all, since reading a malformed argument as
69
70
  * absent would quietly run the action at the thread's base level instead.
70
71
  * 1. LIST MEMBERSHIP (router ON only): a resolved model outside the effective
71
- * candidate list is rejected, naming the list. With the router OFF the model
72
- * argument behaves exactly as it did before the router existed.
72
+ * candidate list is rejected, naming the list. With the router OFF this guard
73
+ * does not enforce a list; the raw model argument continues to pi unchanged.
73
74
  * A THREAD'S BASE is exempt by REPAIR, not by exception — see THE ONE RULE
74
75
  * above: absent or off-list, it is re-seeded, so only an EXPLICIT off-list
75
76
  * model is ever refused.
76
77
  * 5. CONTEXT WINDOW: never a hard block, and it runs BEFORE the effort guards so
77
- * that every effort judgement is about the model that will actually run. The
78
+ * that every effort judgement is about the model the planner routes to. The
78
79
  * REGISTRY window (which is what a RouterCandidate carries; a profile's own
79
80
  * figure is documentation only) reduced by pi's compaction reserve, judged by
80
81
  * pi's OWN predicate. A model that cannot hold the thread's context is
@@ -153,7 +154,7 @@ export interface RoutePlanInput {
153
154
  requestedModel?: string;
154
155
  /** The dispatch's `effort` argument, raw and unvalidated. */
155
156
  requestedEffort?: string;
156
- /** The session's FROZEN router resolution. An off resolution = the pre-router dispatch path. */
157
+ /** The session's FROZEN router resolution. An off resolution supplies no router candidates. */
157
158
  resolution: ModelRouterResolution;
158
159
  /** router.allowUnmeasuredEffort. Default TRUE: only an explicit false refuses an evidence gap. */
159
160
  allowUnmeasuredEffort?: boolean;
@@ -429,7 +430,7 @@ export type EffortSwitchDecision =
429
430
  * whole rule, in one pure function, mirroring decideModelSwitch:
430
431
  *
431
432
  * 1. the level the PLAN resolved (an explicit `effort`, the thread's stored default
432
- * once re-validated, or one derived for the model that runs);
433
+ * once re-validated, or one derived for the model the planner routes to);
433
434
  * 2. failing that, the level the session was OPENED on — pi's clamped settings
434
435
  * default. This is the RESTORE, and it is what makes `effort` per-ACTION: without
435
436
  * it a level set by one action silently governs the next (BG18).
@@ -515,11 +516,10 @@ export type RoutePlanVerdict = RoutePlanProceed | RoutePlanReject;
515
516
  /**
516
517
  * Normalise whatever the session's router resolver handed back.
517
518
  *
518
- * A malformed or half-built resolution must leave the dispatch path exactly as it
519
- * is with the router OFF — that is the pre-router behaviour, so it is always a
520
- * safe answer — because the guards below walk `candidates` directly. (checkEffort
521
- * tolerates a junk resolution on its own, CQ5; this is about the list guard and
522
- * the window guard.) Shared with threads.ts so the shape check has ONE definition.
519
+ * A malformed or half-built resolution falls back to the shared ROUTER_OFF value
520
+ * because candidate-dependent planner paths below walk `candidates` directly.
521
+ * (checkEffort tolerates a junk resolution on its own, CQ5.) Shared with threads.ts
522
+ * so the shape check has ONE definition.
523
523
  */
524
524
  export function usableResolution(value: unknown): ModelRouterResolution {
525
525
  const resolution = value as ModelRouterResolution | undefined;
@@ -719,8 +719,9 @@ function count(tokens: number): string {
719
719
  * failover. What remains is the one rule failover itself must obey (never the
720
720
  * model that just failed) and a NON-SUBSTITUTING window check that warns and
721
721
  * proceeds — substituting here would be the router vetoing failover by another
722
- * name. Gated on the router being ON so a router-off session keeps exactly its
723
- * pre-router failover behaviour.
722
+ * name. Gated on the router being ON, so a router-OFF failover gets no window
723
+ * warning at all: that is the pre-router behaviour of THIS branch, not of the
724
+ * dispatch path, where a router-OFF action's own `model` still moves a session.
724
725
  */
725
726
  function planFailoverSwitch(input: RoutePlanInput, resolution: ModelRouterResolution, target: string | undefined): RoutePlanVerdict {
726
727
  const warnings: string[] = [];
@@ -810,10 +811,10 @@ export function planRoute(input: RoutePlanInput): RoutePlanVerdict {
810
811
  /** Router OFF only: the thread's pre-router `model` pin, which OPENS a session and never switches one. */
811
812
  let pin: string | undefined;
812
813
  if (!resolution.on) {
813
- // ROUTER OFF — this module is INVISIBLE (module header). The pre-router code
814
- // passed ONE thing to the worker opener — the thread's `model` pin, undefined for
815
- // most threads — and never touched a live session's model again. So that is all
816
- // that happens here, for existing and new threads alike:
814
+ // ROUTER OFF — this planner seeds no base and applies no candidate-dependent
815
+ // guard. The pre-router code passed ONE thing to the worker opener — the thread's
816
+ // `model` pin, undefined for most threads — and never used that pin to move a
817
+ // live session. This branch preserves that open-only planning rule:
817
818
  //
818
819
  // · nothing is SEEDED and nothing is PERSISTED: `baseModel`/`baseEffort` stay
819
820
  // undefined, so the caller writes no base onto the record. An earlier version
@@ -924,9 +925,9 @@ export function planRoute(input: RoutePlanInput): RoutePlanVerdict {
924
925
  let model = explicit ?? baseModel ?? pin;
925
926
  const openOnly = model !== undefined && model === pin && explicit === undefined;
926
927
 
927
- // GUARD 1 — list membership. Router ON only; with the router off the `model`
928
- // argument behaves exactly as it did before the router existed. Validated on the
929
- // RESOLVED model, but only an EXPLICIT one can trip it: a new thread's base is a
928
+ // GUARD 1 — list membership. Router ON only; with the router off this guard does
929
+ // not inspect or reject the raw `model` argument. Validated on the RESOLVED model,
930
+ // but only an EXPLICIT one can trip it: a new thread's base is a
930
931
  // listed candidate by construction (D48), and an existing thread's was re-seeded
931
932
  // above if it was not — which is what keeps a dispatch that omitted `model` from
932
933
  // ever landing here.
@@ -942,8 +943,8 @@ export function planRoute(input: RoutePlanInput): RoutePlanVerdict {
942
943
  }
943
944
 
944
945
  // GUARD 5 — CONTEXT WINDOW, and it runs BEFORE the effort guards on purpose: the
945
- // model this action runs on must be settled before any level is judged against
946
- // it, or the judgement is about a model that will be substituted away. Never a
946
+ // model the planner routes this action to must be settled before any level is judged
947
+ // against it, or the judgement is about a model that will be substituted away. Never a
947
948
  // hard block, and router ON only: with the router off there is no candidate list
948
949
  // to fall back to, and the pre-router behaviour is no check at all.
949
950
  let substitutedFrom: string | undefined;
@@ -994,17 +995,17 @@ export function planRoute(input: RoutePlanInput): RoutePlanVerdict {
994
995
  }
995
996
  }
996
997
 
997
- // ---- the EFFORT, for the model that will ACTUALLY run (THE ONE RULE, effort half)
998
+ // ---- the EFFORT, for the model the planner routes to (THE ONE RULE, effort half)
998
999
  //
999
- // The model is final now (guard 1 accepted it, guard 5 may have moved it), so the
1000
- // level can be settled against THAT model and nothing else:
1000
+ // The routed model is final inside planRoute (guard 1 accepted it, guard 5 may have
1001
+ // moved it), so the level can be settled against THAT model and nothing else:
1001
1002
  //
1002
1003
  // · an EXPLICIT `effort` is judged against it, hard — the caller named a level and
1003
1004
  // is entitled to be told it does not exist there rather than silently clamped;
1004
1005
  // · a level INHERITED from the thread's base applies only while the base model is
1005
- // the model that runs. The moment they differ — an explicit `model`, or a window
1006
- // substitution — the inherited level is DROPPED and re-derived for the model that
1007
- // runs (its lowest MEASURED level, or nothing when it has none). Carrying it over
1006
+ // the routed model. The moment they differ — an explicit `model`, or a window
1007
+ // substitution — the inherited level is DROPPED and re-derived for the routed
1008
+ // model (its lowest MEASURED level, or nothing when it has none). Carrying it over
1008
1009
  // was BG14: an action explicitly routed to another model inherited a budget
1009
1010
  // derived for the base, and was warned about — or, with allowUnmeasuredEffort
1010
1011
  // false, REJECTED — for a level nobody had asked for.
@@ -36,26 +36,25 @@ export interface ThreadRecord {
36
36
  status: "idle" | "running";
37
37
  /**
38
38
  * PRE-ROUTER pin: "provider/id" passed as `model` when the thread was created
39
- * WITH THE ROUTER OFF. It keeps exactly its pre-router meaning — the model a NEW
40
- * worker session for this thread OPENS on, never a reason to switch a live one —
41
- * so with the router off this feature stays invisible. With the router ON a
42
- * `model` argument routes ONE action and is deliberately NOT recorded here; see
43
- * `baseModel`.
39
+ * WITH THE ROUTER OFF. It names what a NEW worker session opens on and never
40
+ * instructs a live one to switch. With the router ON a `model` argument routes
41
+ * ONE action and is deliberately NOT recorded here; see `baseModel`.
44
42
  */
45
43
  model?: string;
46
44
  /**
47
- * The thread's DEFAULT model, canonical "provider/id" — what a dispatch that
48
- * omits `model` runs on. Written ONLY while the router is on (with the router off
45
+ * The thread's DEFAULT plan model, canonical "provider/id" — the target when a
46
+ * dispatch omits `model`. Written ONLY while the router is on (with the router off
49
47
  * nothing is seeded or persisted), and always one of the effective candidates:
50
48
  * a base that is absent or has fallen off the list is re-seeded on the next
51
49
  * dispatch (route.ts's THE ONE RULE). DISTINCT from whatever a single action was
52
- * routed to: a routed action never becomes the thread's base. Absent = unknown.
50
+ * routed to: a routed action never becomes the thread's base. A live failover may
51
+ * temporarily override it without changing this record. Absent = unknown.
53
52
  */
54
53
  baseModel?: string;
55
54
  /**
56
55
  * The thread's DEFAULT effort level, derived for `baseModel` and valid only for
57
- * it: a dispatch whose model differs re-derives the level for the model that
58
- * actually runs. Absent = unknown ⇒ the worker session's own opening level.
56
+ * it: a dispatch whose model differs re-derives the level for the model it
57
+ * routes to. Absent = unknown ⇒ the worker session's own opening level.
59
58
  *
60
59
  * The type is a claim about what slate WROTE, not a guarantee about what it reads
61
60
  * back: this record is restored from an unversioned, hand-editable snapshot, so the
@@ -469,11 +468,13 @@ export interface ContextBudgetObject {
469
468
  /**
470
469
  * Action-level model router (D4/D53). `models` is the CLOSED list of models the
471
470
  * router may route an action to, in canonical "provider/id" form; empty or
472
- * absent means the router is OFF and dispatch behaves exactly as it did before
473
- * the router existed. `allowUnmeasuredEffort` (default TRUE) decides what the
474
- * dispatch path does with an effort level that is ladder-valid but has no
475
- * capability evidence — an evidence gap is advisory, not a prohibition.
476
- * Validated by sanitizeRouterConfig in model-router.ts.
471
+ * absent means the router is OFF, so no candidate list or router-owned base,
472
+ * window, billing or substitution mechanism applies. Per-action arguments and
473
+ * pre-existing failover remain outside that feature-off statement.
474
+ * `allowUnmeasuredEffort` (default TRUE) decides what the dispatch path does
475
+ * with an effort level that is ladder-valid but has no capability evidence —
476
+ * an evidence gap is advisory, not a prohibition. Validated by
477
+ * sanitizeRouterConfig in model-router.ts.
477
478
  */
478
479
  export interface RouterConfig {
479
480
  models?: string[];
@@ -15,11 +15,11 @@
15
15
  * this, `model` was accepted only when a thread was created and silently ignored
16
16
  * on every later dispatch. The rules the dispatch path implements:
17
17
  *
18
- * - RESOLUTION: the effective model is the explicit argument, else the thread's
19
- * base model. The effective effort is the explicit argument, else the thread's
20
- * base effort WHEN THE BASE MODEL IS THE ONE THAT RUNS, else a level derived
21
- * for the model that does — a level never travels between models (route.ts's
22
- * THE ONE RULE, effort half). The GUARDS ALWAYS VALIDATE THE RESOLVED PAIR,
18
+ * - RESOLUTION: the planner's model is the explicit argument, else the thread's
19
+ * base model. Its effort is the explicit argument, else the thread's base effort
20
+ * WHEN THE BASE MODEL IS THE ROUTE TARGET, else a level derived for that target —
21
+ * a level never travels between planner targets (route.ts's THE ONE RULE, effort
22
+ * half). The GUARDS ALWAYS VALIDATE THE RESOLVED PAIR,
23
23
  * never only the explicit arguments — an omitted argument must not escape
24
24
  * validation.
25
25
  *
@@ -36,27 +36,27 @@
36
36
  * once, in route.ts's header: with the router ON a thread's base is ALWAYS a
37
37
  * listed candidate.
38
38
  *
39
- * - ROUTER OFF = PRE-ROUTER BEHAVIOUR, exactly, and INVISIBLY. No list guard, no
40
- * window guard, no billing notice, no seeded or persisted base, and no
41
- * consultation of the orchestrator's tracked model: a dispatch runs on the
42
- * `model` ARGUMENT, else on the thread's creation-time PIN — which, exactly as
43
- * before, is only the model a NEW worker session opens with and never a reason
44
- * to switch a live one (`openOnly`) — else on the host's current model, which
45
- * is what worker.ts did before. Two additions: an explicitly passed `effort` is
46
- * validated against the target model's ladder (that argument did not exist
47
- * before, and pi would otherwise silently CLAMP a level the model does not
48
- * offer), and a level set by one action is put back to the session's opening
49
- * level by the next, so it cannot leak between actions. The MODEL an explicit
50
- * argument routes to is reverted the same way (BG22): both axes are per-ACTION,
51
- * and the revert target is what the session opened on — except while a failover
52
- * holds the session, which slate must not undo (BG16).
39
+ * - ROUTER OFF MAKES ROUTER-OWNED MECHANISMS INERT. No list guard, window guard,
40
+ * billing notice, seeded or persisted base, or consultation of the
41
+ * orchestrator's tracked model: a model-less plan targets the thread's
42
+ * creation-time PIN — which only opens a NEW worker session (`openOnly`) — and
43
+ * nothing at all when there is no pin, so a NEW session opens on the host's
44
+ * current model (worker.ts) and a reused one is reverted to the model it opened
45
+ * on — outside cases M1, M3 and M7 of the doc named below (BG16, BG24).
46
+ * The `model` and `effort` arguments still apply per action: an explicit
47
+ * `effort` is validated against the plan target's ladder whenever that ladder is
48
+ * readable, and a level set by one action is put back to the session's opening
49
+ * level by the next. The MODEL an explicit argument routes to is reverted the same
50
+ * way (BG22), outside those same cases; the failover hold predates the router, and
51
+ * an OFF plan does not undo it. docs/model-routing.md's "Known cases where the
52
+ * model or level differs" collects the ones known to bite, this file's included.
53
+ * It does not claim to be exhaustive, and this code is the authority.
53
54
  *
54
- * An earlier iteration seeded the base from the tracker here and persisted it,
55
- * which was strictly worse than the pre-router code in the same file: it undid
56
- * failovers on reused sessions, could strand a thread for good once its tracked
57
- * model lost credentials, and stopped a restarted thread following the host's
58
- * model. Equivalence is the requirement; the tracker survives only as the
59
- * episode compressor's last-resort rung.
55
+ * An earlier iteration seeded the base from the tracker here and persisted it.
56
+ * That undid failovers on reused sessions, could strand a thread once its
57
+ * tracked model lost credentials, and stopped a restarted thread following the
58
+ * host's model. The tracker survives only as the episode compressor's
59
+ * last-resort rung.
60
60
  *
61
61
  * - VALIDATION HAPPENS TWICE. Early, in dispatch(), before any state mutation
62
62
  * or session work, so a bad pick is a TOOL ERROR rather than a billed failed
@@ -266,13 +266,12 @@ export class ThreadManager {
266
266
  // This session's MEMOIZED model-router resolution (model-router.ts's
267
267
  // createModelRouterResolver), bound by value for the same reason as the
268
268
  // resolver above: a manager orphaned by a session swap must keep answering
269
- // with its own session's frozen candidate list. Defaults to the OFF
270
- // resolution, which is byte-for-byte the pre-router dispatch path.
269
+ // with its own session's frozen candidate list. Defaults to the shared OFF
270
+ // resolution, which supplies no candidates or router-owned base.
271
271
  private resolveRouter: () => ModelRouterResolution = () => ROUTER_OFF,
272
272
  // The orchestrator's base model, EXCLUDING failover fallbacks (base-model.ts).
273
- // Consulted only when the router is off, to seed a new thread's base model.
274
- // undefined (the default) = no tracker ⇒ fall back to the pre-router
275
- // behaviour, where the worker opens on the host session's current model.
273
+ // Consulted only for the episode compressor's last-resort model rung; route
274
+ // planning never seeds a worker-thread base from this tracker.
276
275
  private baseModelTracker?: BaseModelTracker,
277
276
  ) {
278
277
  this.semaphore = new Semaphore(config.maxConcurrent ?? 4); // default rationale: docs/design-principles.md §5 repo-local note
@@ -412,11 +411,11 @@ export class ThreadManager {
412
411
  /**
413
412
  * This session's frozen router resolution, defensively.
414
413
  *
415
- * A THROWING resolver, or one that hands back a malformed object, must leave the
416
- * dispatch path exactly as it is with the router off: OFF is the pre-router
417
- * behaviour, so it is always a safe answer. The SHAPE check itself is route.ts's
418
- * usableResolution, so the planner and this module cannot disagree about what a
419
- * usable resolution is.
414
+ * A THROWING resolver, or one that hands back a malformed object, falls back to
415
+ * the shared OFF resolution. That answer supplies no candidates, so list, window,
416
+ * billing and substitution paths cannot walk an unreadable shape. The SHAPE check
417
+ * itself is route.ts's usableResolution, so the planner and this module cannot
418
+ * disagree about what a usable resolution is.
420
419
  */
421
420
  private routerResolution(): ModelRouterResolution {
422
421
  try {
@@ -525,11 +524,12 @@ export class ThreadManager {
525
524
  ): RoutePlanInput {
526
525
  const resolution = this.routerResolution();
527
526
  // CQ17: the window guard is router-ON only, so with the router OFF pi's compaction
528
- // settings are NOT READ and no predicate is built. "Router off = invisible" is
529
- // about what this feature DOES, not only about what it decides: the read is a
527
+ // settings are NOT READ and no predicate is built. The router-OFF path must add no
528
+ // router-owned SIDE EFFECT, not merely no router-owned decision: the read is a
530
529
  // lock-protected disk read, and performing it for a guard that cannot fire is the
531
- // kind of side effect the invariant exists to forbid. (Memoized per manager, so
532
- // this was never a cost question.) The router-ON path is untouched, including the
530
+ // kind of side effect that rule exists to forbid. (Memoized per manager, so this
531
+ // was never a cost question.) Per-action argument and effort paths are unaffected
532
+ // by it. The router-ON path is untouched, including the
533
533
  // `enabled: true` forcing below, which is load-bearing.
534
534
  const settings = resolution.on ? this.compactionSettings(ctx) : undefined;
535
535
  return {
@@ -650,7 +650,7 @@ export class ThreadManager {
650
650
  );
651
651
  };
652
652
  abortIfBlocked();
653
- // WHICH MODEL this action runs on is decided by route.ts's pure `decideModelSwitch`
653
+ // WHICH MODEL this action starts on is decided by route.ts's pure `decideModelSwitch`
654
654
  // (the plan's model, else the session's opening model as a REVERT, standing down
655
655
  // while a failover holds it). Everything it needs is passed explicitly, so the
656
656
  // decision — the part with the interesting cases — is checkable without a
@@ -1169,11 +1169,11 @@ export class ThreadManager {
1169
1169
  workerEffortUnmeasured: effortIsAsPlanned,
1170
1170
  workerEffortJudgedFor: plan?.effortJudgedFor,
1171
1171
  configuredModel: this.config.episodeModel,
1172
- // The compressor's LAST-RESORT rung: the ORCHESTRATOR's base model, from the
1173
- // same tracker the router-off base resolution uses (failover fallbacks
1174
- // excluded, spec-validated). Without this the pin's bottom rung could never
1175
- // fire, and a project with no episodeModel and no usable Sonnet would drop
1176
- // straight to the uncompressed fallback.
1172
+ // The compressor's LAST-RESORT rung: the ORCHESTRATOR's base model from the
1173
+ // tracker (failover fallbacks excluded, spec-validated). Route planning no
1174
+ // longer consumes this tracker. Without it the compressor pin's bottom rung
1175
+ // could never fire, and a project with no episodeModel and no usable Sonnet
1176
+ // would drop straight to the uncompressed fallback.
1177
1177
  orchestratorBaseModel: this.trackedBaseModel(),
1178
1178
  modelFailover: this.config.modelFailover,
1179
1179
  signal: signal?.aborted ? undefined : signal,
@@ -14,21 +14,21 @@ export function registerSlateTools(pi: ExtensionAPI, store: SlateStore, getManag
14
14
  name: "thread",
15
15
  label: "Thread",
16
16
  description: [
17
- "Dispatch ONE bounded action to a persistent worker thread and receive back an episode",
18
- "(a compressed, structured record of what the thread did).",
19
- "Omit `thread` to create a new thread (give it a short `name`); pass an existing thread id",
20
- "to continue that thread — it retains the context of all its previous actions.",
21
- "Use `context` to inject prior episodes (by id, from any thread) into the action.",
22
- "Threads are serial; to parallelize, dispatch to DIFFERENT threads in one message.",
23
- "`model` (\"provider/id\") and `effort` (pi thinking level) route THIS ACTION ONLY — new thread",
24
- "or continuation alike; the next action reverts to the thread's defaults, which is also what",
25
- "an omitted argument runs on.",
26
- "`tools` applies only when creating a new thread.",
27
- "Guarded: a level the model does not offer is a tool error, as is a model outside the routable",
28
- "list where a project configures one; advisory notices (evidence gaps, cost cliffs, window",
29
- "substitution) are prefixed to the episode as ⚠ lines.",
30
- "An episode header of STATUS: FAILED means the action failed — read it and adapt.",
31
- "Tasks that modify repository files require the track workflow's pre-implementation gates to have run first.",
17
+ "Dispatch ONE bounded action to a persistent worker thread; receive an episode",
18
+ "(a compressed, structured record of its work).",
19
+ "Omit `thread` and give a short `name` to create one; pass its id to continue with all prior-action context.",
20
+ "Use `context` to inject episodes by id from any thread.",
21
+ "Each thread is serial; use DIFFERENT threads in one message for parallel work.",
22
+ "`model` (\"provider/id\") and `effort` (pi thinking level) route THIS ACTION ONLY on new or continued threads.",
23
+ "With routing on, omission plans for the thread's base pair. With routing off, an omitted model",
24
+ "plans for its pre-router pin; no pin gives no target. Omitted effort resolves no level. The session",
25
+ "then returns to what it opened on. See docs/model-routing.md § Known cases where the model or level",
26
+ "differs for what can run instead.",
27
+ "`tools` applies only when creating a thread.",
28
+ "Slate rejects a level the model does not offer and, when configured, a model outside the routable list.",
29
+ "⚠ advisory notices before the episode report evidence gaps, cost cliffs, or window substitution.",
30
+ "STATUS: FAILED means the action failed — read the episode and adapt.",
31
+ "File-changing tasks require the track workflow's pre-implementation gates first.",
32
32
  ].join(" "),
33
33
  promptSnippet: "Dispatch one bounded action to a persistent worker thread; returns a compressed episode",
34
34
  promptGuidelines: [
@@ -43,14 +43,21 @@ export function registerSlateTools(pi: ExtensionAPI, store: SlateStore, getManag
43
43
  Type.Array(Type.String(), { description: "Episode ids to inject as context (e.g. [\"t1.e2\"])" }),
44
44
  ),
45
45
  model: Type.Optional(
46
- Type.String({ description: "Worker model \"provider/id\" for THIS action; omit to use the thread's base model" }),
46
+ Type.String({
47
+ description:
48
+ "Worker model \"provider/id\" for THIS action. Omit it to plan for the thread's base model with " +
49
+ "routing on, or for its pre-router pin with routing off. With no pin the worker session returns to " +
50
+ "the model it opened on. Cases M1, M3 and M7 in docs/model-routing.md § Known cases where the model " +
51
+ "or level differs are when it does not.",
52
+ }),
47
53
  ),
48
54
  effort: Type.Optional(
49
55
  Type.String({
50
56
  description:
51
- "Thinking level for THIS action: off, minimal, low, medium, high, xhigh or max " +
52
- "(only levels the target model offers); omit to let slate choose — the thread's " +
53
- "default level, or one measured for the model that runs",
57
+ "Thinking level for THIS action: off, minimal, low, medium, high, xhigh or max. " +
58
+ "Slate refuses a level the routed model's known ladder lacks, outside case E2 in " +
59
+ "docs/model-routing.md. Omit it to plan for the thread's default or a measured level with routing " +
60
+ "on, or for the level the worker session opened on with routing off.",
54
61
  }),
55
62
  ),
56
63
  tools: Type.Optional(Type.Array(Type.String(), { description: "Worker tool allowlist (new threads only)" })),
@@ -131,12 +138,11 @@ export function registerSlateTools(pi: ExtensionAPI, store: SlateStore, getManag
131
138
  name: "threads",
132
139
  label: "Threads",
133
140
  description:
134
- "List all worker threads: id, name, status, episodes so far, last activity, and models — " +
135
- "base=<the thread's default model>@<effort>? (what a dispatch that omits `model` runs on; " +
136
- "the trailing ? marks the LEVEL as provisional — it is the stored default, re-checked against " +
137
- "the model's current capability data on every dispatch and silently re-derived if it no longer " +
138
- "holds) and last=<the model its last action actually ran on>@<effort>, which is fact. " +
139
- "Use this to decide whether to continue an existing thread or create a new one.",
141
+ "List worker threads, their status, episodes, activity, and models. " +
142
+ "base=<model>@<effort>? is the nominal plan target when `model` is omitted. " +
143
+ "The ? marks effort as provisional because Slate re-checks and may re-derive it. " +
144
+ "last=<model>@<effort> records the last action. live=<model> (failover) shows a held fallback " +
145
+ "that currently overrides the nominal base.",
140
146
  promptSnippet: "List worker threads and their episodes",
141
147
  parameters: Type.Object({}),
142
148
  async execute() {
@@ -147,15 +153,16 @@ export function registerSlateTools(pi: ExtensionAPI, store: SlateStore, getManag
147
153
  const lines = threads.map((t) => {
148
154
  const episodes = t.episodeIds.length > 0 ? t.episodeIds.join(", ") : "(none)";
149
155
  const marks: string[] = [];
150
- // The thread's DEFAULT model — what a dispatch that omits `model` runs on
151
- // (?? t.model: a thread created before per-action routing existed carries
152
- // only the pre-router pin). Absent for a thread whose base could not be
153
- // resolved: it then runs on the host's current model, as it always did.
156
+ // The thread's NOMINAL model — the planner target for a dispatch that omits
157
+ // `model` (?? t.model: an older thread carries only the pre-router pin).
158
+ // Absent means the plan has no model: a NEW session then opens on the host
159
+ // model and a reused one is reverted to the model it opened on. A separate
160
+ // `live=` marker below overrides this display while failover holds the session.
154
161
  const base = t.baseModel ?? t.model;
155
- // CQ19: the MODEL half is authoritative (an unroutable base is re-seeded, so this
156
- // is what a dispatch that omits `model` will use), but the LEVEL half is only a
157
- // STORED default: every dispatch re-checks it against the model's current
158
- // capability data and silently derives a fresh one if the profile table has moved
162
+ // CQ19: the MODEL half is authoritative as nominal planning state (an unroutable
163
+ // base is re-seeded, so this is what an omitted `model` resolves to), but the
164
+ // LEVEL half is only a STORED default: every dispatch re-checks it against the
165
+ // model's current capability data and silently derives a fresh one if the table has moved
159
166
  // under it (BG23). Reporting it bare would present a value that may not survive
160
167
  // contact with the next action as fact — the `?` says so, and the tool description
161
168
  // says what it means. `last=` below carries no such caveat: that one is what ran.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ytdb-slate",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Slate: thread-weaving orchestration for the pi coding agent — bounded worker actions, compressed episodes, mandatory research/design/adversarial/track review workflow, configurable model failover, and orchestrator context-budget handoff.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {