ytdb-slate 0.3.0 → 0.5.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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Slate is a thread-weaving orchestration extension for the [pi coding agent](https://pi.dev).
4
4
 
5
- The orchestrator (your main pi session) dispatches **bounded actions** to persistent **worker threads**. Each completed action is compressed by an LLM into an **episode** — a durable, structured record (intent, actions, findings, artifacts, open issues, handoff notes) that the orchestrator composes into further dispatches instead of re-reading raw transcripts. On top of that, Slate injects a mandatory workflow doctrine: research before design, design review, adversarial review, and track review — with optional umbrella **draft-PR publishing** for tracks. An opt-in **model-failover** map adds high availability: when a model API fails, the orchestrator, worker threads, and episode compression each retry once on a configured equal-quality alternative.
5
+ The orchestrator (your main pi session) dispatches **bounded actions** to persistent **worker threads**. Each completed action is compressed by an LLM into an **episode** — a durable, structured record (intent, actions, findings, artifacts, open issues, handoff notes) that the orchestrator composes into further dispatches instead of re-reading raw transcripts. On top of that, Slate injects a mandatory workflow doctrine: research before design, design review, adversarial review, and track review — with optional umbrella **draft-PR publishing** for tracks. An opt-in **model-failover** map adds high availability: when a model API fails, the orchestrator, worker threads, and episode compression each retry once on a configured equal-quality alternative. A second opt-in, **action-level model routing**, gives each dispatched action a model and an effort level chosen to be up to the task and no more, so cost is bounded per action instead of per session.
6
6
 
7
7
  ## Why Slate?
8
8
 
@@ -51,6 +51,12 @@ This summary is orientation only; the shipped docs listed below are normative.
51
51
 
52
52
  Slate can ride through model API outages. The opt-in `modelFailover` map in `.pi/slate.json` (trusted projects only) maps a model (`provider/id`) to an equal-quality alternative; on an eligible model API failure — not an abort or a context overflow, and for the orchestrator and worker sites only after pi's own retries are exhausted — the affected site (orchestrator, worker thread, or episode compression) retries once on the mapped model (single hop, never chained). An orchestrator failover — like the handoff adoption that re-applies a parent session's model — would otherwise leave pi's global model defaults changed, so Slate restores them on a best-effort basis unless `preserveGlobalModelDefault` is `false`. The map is empty by default (feature off) and read once at session start. Full semantics: the `modelFailover` row in [Configuration](#configuration) and the shipped [`docs/model-failover.md`](docs/model-failover.md) — if they disagree, that document wins.
53
53
 
54
+ ## Action-level model routing
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.
57
+
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
+
54
60
  ## Install
55
61
 
56
62
  Install into a project (pinned, project-scoped — recorded in `.pi/settings.json`):
@@ -68,8 +74,9 @@ Optional config file: `slate.json` in the project's pi config dir (`.pi/slate.js
68
74
  | Key | Type | Default | Semantics |
69
75
  | --- | --- | --- | --- |
70
76
  | `orchestratorModeDefault` | boolean | `false` | Start fresh interactive sessions with orchestrator mode ON. |
71
- | `episodeModel` | string | newest available Anthropic Sonnet, else the worker's own model | Model (`provider/id`) used to compress a finished action into an episode. |
77
+ | `episodeModel` | string | newest available Anthropic Sonnet, else the orchestrator's own base model | Model (`provider/id`) used to compress a finished action into an episode. Never the model an action was routed to: an episode is read by every later consumer of the thread, so one cheap route must not degrade the record. An unusable value is reported and the default is used. |
72
78
  | `workerTools` | string[] | `["read", "bash", "edit", "write", "grep", "find", "ls"]` | Tools available to worker threads (an empty list also falls back to the default). |
79
+ | `workerExtensions` | string[] | `[]` | Regex patterns (matched **unanchored**) selecting which of the host session's already-loaded extensions also load into every worker thread; each matched extension's tools are added **on top of** `workerTools`. Empty (default) means workers load no extensions. The orchestrator keeps its restricted tool set but its doctrine is told what was whitelisted. Invalid patterns are dropped with a warning at session start. |
73
80
  | `maxConcurrent` | number | `4` | Maximum number of worker actions running concurrently (must be ≥ 1 — unenforced: a value of 0 or less silently hangs all dispatches). Excess dispatches wait in a queue; actions on the same thread always run in dispatch order. Default rationale: shipped `docs/design-principles.md` §5 (repo-local note). |
74
81
  | `contextBudget` | number \| object | `256000` (Anthropic models: `400000`) | Absolute orchestrator context budget (tokens) at which Slate auto-pauses and prepares a fresh-session handoff — semantics, defaults, per-model overrides, and rationale in [`docs/context-budget.md`](docs/context-budget.md). |
75
82
  | `orchestratorPromptDocs` | string[] | `[]` | Project markdown files (paths relative to the project root) whose **contents** are appended to the orchestrator system prompt. |
@@ -79,6 +86,8 @@ Optional config file: `slate.json` in the project's pi config dir (`.pi/slate.js
79
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. |
80
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). |
81
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). |
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. |
82
91
 
83
92
  Example `.pi/slate.json` (the `docs/agents/...` paths are placeholders — point them at markdown files that actually exist in **your** project):
84
93
 
@@ -92,19 +101,41 @@ Example `.pi/slate.json` (the `docs/agents/...` paths are placeholders — point
92
101
  "workflow": { "draftPRs": true },
93
102
  "doctrineExtraPath": "docs/agents/workflow-additions.md",
94
103
  "reviewPerspectivesPath": "docs/agents/review-perspectives.md",
95
- "modelFailover": { "anthropic/claude-sonnet-5": "openai/gpt-5.2" }
104
+ "modelFailover": { "anthropic/claude-sonnet-5": "openai/gpt-5.2" },
105
+ "router": { "models": ["openai/gpt-5.6-luna", "anthropic/claude-opus-5"] }
96
106
  }
97
107
  ```
98
108
 
99
109
  > **Silent skip:** the project-file keys fail silently — no error is shown. For the content-injected keys (`orchestratorPromptDocs`, `workerPromptDocs`, `doctrineExtraPath`) a missing, unreadable, or empty file is skipped and nothing is injected. For `reviewPerspectivesPath` the pointer is omitted only when the file is missing — the file is not read at injection time, so an unreadable or empty file is still cited. Verify your paths after copying the example.
100
110
 
111
+ **Worker extensions (`workerExtensions`).** By default worker threads load no extensions. This key is a list of regex patterns that select extensions the **host session has already loaded** and load them into every worker too. Each pattern is matched **unanchored** (unlike `contextBudget.overrides`, which is anchored) against a load unit's recorded source spec (e.g. `npm:pi-web-search@1.3.1`), its load-unit path, or the entry path of any tool that unit contributes, so a bare package name matches:
112
+
113
+ ```json
114
+ {
115
+ "workerExtensions": ["pi-smart-fetch", "pi-web-search"]
116
+ }
117
+ ```
118
+
119
+ Every worker then gets the fetch and web-search tools **on top of** `workerTools` (and on top of the per-dispatch `tools` argument of the `thread` tool — those two govern the built-in tools only). The orchestrator itself does **not** gain these tools — orchestrator mode keeps its restricted set — but its doctrine gains a rule naming each whitelisted extension and its tools, so it knows what it can delegate. pi's discovery, project-trust gating, and dedup remain the only ingress: a worker can never load an extension the host is not running, an extension that registers no tools cannot be whitelisted, and a host started with extensions disabled offers nothing to whitelist.
120
+
121
+ **What to know before whitelisting** — it reaches past Slate's isolation, so it is an operator decision:
122
+
123
+ - **Delegation is unbounded.** Slate guarantees only that no worker obtains Slate's own `thread`/`threads`/`episode` tools. A whitelisted extension that ships its own sub-agent or delegation tool under any other name gives workers delegation Slate can neither detect, bound, nor account for.
124
+ - **Credential and filesystem reach.** Inside a worker the extension has the same filesystem and credential access it has in the host; Slate's read-only settings snapshot blocks pi-settings writes and nothing else.
125
+ - **Unsynced model-scoped tools.** Worker sessions never fire the session-start event, so an extension whose tool set is model-scoped stays unsynced and may offer a tool the worker's model cannot serve (e.g. pi-web-search's `url_context` on a non-Google worker model) — the call simply fails and the episode records it.
126
+ - **Abort.** A third-party extension may ignore the abort signal, so its network activity can outlive an abort or a context-budget pause.
127
+ - **Cost.** Provider-native tool billing can escape Slate's worker cost accounting.
128
+ - **Pathological patterns.** The patterns are regexes from your own trusted config — the same file that already steers models, prompts, and tool lists — matched with no time bound while the extension set is resolved once per session. A pattern with catastrophic backtracking can stall that resolution; treat it as a footgun to avoid, not a privilege boundary.
129
+
130
+ The load-time recursion guard behind this — and the risks it does and does not cover — is in [`docs/design-principles.md`](docs/design-principles.md).
131
+
101
132
  ## Trust
102
133
 
103
134
  Slate reads project configuration (`.pi/slate.json`) and injects project files (`orchestratorPromptDocs`, `workerPromptDocs`, `doctrineExtraPath`, `reviewPerspectivesPath`) **only in trusted projects**. In untrusted projects Slate runs with built-in defaults and injects nothing from the working tree.
104
135
 
105
136
  ## Shipped docs
106
137
 
107
- In orchestrator mode, Slate appends a short **doctrine** (a block of numbered rules) to the orchestrator's system prompt each turn. The doctrine does not embed the workflow docs — it cites them by **absolute path**, resolved inside the installed package (not your project), and the orchestrator reads them on demand:
138
+ In orchestrator mode, Slate appends a short **doctrine** (a block of numbered rules) to the orchestrator's system prompt each turn. The doctrine does not embed the workflow docs — it cites them by **absolute path**, resolved inside the installed package (not your project), and the orchestrator reads them on demand. Those embedded paths are also why the block's character count depends on your install location; [`docs/context-budget.md`](docs/context-budget.md) has the measured sizes, with and without the optional rules, and the arithmetic for your own install:
108
139
 
109
140
  - `docs/track-workflow.md` — the track-based workflow (research → design review → adversarial review → track review)
110
141
  - `docs/pr-publishing.md` — umbrella draft-PR publishing (cited only when `workflow.draftPRs` is `true`)
@@ -112,6 +143,7 @@ In orchestrator mode, Slate appends a short **doctrine** (a block of numbered ru
112
143
  - `docs/design-principles.md` — Slate's own design rationale
113
144
  - `docs/model-failover.md` — the opt-in `modelFailover` map (**reference documentation** — unlike the entries above it is not workflow doctrine and is not cited by the doctrine)
114
145
  - `docs/context-budget.md` — the orchestrator `contextBudget`: defaults, per-model overrides, the window clamp, and the pricing rationale (also **reference documentation**, not cited by the doctrine)
146
+ - `docs/model-routing.md` — action-level model routing: the two `router` keys, how a model becomes routable, how an omitted effort level resolves, the dispatch guards, and the warnings a first routed session emits. Unlike the two entries above it **is** cited by the doctrine, by absolute path, but only while the routing rule renders — it is the extra embedded path that appears with the rule, the fourth normally and the fifth when `workflow.draftPRs` is also on, which the size arithmetic in `docs/context-budget.md` accounts for. The routable model list itself is never read from this file: the rule renders it live from the session's own resolution, because that set depends on your registry and credentials.
115
147
 
116
148
  Project-specific additions layer on top — they extend, not replace, the shipped doctrine — via two distinct mechanisms:
117
149
 
@@ -118,6 +118,114 @@ paused, threshold compactions pass through as an escape valve.
118
118
  Overflow-recovery compaction and manual `/compact` are never
119
119
  touched.
120
120
 
121
+ ## What the always-loaded doctrine costs the budget
122
+
123
+ The budget's denominator includes Slate's own always-loaded block.
124
+ While orchestrator mode is on, the doctrine sits in the
125
+ orchestrator's system prompt for the whole session — and, like the
126
+ rest of the conversation (see the pricing cliff above), it is sent
127
+ again with every request. It occupies its space in the window once;
128
+ you pay its input tokens every turn.
129
+
130
+ **Its size depends on where the package is installed.** That is not
131
+ obvious, and it catches anyone who measures: the doctrine cites the
132
+ shipped docs by ABSOLUTE path, so three to five full filesystem
133
+ paths are embedded in the block — three at minimum
134
+ (`track-workflow.md`, `review-rules.md`, `design-principles.md`),
135
+ plus `pr-publishing.md` when `workflow.draftPRs` is on, plus
136
+ `model-routing.md` when the routing rule renders. Every additional
137
+ character in the installed `docs/` directory therefore costs 3–5
138
+ characters of doctrine.
139
+
140
+ So the figures below are **portable characters**, defined exactly as
141
+ Slate's own automated size check defines them: the block with each
142
+ occurrence of the installed `docs/` DIRECTORY removed, keeping the
143
+ filename. That makes them install-invariant by construction —
144
+ verified by measuring at a 12-character docs path and at this repo's
145
+ 58-character one, which give identical portable counts — and it
146
+ makes the arithmetic one multiplication:
147
+
148
+ > rendered characters = portable + (embedded paths × length of your
149
+ > installed `docs/` directory)
150
+
151
+ Every row below states its full basis, because router state, model
152
+ count and `draftPRs` each move the number:
153
+
154
+ | router | models | `draftPRs` | paths | portable | lines | rendered at a 58-char docs dir |
155
+ | --- | --- | --- | --- | --- | --- | --- |
156
+ | off | — | off | 3 | 1,929 | 38 | 2,103 |
157
+ | off | — | on | 4 | 1,948 | 38 | 2,180 |
158
+ | on | 6 configured | off | 4 | 3,870 | 58 | 4,102 |
159
+ | on | 6 configured | on | 5 | 3,889 | 58 | 4,179 |
160
+ | on | all 9 shipped | off | 4 | 4,448 | 61 | 4,680 |
161
+ | on | all 9 shipped | on | 5 | 4,467 | 61 | 4,757 |
162
+
163
+ An untrusted project reads the first row whatever its `slate.json`
164
+ says: no project config is loaded, so neither optional rule renders.
165
+ Line counts do not vary with the install path. Enabling `draftPRs`
166
+ costs 19 portable characters plus one more embedded path.
167
+
168
+ Two parts of the block grow with configuration rather than with the
169
+ path:
170
+
171
+ - The routing rule is a live table with ONE ROW PER ROUTABLE MODEL,
172
+ so what renders is the models you CONFIGURE — not the nine Slate
173
+ ships profiles for. It is 1,941 portable characters / 20 lines for
174
+ six configured models and 2,519 / 23 for all nine. A model ROW
175
+ costs 154–186 characters; the legend adds a one-off clause per
176
+ marker it has to explain, so listing the first cheap-tier model
177
+ costs ~50 characters more than its row (two new clauses). That is
178
+ why stepping from six to nine averages 193 per model where the
179
+ first six average 171.
180
+ - The worker-extension rule grows per whitelisted extension and per
181
+ tool that extension contributes, so it has no fixed size; one
182
+ extension contributing two tools measured 373 portable characters
183
+ / 7 lines.
184
+
185
+ Against a 256,000-token budget none of this is material — as a rough
186
+ estimate, at 4 characters per token (no tokenizer was run, and the
187
+ table is denser than prose, so treat it as a floor) the whole block
188
+ is ≈480–525 tokens with both optional rules off and ≈970–1,025 with
189
+ six models routed, each range spanning portable to as-rendered-here.
190
+ That is under half a percent of the default budget, though it is
191
+ re-sent on every request rather than paid once. It is listed here
192
+ because it is context Slate itself puts in front of the model on
193
+ every turn, and because it is the number to check before assuming
194
+ the budget's headroom is all conversation.
195
+
196
+ ## Worker threads: the routing window guard
197
+
198
+ The budget above is the ORCHESTRATOR's. Worker threads have no
199
+ budget and never pause; with action-level routing on, they get a
200
+ weaker but automatic counterpart. Before each dispatch — whenever
201
+ the thread's context size is knowable, so not for a thread whose
202
+ worker session has not been opened yet — the routed model's
203
+ REGISTRY context window is checked against that size using pi's OWN
204
+ compaction settings, the same settings whose `reserveTokens` clamps
205
+ the budget above. (The check runs even if you disabled
206
+ auto-compaction: turning compaction off does not make an
207
+ over-window dispatch safe, it only turns the compaction into an
208
+ overflow error.) If the thread no longer fits, the action is moved
209
+ to the widest listed model when that one is strictly wider, with a
210
+ warning; if nothing listed is wider it runs anyway and pi compacts.
211
+ The guard never blocks a dispatch and never pauses a thread.
212
+
213
+ Two consequences for this document's subject:
214
+
215
+ - The registry window is what decides the substitution, so the
216
+ overrides in the next section change worker routing too: raising
217
+ `gpt-5.6-*` to 1,050,000 makes those models genuinely wide
218
+ candidates rather than 272K ones.
219
+ - A narrow model in `router.models` is not a hazard the way it would
220
+ be for the orchestrator — it is a model long threads get routed
221
+ AWAY from, paying the wider model's rate instead. That is not
222
+ silent: the substituted action carries a ⚠ notice naming both
223
+ models.
224
+
225
+ Routing itself — the two `router` keys, what makes a model routable,
226
+ how effort levels resolve and the full guard list — is documented in
227
+ `model-routing.md` in this directory.
228
+
121
229
  ## Using GPT-5.6's full 1.05M window
122
230
 
123
231
  Two deliberate steps. First, raise the registry window in
@@ -155,6 +263,17 @@ dropped:
155
263
  WARNING: beyond 272K total input every request bills at the
156
264
  long-context rates listed above.
157
265
 
266
+ With action-level routing on, this override also settles a warning
267
+ you will otherwise see once per session for each configured
268
+ `gpt-5.6-*` model: Slate's own profile for those models records a
269
+ 1,050,000-token window, pi's stock registry reports 272,000, and the
270
+ router reports that divergence without adjudicating it (routing uses
271
+ the registry figure). Raising the registry window removes the
272
+ disagreement at its source. Leaving it alone is equally valid — the
273
+ registry figure is the conservative one — and `model-routing.md`
274
+ explains why Slate does not silence the warning by editing its own
275
+ number.
276
+
158
277
  ## Accepted limitations
159
278
 
160
279
  - On models with a 272K pricing tier and a larger registry window
@@ -174,3 +293,9 @@ long-context rates listed above.
174
293
  compaction can land in the same cycle and the handoff brief is
175
294
  written from compacted context — episodes and thread state survive
176
295
  on disk, so handoff still functions.
296
+ - The doctrine sizes above cover the shipped rules only. A project
297
+ that also injects `doctrineExtraPath` or `orchestratorPromptDocs`,
298
+ or whitelists worker extensions, pays for those on top in the same
299
+ always-loaded position, and Slate measures none of it against the
300
+ budget separately — it simply arrives as context the budget then
301
+ has less room for.
@@ -8,9 +8,22 @@ for its content. This document records the reasoning behind the
8
8
  extension's design so maintainers — and the orchestrator itself, on
9
9
  demand — can check changes and behavior against the original intent.
10
10
 
11
- Note: module headers cite ExecPlan decision ids (D3–D9, M1–M3) from the
12
- original implementation plan, which is not part of this repository. This
13
- document is the in-repo source for the architecture rationale.
11
+ Note: module headers cite ids from design records that are not part of this
12
+ repository — the original implementation plan (ExecPlan: D3–D9, M1–M3), later
13
+ design rounds (higher-numbered D ids, and W ids for named warnings), and
14
+ per-round review findings, whose prefix says which review raised them:
15
+ AD (adversarial), AF (agent-failure), BG (blocker/bug), CN (concurrency),
16
+ CQ (code quality), DF (data fidelity), N (numeric), RG (regression),
17
+ RI (research integrity), SE (security), WB (worker boundary) and WS (worker
18
+ safety). `extension/model-profiles.ts` additionally cites research TRACE keys
19
+ (`G1a`, `GM3`, `O4.2`, `A4b`, …). Those name rows in the research corpus that
20
+ produced the profile table; the corpus lives in the project repository and is
21
+ NOT part of the published package, so in an installed copy the keys are
22
+ provenance marks to quote when reporting a data problem, not links to follow.
23
+ What a consumer can act on without the corpus is in `model-routing.md`
24
+ (§ where the numbers come from): the table's provenance rules, its `asOf`
25
+ date, and which fields are marked untraced. This document is the in-repo
26
+ source for the architecture rationale.
14
27
 
15
28
  ## 1. The problems Slate is built to solve
16
29
 
@@ -125,8 +138,11 @@ or two problems for the others:
125
138
  not blind retry.
126
139
  - **P6 — Make desired behavior the natural behavior.** In orchestrator
127
140
  mode, tactical tools are removed, so delegation is the only way to act.
128
- - **P7 — Guard against over-decomposition.** Workers cannot spawn threads
129
- (depth-1); recursion is structurally impossible.
141
+ - **P7 — Guard against over-decomposition.** Workers never receive
142
+ Slate's dispatch tools (`thread`/`threads`/`episode`), so no worker can
143
+ spawn a Slate thread — Slate's own recursion stays depth-1. That is a
144
+ narrow load-time invariant, not a general delegation bound (see the
145
+ recursion-guard note in §5).
130
146
  - **P8 — Per-episode feedback beats blind N-step execution.** Bounded
131
147
  actions return before the next decision, so course correction is always
132
148
  possible.
@@ -146,7 +162,7 @@ or two problems for the others:
146
162
  | P4 context by reference | `tools.ts` `context` parameter injects prior episodes by id |
147
163
  | P5 adaptive decomposition | doctrine rule 6 in `mode.ts`; no plan structure imposed anywhere |
148
164
  | P6 natural behavior | `mode.ts` `ORCHESTRATOR_TOOLS` restriction (read-only + slate tools) |
149
- | P7 over-decomposition guard | `worker.ts` recursion guard — workers load no extensions, so the `thread` tool never exists for them |
165
+ | P7 over-decomposition guard | `worker.ts` recursion guard — load-scoped barriers keep Slate's `thread`/`threads`/`episode` tools out of every worker (recursion-guard note below) |
150
166
  | P8 per-episode feedback | `threads.ts` synchronous dispatch; episode returned to orchestrator |
151
167
  | P9 parallelism | `threads.ts` `maxConcurrent` queueing; doctrine rule 2 in `mode.ts` |
152
168
  | P10 context as RAM | `handoff.ts` context-budget auto-pause + fresh-session handoff |
@@ -164,6 +180,127 @@ while staying safe on common consumer API tiers; wider fan-outs only
164
180
  pay tail latency, and projects with higher-tier keys raise the cap in
165
181
  `slate.json`.
166
182
 
183
+ Repo-local note (not from the report): **action-level model routing**
184
+ applies P10's discipline to a second scarce resource. P10 treats
185
+ CONTEXT as RAM; the optional `router.models` list treats SPEND the same
186
+ way — each dispatched action gets a model and an effort level chosen to
187
+ be up to the task and no more, instead of every action inheriting
188
+ whatever model the orchestrator happens to be running. It is also a P1
189
+ corollary: an action is only a bounded unit if what it may cost is
190
+ bounded too, and the closed model list plus a derived (never inflated)
191
+ effort level is what bounds it. The two disciplines even share
192
+ machinery — pi's compaction settings, which clamp the orchestrator's
193
+ context budget, also decide per dispatch whether a worker thread still
194
+ fits the model it is routed to, in which case the action moves to the
195
+ widest listed model rather than being blocked.
196
+
197
+ The feature is OFF until `router.models` lists something, and it is
198
+ built on a deliberate split that is worth holding onto when reasoning
199
+ about the architecture: the code ENFORCES less than the routing data
200
+ ADVISES. Dispatch guards refuse what is mechanically wrong — a model
201
+ outside the configured list, a level a model does not offer — because
202
+ those are decidable from the data. The obligations that require
203
+ JUDGMENT about the work, such as which actions count as review or gate
204
+ actions and what a compliance constraint demands of a given task, are
205
+ stated in the doctrine for the orchestrator to honor and are not
206
+ guards. That is the same reasoning as P6: the harness makes the right
207
+ thing natural and blocks what it can decide, rather than pretending to
208
+ adjudicate what it cannot see. Which obligations fall on which side of
209
+ that line changes as the feature grows, so this document deliberately
210
+ does not restate the list — `model-routing.md` in this directory owns
211
+ it, along with the mechanics and the provenance of the routing data.
212
+
213
+ Repo-local note (not from the report): the P7 guard was originally
214
+ absolute — workers loaded no extensions, so Slate's `thread` tool simply
215
+ never existed for them. The optional `workerExtensions` key (`slate.json`,
216
+ trusted projects only — see the README config reference) relaxes that: it
217
+ whitelists extensions the host session has ALREADY loaded and loads them
218
+ into every worker. The guard therefore no longer rests on "workers load
219
+ nothing" but on a narrower, more precise invariant: no worker can ever
220
+ obtain SLATE's dispatch tools (`thread`/`threads`/`episode`).
221
+
222
+ Three load-scoped barriers buy that invariant, all applied per LOAD UNIT
223
+ — the owning package directory when the extension is package-originated
224
+ and the package's declared entries provably match what the host loaded
225
+ (BG20, below), otherwise the extension's own entry file:
226
+
227
+ 1. Only whitelisted units load, through pi's resource loader in allowlist
228
+ mode; a worker's extension set is exactly the allowlist and no
229
+ non-whitelisted module is ever imported (load errors surface as
230
+ warnings). Candidates are only what the host session already loaded,
231
+ enumerated from its tool registry — so an extension registering no
232
+ tools is invisible, and a host started with extensions disabled yields
233
+ an empty candidate set.
234
+ 2. Any unit that contains Slate's own package root is dropped whatever the
235
+ patterns say.
236
+ 3. Any unit whose tools collide with Slate's `thread`/`threads`/`episode`
237
+ or with pi's built-ins (`read`/`bash`/`edit`/`write`/`grep`/`find`/`ls`)
238
+ is dropped WHOLE — pi's registry lets an extension tool overwrite a
239
+ same-named built-in, so a partial load could shadow the very tools the
240
+ guard protects.
241
+
242
+ Exclusion has to happen at load time, at unit granularity, rather than by
243
+ loading everything and filtering the tool list afterwards: a
244
+ worker-loaded extension is not tools-only. Its event handlers, tools, and
245
+ flags all go live on load, so once a unit has loaded its side effects have
246
+ already run and dropping its tools afterwards would leave its handlers and
247
+ flags active. Dropping the whole unit before it loads is the only clean
248
+ cut — which is also why barrier 3 rejects a colliding unit entirely
249
+ instead of suppressing just the offending tool. (A package directory is
250
+ handed to pi whole — letting pi's own manifest resolution expand it, so
251
+ no-tool companion entries load alongside the tool-bearing ones — only when
252
+ the manifest's declared entries are ALL literal relative paths AND the
253
+ host loaded every one of them. A glob or override-form entry, or a host
254
+ running only a filtered subset, fails that equivalence check and drops the
255
+ unit to exactly the entry files the host loaded; that fallback loses the
256
+ package's no-tool companion entries, since an entry that registered no
257
+ tool cannot be shown to be running.)
258
+
259
+ That collision barrier is best-effort and load-time only: it runs before a
260
+ unit loads, against its tools as the HOST registry reports them, and once
261
+ more after the load, against the tools the units actually registered — a
262
+ post-load collision fails the dispatch CLOSED rather than opening the
263
+ worker. Neither pass can see a tool an extension registers only LATER, on
264
+ the worker's first turn (a `before_agent_start` handler, say). Slate's own
265
+ dispatch tools do not depend on that scan: `createAgentSession` is given
266
+ an `excludeTools` denylist of `thread`/`threads`/`episode` that pi applies
267
+ AFTER the tool allowlist and re-applies on every tool-registry refresh, so
268
+ they can never be active in a worker no matter when or by whom they are
269
+ registered — a deferred registration included. A deferred registration
270
+ that shadows a pi BUILT-IN (`read`, `bash`, …) has no such gap-closer —
271
+ the worker needs the real built-in — so it stays outside Slate's control,
272
+ exactly as available to any extension the host session runs. The guard
273
+ bounds Slate's dispatch tools, not every tool an extension might register
274
+ at runtime.
275
+
276
+ The orchestrator does NOT get the whitelisted tools; orchestrator mode
277
+ keeps its restricted set. Instead its doctrine gains a rule listing each
278
+ whitelisted extension with its tool names and descriptions, so it knows
279
+ what it can delegate. That doctrine rule and the worker allowlists come
280
+ from a single memoized resolution per session, so the two cannot drift.
281
+
282
+ The invariant bought is exactly that: no worker gets Slate's dispatch
283
+ tools. It is NOT a general recursion or delegation bound, and the accepted
284
+ risks that follow are the operator's to weigh:
285
+
286
+ - A whitelisted extension that registers its OWN sub-agent or delegation
287
+ tool under any other name gives workers unbounded delegation that Slate
288
+ can neither detect nor bound, outside Slate's episode and cost
289
+ accounting. Whitelisting such an extension is a deliberate operator
290
+ decision.
291
+ - Inside a worker a whitelisted extension has the same filesystem and
292
+ credential reach it has in the host; Slate's read-only settings snapshot
293
+ blocks pi-settings writes and nothing else.
294
+ - Worker sessions never fire the session-start event, so an extension
295
+ whose model-scoped tool manager depends on it stays unsynced and may
296
+ offer a tool the worker's model cannot serve — the call simply fails and
297
+ the episode records it.
298
+ - Third-party extensions may ignore the abort signal, so their network
299
+ activity can outlive an abort or a context-budget pause.
300
+ - Host and worker copies of the same extension share module-level state
301
+ through pi's process-global module cache.
302
+ - Provider-native tool billing may escape Slate's worker cost accounting.
303
+
167
304
  ## 6. Runtime knowledge: what the orchestrator knows, and when
168
305
 
169
306
  The orchestrator's knowledge of these principles is two-tier, following
@@ -174,7 +311,19 @@ the load-on-demand discipline the extension itself prescribes:
174
311
  `review-rules.md`, and a pointer to the track-based workflow protocol
175
312
  that ships with this package (`track-workflow.md` in this directory),
176
313
  appended to the system prompt every turn while orchestrator mode is
177
- on. It costs a few hundred tokens and covers everything routine
314
+ on. Sizing it matters to the tiering argument, so it is measured
315
+ rather than asserted: with both conditional tail rules and
316
+ `workflow.draftPRs` off, the block is **1,929 portable characters /
317
+ 38 lines**, and configuring the router for six models roughly
318
+ DOUBLES it, to **3,870** on that same basis — the routing rule
319
+ renders one table row per routable model. (Portable characters, and
320
+ the rest of the configurations, are defined and tabulated in
321
+ `context-budget.md`; the raw count also depends on where the package
322
+ is installed, because the doctrine embeds absolute doc paths.) At a
323
+ rough 4 characters per token that is order-500 tokens, order-1,000
324
+ with the router on — so the original "a few hundred tokens" holds
325
+ only for the fixed rules, and a configured router buys its table with
326
+ a real slice of every request. It still covers everything routine
178
327
  dispatching needs.
179
328
  - **Tier 2 — on demand.** This document. The doctrine carries a short
180
329
  pointer to it (doctrine rule 10); the orchestrator reads it only when