model-orchestrator 0.1.31 → 0.1.33

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/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ All notable changes to this project are documented here. The format follows [Kee
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.1.33] - 2026-09-24
8
+
9
+ ### Added
10
+
11
+ - **Opt-in snippet application.** `--apply-snippets` applies a replaceable Claude Code rules block and merges hooks with timestamped backups, validation before writes, dry-run previews and manual uninstall guidance. ([#41](https://github.com/aunysillyme/model-orchestrator/issues/41))
12
+ - **Model orchestrator or a model proxy.** README and `llms.txt` explain the request layer, when to pick each approach and how they compose. ([#44](https://github.com/aunysillyme/model-orchestrator/issues/44))
13
+
14
+ ### Fixed
15
+
16
+ - **Selected lane guidance.** Delegation rows and related cost and research advice render from selected catalog lane categories, keeping absent categories out of active picks. ([#42](https://github.com/aunysillyme/model-orchestrator/issues/42))
17
+ - **Rules folder relocation.** Project-contained snippets use relative paths; external rules paths carry relocation guidance. Installer hooks that read rules honour `MODEL_ORCHESTRATOR_RULES_DIR`, and the generated README names the path case. ([#43](https://github.com/aunysillyme/model-orchestrator/issues/43))
18
+
19
+ ## [0.1.32] - 2026-09-23
20
+
21
+ ### Changed
22
+
23
+ - **README shows what the install gives you, not a file dump.** The 50-line dry-run file list under the demo became a six-row table naming each part and what it does for you; the preview command stays, and the full file list is one link away in `docs/install.md`.
24
+
7
25
  ## [0.1.31] - 2026-09-23
8
26
 
9
27
  ### Added
@@ -427,7 +445,9 @@ First release.
427
445
  - Tests: a case per fix, judges proven to go red, mutation checks; `npm test` prints the current count.
428
446
  - 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.
429
447
 
430
- [Unreleased]: https://github.com/aunysillyme/model-orchestrator/compare/v0.1.31...HEAD
448
+ [Unreleased]: https://github.com/aunysillyme/model-orchestrator/compare/v0.1.33...HEAD
449
+ [0.1.33]: https://github.com/aunysillyme/model-orchestrator/compare/v0.1.32...v0.1.33
450
+ [0.1.32]: https://github.com/aunysillyme/model-orchestrator/compare/v0.1.31...v0.1.32
431
451
  [0.1.31]: https://github.com/aunysillyme/model-orchestrator/compare/v0.1.30...v0.1.31
432
452
  [0.1.30]: https://github.com/aunysillyme/model-orchestrator/compare/v0.1.29...v0.1.30
433
453
  [0.1.29]: https://github.com/aunysillyme/model-orchestrator/compare/v0.1.28...v0.1.29
package/README.md CHANGED
@@ -14,66 +14,25 @@ npx model-orchestrator
14
14
 
15
15
  <img src="docs/demo.gif" alt="A terminal running npx model-orchestrator with --dry: it prints the level, the AIs detected, both target folders and all 38 files it would write, then says nothing was written." width="100%" />
16
16
 
17
- A few questions, then 38 files for a three-AI setup. The same plan as text (paths shown relative to the project folder):
17
+ A few questions, then it writes the setup for the AIs you picked. For Claude Code, Codex and Grok that is 38 files:
18
18
 
19
- ```text
20
- Plan
21
- level 2 Intermediate
22
- access claude-code, codex, grok
23
- primary claude-code
24
- tools codecalc
25
- plans none stated
26
- folder ./ai-orchestrator
27
- project . (8 subagents + 3 hooks go here)
28
- files 38
29
- - README.md
30
- - TASK_BUNDLE.md
31
- - protocols/README.md
32
- - protocols/build-protocol.md
33
- - protocols/deep-research.md
34
- - protocols/docs-then-prove.md
35
- - protocols/gap-analysis.md
36
- - protocols/memory-and-record.md
37
- - protocols/numbers-and-logic.md
38
- - protocols/propagate.md
39
- - ORCHESTRATOR.md
40
- - [project] .claude/agents/builder.md
41
- - [project] .claude/agents/bulk-worker.md
42
- - [project] .claude/agents/code-reviewer.md
43
- - [project] .claude/agents/deep-planner.md
44
- - [project] .claude/agents/done-verifier.md
45
- - [project] .claude/agents/finding-verifier.md
46
- - [project] .claude/agents/live-researcher.md
47
- - [project] .claude/agents/reader.md
48
- - CLAUDE.snippet.md
49
- - [project] .claude/hooks/route-gate.mjs
50
- - [project] .claude/hooks/subagent-context.mjs
51
- - [project] .claude/hooks/route-metrics.mjs
52
- - settings.hooks.snippet.json
53
- - CODECALC.md
54
- - mcp/agy.mcp_config.json
55
- - mcp/codex.config.toml
56
- - mcp/mcpServers.json
57
- - mcp/vscode.mcp.json
58
- - mcp/zed.settings.json
59
- - CLI-RUN.md
60
- - DELEGATION_MATRIX.md
61
- - RESEARCH_TRIAGE.md
62
- - ROUTING.md
63
- - TIERS.md
64
- - bin/cli-run.mjs
65
- - bin/lanes.json
66
- - MANIFEST.json
67
-
68
- --dry: nothing written.
69
- ```
19
+ | Part | What it does for you |
20
+ |---|---|
21
+ | `ROUTING.md`, `TIERS.md`, `DELEGATION_MATRIX.md` | tell your agent which model or CLI handles each kind of task, and at what effort |
22
+ | `TASK_BUNDLE.md` and `protocols/` | the brief every hand-off carries, plus build, research, audit and record-keeping steps |
23
+ | 8 subagents in `.claude/agents/` | builder, planner, reviewer, researcher, bulk worker, reader and two verifiers, each with its own tools |
24
+ | 3 hooks in `.claude/hooks/` | put the routing table in front of your agent on every prompt and every subagent start, and log where work went |
25
+ | `bin/cli-run.mjs` | calls Codex, Grok and the other CLIs, and counts a run as done only when it returns a result |
26
+ | `mcp/` and `CODECALC.md` | ready-to-paste configs for the companion tools you chose |
70
27
 
71
- Run it yourself, in any folder:
28
+ Preview it in any folder, nothing is written:
72
29
 
73
30
  ```bash
74
31
  npx model-orchestrator --yes --level 2 --ais claude-code,codex,grok --primary claude-code --dir ./ai-orchestrator --project . --dry
75
32
  ```
76
33
 
34
+ Every file, folder by folder: [docs/install.md](docs/install.md#what-gets-written-level-3-everything).
35
+
77
36
  The recording above comes from the published package under `asciinema`, rendered with `agg`: `bash scripts/record-demo.sh`.
78
37
 
79
38
  - **What it is:** routing rules, subagent definitions and a CLI lane runner (`cli-run`) for the AI tools you already pay for.
@@ -81,6 +40,16 @@ The recording above comes from the published package under `asciinema`, rendered
81
40
  - **Use it when:** you run more than one model or agent and want the expensive tier kept for planning and judgment.
82
41
  - **For agents:** [`llms.txt`](llms.txt) summarizes the package and links every doc; [`AGENTS.md`](AGENTS.md) has the headless commands.
83
42
 
43
+ ## Model orchestrator or a model proxy
44
+
45
+ An HTTP proxy or gateway such as LiteLLM, Portkey, OpenRouter or claude-code-router
46
+ swaps the model per request underneath the agent.
47
+ model-orchestrator is an installer that writes routing rules, subagents, hooks
48
+ and a lane runner above the request layer for the coding agents and subscription CLIs you already pay for.
49
+ Pick a proxy for request-level model routing and a shared API entry point.
50
+ Pick model-orchestrator for task delegation across your agents, tiers and CLIs.
51
+ They compose: your agent follows the installed rules, and a proxy can route its API requests underneath.
52
+
84
53
  ## After you install
85
54
 
86
55
  For the Claude Code setup above, follow the activation summary from the project folder:
@@ -89,6 +58,8 @@ For the Claude Code setup above, follow the activation summary from the project
89
58
  2. **Hooks:** merge `ai-orchestrator/settings.hooks.snippet.json` into `.claude/settings.json` (create it if missing).
90
59
  3. **Smoke test:** run `node ./ai-orchestrator/bin/cli-run.mjs --doctor` to check the enabled lanes. Add `--run` to send each lane one tiny prompt.
91
60
 
61
+ Add `--apply-snippets` to apply the rules and hooks steps for a Claude Code primary. It is off by default. The installer replaces one marked block in `CLAUDE.md`, preserves the surrounding bytes, and merges hooks while keeping existing settings and avoiding duplicate commands with the same arguments. Existing files changed by the run get timestamped backups beside them (`<name>.bak-YYYYMMDDTHHMMSS`); every backup path is printed. Preview with `--apply-snippets --dry-run`. Invalid settings JSON stops the run before any writes. Other primaries get the snippet name to paste by hand.
62
+
92
63
  Run `claude` from the project folder to load the subagents. Follow the sign-in and companion-tool steps printed for your selection; the same steps are saved in `ai-orchestrator/README.md`.
93
64
 
94
65
  A real call through the lane runner, captured from a fresh install on 2026-09-23 (Codex CLI 0.154.0). Your agent sends a small read to Codex at low effort, and `cli-run` prints one status line with the route it used:
package/bin/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // model-orchestrator installer.
3
3
  // Asks which level you want and which AIs you have access to, then writes the
4
4
  // matching files into a folder. It never writes a secret, never runs a vendor
5
- // shell script, and never overwrites a file you already have unless --force.
5
+ // shell script. Existing documents are preserved unless an update is requested.
6
6
 
7
7
  import { stdin, stdout } from 'node:process';
8
8
  import { makeAsker } from '../src/prompt.js';
@@ -18,13 +18,14 @@ import { planFiles, writeFiles, resolveSelection, resolveTools, resolveApis, dir
18
18
  // shell, or fall back to the escaped cmd.exe path it also provides.
19
19
  import { windowsSpawnPlan } from './cli-run.mjs';
20
20
  import { uninstallFiles } from '../src/uninstall.js';
21
+ import { assertSnippetPrimary, planSnippetApplication } from '../src/apply-snippets.js';
21
22
 
22
23
  // One strict parse. Unknown flags, missing values and duplicates are usage
23
24
  // errors (exit 2) before anything is planned, so a typo like --dryy can never
24
25
  // turn a dry run into a real one.
25
26
  const SPEC = {
26
27
  level: 'value', ais: 'value', primary: 'value', dir: 'value', project: 'value', tools: 'value', apis: 'value', plans: 'value',
27
- yes: 'bool', force: 'bool', dry: 'bool', 'dry-run': 'bool', uninstall: 'bool', 'no-install': 'bool', 'no-tools': 'bool', 'no-apis': 'bool', 'effort-auto': 'bool', 'upgrade-runtime': 'bool', 'update-docs': 'bool', list: 'bool', help: 'bool', h: 'bool', version: 'bool', v: 'bool'
28
+ 'apply-snippets': 'bool', yes: 'bool', force: 'bool', dry: 'bool', 'dry-run': 'bool', uninstall: 'bool', 'no-install': 'bool', 'no-tools': 'bool', 'no-apis': 'bool', 'effort-auto': 'bool', 'upgrade-runtime': 'bool', 'update-docs': 'bool', list: 'bool', help: 'bool', h: 'bool', version: 'bool', v: 'bool'
28
29
  };
29
30
  export function parseArgs(argv) {
30
31
  const out = {};
@@ -112,6 +113,7 @@ Flags
112
113
  --project path the project root your agent runs from; subagent definitions go here (default: current directory,
113
114
  so set it: a run from your home folder otherwise drops the subagent files there)
114
115
  --yes skip confirmations
116
+ --apply-snippets apply Claude Code rules and hooks with timestamped backups (opt-in)
115
117
  --force overwrite every file that already exists, documents included
116
118
  --upgrade-runtime replace the runtime files (cli-run, the audit job, compose, gateway config, setup script) even
117
119
  when they cannot be verified as untouched; documents are still kept
@@ -193,6 +195,7 @@ async function main() {
193
195
  console.log(dry ? 'Uninstall preview (--dry): nothing changed.' : 'Uninstall complete.');
194
196
  for (const action of actions) console.log(action);
195
197
  console.log('\nManual steps: remove the pasted model-orchestrator block from CLAUDE.md and its merged hook entries from .claude/settings.json. Keep your other rules and hooks.');
198
+ console.log(`Applied rules use <!-- model-orchestrator:start --> and <!-- model-orchestrator:end -->. Backups stay beside the originals: ${join(project, 'CLAUDE.md.bak-YYYYMMDDTHHMMSS')} and ${join(project, '.claude', 'settings.json.bak-YYYYMMDDTHHMMSS')}. Review backups before restoring them; later edits may need to be kept.`);
196
199
  console.log('For another primary agent, remove its pasted activation block from its rules file.');
197
200
  return;
198
201
  }
@@ -366,7 +369,10 @@ async function main() {
366
369
  }
367
370
 
368
371
  // 5. Plan
369
- const files = planFiles({ level, selected, primary, dir, project, tools, apis, plans, effortAuto });
372
+ const applySnippets = flag('apply-snippets');
373
+ if (applySnippets) assertSnippetPrimary(primary);
374
+ const files = planFiles({ level, selected, primary, dir, project, tools, apis, plans, effortAuto, applySnippets });
375
+ if (applySnippets) files.push(...planSnippetApplication({ primary, project, files }));
370
376
  const lvl = LEVELS.find((l) => l.id === level);
371
377
  const agentFiles = files.filter((f) => f.root === 'project');
372
378
  const projectKinds = [
@@ -384,6 +390,12 @@ async function main() {
384
390
  }
385
391
  if (flag('dry') || flag('dry-run')) {
386
392
  for (const f of files) console.log(' - ' + (f.root === 'project' ? '[project] ' : '') + f.rel);
393
+ if (applySnippets) {
394
+ const preview = writeFiles(files, { dir, project, dry: true, force: flag('force'), upgradeRuntime: flag('upgrade-runtime'), updateDocs: flag('update-docs'), prevManifest: prev, backupExisting: true });
395
+ for (const path of preview.written) console.log(' would write ' + path);
396
+ for (const path of [...preview.skipped, ...preview.conflicts, ...preview.unverifiable, ...preview.docsConflict, ...preview.docsUnverifiable]) console.log(' would keep ' + path);
397
+ for (const path of preview.backups) console.log(' would back up ' + path);
398
+ }
387
399
  console.log('\n--dry: nothing written.');
388
400
  rl && rl.close();
389
401
  return;
@@ -405,7 +417,7 @@ async function main() {
405
417
 
406
418
  let written, skipped, upgraded, conflicts, unverifiable, docsUpdated, docsConflict, docsUnverifiable;
407
419
  try {
408
- ({ written, skipped, upgraded, conflicts, unverifiable, docsUpdated, docsConflict, docsUnverifiable } = writeFiles(files, { dir, project, force: flag('force'), upgradeRuntime: flag('upgrade-runtime'), updateDocs: flag('update-docs'), prevManifest: prev }));
420
+ ({ written, skipped, upgraded, conflicts, unverifiable, docsUpdated, docsConflict, docsUnverifiable } = writeFiles(files, { dir, project, force: flag('force'), upgradeRuntime: flag('upgrade-runtime'), updateDocs: flag('update-docs'), prevManifest: prev, backupExisting: applySnippets, onBackup: (path) => console.log(' backup ' + path) }));
409
421
  } catch (e) {
410
422
  if (e && e.code === 'PREFLIGHT') bad(e.message);
411
423
  throw e;
@@ -479,7 +491,7 @@ async function main() {
479
491
  // 7. Activation summary: writing the folder is half the job. Say exactly what
480
492
  // turns it on, in order, with one command that proves it. The generated
481
493
  // README renders this same array, so the two surfaces cannot disagree (#20).
482
- const steps = activationSteps({ level, selected, primary, tools, dir, project });
494
+ const steps = activationSteps({ level, selected, primary, tools, dir, project, applySnippets });
483
495
  console.log('\nTo activate, in order:');
484
496
  steps.forEach((st, i) => console.log(` ${i + 1}. ${st}`));
485
497
  console.log(`\nStart here: ${join(dir, 'README.md')} (written for level ${level} and the AIs you picked).`);
@@ -498,5 +510,5 @@ function isEntryPoint() {
498
510
  }
499
511
  if (isEntryPoint()) main().catch((e) => {
500
512
  console.error('model-orchestrator: ' + (e && e.message ? e.message : e));
501
- process.exit(e && ['EOF', 'UNINSTALL'].includes(e.code) ? 2 : 1);
513
+ process.exit(e && ['EOF', 'UNINSTALL', 'PREFLIGHT'].includes(e.code) ? 2 : 1);
502
514
  });
package/docs/install.md CHANGED
@@ -11,7 +11,22 @@ The installer asks a few things, then writes a folder:
11
11
  2. **Which AIs do you have access to?** (it marks the ones already on your PATH)
12
12
  3. **Which one is your primary agent?** (the one that runs the system)
13
13
 
14
- It never writes a secret, never runs a vendor shell script for you, and never overwrites a document you already have unless you pass `--force`. Two exceptions, both stated when they happen: `MANIFEST.json` and `bin/lanes.json` are machine-owned and rewritten on every run so a changed selection applies; runtime files (`cli-run`, the audit job, compose, gateway config, setup script) are upgraded when the installed copy matches the hash a previous run recorded, kept and reported as a conflict when you edited them, and kept as unverifiable when no manifest exists (`--upgrade-runtime` replaces runtime files only). The same hash rule is available for documents on request: `--update-docs` regenerates the documents a previous run wrote and nobody edited, so a changed selection reaches `ROUTING.md` and the delegation matrix without `--force`; edited documents are kept and named. Docs and protocols go to `--dir` (default `./ai-orchestrator`); subagent definitions (and, on Claude Code, three hook scripts) go to the project root your agent runs from (`--project`, default the current directory), because that is the only place Claude Code and Antigravity read them. It ends with an activation summary: what to copy where, which sign-ins, and one smoke command. Use `--uninstall` to remove unedited managed files, then remove your manually merged activation entries. See [Uninstall](#uninstall).
14
+ It never writes a secret, never runs a vendor shell script for you, and preserves existing documents by default. `--force` explicitly replaces them; `--apply-snippets` opts into the backed-up activation merge described below. Two exceptions, both stated when they happen: `MANIFEST.json` and `bin/lanes.json` are machine-owned and rewritten on every run so a changed selection applies; runtime files (`cli-run`, the audit job, compose, gateway config, setup script) are upgraded when the installed copy matches the hash a previous run recorded, kept and reported as a conflict when you edited them, and kept as unverifiable when no manifest exists (`--upgrade-runtime` replaces runtime files only). The same hash rule is available for documents on request: `--update-docs` regenerates the documents a previous run wrote and nobody edited, so a changed selection reaches `ROUTING.md` and the delegation matrix without `--force`; edited documents are kept and named. Docs and protocols go to `--dir` (default `./ai-orchestrator`); subagent definitions (and, on Claude Code, three hook scripts) go to the project root your agent runs from (`--project`, default the current directory), because that is the only place Claude Code and Antigravity read them. It ends with an activation summary: what to copy where, which sign-ins, and one smoke command. Use `--uninstall` to remove unedited managed files, then remove your manually merged activation entries. See [Uninstall](#uninstall).
15
+
16
+ ## Apply Claude Code snippets
17
+
18
+ Pass `--apply-snippets` with a Claude Code primary to apply both activation snippets. The flag is off by default; other primaries exit 2 and name the snippet to paste by hand.
19
+
20
+ - **Rules:** create `CLAUDE.md` if missing, or insert a block between `<!-- model-orchestrator:start -->` and `<!-- model-orchestrator:end -->`. Reruns replace that block in place, keeping every byte outside it. Incomplete or duplicate markers are refused before writing.
21
+ - **Settings:** create `.claude/settings.json` if missing, or merge the generated hooks while preserving existing keys and hooks. A command with the same arguments already present in that event is kept once. Unparseable JSON exits 2, names the file, and writes nothing anywhere.
22
+ - **Backups:** every existing file changed during an apply run gets a sibling `<name>.bak-YYYYMMDDTHHMMSS`, including generated files replaced on a rerun. Each path is printed. New and unchanged activation files need no backup; existing backups are preserved.
23
+ - **Preview:** `--apply-snippets --dry` and `--apply-snippets --dry-run` list writes, kept files and backup paths without changing the filesystem.
24
+ - **Activation:** the summary reports the applied rules and hooks, then lists sign-ins and verification. Start a fresh Claude Code session to verify the instructions loaded.
25
+ - **Revert:** uninstall leaves the marked block, settings and backups in place and names them in its manual steps. Review a backup before restoring it so later edits survive.
26
+
27
+ ```bash
28
+ npx model-orchestrator --yes --level 2 --ais claude-code,codex --primary claude-code --project . --dir ./ai-orchestrator --apply-snippets
29
+ ```
15
30
 
16
31
  ## Plans and automatic effort
17
32
 
@@ -23,10 +38,12 @@ An install has two targets, and a scripted run should set both.
23
38
  | Flag | Default | What lands there |
24
39
  |---|---|---|
25
40
  | `--dir` | `./ai-orchestrator` | the docs, protocols and (level 2+) `bin/cli-run.mjs`. Named after what it contains, not after this package, so a project can hold one without looking like a checkout of it. Pass `--dir ./model-orchestrator` if you prefer the package name. |
26
- | `--project` | the current directory | the subagent definitions, and the rules file your agent reads. Only Claude Code (`.claude/agents/`) and Antigravity (`.agents/agents/`) get files here, because that is the only place those CLIs look. Claude Code also gets three hook scripts in `.claude/hooks/`, wired by a settings snippet you merge yourself. |
41
+ | `--project` | the current directory | the subagent definitions, and the rules file your agent reads. Only Claude Code (`.claude/agents/`) and Antigravity (`.agents/agents/`) get files here, because that is the only place those CLIs look. Claude Code also gets three hook scripts in `.claude/hooks/`, wired by a settings snippet you merge yourself or apply with `--apply-snippets`. |
27
42
 
28
43
  `--project` defaulting to the current directory is the one that surprises people: run the command from your home folder with Claude Code as the primary and the subagent and hook files land in your home folder. The installer prints the resolved project path in the plan and says when you left it at the default. Set it.
29
44
 
45
+ Rules inside the project use project-relative snippet paths, so moving the whole project preserves them. Rules outside the project use absolute paths and carry a relocation note. After moving those rules, re-run the installer or set `MODEL_ORCHESTRATOR_RULES_DIR` for the installed rule-reading hooks, and update your agent instruction paths. An absolute override names the new folder; a relative override is relative to `CLAUDE_PROJECT_DIR`. `route-metrics` reads no rules and keeps its home-directory log. The separately installed Claude Code plugin keeps its existing default-path lookup.
46
+
30
47
  ## Non-interactive
31
48
 
32
49
  ```bash
@@ -62,7 +79,7 @@ npx model-orchestrator --uninstall --dir ./ai-orchestrator --project ./my-app
62
79
  - **Target paths:** `--dir` and `--project` must match the installation paths in the manifest. A mismatch exits 2 before removal.
63
80
  - **Preview:** `--dry` (or `--dry-run`) lists the planned removals and writes nothing.
64
81
 
65
- The command prints the remaining manual steps: remove the pasted model-orchestrator block from `CLAUDE.md` and its merged hook entries from `.claude/settings.json`. Keep your other rules and hooks. With another primary agent, remove its pasted activation block from the corresponding rules file.
82
+ The command prints the remaining manual steps: remove the pasted model-orchestrator block from `CLAUDE.md` and its merged hook entries from `.claude/settings.json`. Applied blocks use `<!-- model-orchestrator:start -->` and `<!-- model-orchestrator:end -->`. Timestamped `CLAUDE.md.bak-*` and `.claude/settings.json.bak-*` backups stay beside the originals for a reviewed restore. Keep your other rules and hooks. With another primary agent, remove its pasted activation block from the corresponding rules file.
66
83
 
67
84
  ## What gets written (level 3, everything)
68
85
 
package/llms.txt CHANGED
@@ -8,6 +8,8 @@ Levels: 1 beginner (one agent or chat app), 2 intermediate (several agent CLIs,
8
8
 
9
9
  Claude Code plugin: `/plugin marketplace add aunysillyme/model-orchestrator`, then `/plugin install model-orchestrator@model-orchestrator`. It installs the two read-only hooks (`route-gate`, `subagent-context`) and eight subagents; the routing rules still come from `npx model-orchestrator`, and the routing log (`route-metrics.mjs`) is npm-only.
10
10
 
11
+ Pick a model proxy (LiteLLM, Portkey, OpenRouter, claude-code-router) for per-request model routing underneath an agent; pick model-orchestrator for installed routing rules, subagents, hooks and a lane runner for coding agents and subscription CLIs. They compose.
12
+
11
13
  ## Docs
12
14
 
13
15
  - [README](https://github.com/aunysillyme/model-orchestrator/blob/main/README.md): what it is, a real dry-run plan, the levels, the AIs, the principles
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "model-orchestrator",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "description": "Model orchestrator for Claude Code, Codex, Antigravity (Google): routing rules so small work goes to cheap tiers and fewer tokens go to frontier models.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/README.md CHANGED
@@ -5,6 +5,7 @@
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
+ | `apply-snippets.js` | validates the two Claude Code activation targets before any writes, builds a replaceable marked block and merges hook entries. `writeFiles` applies these opt-in entries with backups and rollback; they stay outside the uninstall manifest. |
8
9
  | `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. |
9
10
  | `prompt.js` | line-buffered questions for the interactive path; piped answers are queued, EOF mid-prompt aborts instead of confirming a write. |
10
11
  | `render.js` | `{{KEY}}` substitution. Throws on an unknown key, so a template typo fails the test suite instead of shipping a literal placeholder. |
@@ -0,0 +1,81 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { preflight, snippetFor } from './install.js';
4
+
5
+ export const START = '<!-- model-orchestrator:start -->';
6
+ export const END = '<!-- model-orchestrator:end -->';
7
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
8
+ const refuse = (message) => Object.assign(new Error(message), { code: 'PREFLIGHT' });
9
+
10
+ function mergeHooks(settings, incoming, path) {
11
+ if (!object(settings) || (settings.hooks !== undefined && !object(settings.hooks))) {
12
+ throw refuse(`${path}: expected a JSON object with an optional hooks object`);
13
+ }
14
+ const hooks = { ...settings.hooks };
15
+ for (const [event, groups] of Object.entries(incoming)) {
16
+ const existing = hooks[event] === undefined ? [] : hooks[event];
17
+ if (!Array.isArray(existing) || existing.some((group) => !object(group) || !Array.isArray(group.hooks))) {
18
+ throw refuse(`${path}: hooks.${event} must contain hook groups`);
19
+ }
20
+ // A hook only counts as wired under the same matcher: the same command under a
21
+ // narrower matcher never fires for the tools the snippet's group targets.
22
+ const key = (group, hook) => JSON.stringify([group.matcher ?? '', hook.command, hook.args || []]);
23
+ const seen = new Set(existing.flatMap((group) => group.hooks.filter(object).map((hook) => key(group, hook))));
24
+ const added = [];
25
+ for (const group of groups) {
26
+ const missing = group.hooks.filter((hook) => {
27
+ if (seen.has(key(group, hook))) return false;
28
+ seen.add(key(group, hook));
29
+ return true;
30
+ });
31
+ if (missing.length) added.push({ ...group, hooks: missing });
32
+ }
33
+ hooks[event] = [...existing, ...added];
34
+ }
35
+ return { ...settings, hooks };
36
+ }
37
+
38
+ function markedContent(original, snippet, path) {
39
+ const start = original.indexOf(START);
40
+ const end = original.indexOf(END);
41
+ const block = Buffer.from(`${START}\n${snippet.trimEnd()}\n${END}`);
42
+ if (start === -1 && end === -1) {
43
+ const separator = original.length && original.at(-1) !== 10 ? '\n\n' : original.length ? '\n' : '';
44
+ return Buffer.concat([original, Buffer.from(separator), block, Buffer.from('\n')]);
45
+ }
46
+ if (start < 0 || end < start || original.indexOf(START, start + START.length) !== -1 || original.indexOf(END, end + END.length) !== -1) {
47
+ throw refuse(`${path}: expected one matching ${START} / ${END} block`);
48
+ }
49
+ return Buffer.concat([original.subarray(0, start), block, original.subarray(end + Buffer.byteLength(END))]);
50
+ }
51
+
52
+ export function assertSnippetPrimary(primary) {
53
+ if (primary?.id !== 'claude-code') {
54
+ throw refuse(`--apply-snippets requires claude-code as primary; paste ${snippetFor(primary) || 'PASTE-INTO-YOUR-AGENT.md'} by hand`);
55
+ }
56
+ }
57
+
58
+ // Read and validate both user files before the installer writes anything.
59
+ // These entries deliberately stay outside the uninstall manifest.
60
+ export function planSnippetApplication({ primary, project, files }) {
61
+ assertSnippetPrimary(primary);
62
+ const targets = ['CLAUDE.md', join('.claude', 'settings.json')];
63
+ const problems = preflight(targets.map((rel) => ({ rel })), project);
64
+ if (problems.length) throw refuse(problems.join('; '));
65
+ const settingsPath = join(project, targets[1]);
66
+ const priorSettings = existsSync(settingsPath) ? readFileSync(settingsPath) : null;
67
+ let settings = {};
68
+ if (priorSettings) {
69
+ try { settings = JSON.parse(priorSettings.toString('utf8')); }
70
+ catch { throw refuse(`${settingsPath}: invalid JSON; nothing written`); }
71
+ }
72
+ const incoming = JSON.parse(files.find((f) => f.rel === 'settings.hooks.snippet.json').content);
73
+ const merged = mergeHooks(settings, incoming.hooks, settingsPath);
74
+ const rulesPath = join(project, targets[0]);
75
+ const priorRules = existsSync(rulesPath) ? readFileSync(rulesPath) : null;
76
+ const snippet = files.find((f) => f.rel === 'CLAUDE.snippet.md').content;
77
+ return [
78
+ { rel: targets[0], original: priorRules, content: markedContent(priorRules || Buffer.alloc(0), snippet, rulesPath) },
79
+ { rel: targets[1], original: priorSettings, content: priorSettings && JSON.stringify(settings) === JSON.stringify(merged) ? priorSettings : JSON.stringify(merged, null, 2) + '\n' }
80
+ ].map((file) => ({ ...file, root: 'project', mode: 0o644, applySnippet: true }));
81
+ }
package/src/catalog.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // bin binary to look for on PATH, or null
9
9
  // access 'subscription' ($0 per call on a plan you already pay for), 'metered' (per token), 'free', 'local'
10
10
  // lane 'A' = subscription CLI, 'B' = metered API, 'local' = stays on the machine
11
+ // laneCategories routing capabilities used to filter generated lane advice
11
12
  // role the one job it wins at in a multi-AI stack
12
13
  // minLevel 1 beginner, 2 intermediate, 3 advanced
13
14
  // install { npm: pkg } for a global npm install the installer may run after you say yes,
@@ -88,6 +89,7 @@ export const AIS = [
88
89
  },
89
90
  {
90
91
  id: 'codex',
92
+ laneCategories: ['second-coder'],
91
93
  name: 'Codex CLI (OpenAI, ChatGPT plan)',
92
94
  vendor: 'OpenAI',
93
95
  kind: 'agent-cli',
@@ -110,6 +112,7 @@ export const AIS = [
110
112
  },
111
113
  {
112
114
  id: 'agy',
115
+ laneCategories: ['fan-out', 'largest-context'],
113
116
  name: 'Antigravity CLI `agy` (Google AI plan)',
114
117
  vendor: 'Google',
115
118
  kind: 'agent-cli',
@@ -134,6 +137,7 @@ export const AIS = [
134
137
  },
135
138
  {
136
139
  id: 'grok',
140
+ laneCategories: ['live-data'],
137
141
  name: 'Grok CLI (xAI, X Premium)',
138
142
  vendor: 'xAI',
139
143
  kind: 'agent-cli',
@@ -156,6 +160,7 @@ export const AIS = [
156
160
  },
157
161
  {
158
162
  id: 'hermes',
163
+ laneCategories: ['free'],
159
164
  name: 'Hermes Agent (Nous Research)',
160
165
  vendor: 'Nous Research',
161
166
  kind: 'agent-cli',
@@ -173,6 +178,7 @@ export const AIS = [
173
178
  },
174
179
  {
175
180
  id: 'qwen',
181
+ laneCategories: ['cheapest-metered'],
176
182
  name: 'Qwen Code CLI (Alibaba, provider-agnostic)',
177
183
  vendor: 'Alibaba',
178
184
  kind: 'agent-cli',
@@ -191,6 +197,7 @@ export const AIS = [
191
197
  },
192
198
  {
193
199
  id: 'ollama',
200
+ laneCategories: ['local'],
194
201
  name: 'Ollama (local models)',
195
202
  vendor: 'Ollama',
196
203
  kind: 'local',
package/src/install.js CHANGED
@@ -161,6 +161,30 @@ export function auditLane(selected) {
161
161
  // recommend a command its own lanes.json disables.
162
162
  export function laneVars(selected) {
163
163
  const has = (id) => selected.some((a) => a.id === id);
164
+ const categories = new Set(selected.flatMap((a) => a.laneCategories || []));
165
+ const supplies = (category) => categories.has(category);
166
+ const picks = [
167
+ ['cheapest-metered', 'Bulk classify / extract / summarize, data may leave the machine', 'the cheapest metered lane, then the fast tier', 'use the selected bulk lane and verify its output'],
168
+ ['local', 'Bulk work on data that must stay local', 'the local lane', 'a privacy lane; route here for confinement'],
169
+ ['fan-out', 'Many independent items each needing its own agent turn', 'a concurrent fan-out lane', 'one call, N children, on a subscription'],
170
+ ['live-data', 'Live web or social reads', 'the live-data CLI', 'subscription-covered; the same search on the API bills per call'],
171
+ ['second-coder', 'Code review, no changes', 'standard tier, or the second-coder CLI', 'a different model family catches what one misses'],
172
+ ['second-coder', 'Second-opinion audit of a security-shaped diff', 'the second-coder CLI in read-only audit mode', 'a second family challenges, the orchestrator reproduces'],
173
+ [null, 'Deep architecture / planning', 'deep tier', 'expensive to get wrong'],
174
+ [null, 'Well-specified execution', 'the orchestrator', 'execution does not need the top tier'],
175
+ ['largest-context', 'Long-document analysis', 'the largest-context lane, or caching on the primary', 'window size vs re-query cost'],
176
+ [null, 'Routing decisions themselves', 'the cheapest lane you have, or none', 'spend only the tokens the routing decision needs'],
177
+ ['free', 'Rough drafts, divergent reads, first-pass summaries', 'the free tier', '$0, and disagreement with the primary is information'],
178
+ ['cheapest-metered', 'Anything citing a line, a number, or a source', 'the cheapest metered lane with a full verification pass', 'verify every supporting number and citation']
179
+ ].filter(([category]) => !category || supplies(category)).map(([, ...row]) => row);
180
+ const cost = [
181
+ 'Prompt caching everywhere it fits: frozen prefix first, volatile text last.',
182
+ 'Cascade: cheapest capable tier first, escalate on signal.',
183
+ ...(supplies('cheapest-metered') ? ['Batch APIs where the selected provider supports them, for work that can wait.'] : []),
184
+ ...(supplies('free') ? ['A free model for routing decisions.'] : []),
185
+ 'Effort and reasoning knobs before model swaps; often the bigger lever.',
186
+ 'Alias-based config so a vendor rename is a one-line repoint.'
187
+ ];
164
188
  const enabled = selected.filter((a) => a.cliRun).map((a) => a.id);
165
189
  const cr = (id) => '`cli-run ' + id + '`';
166
190
  const step0 = [];
@@ -193,6 +217,16 @@ export function laneVars(selected) {
193
217
  if (has('hermes')) run.push('node bin/cli-run.mjs hermes --brief "$BRIEF" --timeout 900 > research/out-hermes.md');
194
218
  if (has('qwen')) run.push('node bin/cli-run.mjs qwen --brief "$BRIEF" --timeout 900 > research/out-qwen.md');
195
219
  return {
220
+ TASK_LANES_TABLE: table(picks, ['Task type', 'Pick', 'Why']),
221
+ COST_PLAYBOOK: cost.map((line, i) => `${i + 1}. ${line}`).join('\n'),
222
+ FAN_OUT_ADVICE: supplies('fan-out') ? ' Many independent items each needing its own agent turn → the selected concurrent fan-out lane.' : '',
223
+ METERED_CITATION_NOTE: supplies('cheapest-metered') ? " A lane's figure is re-derived before it is repeated: verify every supporting number and citation from the cheapest metered lane." : '',
224
+ RESEARCH_SELECTION_ADVICE: enabled.length >= 2
225
+ ? 'Send the same PLAN to your selected CLI lanes, preferring different model families. Run each through `cli-run` so a run that produced nothing exits 10 and is treated as a missing engine.'
226
+ : 'Use the primary agent for the sweep, then a fresh-context second-opinion turn. Add CLI lanes from different model families for independent research passes.',
227
+ GAP_ANALYSIS_LANE: supplies('second-coder')
228
+ ? 'a **different model family** reading the same artifact. Use the selected second-opinion coder lane in read-only mode; verify each finding before acting.'
229
+ : 'a fresh-context second pass reading the same artifact. Use a different model family when one is available; verify each finding before acting.',
196
230
  LANE_STEP0: step0.length ? step0.map((l) => ' - ' + l).join('\n') : ' - none selected yet: every task stays on your primary agent\'s tiers until you add a lane (re-run the installer with more AIs)',
197
231
  STAGE1_LANES: stage1.length ? '; ' + stage1.join(', ') : '',
198
232
  ATTACK_LANE: has('codex') ? '`cli-run codex --audit` (a second model family in a read-only sandbox)' : 'code-reviewer at deep tier, in a fresh context told to challenge and allowed to answer CLEAN',
@@ -366,15 +400,17 @@ export function activationSteps(opts) {
366
400
  const projectAbs = resolve(opts.project || process.cwd());
367
401
  const snippet = snippetFor(primary);
368
402
  const steps = [];
369
- if (snippet && primary.rulesFile) steps.push(`copy the block in ${join(dirAbs, snippet)} into ${join(projectAbs, primary.rulesFile)} (create it if missing)`);
403
+ if (opts.applySnippets) steps.push(`applied ${join(dirAbs, snippet)} to the model-orchestrator marked block in ${join(projectAbs, 'CLAUDE.md')}`);
404
+ else if (snippet && primary.rulesFile) steps.push(`copy the block in ${join(dirAbs, snippet)} into ${join(projectAbs, primary.rulesFile)} (create it if missing)`);
370
405
  // A chat app has no possessive that survives its catalog note: "Claude app or
371
406
  // claude.ai (chat only, no CLI)'s custom instructions" was the sentence this
372
407
  // replaces (#22).
373
408
  else if (snippet) steps.push(`open ${primary.chatName || primary.name} and paste the block in ${join(dirAbs, snippet)} into its ${primary.chatSurface || 'custom instructions'}`);
374
409
  if (primary && primary.agentsDir) steps.push(`subagents are in ${join(projectAbs, primary.agentsDir)}; run ${primary.bin} from ${projectAbs} to pick them up`);
375
410
  // Only claude-code ships hooks (route-gate, subagent-context): the wiring
376
- // lives in a snippet, never written into a settings.json the user already has.
377
- if (subagentsLoadRules(primary)) steps.push(`merge the hooks in ${join(dirAbs, 'settings.hooks.snippet.json')} into ${join(projectAbs, '.claude', 'settings.json')} (create it if missing) to wire the route-gate, subagent-context and route-metrics hooks`);
411
+ // lives in a snippet, applied only when the user opts in.
412
+ if (opts.applySnippets) steps.push(`applied hooks to ${join(projectAbs, '.claude', 'settings.json')}, preserving existing settings and hooks`);
413
+ else if (subagentsLoadRules(primary)) steps.push(`merge the hooks in ${join(dirAbs, 'settings.hooks.snippet.json')} into ${join(projectAbs, '.claude', 'settings.json')} (create it if missing) to wire the route-gate, subagent-context and route-metrics hooks`);
378
414
  for (const a of selected.filter((a) => a.bin && a.kind === 'agent-cli')) steps.push(`sign in to ${a.name}: ${a.auth}`);
379
415
  // A local runtime has a bin but no sign-in, so the agent-cli loop above skips it
380
416
  // and before this it appeared in no ordered list at any level (#26).
@@ -442,9 +478,12 @@ function vars(opts) {
442
478
  let rulesPath = relative(projectAbs, dirAbs).split(sep).join(posix.sep);
443
479
  if (rulesPath === '') rulesPath = '.';
444
480
  else if (rulesPath.startsWith('..')) rulesPath = dirPosix; // outside the project: absolute is the only honest path
481
+ const rulesPathNote = rulesPath === dirPosix
482
+ ? 'Moved the folder? Re-run the installer or set MODEL_ORCHESTRATOR_RULES_DIR to the rules folder for the hooks, and update the paths in your agent instructions.'
483
+ : '';
445
484
  const pinOf = (id) => (toolById[id] && toolById[id].pin) || 'latest';
446
485
  const snippet = snippetFor(primary);
447
- const steps = activationSteps({ level, selected, primary, tools, dir: opts.dir, project: opts.project });
486
+ const steps = activationSteps({ level, selected, primary, tools, dir: opts.dir, project: opts.project, applySnippets: opts.applySnippets });
448
487
  const proofs = proofSteps({ level, primary });
449
488
  const routingFile = level >= 2 ? 'ROUTING.md' : 'ORCHESTRATOR.md';
450
489
  // The path route-gate.mjs and subagent-context.mjs resolve at runtime,
@@ -463,18 +502,32 @@ function vars(opts) {
463
502
  else if (readsProjectRules) whereThingsWent.push(`- Project root (where ${primary.name} reads \`${primary.rulesFile}\`): \`${projectAbs}\`` + (existsSync(projectAbs) ? '' : ' (this run wrote nothing there; create the folder before you copy the snippet in)'), '- Subagent definitions: none, this agent has no subagent folder');
464
503
  else whereThingsWent.push('- Project root: none. A chat app reads pasted instructions, not files, so this install wrote nothing to a project folder.', '- Subagent definitions: none');
465
504
  whereThingsWent.push(`- The rules path your snippets use: \`${rulesPath}\``);
505
+ whereThingsWent.push(rulesPathNote
506
+ ? '- Rules location: absolute, because this folder is outside the project. ' + rulesPathNote
507
+ : '- Rules location: project-relative, so moving the project and its rules folder together preserves the paths.');
466
508
  return {
467
509
  ...laneVars(selected),
468
510
  ACTIVATION_STEPS: steps.map((st, i) => `${i + 1}. ${st}`).join('\n'),
469
511
  PROOF_STEPS: proofs.map((st, i) => `${i + 1}. ${st}`).join('\n'),
470
- LOAD_IT: readsProjectRules
512
+ LOAD_IT: opts.applySnippets
513
+ ? 'The installer applied the generated rules to the model-orchestrator marked block in `CLAUDE.md` and merged the hooks into `.claude/settings.json`. Existing files changed by this run have timestamped backups beside them; their paths were printed in the terminal.'
514
+ : readsProjectRules
471
515
  ? `${primary.name} reads its rules from \`${primary.rulesFile}\` in the project root. The installer wrote \`${snippet}\` next to this README; copy its contents into \`${join(projectAbs, primary.rulesFile)}\`, creating that file if it does not exist. Nothing was appended to a file you already had.`
472
516
  : snippet
473
517
  ? `${primary.name} has no project rules file, so the rules travel by paste. The installer wrote \`${snippet}\` next to this README; open ${primary.chatName || primary.name} and paste its contents into ${primary.chatSurface || 'custom instructions'}. Nothing was appended to a file you already had.`
474
518
  : 'No primary agent was selected, so no activation file was written. Re-run the installer and pick one.',
519
+ CLAUDE_SNIPPET_INTRO: opts.applySnippets
520
+ ? '# Model orchestrator activation\n\nThe installer applied these rules to the marked block in `CLAUDE.md` at your project root.'
521
+ : "# Add this to your project's CLAUDE.md\n\nCopy the block below into `CLAUDE.md` at your project root (create the file if it does not exist). The installer did not modify any file you already had.",
522
+ CLAUDE_HOOKS_ACTIVATION: opts.applySnippets
523
+ ? 'The installer merged the hook entries into `.claude/settings.json` to wire all three in.'
524
+ : 'Merge `settings.hooks.snippet.json`, written next to this file, into `.claude/settings.json` to wire all three in.',
475
525
  CHAT_UPLOAD_NOTE: primary && primary.kind === 'chat' ? ' A chat app cannot open a local path: upload or paste any protocol file you want it to read.' : '',
476
526
  WHERE_THINGS_WENT: whereThingsWent.join('\n'),
477
527
  RULES_PATH: rulesPath,
528
+ RULES_PATH_NOTE: rulesPathNote,
529
+ RULES_PATH_NOTE_COMMENT: rulesPathNote ? '// ' + rulesPathNote : '',
530
+ RULES_DIR_OVERRIDE_JS: 'process.env.MODEL_ORCHESTRATOR_RULES_DIR',
478
531
  ROUTING_FILE: level >= 2 ? 'ROUTING.md' : 'ORCHESTRATOR.md',
479
532
  PROJECT_DIR: projectAbs,
480
533
  AGENTS_DIR: primary && primary.agentsDir ? join(projectAbs, primary.agentsDir) : 'none (your primary agent has no subagent folder)',
@@ -587,8 +640,7 @@ export function planFiles(opts) {
587
640
  add('CLAUDE.snippet.md', render(readFileSync(join(TEMPLATES, 'agents', 'snippets', 'claude-code.md'), 'utf8'), v));
588
641
  // Delegate-by-default hooks (0.1.15), claude-code only: route-gate.mjs (UserPromptSubmit)
589
642
  // and subagent-context.mjs (SubagentStart) live where Claude Code looks for
590
- // project hooks; the wiring snippet is a document the user merges in, never
591
- // written into a settings.json they already have.
643
+ // project hooks; the wiring snippet is merged by hand or with --apply-snippets.
592
644
  add(join('.claude', 'hooks', 'route-gate.mjs'), render(readFileSync(join(TEMPLATES, 'agents', 'snippets', 'route-gate.mjs'), 'utf8'), v), 0o755, 'project');
593
645
  add(join('.claude', 'hooks', 'subagent-context.mjs'), render(readFileSync(join(TEMPLATES, 'agents', 'snippets', 'subagent-context.mjs'), 'utf8'), v), 0o755, 'project');
594
646
  // route-metrics.mjs (0.1.16), claude-code only: five events (UserPromptSubmit,
@@ -810,6 +862,15 @@ export function writeFiles(files, opts) {
810
862
  if (!groups[k].length) continue;
811
863
  problems.push(...preflight(groups[k], roots[k]).map((p) => (k === 'project' ? `[project] ${p}` : p)));
812
864
  }
865
+ if (!problems.length) {
866
+ for (const f of files.filter((file) => file.applySnippet)) {
867
+ const abs = resolve(roots[f.root], f.rel);
868
+ const current = existsSync(abs) ? readFileSync(abs) : null;
869
+ if (current === null ? f.original !== null : !Buffer.isBuffer(f.original) || !current.equals(f.original)) {
870
+ problems.push(`${abs}: changed since snippet planning; re-run the installer`);
871
+ }
872
+ }
873
+ }
813
874
  if (problems.length) {
814
875
  const e = new Error('refusing to write:\n ' + problems.join('\n '));
815
876
  e.code = 'PREFLIGHT';
@@ -823,6 +884,7 @@ export function writeFiles(files, opts) {
823
884
  const docsUpdated = []; // --update-docs: documents regenerated because the installed copy was an untouched generated one
824
885
  const docsConflict = []; // --update-docs: documents kept because you edited them
825
886
  const docsUnverifiable = []; // --update-docs: documents kept because there is no manifest to compare against
887
+ const backups = [];
826
888
  const created = [];
827
889
  // Only directories actually created by this install are owned. Preserve the
828
890
  // previous inventory on reruns; legacy manifests deliberately own none.
@@ -851,7 +913,11 @@ export function writeFiles(files, opts) {
851
913
  const label = (k === 'project' ? '[project] ' : '') + f.rel.split(sep).join('/');
852
914
  const key = label;
853
915
  const cls = k === 'dir' ? fileClass(f.rel) : 'document';
854
- if (exists && !force) {
916
+ if (exists && f.applySnippet && Buffer.from(f.content).equals(readFileSync(abs))) {
917
+ skipped.push(label);
918
+ continue;
919
+ }
920
+ if (exists && !force && !f.applySnippet) {
855
921
  if (cls === 'document') {
856
922
  // Documents are the user's. Without --update-docs they are never touched.
857
923
  // With it, the same hash rule the runtime class uses applies: regenerate
@@ -911,6 +977,17 @@ export function writeFiles(files, opts) {
911
977
  }
912
978
  content = JSON.stringify(m, null, 2) + '\n';
913
979
  }
980
+ if (exists && opts.backupExisting) {
981
+ let stamp = Date.now();
982
+ let backup;
983
+ do {
984
+ backup = abs + '.bak-' + new Date(stamp).toISOString().replace(/[-:]/g, '').slice(0, 15);
985
+ stamp += 1000;
986
+ } while (existsSync(backup));
987
+ if (!dry) writeFileSync(backup, readFileSync(abs), { flag: 'wx', mode: statSync(abs).mode & 0o777 });
988
+ backups.push(backup);
989
+ if (!dry) opts.onBackup?.(backup);
990
+ }
914
991
  if (!dry) {
915
992
  if (exists) originals.set(abs, { content: readFileSync(abs), mode: statSync(abs).mode });
916
993
  const missingDirectories = [];
@@ -933,7 +1010,7 @@ export function writeFiles(files, opts) {
933
1010
  }
934
1011
  writeFileSync(abs, content, { flag: exists ? 'w' : 'wx' });
935
1012
  if (!exists) created.push(abs);
936
- chmodSync(abs, f.mode);
1013
+ if (!exists || !f.applySnippet) chmodSync(abs, f.mode);
937
1014
  }
938
1015
  written.push(label);
939
1016
  }
@@ -956,7 +1033,7 @@ export function writeFiles(files, opts) {
956
1033
  }
957
1034
  throw e;
958
1035
  }
959
- return { written, skipped, upgraded, conflicts, unverifiable, docsUpdated, docsConflict, docsUnverifiable };
1036
+ return { written, skipped, upgraded, conflicts, unverifiable, docsUpdated, docsConflict, docsUnverifiable, backups };
960
1037
  }
961
1038
 
962
1039
  export function resolveSelection(ids) {
package/src/plugin.js CHANGED
@@ -32,6 +32,9 @@ export const HAND_OWNED = ['README.md', 'hooks/hooks.json'];
32
32
  export function pluginVars() {
33
33
  return {
34
34
  PRIMARY_NAME: 'Claude Code',
35
+ RULES_PATH_NOTE_COMMENT: '',
36
+ // Plugin hooks retain their existing project-root-only environment access.
37
+ RULES_DIR_OVERRIDE_JS: "''",
35
38
  RULES_FILE_REL: DEFAULT_RULES.join(' or '),
36
39
  RULES_FILE_REL_JSON: JSON.stringify(DEFAULT_RULES[0] + ' (' + DEFAULT_RULES[1] + ' on a level 1 install)'),
37
40
  TASK_BUNDLE_REL_JSON: JSON.stringify(DEFAULT_TASK_BUNDLE),
package/src/uninstall.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync, rmdirSync, unlinkSync } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
- import { isAbsolute, join, relative, resolve, sep, win32 } from 'node:path';
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32 } from 'node:path';
4
4
  import { dirProblems, realRoot } from './install.js';
5
5
 
6
6
  const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
@@ -112,6 +112,18 @@ export function uninstallFiles({ dir, project, dry = false }) {
112
112
  }
113
113
 
114
114
  const actions = [];
115
+ const backupTargets = [...files, manifest];
116
+ if (data.primary === 'claude-code') backupTargets.push(entry('[project] CLAUDE.md', roots), entry('[project] .claude/settings.json', roots));
117
+ for (const item of backupTargets) {
118
+ const parent = dirname(item.abs);
119
+ if (!inspect({ root: item.root, abs: parent, directory: true })) continue;
120
+ const prefix = basename(item.abs) + '.bak-';
121
+ for (const name of readdirSync(parent).sort()) {
122
+ if (name.startsWith(prefix) && /^\d{8}T\d{6}$/.test(name.slice(prefix.length))) {
123
+ actions.push(' keep backup ' + join(parent, name));
124
+ }
125
+ }
126
+ }
115
127
  const pending = [];
116
128
  let edited = false;
117
129
  for (const item of files) {
@@ -1,11 +1,10 @@
1
- # Add this to your project's CLAUDE.md
2
-
3
- Copy the block below into `CLAUDE.md` at your project root (create the file if it does not exist). The installer did not modify any file you already had.
1
+ {{CLAUDE_SNIPPET_INTRO}}
4
2
 
5
3
  ```markdown
6
4
  ## Model orchestrator
7
5
 
8
6
  Routing rules live in `{{RULES_PATH}}/{{ROUTING_FILE}}`. Read them before any build task. Quick version, first match wins:
7
+ {{RULES_PATH_NOTE}}
9
8
 
10
9
  1. Bulk, mechanical, many similar items -> bulk-worker (fast tier).
11
10
  2. Needs live data -> live-researcher (standard tier + tools).
@@ -28,6 +27,6 @@ Numbers, comparisons, complexity and equivalence claims go through codecalc (or
28
27
  Anything durable is searched for before it is written and its folder index is corrected in the same pass; one writer per run: `{{RULES_PATH}}/protocols/memory-and-record.md`.
29
28
  ```
30
29
 
31
- Subagents were written to `{{AGENTS_DIR}}` (the project root, which is where Claude Code reads project-level agents; `--project` changes it). Run `claude` from `{{PROJECT_DIR}}` and they are available as {{AGENTS_LIST_LINE}}.
30
+ Subagents were written to `.claude/agents/` under the project root, which is where Claude Code reads project-level agents (`--project` selects that root). Run `claude` from your project root and they are available as {{AGENTS_LIST_LINE}}.
32
31
 
33
- Three hooks were written to `{{AGENTS_DIR}}/../hooks/` (`.claude/hooks/`): `route-gate.mjs` injects the routing table on every prompt, `subagent-context.mjs` reminds a spawned subagent where the rules and the task-bundle format live, and `route-metrics.mjs` appends each turn's route marker and subagent dispatch to a local `route-metrics.jsonl` log (read it with `node .claude/hooks/route-metrics.mjs --summary`). Merge `settings.hooks.snippet.json`, written next to this file, into `.claude/settings.json` to wire all three in.
32
+ Three hooks were written to `.claude/hooks/` under the project root: `route-gate.mjs` injects the routing table on every prompt, `subagent-context.mjs` reminds a spawned subagent where the rules and the task-bundle format live, and `route-metrics.mjs` appends each turn's route marker and subagent dispatch to a local `route-metrics.jsonl` log (read it with `node .claude/hooks/route-metrics.mjs --summary`). {{CLAUDE_HOOKS_ACTIVATION}}
@@ -6,6 +6,7 @@ Your agent, {{PRIMARY_NAME}}, reads `{{PRIMARY_RULES_FILE}}` from the project ro
6
6
  ## Model orchestrator
7
7
 
8
8
  Routing rules live in `{{RULES_PATH}}/{{ROUTING_FILE}}`. Read them before any build task.
9
+ {{RULES_PATH_NOTE}}
9
10
 
10
11
  Route by capability tier, first match wins: bulk and mechanical -> fast tier · needs live data -> standard tier with tools · review without changing -> standard, read-only · ambiguous or expensive to get wrong -> deep tier, then hand the plan down · everything else -> build it directly at standard tier.
11
12
 
@@ -12,7 +12,7 @@
12
12
  // a full read of an arbitrarily large or non-regular file), and never
13
13
  // executes anything it reads. See docs/audit-brief.md for the security notes.
14
14
  import { statSync, openSync, readSync, closeSync, realpathSync } from 'node:fs';
15
- import { join, isAbsolute } from 'node:path';
15
+ import { join, isAbsolute, basename } from 'node:path';
16
16
 
17
17
  // One template, two renders. The installer renders a one-element list from
18
18
  // the level and directory the user chose: a level 1 install points it at
@@ -22,6 +22,7 @@ import { join, isAbsolute } from 'node:path';
22
22
  // render from, so it lists the installer's default locations and reads the
23
23
  // first one that exists.
24
24
  const RULES_CANDIDATES = {{RULES_CANDIDATES_JSON}};
25
+ {{RULES_PATH_NOTE_COMMENT}}
25
26
  const RULES_FILE_REL = RULES_CANDIDATES.join(' or ');
26
27
  // Empty in an installer render, whose rules file was written by the same run.
27
28
  // The plugin renders a next step for a project that has no rules yet, so a
@@ -68,6 +69,8 @@ function projectRoot() {
68
69
  }
69
70
 
70
71
  function resolveRulesPath(rel, root) {
72
+ const override = {{RULES_DIR_OVERRIDE_JS}};
73
+ if (override) return isAbsolute(override) ? join(override, basename(rel)) : root ? join(root, override, basename(rel)) : null;
71
74
  if (isAbsolute(rel)) return rel;
72
75
  return root ? join(root, rel) : null;
73
76
  }
@@ -34,6 +34,8 @@ import {
34
34
  import { join } from 'node:path';
35
35
  import { homedir } from 'node:os';
36
36
 
37
+ // Telemetry has no rules lookup: MODEL_ORCHESTRATOR_RULES_DIR changes the
38
+ // two context hooks, while metrics keep their existing home-directory log.
37
39
  const HOME_DIR = join(homedir(), '.ai-orchestrator');
38
40
  const LOG_FILE = join(HOME_DIR, 'route-metrics.jsonl');
39
41
  const STATE_DIR = join(HOME_DIR, 'route-metrics.state');
@@ -11,14 +11,22 @@
11
11
  // Fail-open by design: a miss here is a stray context string, not a gate.
12
12
  // This script always exits 0, never executes anything it reads, and never
13
13
  // blocks on stdin past a short bound (see drainStdin below).
14
- import { isAbsolute } from 'node:path';
14
+ import { isAbsolute, join, basename } from 'node:path';
15
15
 
16
16
  // Rendered at install time so a --dir outside the project still names an
17
17
  // honest path rather than a hardcoded one.
18
- const RULES_FILE_REL = {{RULES_FILE_REL_JSON}};
19
- const TASK_BUNDLE_REL = {{TASK_BUNDLE_REL_JSON}};
18
+ {{RULES_PATH_NOTE_COMMENT}}
19
+ const RULES_FILE_REL = {{RULES_DIR_OVERRIDE_JS}}
20
+ ? {{RULES_CANDIDATES_JSON}}.map(rulesPath).join(' or ')
21
+ : {{RULES_FILE_REL_JSON}};
22
+ const TASK_BUNDLE_REL = rulesPath({{TASK_BUNDLE_REL_JSON}});
20
23
  const STDIN_DRAIN_MS = 250; // hard cap: never let an open, never-closed stdin pipe hold this hook open
21
24
 
25
+ function rulesPath(baked) {
26
+ const override = {{RULES_DIR_OVERRIDE_JS}};
27
+ return override ? join(override, basename(baked)) : baked;
28
+ }
29
+
22
30
  const additionalContext = [
23
31
  'SUBAGENT CONTEXT (model-orchestrator).',
24
32
  'Routing rules: ' + RULES_FILE_REL + (isAbsolute(RULES_FILE_REL) ? '.' : ' (relative to the project root).'),
@@ -33,9 +33,9 @@ Agreement is weak evidence. Disagreement is the signal.
33
33
 
34
34
  You still get the shape. Run PLAN as its own turn and inspect it before spending anything. Run the sweep. Then run a **fresh-context second-opinion turn** with a brief that says "question the premise; list what this report would get wrong if its sources were stale". Plant one deliberately wrong figure in the brief and see whether it corrects it: if it does not, its confirmations are worth less than they look. Mark every claim.
35
35
 
36
- ## Level 2 and up: three engines, one triager
36
+ ## Level 2 and up: selected engines, one triager
37
37
 
38
- Fan out the same PLAN to three different model families through their CLIs (a web-sweep lane, a second-opinion-read lane, a live-data lane). Run them through `cli-run` so a run that produced nothing is caught as `rc=10` rather than read as an empty finding. The orchestrator triages: it opens the primary sources itself, marks each claim, and writes the brief. Only the orchestrator writes the durable record; every other engine proposes.
38
+ {{RESEARCH_SELECTION_ADVICE}} The orchestrator triages: it opens the primary sources itself, marks each claim, and writes the brief. Only the orchestrator writes the durable record; every other engine proposes.
39
39
 
40
40
  Known failure shape: one engine will return confident unsourced numerics and claim full coverage. Downgrade those to hypothesis. The engines that report their own gaps honestly are the ones to weight.
41
41
 
@@ -14,7 +14,7 @@ Verification asks "is what I did correct?". Gap analysis asks "what did I not do
14
14
  ## Who runs it
15
15
 
16
16
  - **Level 1 (one agent):** the same agent, in a fresh turn, with a brief that says "you are looking for what is missing; do not re-verify what is present". Fresh context matters more than a different model.
17
- - **Level 2 and up:** a **different model family** reading the same artifact. Disagreement between two families is the cheapest available signal that something is soft. The second-opinion coder lane (read-only mode) is the natural fit.
17
+ - **Level 2 and up:** {{GAP_ANALYSIS_LANE}}
18
18
  - **Level 3:** make it recurring. A weekly audit job enumerates live state (lanes, jobs, services, model lists), diffs it against the plan, and files a report. It catches the dead lane and the silently renamed model nobody noticed.
19
19
 
20
20
  ## The second half: analyze, compare, suggest
@@ -12,20 +12,7 @@ Generated {{DATE}} from the AIs you said you have: `{{AI_IDS}}`.
12
12
 
13
13
  ## Task → lane
14
14
 
15
- | Task type | Pick | Why |
16
- |---|---|---|
17
- | Bulk classify / extract / summarize, data may leave the machine | the cheapest metered lane, then the fast tier | cost gap is an order of magnitude; batch APIs add more |
18
- | Bulk work on data that must stay local | the local lane | a privacy lane, never a cost lane; route here for confinement, not to save money |
19
- | Many independent items each needing its own agent turn | a concurrent fan-out lane | one call, N children, on a subscription |
20
- | Live web or social reads | the live-data CLI | subscription-covered; the same search on the API bills per call |
21
- | Code review, no changes | standard tier, or the second-coder CLI | a different model family catches what one misses |
22
- | Second-opinion audit of a security-shaped diff | the second-coder CLI in read-only audit mode | Claude writes, a second family challenges, the orchestrator reproduces |
23
- | Deep architecture / planning | deep tier | expensive to get wrong |
24
- | Well-specified execution | the orchestrator | execution does not need the top tier |
25
- | Long-document analysis | the largest-context lane, or caching on the primary | window size vs re-query cost |
26
- | Routing decisions themselves | the cheapest lane you have, or none | never spend deep tokens deciding not to use deep |
27
- | Rough drafts, divergent reads, first-pass summaries | the free tier | $0, and disagreement with the primary is information |
28
- | Anything citing a line, a number, or a source | never the cheapest metered lane without a full verification pass | measured: conclusions right, every supporting number invented |
15
+ {{TASK_LANES_TABLE}}
29
16
 
30
17
  ## Install and sign-in
31
18
 
@@ -33,12 +20,7 @@ Generated {{DATE}} from the AIs you said you have: `{{AI_IDS}}`.
33
20
 
34
21
  ## Cost playbook
35
22
 
36
- 1. Prompt caching everywhere it fits: frozen prefix first, volatile text last.
37
- 2. Cascade: cheapest capable tier first, escalate on signal.
38
- 3. Batch APIs for anything not latency-sensitive.
39
- 4. A free or local model for routing decisions.
40
- 5. Effort and reasoning knobs before model swaps; often the bigger lever.
41
- 6. Alias-based config so a vendor rename is a one-line repoint.
23
+ {{COST_PLAYBOOK}}
42
24
 
43
25
  ## Privacy gate
44
26
 
@@ -1,6 +1,6 @@
1
1
  # RESEARCH_TRIAGE.md: engines in parallel, one triager
2
2
 
3
- The deep-research lane at level 2: fan the same plan out to different model families through their CLIs, then triage against primary sources you open yourself.
3
+ {{RESEARCH_SELECTION_ADVICE}} Then triage against primary sources you open yourself.
4
4
 
5
5
  Your `cli-run` lanes: {{CLI_RUN_LANES}} ({{RESEARCH_ENGINES}} research engine(s) below). Everything in this file was rendered from that selection; a lane that is not listed is not one you have.
6
6
 
@@ -19,7 +19,7 @@ BRIEF=research/brief.md # purpose, sub-questions, source standard, report c
19
19
  {{RESEARCH_RUN}}
20
20
  ```
21
21
 
22
- Then the orchestrator reads the three outputs, opens every primary source that carries a decision, and writes one dated brief with marks: **CONFIRMED** (two engines + primary source) · **DISAGREEMENT** (both readings kept) · **REPORTED** (someone's own post, quoted not trusted) · **UNVERIFIED**.
22
+ Then the orchestrator reads the available outputs, opens every primary source that carries a decision, and writes one dated brief with marks: **CONFIRMED** (two engines + primary source) · **DISAGREEMENT** (both readings kept) · **REPORTED** (someone's own post, quoted not trusted) · **UNVERIFIED**.
23
23
 
24
24
  ## Triage discipline
25
25
 
@@ -18,7 +18,7 @@ Rule of thumb: never spend a frontier token on a task a cheap tier finishes corr
18
18
 
19
19
  0. **Is there a cheaper or better external lane for this?** Check `DELEGATION_MATRIX.md`. Your enabled lanes, every one called through `bin/cli-run.mjs`:
20
20
  {{LANE_STEP0}}
21
- 1. **Bulk and mechanical?** → fast tier{{BULK_LANE}}. Many independent items each needing its own agent turn → a concurrent fan-out lane if you have one.
21
+ 1. **Bulk and mechanical?** → fast tier{{BULK_LANE}}.{{FAN_OUT_ADVICE}}
22
22
  1a. **Reading or digesting many files or notes, not writing?** → reader. Different from a bulk pass: reader reports, it does not classify, tag or transform.
23
23
  2. **Needs live data?** → {{LIVE_LANE}} standard tier with web tools.
24
24
  3. **Reviewing without changing?** → standard tier read-only. Security-critical → {{ATTACK_LANE}}.
@@ -48,7 +48,7 @@ Caps: two deep-tier checkpoints per build. CLI lanes are $0 and do not count.
48
48
 
49
49
  ## Numbers and logic
50
50
 
51
- Every number, comparison, complexity or equivalence claim goes through a tool that computes (`protocols/numbers-and-logic.md`; companion: codecalc, {{CODECALC_STATUS}}). A lane's figure is re-derived before it is repeated: the cheapest metered lane measured 0 of 11 line citations correct while its conclusions were right.
51
+ Every number, comparison, complexity or equivalence claim goes through a tool that computes (`protocols/numbers-and-logic.md`; companion: codecalc, {{CODECALC_STATUS}}).{{METERED_CITATION_NOTE}}
52
52
 
53
53
  ## Memory and record
54
54