model-orchestrator 0.1.18 → 0.1.20
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/AGENTS.md +2 -0
- package/CHANGELOG.md +36 -1
- package/README.md +20 -2
- package/llms.txt +3 -0
- package/package.json +3 -2
- package/scripts/README.md +1 -0
- package/scripts/gen-plugin.js +16 -0
- package/src/README.md +1 -0
- package/src/install.js +8 -1
- package/src/plugin.js +83 -0
- package/templates/agents/claude-code/README.md +2 -0
- package/templates/agents/claude-code/builder.md +1 -0
- package/templates/agents/claude-code/deep-planner.md +1 -0
- package/templates/agents/claude-code/live-researcher.md +1 -0
- package/templates/agents/snippets/route-gate.mjs +108 -26
package/AGENTS.md
CHANGED
|
@@ -10,6 +10,7 @@ model-orchestrator writes routing rules, subagents and a CLI lane runner so an a
|
|
|
10
10
|
- `npx model-orchestrator --yes --level 2 --ais claude-code,codex --project <repo> --dir <repo>/ai-orchestrator --dry-run` prints the plan and writes nothing.
|
|
11
11
|
- Drop `--dry-run` to write it. Existing files are never overwritten without `--force`; activation snippets (for example `CLAUDE.snippet.md`, `settings.hooks.snippet.json`) are written for a person or agent to merge.
|
|
12
12
|
- The generated `README.md` in `--dir` lists what to copy where and one smoke command to prove the rules took.
|
|
13
|
+
- Claude Code users can install the hooks and subagents as a plugin instead of merging snippets: `claude plugin marketplace add aunysillyme/model-orchestrator`, then `claude plugin install model-orchestrator@model-orchestrator`. The rules still come from the installer above; see [`plugin/README.md`](plugin/README.md).
|
|
13
14
|
- A summary for LLMs, with links to every doc: [`llms.txt`](llms.txt).
|
|
14
15
|
|
|
15
16
|
## Working on this repository
|
|
@@ -19,6 +20,7 @@ The files the installer writes for end users live under `templates/`.
|
|
|
19
20
|
- Read `CONTRIBUTING.md` first, then `src/README.md` (the catalog drives everything) and `docs/audit-brief.md` (the threat model and what has already been attacked).
|
|
20
21
|
- Run `npm test` before proposing a change and quote the count and the exit code; the suite prints the current number.
|
|
21
22
|
- Everything renders from `src/catalog.js`. Add an AI or a tool there, not in a template. Templates carry no logic.
|
|
23
|
+
- `plugin/` is generated. Edit the agent or hook in `templates/`, then `npm run gen:plugin`; `test/plugin.test.js` fails when the committed bundle drifts. A plugin hook may only read: no network, no file writes, no subprocess.
|
|
22
24
|
- Never put a value that looks like a credential anywhere in this repo, including tests and examples. Environment variable names only.
|
|
23
25
|
- `bin/cli.js` writes only inside `--dir` and `--project`, never over a document without `--force`, and never runs a vendor script. A change that weakens any of those will be refused in review; the tests that hold them are in `test/install.test.js` and `test/cli.test.js`.
|
|
24
26
|
- `bin/cli-run.mjs` must exit non-zero when a lane produced nothing. Every judge has a red case in `test/judges.test.js`; add one before you change a judge.
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,39 @@ All notable changes to this project are documented here. The format follows [Kee
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.1.20] - 2026-09-12
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **A Claude Code plugin.** `/plugin marketplace add aunysillyme/model-orchestrator`, then `/plugin install model-orchestrator@model-orchestrator`, installs `route-gate.mjs`, `subagent-context.mjs` and the eight subagents without merging a settings snippet by hand. The bundle lives in `plugin/`, listed by `.claude-plugin/marketplace.json` at the repo root. It passes `claude plugin validate --strict`, the check Anthropic's community marketplace review runs, and all eight checks of Sigistry's public plugin verification methodology (1.2), run standalone before release as a quality bar; the plugin is not listed there.
|
|
12
|
+
- **The plugin is generated, never a second copy.** `npm run gen:plugin` renders `plugin/` from the same `templates/` the installer uses, and `test/plugin.test.js` fails when the committed bundle drifts from that, when `plugin.json`'s version is not `package.json`'s, when `hooks/hooks.json` references a hook that is not shipped, when a plugin hook gains a network call, a file write, credential access, dynamic evaluation or a subprocess, when an agent has no `tools:` line or a review-type agent carries Write or Edit, and when the plugin README loses its install commands. Each check was proved red against the real files before it was trusted.
|
|
13
|
+
- **The plugin's route gate works without an install step.** A plugin cannot be rendered per project, so its `route-gate.mjs` reads the installer's default locations, `ai-orchestrator/ROUTING.md` then `ai-orchestrator/ORCHESTRATOR.md`, and takes the first that exists. Something at the first path that is not a readable file (a directory, a FIFO) is reported, never skipped for the second. With neither present it tells Claude on every prompt, and the user once at session start, to run `npx model-orchestrator`, so a project with no rules is never a silent no-op.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **`builder`, `deep-planner` and `live-researcher` now declare their tools, for npm installs too.** Until now they carried no `tools:` line and inherited every tool the session had, MCP tools included. `builder` gets `Read, Write, Edit, Glob, Grep, Bash`; `deep-planner` gets `Read, Glob, Grep` (its prompt already says it never edits); `live-researcher` gets `WebSearch, WebFetch`. This narrows what those three agents can do in an existing install once regenerated: if you relied on `builder` calling an MCP tool, or `deep-planner` running a command, add the tool to that agent's `tools:` line or delete the line.
|
|
18
|
+
- **`route-gate.mjs` takes a list of rules paths instead of one.** An installer render is a one-element list with no setup hint, so an npm install behaves exactly as before; a test pins that render.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **`route-gate.mjs` could emit more than Claude Code's 10,000-character hook output cap, on npm installs too.** The routing table was capped at 4,000 characters, but a fallback message embeds the resolved project path and the error text, so a 12,000-character `CLAUDE_PROJECT_DIR` produced 12,146 characters from an installer render. Every string the hook emits is now capped at 8,000 characters, with a test on both renders. Found by the pre-release audit round and reproduced before the fix.
|
|
23
|
+
- **The plugin's hook-safety test could not see an async write or subprocess.** `writeFileSync?` matches `writeFileSyn` and `writeFileSync`, never `writeFile`, and every `process.env` read was exempt. The check now matches the Sync and async form of every file write and subprocess call, refuses dynamic `import(` and `require(`, allows static imports of `node:fs` and `node:path` only, requires `openSync` to open read-only, and allows no environment variable but `CLAUDE_PROJECT_DIR`, with a red case for each. Found by the same audit round.
|
|
24
|
+
|
|
25
|
+
### Not changed
|
|
26
|
+
|
|
27
|
+
- `route-metrics.mjs` still installs with `npx model-orchestrator`, unchanged. It is left out of the plugin only, because it writes a log to disk and the plugin ships only hooks that read.
|
|
28
|
+
|
|
29
|
+
## [0.1.19] - 2026-09-12
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- **The `route-gate.mjs` non-regular-file guard is now tested on every OS, not only where `mkfifo` exists.** The FIFO test is the only one that can prove the HANG the guard exists to prevent (a naive `readFileSync` on a writer-less FIFO blocks forever), and Windows has no `mkfifo` to build one, so that test was skipped there and nothing exercised `!st.isFile()` on Windows at all. A directory at the same rules path reaches the same guard before any `open` or `read` call, on every OS, so the guard itself is covered everywhere and the win32 skip is no longer its only coverage.
|
|
34
|
+
- **A guard that refuses an undocumented test skip.** `test/prose.test.js` pins each skip to its file, its exact marker and a phrase the README has to carry, then counts every `{ skip:` in the suite and fails if the totals disagree. Proved in both directions: adding a skip anywhere fails it, and removing a skip's explanation from the README fails it.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- **The README's Windows skip list named three of the four skips.** The `mkfifo` skip in `test/hooks.test.js` had never been documented, in the README or the changelog, while the sentence above it read "a few narrow skips remain" and enumerated the rest. A skipped test reads as a test that passed, so an undocumented skip is a coverage claim nobody made deliberately. All four are named now, the conditional `statSync().mode` assertion is labelled as the one-assertion case it is rather than a skipped test, and the sentence says outright that the list is enforced by a test rather than maintained by hand.
|
|
39
|
+
|
|
7
40
|
## [0.1.18] - 2026-09-11
|
|
8
41
|
|
|
9
42
|
### Fixed
|
|
@@ -282,7 +315,9 @@ First release.
|
|
|
282
315
|
- Tests: a case per fix, judges proven to go red, mutation checks; `npm test` prints the current count.
|
|
283
316
|
- Adversarial audit: two Codex rounds plus a two-engine review (Codex, Antigravity); findings and fixes in `docs/audit-brief.md`. After the review: subagents go to the project root (`--project`), snippet paths computed from `--dir`, lane sections rendered from the selection, a primary agent required, level 3 asks for API keys separately from CLIs, images and CLI installs pinned, an activation summary at the end of every install.
|
|
284
317
|
|
|
285
|
-
[Unreleased]: https://github.com/aunysillyme/model-orchestrator/compare/v0.1.
|
|
318
|
+
[Unreleased]: https://github.com/aunysillyme/model-orchestrator/compare/v0.1.20...HEAD
|
|
319
|
+
[0.1.20]: https://github.com/aunysillyme/model-orchestrator/compare/v0.1.19...v0.1.20
|
|
320
|
+
[0.1.19]: https://github.com/aunysillyme/model-orchestrator/compare/v0.1.18...v0.1.19
|
|
286
321
|
[0.1.18]: https://github.com/aunysillyme/model-orchestrator/compare/v0.1.17...v0.1.18
|
|
287
322
|
[0.1.17]: https://github.com/aunysillyme/model-orchestrator/compare/v0.1.16...v0.1.17
|
|
288
323
|
[0.1.16]: https://github.com/aunysillyme/model-orchestrator/compare/v0.1.15...v0.1.16
|
package/README.md
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/model-orchestrator) [](https://github.com/aunysillyme/model-orchestrator/actions/workflows/test.yml) [](LICENSE) [](package.json)
|
|
4
4
|
|
|
5
|
-
**Route every task to the right model, agent or LLM, and spend fewer tokens.** A model orchestrator for AI coding agents and LLMs: Claude Code, Codex, Gemini, Grok, Qwen, Ollama. One installer asks what you have access to and writes routing rules, subagents and a CLI runner for exactly that setup, from one chat app to several agent CLIs or a virtual machine. Routing rules tell your agent which model, subagent or CLI to use for each task, so small work goes to cheap tiers and fewer tokens go to frontier models; the runner executes the lane it is given. On Claude Code it also delegates execution to subagents by default, with two hooks that inject the routing table every turn.
|
|
5
|
+
**Route every task to the right model, agent or LLM, and spend fewer tokens.** A model orchestrator for AI coding agents and LLMs: Claude Code, Codex, Gemini, Grok, Qwen, Ollama. One installer asks what you have access to and writes routing rules, subagents and a CLI runner for exactly that setup, from one chat app to several agent CLIs or a virtual machine. Routing rules tell your agent which model, subagent or CLI to use for each task, so small work goes to cheap tiers and fewer tokens go to frontier models; the runner executes the lane it is given. On Claude Code it also delegates execution to subagents by default, with two hooks that inject the routing table every turn, and the hooks and subagents also install as a [Claude Code plugin](#claude-code-plugin).
|
|
6
6
|
|
|
7
7
|
## At a glance
|
|
8
8
|
|
|
9
9
|
- **What it is:** routing rules, subagent definitions and a CLI lane runner (`cli-run`) for the AI tools you already pay for.
|
|
10
10
|
- **What it is not:** a proxy, a gateway or an API router. It does not automatically compare prices or select models; your agent follows the rules and chooses.
|
|
11
11
|
- **Install:** `npx model-orchestrator` (interactive), or headless from a script or an agent: `npx model-orchestrator --yes --level 2 --ais claude-code,codex --project . --dir ./ai-orchestrator`.
|
|
12
|
+
- **Claude Code plugin:** `/plugin marketplace add aunysillyme/model-orchestrator`, then `/plugin install model-orchestrator@model-orchestrator`. The routing rules still come from the installer; see [Claude Code plugin](#claude-code-plugin).
|
|
12
13
|
- **Use it when:** you run more than one model or agent and want each task sent to the smallest one that can do it well.
|
|
13
14
|
- **What it saves:** frontier-model tokens. Bulk work, reading and checks go to fast tiers; the expensive tier is kept for planning and judgment.
|
|
14
15
|
- **For agents:** [`llms.txt`](llms.txt) summarizes the package and links every doc; [`AGENTS.md`](AGENTS.md) has the headless commands.
|
|
@@ -90,6 +91,22 @@ npx model-orchestrator --yes --level 2 --ais claude-code,codex --project ~/my-ap
|
|
|
90
91
|
npx model-orchestrator --yes --level 2 --ais claude-code,codex,grok --primary claude-code --dir ./ai-orchestrator --project . --update-docs # added a lane: regenerate the docs you never edited
|
|
91
92
|
```
|
|
92
93
|
|
|
94
|
+
## Claude Code plugin
|
|
95
|
+
|
|
96
|
+
The Claude Code hooks and subagents also ship as a plugin, so they install and update through Claude Code itself instead of a settings snippet you merge by hand:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
/plugin marketplace add aunysillyme/model-orchestrator
|
|
100
|
+
/plugin install model-orchestrator@model-orchestrator
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- **What it ships:** `route-gate.mjs` (UserPromptSubmit, plus a one-line notice at session start when the project has no rules), `subagent-context.mjs` (SubagentStart), and the eight subagents, each with an explicit tool list. Agents load namespaced, as `model-orchestrator:builder`.
|
|
104
|
+
- **What it still needs from the installer:** the routing rules. A plugin runs no install step, so it reads the installer's default locations, `ai-orchestrator/ROUTING.md` then `ai-orchestrator/ORCHESTRATOR.md`, and names `npx model-orchestrator` when neither exists. A project installed with a different `--dir` should wire the installer's rendered hooks instead.
|
|
105
|
+
- **What it leaves out:** `route-metrics.mjs`, the routing log. It writes to disk and the plugin ships only hooks that read, so `npx model-orchestrator` is how you get it.
|
|
106
|
+
- **How it is kept honest:** `plugin/` is generated from `templates/` by `npm run gen:plugin`, and `test/plugin.test.js` fails when the committed bundle drifts, when a hook gains a network call, a write or a subprocess, or when an agent loses its tool list. The bundle passes `claude plugin validate --strict`, the check Anthropic's community marketplace review runs on every submission.
|
|
107
|
+
|
|
108
|
+
Details, including running the plugin next to an npm install: [plugin/README.md](plugin/README.md).
|
|
109
|
+
|
|
93
110
|
## What gets written (level 3, everything)
|
|
94
111
|
|
|
95
112
|
```
|
|
@@ -117,6 +134,7 @@ ai-orchestrator/
|
|
|
117
134
|
| [`src/`](src/README.md) | the catalog, the pure planner, detection, rendering |
|
|
118
135
|
| [`templates/`](templates/README.md) | everything the installer can write, by level, plus `tools/` for companions |
|
|
119
136
|
| [`docs/`](docs/README.md) | the three parts and the catalog |
|
|
137
|
+
| [`plugin/`](plugin/README.md) | the Claude Code plugin, generated from `templates/` by `npm run gen:plugin`; `.claude-plugin/marketplace.json` at the root lists it |
|
|
120
138
|
| [`test/`](test/README.md) | `npm test`: judges proven to go red, catalog integrity, planner, end-to-end install in a temp dir; `.github/workflows/test.yml` runs it on Ubuntu, macOS and Windows, Node 18/20/22 |
|
|
121
139
|
|
|
122
140
|
## What is enforced, what is delegated, what is an instruction
|
|
@@ -260,7 +278,7 @@ Yes. `--yes` with `--level`, `--ais` and `--project` runs headless, `--dry-run`
|
|
|
260
278
|
|
|
261
279
|
## Requirements
|
|
262
280
|
|
|
263
|
-
Node 18 or newer. No dependencies. Works on macOS and Linux; the level 3 box templates assume Ubuntu. Windows: CI runs the suite on `windows-latest` (Node 18, 20, 22), including lane execution end to end through `cli-run` against a fake CLI installed the same way npm installs a real one (a `.cmd` shim). `cli-run` never runs a lane through `cmd.exe` when it can avoid it: it resolves the shim to the Node script underneath and spawns Node directly, so a prompt reaching a real lane never passes through a Windows shell. A `.cmd` or `.bat` lane that cannot be resolved that way (an old or hand-edited shim) is refused with exit 13 and a message saying how to fix it, rather than run through `cmd.exe`: a batch file re-reads its arguments after `cmd.exe` has parsed them once, and no escaping fully contains a prompt through both passes. Install, detection, the hooks and `cli-run`'s `taskkill` tree kill are tested on Windows too, including SIGTERM/SIGINT to the wrapper (Windows has no OS-level signals: both terminate it unconditionally, verified there rather than treated the same as POSIX).
|
|
281
|
+
Node 18 or newer. No dependencies. Works on macOS and Linux; the level 3 box templates assume Ubuntu. Windows: CI runs the suite on `windows-latest` (Node 18, 20, 22), including lane execution end to end through `cli-run` against a fake CLI installed the same way npm installs a real one (a `.cmd` shim). `cli-run` never runs a lane through `cmd.exe` when it can avoid it: it resolves the shim to the Node script underneath and spawns Node directly, so a prompt reaching a real lane never passes through a Windows shell. A `.cmd` or `.bat` lane that cannot be resolved that way (an old or hand-edited shim) is refused with exit 13 and a message saying how to fix it, rather than run through `cmd.exe`: a batch file re-reads its arguments after `cmd.exe` has parsed them once, and no escaping fully contains a prompt through both passes. Install, detection, the hooks and `cli-run`'s `taskkill` tree kill are tested on Windows too, including SIGTERM/SIGINT to the wrapper (Windows has no OS-level signals: both terminate it unconditionally, verified there rather than treated the same as POSIX). Four narrow skips remain on Windows, each for a POSIX behavior the OS or the CI shell genuinely does not have, and each named here because a test that is quietly skipped reads as a test that passed: `statSync().mode`'s executable bit (NTFS has none, so that one assertion is conditional inside a test that otherwise runs everywhere); a lane dying mid-run from a real POSIX signal (a real Windows lane cannot die "by signal"); running `weekly-audit.sh`'s watchdog functions for real under Git Bash's job control, both the end-to-end run and the `bounded()` timeout check (the script itself only ever runs on the Ubuntu box it targets); and a `mkfifo` FIFO at the rules path, the one case that proves `route-gate.mjs` cannot HANG on a non-regular file, since Windows has no `mkfifo` to build one (the guard behind it is covered on every OS by a directory at the same path). The list is not prose on trust: `test/prose.test.js` counts every `skip:` in the suite and fails if one of them is not documented here.
|
|
264
282
|
|
|
265
283
|
**Privacy.** The installer sends no telemetry and makes no network call of its own once it is running. Two things around that are worth being exact about:
|
|
266
284
|
|
package/llms.txt
CHANGED
|
@@ -6,6 +6,8 @@ Install and run: `npx model-orchestrator` (interactive), or headless: `npx model
|
|
|
6
6
|
|
|
7
7
|
Levels: 1 beginner (one agent or chat app), 2 intermediate (several agent CLIs, each called through `cli-run`), 3 advanced (adds a virtual machine with a gateway and a scheduled audit job). On Claude Code the install also delegates execution to subagents by default and ships two hooks (UserPromptSubmit, SubagentStart) that inject the routing table every turn.
|
|
8
8
|
|
|
9
|
+
Claude Code plugin: `/plugin marketplace add aunysillyme/model-orchestrator`, then `/plugin install model-orchestrator@model-orchestrator`. It installs the two hooks and eight subagents; the routing rules still come from `npx model-orchestrator`, and the routing log (`route-metrics.mjs`) is npm-only.
|
|
10
|
+
|
|
9
11
|
## Docs
|
|
10
12
|
|
|
11
13
|
- [README](https://github.com/aunysillyme/model-orchestrator/blob/main/README.md): what it writes, flags, principles, what is enforced versus instructed
|
|
@@ -16,6 +18,7 @@ Levels: 1 beginner (one agent or chat app), 2 intermediate (several agent CLIs,
|
|
|
16
18
|
|
|
17
19
|
## Reference
|
|
18
20
|
|
|
21
|
+
- [Claude Code plugin](https://github.com/aunysillyme/model-orchestrator/blob/main/plugin/README.md): installing the hooks and subagents with `/plugin install`, what the plugin reads, and what it leaves to the installer
|
|
19
22
|
- [CLI runner](https://github.com/aunysillyme/model-orchestrator/blob/main/bin/README.md): `cli-run` lanes, exit codes, the route logged per run
|
|
20
23
|
- [Templates](https://github.com/aunysillyme/model-orchestrator/blob/main/templates/README.md): the routing, tiers, task bundle and protocol files the installer renders
|
|
21
24
|
- [Changelog](https://github.com/aunysillyme/model-orchestrator/blob/main/CHANGELOG.md): every release and the issue behind each fix
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "model-orchestrator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"description": "Model orchestrator for AI coding agents and LLMs: Claude Code, Codex, Gemini, Grok, Qwen, Ollama. Routing rules tell your agent which model, subagent or CLI to use for each task, so small work goes to cheap tiers and fewer tokens go to frontier models. One installer, plus a CLI runner that logs every route.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"test": "node --test",
|
|
25
25
|
"prepublishOnly": "npm test",
|
|
26
26
|
"dry-run": "node bin/cli.js --yes --level 2 --ais claude-code,codex,grok --dir ./tmp-dry-run --dry",
|
|
27
|
-
"gen:catalog": "node scripts/gen-catalog.js"
|
|
27
|
+
"gen:catalog": "node scripts/gen-catalog.js",
|
|
28
|
+
"gen:plugin": "node scripts/gen-plugin.js"
|
|
28
29
|
},
|
|
29
30
|
"engines": {
|
|
30
31
|
"node": ">=18"
|
package/scripts/README.md
CHANGED
|
@@ -3,3 +3,4 @@
|
|
|
3
3
|
| File | Job |
|
|
4
4
|
|---|---|
|
|
5
5
|
| `gen-catalog.js` | regenerates `docs/catalog.md` AND the vendor compatibility table in `README.md` (between the `vendor-table` markers) from `src/catalog.js`; `npm run gen:catalog`. `test/catalog.test.js` fails if either generated surface disagrees with the catalog. |
|
|
6
|
+
| `gen-plugin.js` | regenerates the Claude Code plugin bundle in `plugin/` (agents, the two read-only hooks, `plugin.json`, `LICENSE`) from `templates/`, using the plan in `src/plugin.js`; `npm run gen:plugin`. `test/plugin.test.js` fails if the committed bundle disagrees. `plugin/README.md` and `plugin/hooks/hooks.json` are hand-owned. |
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Regenerates the Claude Code plugin bundle under plugin/ from the installer's
|
|
3
|
+
// templates (src/plugin.js has the plan). The test suite checks the committed
|
|
4
|
+
// bundle matches, so an agent or hook edited in templates/ cannot ship to npm
|
|
5
|
+
// users and silently not to plugin users.
|
|
6
|
+
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
7
|
+
import { join, dirname } from 'node:path';
|
|
8
|
+
import { planPluginFiles, PLUGIN_DIR } from '../src/plugin.js';
|
|
9
|
+
|
|
10
|
+
const files = planPluginFiles();
|
|
11
|
+
for (const f of files) {
|
|
12
|
+
const abs = join(PLUGIN_DIR, ...f.rel.split('/'));
|
|
13
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
14
|
+
writeFileSync(abs, f.content);
|
|
15
|
+
}
|
|
16
|
+
console.log('plugin/ regenerated: ' + files.length + ' files');
|
package/src/README.md
CHANGED
|
@@ -5,5 +5,6 @@
|
|
|
5
5
|
| `catalog.js` | the single list of levels and AIs. Add an AI here and the prompts, docs tables, delegation matrix, gateway config and installer all pick it up. Nothing else lists AIs. |
|
|
6
6
|
| `detect.js` | PATH lookup for a binary, plus the few places vendor installers drop binaries without touching PATH. No shell-outs. |
|
|
7
7
|
| `install.js` | pure planner: turns (level, selection, primary) into a list of files to write, rendering templates and computing every generated table. `writeFiles` is the only thing that touches disk. `activationSteps()` and `snippetFor()` live here so the terminal summary and the generated README render the same list. `subagentsLoadRules(primary)` gates every delegate-by-default render var (builder-by-default wording, the route-gate table, the inline-threshold note) on the one verified premise: a claude-code subagent loads CLAUDE.md. |
|
|
8
|
+
| `plugin.js` | the plan for the Claude Code plugin bundle in `plugin/`: `planPluginFiles()` renders the claude-code agents and the two read-only hooks from the same templates `install.js` uses, with plugin render vars (the installer's default rules paths, the setup hint for a project with no rules) instead of per-install ones. Pure; `scripts/gen-plugin.js` writes it and `test/plugin.test.js` checks the committed copy. |
|
|
8
9
|
| `prompt.js` | line-buffered questions for the interactive path; piped answers are queued, EOF mid-prompt aborts instead of confirming a write. |
|
|
9
10
|
| `render.js` | `{{KEY}}` substitution. Throws on an unknown key, so a template typo fails the test suite instead of shipping a literal placeholder. |
|
package/src/install.js
CHANGED
|
@@ -532,7 +532,14 @@ function vars(opts) {
|
|
|
532
532
|
AGENTS_LIST_LINE: claudeAgentIds().map((id) => '`' + id + '`').join(', '),
|
|
533
533
|
RULES_FILE_REL: rulesFileRel,
|
|
534
534
|
RULES_FILE_REL_JSON: JSON.stringify(rulesFileRel),
|
|
535
|
-
TASK_BUNDLE_REL_JSON: JSON.stringify(taskBundleRel)
|
|
535
|
+
TASK_BUNDLE_REL_JSON: JSON.stringify(taskBundleRel),
|
|
536
|
+
// route-gate.mjs takes a candidate list so the plugin bundle (src/plugin.js)
|
|
537
|
+
// can render the installer's default locations from the same template. An
|
|
538
|
+
// install knows its one rules file, and wrote it, so it needs no hint.
|
|
539
|
+
RULES_CANDIDATES_JSON: JSON.stringify([rulesFileRel]),
|
|
540
|
+
SETUP_HINT_JSON: JSON.stringify(''),
|
|
541
|
+
SETUP_NOTICE_JSON: JSON.stringify(''),
|
|
542
|
+
CONTEXT_SUFFIX_JSON: JSON.stringify('')
|
|
536
543
|
};
|
|
537
544
|
}
|
|
538
545
|
|
package/src/plugin.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The Claude Code plugin bundle under plugin/. Every file this plans is
|
|
2
|
+
// generated from the templates the installer renders, so the plugin carries
|
|
3
|
+
// no second copy of an agent or a hook: `npm run gen:plugin` writes them and
|
|
4
|
+
// test/plugin.test.js fails when the committed bundle drifts from this plan.
|
|
5
|
+
// plugin/README.md and plugin/hooks/hooks.json are plugin-only and hand-owned.
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { join, dirname } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { render } from './render.js';
|
|
10
|
+
import { TEMPLATES, GENERATOR_VERSION, claudeAgentIds } from './install.js';
|
|
11
|
+
|
|
12
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
export const ROOT = join(HERE, '..');
|
|
14
|
+
export const PLUGIN_DIR = join(ROOT, 'plugin');
|
|
15
|
+
export const PLUGIN_NAME = 'model-orchestrator';
|
|
16
|
+
export const REPO_URL = 'https://github.com/aunysillyme/model-orchestrator';
|
|
17
|
+
|
|
18
|
+
// The installer's default --dir, and the rules file each level writes there:
|
|
19
|
+
// ROUTING.md at level 2 and 3, ORCHESTRATOR.md at level 1. Level 2 first, so
|
|
20
|
+
// a project that moved up a level reads the newer file.
|
|
21
|
+
export const DEFAULT_RULES = ['ai-orchestrator/ROUTING.md', 'ai-orchestrator/ORCHESTRATOR.md'];
|
|
22
|
+
export const DEFAULT_TASK_BUNDLE = 'ai-orchestrator/TASK_BUNDLE.md';
|
|
23
|
+
|
|
24
|
+
// route-metrics.mjs is not here on purpose: it appends a routing log to disk,
|
|
25
|
+
// and the plugin ships only hooks that read. `npx model-orchestrator` still
|
|
26
|
+
// installs it.
|
|
27
|
+
export const PLUGIN_HOOKS = ['route-gate.mjs', 'subagent-context.mjs'];
|
|
28
|
+
|
|
29
|
+
// Files in plugin/ that are written by hand, not by planPluginFiles().
|
|
30
|
+
export const HAND_OWNED = ['README.md', 'hooks/hooks.json'];
|
|
31
|
+
|
|
32
|
+
export function pluginVars() {
|
|
33
|
+
return {
|
|
34
|
+
PRIMARY_NAME: 'Claude Code',
|
|
35
|
+
RULES_FILE_REL: DEFAULT_RULES.join(' or '),
|
|
36
|
+
RULES_FILE_REL_JSON: JSON.stringify(DEFAULT_RULES[0] + ' (' + DEFAULT_RULES[1] + ' on a level 1 install)'),
|
|
37
|
+
TASK_BUNDLE_REL_JSON: JSON.stringify(DEFAULT_TASK_BUNDLE),
|
|
38
|
+
RULES_CANDIDATES_JSON: JSON.stringify(DEFAULT_RULES),
|
|
39
|
+
SETUP_HINT_JSON: JSON.stringify(
|
|
40
|
+
'This project has no model-orchestrator routing rules yet. Running `npx model-orchestrator` in the project root writes them (' +
|
|
41
|
+
DEFAULT_RULES[0] +
|
|
42
|
+
'), and this hook reads them from the next prompt on. Until then, pick the lane yourself, and name that command if the user asks about routing.'
|
|
43
|
+
),
|
|
44
|
+
SETUP_NOTICE_JSON: JSON.stringify(
|
|
45
|
+
'model-orchestrator: this project has no routing rules yet, so the plugin has no routing table to inject. Run `npx model-orchestrator` in the project root to write them; the plugin reads them from your next prompt.'
|
|
46
|
+
),
|
|
47
|
+
CONTEXT_SUFFIX_JSON: JSON.stringify(
|
|
48
|
+
'\nThe model-orchestrator plugin installs the agents named above as model-orchestrator:<name>, for example model-orchestrator:builder.'
|
|
49
|
+
)
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function pluginManifest() {
|
|
54
|
+
const n = claudeAgentIds().length;
|
|
55
|
+
return {
|
|
56
|
+
name: PLUGIN_NAME,
|
|
57
|
+
version: GENERATOR_VERSION,
|
|
58
|
+
description:
|
|
59
|
+
'Routing for Claude Code: a hook injects your project\'s routing table on every prompt, so each task goes to the right subagent tier and fewer tokens go to the most expensive model. Ships ' +
|
|
60
|
+
n +
|
|
61
|
+
' subagents across three model tiers.',
|
|
62
|
+
author: { name: 'model-orchestrator maintainers', url: REPO_URL },
|
|
63
|
+
homepage: REPO_URL + '#readme',
|
|
64
|
+
repository: REPO_URL,
|
|
65
|
+
license: 'MIT',
|
|
66
|
+
keywords: ['routing', 'subagents', 'hooks', 'model-router', 'token-optimization', 'delegation']
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Pure: reads templates, writes nothing. Paths are posix, relative to plugin/.
|
|
71
|
+
export function planPluginFiles() {
|
|
72
|
+
const v = pluginVars();
|
|
73
|
+
const files = [];
|
|
74
|
+
files.push({ rel: '.claude-plugin/plugin.json', content: JSON.stringify(pluginManifest(), null, 2) + '\n' });
|
|
75
|
+
for (const id of claudeAgentIds()) {
|
|
76
|
+
files.push({ rel: 'agents/' + id + '.md', content: render(readFileSync(join(TEMPLATES, 'agents', 'claude-code', id + '.md'), 'utf8'), v) });
|
|
77
|
+
}
|
|
78
|
+
for (const hook of PLUGIN_HOOKS) {
|
|
79
|
+
files.push({ rel: 'hooks/' + hook, content: render(readFileSync(join(TEMPLATES, 'agents', 'snippets', hook), 'utf8'), v) });
|
|
80
|
+
}
|
|
81
|
+
files.push({ rel: 'LICENSE', content: readFileSync(join(ROOT, 'LICENSE'), 'utf8') });
|
|
82
|
+
return files;
|
|
83
|
+
}
|
|
@@ -14,3 +14,5 @@ One per tier, plus two checks and two agents with no file-editing tools: `findin
|
|
|
14
14
|
| reader | fast | haiku | low | reads and digests many files or notes; read-only |
|
|
15
15
|
|
|
16
16
|
Aliases resolve to the newest model in each family, so a version bump needs no edit here. Each agent carries its own token-discipline rule; the `effort` field is the third cost lever. None of `done-verifier`, `finding-verifier`, `code-reviewer` or `reader` carries `Write` or `Edit` in its `tools:` line. `reader` is read-only by tool grant as well: it carries no `Bash`. `done-verifier`, `finding-verifier` and `code-reviewer` do carry `Bash`, for their probes and checks (`git log`, `grep`, `wc -l`, `test -f`); nothing in that grant stops any of them from running a command that changes state, so staying read-only there is a rule in each one's prompt, not a restriction on the tool, and each file says so.
|
|
17
|
+
|
|
18
|
+
Every agent names its tools explicitly, so none inherits every tool the session has: `builder` carries `Read, Write, Edit, Glob, Grep, Bash` (it changes files and runs checks), `deep-planner` carries `Read, Glob, Grep` (it plans and never edits), and `live-researcher` carries `WebSearch, WebFetch` (it answers from the web, not local files). The same files ship in the Claude Code plugin under `plugin/agents/`, generated from this folder.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: builder
|
|
3
3
|
description: Executes builds by default on this router, including the main build, from a brief the orchestrator wrote. Use for writing code, editing files, wiring configs, running commands, and implementing a plan the orchestrator briefed. Do not use for open-ended architecture questions or bulk classification; those still go to deep-planner or bulk-worker.
|
|
4
|
+
tools: Read, Write, Edit, Glob, Grep, Bash
|
|
4
5
|
model: sonnet
|
|
5
6
|
effort: high
|
|
6
7
|
---
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: deep-planner
|
|
3
3
|
description: Ambiguous or high-stakes thinking. Use for architecture design, strategy, planning multi-step projects, hard debugging where the cause is unknown, and any "figure out what to even do" request. Do not use for well-specified execution or bulk work.
|
|
4
|
+
tools: Read, Glob, Grep
|
|
4
5
|
model: opus
|
|
5
6
|
effort: xhigh
|
|
6
7
|
---
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: live-researcher
|
|
3
3
|
description: Real-time information. Use for anything that needs current data such as latest news, current API docs or pricing, or recent events. Do not use for questions answerable from local files or general knowledge.
|
|
4
|
+
tools: WebSearch, WebFetch
|
|
4
5
|
model: sonnet
|
|
5
6
|
effort: medium
|
|
6
7
|
---
|
|
@@ -14,36 +14,93 @@
|
|
|
14
14
|
import { statSync, openSync, readSync, closeSync, realpathSync } from 'node:fs';
|
|
15
15
|
import { join, isAbsolute } from 'node:path';
|
|
16
16
|
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// 2+ at ROUTING.md, and a --dir outside the project
|
|
20
|
-
// path instead of a relative one.
|
|
21
|
-
|
|
17
|
+
// One template, two renders. The installer renders a one-element list from
|
|
18
|
+
// the level and directory the user chose: a level 1 install points it at
|
|
19
|
+
// ORCHESTRATOR.md, level 2+ at ROUTING.md, and a --dir outside the project
|
|
20
|
+
// resolves to an absolute path instead of a relative one. The Claude Code
|
|
21
|
+
// plugin (plugin/, generated by scripts/gen-plugin.js) has no install step to
|
|
22
|
+
// render from, so it lists the installer's default locations and reads the
|
|
23
|
+
// first one that exists.
|
|
24
|
+
const RULES_CANDIDATES = {{RULES_CANDIDATES_JSON}};
|
|
25
|
+
const RULES_FILE_REL = RULES_CANDIDATES.join(' or ');
|
|
26
|
+
// Empty in an installer render, whose rules file was written by the same run.
|
|
27
|
+
// The plugin renders a next step for a project that has no rules yet, so a
|
|
28
|
+
// missing file is never a silent no-op: SETUP_HINT goes to the model on every
|
|
29
|
+
// prompt, SETUP_NOTICE to the user once, at session start.
|
|
30
|
+
const SETUP_HINT = {{SETUP_HINT_JSON}};
|
|
31
|
+
const SETUP_NOTICE = {{SETUP_NOTICE_JSON}};
|
|
32
|
+
// Appended after the table. Empty in an installer render.
|
|
33
|
+
const CONTEXT_SUFFIX = {{CONTEXT_SUFFIX_JSON}};
|
|
34
|
+
// Passed only by the plugin's SessionStart entry in hooks/hooks.json.
|
|
35
|
+
const SESSION_START = process.argv.includes('--session-start');
|
|
22
36
|
|
|
23
37
|
const MAX_READ = 64 * 1024; // bounded read: this is a rules file, not a log
|
|
24
38
|
const MAX_CONTEXT = 4000; // bounded injection: a table, not the whole file
|
|
39
|
+
// Bounded output, whatever produced it. The table is capped above, but a
|
|
40
|
+
// fallback or missing-rules message embeds resolved paths and error text
|
|
41
|
+
// whose length the project directory controls, and Claude Code caps hook
|
|
42
|
+
// output strings at 10,000 characters. Every string this hook emits passes
|
|
43
|
+
// through bound().
|
|
44
|
+
const MAX_OUTPUT = 8000;
|
|
25
45
|
const STDIN_DRAIN_MS = 250; // hard cap: never let an open, never-closed stdin pipe hold this hook open
|
|
26
46
|
const START = '<!-- route-gate:start -->';
|
|
27
47
|
const END = '<!-- route-gate:end -->';
|
|
28
48
|
|
|
49
|
+
function bound(text) {
|
|
50
|
+
return text.length > MAX_OUTPUT ? text.slice(0, MAX_OUTPUT - 3) + '...' : text;
|
|
51
|
+
}
|
|
52
|
+
|
|
29
53
|
function fallback(reason) {
|
|
30
54
|
return 'route-gate: ' + reason + '. Pick the lane before acting: read ' + RULES_FILE_REL + ' yourself.';
|
|
31
55
|
}
|
|
32
56
|
|
|
33
|
-
function
|
|
34
|
-
if (isAbsolute(RULES_FILE_REL)) return RULES_FILE_REL;
|
|
57
|
+
function projectRoot() {
|
|
35
58
|
const projectDir = process.env.CLAUDE_PROJECT_DIR;
|
|
36
59
|
if (!projectDir) return null;
|
|
37
60
|
// Resolve through whatever part of the project dir already exists, so a
|
|
38
61
|
// symlinked project folder still resolves to the real path the rules file
|
|
39
62
|
// was written under.
|
|
40
|
-
let root = projectDir;
|
|
41
63
|
try {
|
|
42
|
-
|
|
64
|
+
return realpathSync(projectDir);
|
|
43
65
|
} catch {
|
|
44
|
-
|
|
66
|
+
return projectDir; // keep the unresolved value; the read below reports the real failure
|
|
45
67
|
}
|
|
46
|
-
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolveRulesPath(rel, root) {
|
|
71
|
+
if (isAbsolute(rel)) return rel;
|
|
72
|
+
return root ? join(root, rel) : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ENOENT and ENOTDIR mean "nothing here, try the next candidate". Anything
|
|
76
|
+
// else (a directory, a FIFO, a permission error) means something IS at that
|
|
77
|
+
// path, so it is chosen and readBounded reports the real problem, rather than
|
|
78
|
+
// this hook quietly reading a different file than the one the project has.
|
|
79
|
+
function present(path) {
|
|
80
|
+
try {
|
|
81
|
+
statSync(path);
|
|
82
|
+
return true;
|
|
83
|
+
} catch (e) {
|
|
84
|
+
const code = e && e.code;
|
|
85
|
+
return !(code === 'ENOENT' || code === 'ENOTDIR');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// { path } to read, { error } when the rules cannot be located at all, or
|
|
90
|
+
// { missing } (every path looked at) when a multi-candidate render finds
|
|
91
|
+
// nothing. A one-candidate render never returns { missing }: it keeps the
|
|
92
|
+
// installer's original behaviour exactly, and the read reports what is wrong.
|
|
93
|
+
function locateRules() {
|
|
94
|
+
const root = projectRoot();
|
|
95
|
+
const paths = [];
|
|
96
|
+
for (const rel of RULES_CANDIDATES) {
|
|
97
|
+
const p = resolveRulesPath(rel, root);
|
|
98
|
+
if (!p) return { error: 'CLAUDE_PROJECT_DIR is not set, so ' + RULES_FILE_REL + ' could not be located' };
|
|
99
|
+
paths.push(p);
|
|
100
|
+
}
|
|
101
|
+
if (paths.length === 1) return { path: paths[0] };
|
|
102
|
+
const found = paths.find(present);
|
|
103
|
+
return found ? { path: found } : { missing: paths };
|
|
47
104
|
}
|
|
48
105
|
|
|
49
106
|
// Bounded, regular-file-only read. statSync (not lstatSync) follows a
|
|
@@ -76,21 +133,31 @@ function readBounded(path) {
|
|
|
76
133
|
}
|
|
77
134
|
|
|
78
135
|
function computeContext() {
|
|
79
|
-
const
|
|
80
|
-
if (
|
|
136
|
+
const loc = locateRules();
|
|
137
|
+
if (loc.error) return fallback(loc.error);
|
|
138
|
+
if (loc.missing) return 'route-gate: no routing rules file at ' + loc.missing.join(' or ') + '. ' + SETUP_HINT;
|
|
81
139
|
|
|
82
140
|
let text;
|
|
83
141
|
try {
|
|
84
|
-
text = readBounded(path);
|
|
142
|
+
text = readBounded(loc.path);
|
|
85
143
|
} catch (e) {
|
|
86
144
|
return fallback((e && e.message) || String(e));
|
|
87
145
|
}
|
|
88
146
|
|
|
89
147
|
const s = text.indexOf(START);
|
|
90
148
|
const e = s === -1 ? -1 : text.indexOf(END, s);
|
|
91
|
-
if (s === -1 || e === -1) return fallback(path + ' has no route-gate block');
|
|
149
|
+
if (s === -1 || e === -1) return fallback(loc.path + ' has no route-gate block');
|
|
150
|
+
|
|
151
|
+
return text.slice(s, e + END.length).slice(0, MAX_CONTEXT) + CONTEXT_SUFFIX;
|
|
152
|
+
}
|
|
92
153
|
|
|
93
|
-
|
|
154
|
+
// The message a person sees at session start, or null when there is nothing
|
|
155
|
+
// to say: an installer render (no notice), rules that exist, or a project
|
|
156
|
+
// root that cannot be located.
|
|
157
|
+
function sessionStartNotice() {
|
|
158
|
+
if (!SETUP_NOTICE) return null;
|
|
159
|
+
const loc = locateRules();
|
|
160
|
+
return loc.missing ? SETUP_NOTICE : null;
|
|
94
161
|
}
|
|
95
162
|
|
|
96
163
|
// Drain stdin without ever blocking on it. A bare `readFileSync(0)` waits
|
|
@@ -130,20 +197,35 @@ function drainStdin(timeoutMs) {
|
|
|
130
197
|
});
|
|
131
198
|
}
|
|
132
199
|
|
|
133
|
-
let
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
200
|
+
let payload = null;
|
|
201
|
+
if (SESSION_START) {
|
|
202
|
+
let notice = null;
|
|
203
|
+
try {
|
|
204
|
+
notice = sessionStartNotice();
|
|
205
|
+
} catch {
|
|
206
|
+
notice = null; // a session-start notice is a courtesy; never fail a session over it
|
|
207
|
+
}
|
|
208
|
+
if (notice) payload = JSON.stringify({ systemMessage: bound(notice) });
|
|
209
|
+
} else {
|
|
210
|
+
let additionalContext;
|
|
211
|
+
try {
|
|
212
|
+
additionalContext = computeContext();
|
|
213
|
+
} catch (err) {
|
|
214
|
+
additionalContext = fallback('route-gate.mjs failed unexpectedly (' + ((err && err.message) || err) + ')');
|
|
215
|
+
}
|
|
216
|
+
payload = JSON.stringify({
|
|
142
217
|
hookSpecificOutput: {
|
|
143
218
|
hookEventName: 'UserPromptSubmit',
|
|
144
|
-
additionalContext
|
|
219
|
+
additionalContext: bound(additionalContext)
|
|
145
220
|
}
|
|
146
221
|
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
drainStdin(STDIN_DRAIN_MS).then(() => {
|
|
225
|
+
if (payload === null) {
|
|
226
|
+
process.exit(0);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
147
229
|
// Exit only after the write's callback fires, so a buffered write to a
|
|
148
230
|
// pipe (the common case on Windows, and possible anywhere output exceeds
|
|
149
231
|
// one write's worth) is not truncated by an exit that races ahead of it.
|