model-orchestrator 0.1.35 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/AGENTS.md +31 -21
  2. package/CHANGELOG.md +58 -1
  3. package/README.md +129 -110
  4. package/SECURITY.md +7 -3
  5. package/bin/README.md +57 -6
  6. package/bin/aunx.js +7 -0
  7. package/bin/cli-run.mjs +21 -15
  8. package/bin/cli.js +376 -257
  9. package/docs/README.md +15 -18
  10. package/docs/catalog.md +236 -44
  11. package/docs/companions.md +28 -10
  12. package/docs/guarantees.md +21 -12
  13. package/docs/how-it-routes.md +49 -42
  14. package/docs/install.md +141 -33
  15. package/docs/part-1-beginner.md +37 -45
  16. package/docs/part-2-intermediate.md +34 -52
  17. package/docs/part-3-advanced.md +36 -26
  18. package/docs/security-review-history.md +39 -0
  19. package/llms.txt +24 -25
  20. package/package.json +15 -8
  21. package/proof/README.md +100 -0
  22. package/proof/gate-demo.cast +9 -0
  23. package/proof/gate-demo.gif +0 -0
  24. package/proof/results.json +198 -0
  25. package/proof/scripts/check-gate.js +26 -0
  26. package/proof/scripts/install-time.js +16 -0
  27. package/proof/scripts/lib.js +73 -0
  28. package/proof/scripts/measure.js +15 -0
  29. package/proof/scripts/missing-results.js +30 -0
  30. package/proof/scripts/record-gate.js +38 -0
  31. package/proof/scripts/render.js +18 -0
  32. package/proof/scripts/runner-overhead.js +21 -0
  33. package/src/README.md +10 -3
  34. package/src/activation-ownership.js +19 -0
  35. package/src/apply-companions.js +104 -0
  36. package/src/apply-snippets.js +60 -28
  37. package/src/aunx.js +272 -0
  38. package/src/bounded-file.js +31 -0
  39. package/src/catalog.js +257 -121
  40. package/src/install.js +483 -212
  41. package/src/plugin.js +13 -4
  42. package/src/postinstall.js +57 -0
  43. package/src/roles.js +184 -0
  44. package/src/uninstall.js +128 -10
  45. package/templates/README.md +19 -2
  46. package/templates/advanced/README.md +2 -2
  47. package/templates/advanced/vm/ENVIRONMENT.md +8 -0
  48. package/templates/advanced/vm/PRIVACY_GATES.md +17 -19
  49. package/templates/advanced/vm/README.md +25 -20
  50. package/templates/advanced/vm/box-CLAUDE.md +19 -18
  51. package/templates/advanced/vm/docker-compose.yml +2 -1
  52. package/templates/advanced/vm/jobs/README.md +31 -2
  53. package/templates/advanced/vm/jobs/weekly-audit.service +7 -2
  54. package/templates/advanced/vm/jobs/weekly-audit.sh +24 -17
  55. package/templates/advanced/vm/setup-vm.sh +49 -2
  56. package/templates/agents/README.md +2 -2
  57. package/templates/agents/agy/README.md +20 -3
  58. package/templates/agents/agy/builder.md +11 -7
  59. package/templates/agents/agy/bulk-worker.md +9 -7
  60. package/templates/agents/agy/code-reviewer.md +13 -7
  61. package/templates/agents/agy/deep-planner.md +10 -7
  62. package/templates/agents/agy/done-verifier.md +13 -22
  63. package/templates/agents/agy/finding-verifier.md +14 -22
  64. package/templates/agents/agy/live-researcher.md +10 -7
  65. package/templates/agents/agy/reader.md +10 -12
  66. package/templates/agents/claude-code/README.md +18 -14
  67. package/templates/agents/claude-code/builder.md +10 -15
  68. package/templates/agents/claude-code/bulk-worker.md +8 -10
  69. package/templates/agents/claude-code/code-reviewer.md +11 -17
  70. package/templates/agents/claude-code/deep-planner.md +9 -11
  71. package/templates/agents/claude-code/done-verifier.md +12 -33
  72. package/templates/agents/claude-code/finding-verifier.md +13 -39
  73. package/templates/agents/claude-code/live-researcher.md +9 -11
  74. package/templates/agents/claude-code/reader.md +9 -18
  75. package/templates/agents/snippets/chat.md +9 -10
  76. package/templates/agents/snippets/claude-code.md +17 -18
  77. package/templates/agents/snippets/generic.md +9 -11
  78. package/templates/agents/snippets/route-gate.mjs +2 -2
  79. package/templates/agents/snippets/route-metrics.mjs +1 -1
  80. package/templates/agents/snippets/subagent-context.mjs +4 -4
  81. package/templates/beginner/ORCHESTRATOR.md +31 -36
  82. package/templates/beginner/README.md +1 -1
  83. package/templates/common/ACCEPTANCE_CHECKS.json +12 -0
  84. package/templates/common/CONTEXT.md +37 -0
  85. package/templates/common/DECISIONS.md +11 -0
  86. package/templates/common/README.md +24 -11
  87. package/templates/common/TASK_BRIEF.md +84 -0
  88. package/templates/common/protocols/README.md +14 -11
  89. package/templates/common/protocols/acceptance-checks.md +15 -0
  90. package/templates/common/protocols/build-protocol.md +91 -106
  91. package/templates/common/protocols/context-file.md +10 -0
  92. package/templates/common/protocols/decision-log.md +9 -0
  93. package/templates/common/protocols/deep-research.md +20 -34
  94. package/templates/common/protocols/docs-then-prove.md +13 -18
  95. package/templates/common/protocols/gap-analysis.md +15 -21
  96. package/templates/common/protocols/memory-and-record.md +21 -20
  97. package/templates/common/protocols/numbers-and-logic.md +20 -26
  98. package/templates/common/protocols/propagate.md +18 -27
  99. package/templates/intermediate/CLI-RUN.md +83 -113
  100. package/templates/intermediate/DELEGATION_MATRIX.md +9 -3
  101. package/templates/intermediate/README.md +3 -3
  102. package/templates/intermediate/RESEARCH_TRIAGE.md +23 -15
  103. package/templates/intermediate/ROUTING.md +54 -51
  104. package/templates/intermediate/TIERS.md +37 -76
  105. package/templates/tools/README.md +1 -1
  106. package/templates/tools/codecalc/CODECALC.md +4 -4
  107. package/templates/tools/codecalc/mcp/agy.mcp_config.json +1 -1
  108. package/templates/tools/codecalc/mcp/codex.config.toml +1 -1
  109. package/templates/tools/codecalc/mcp/mcpServers.json +1 -1
  110. package/templates/tools/codecalc/mcp/vscode.mcp.json +1 -1
  111. package/templates/tools/codecalc/mcp/zed.settings.json +1 -1
  112. package/templates/tools/context7/CONTEXT7.md +6 -10
  113. package/templates/tools/obsidian-tc/OBSIDIAN-TC.md +3 -3
  114. package/templates/tools/obsidian-tc/mcp/obsidian-tc.agy.mcp_config.json +1 -1
  115. package/templates/tools/obsidian-tc/mcp/obsidian-tc.codex.config.toml +1 -1
  116. package/templates/tools/obsidian-tc/mcp/obsidian-tc.mcpServers.json +1 -1
  117. package/templates/tools/obsidian-tc/mcp/obsidian-tc.vscode.mcp.json +1 -1
  118. package/templates/tools/obsidian-tc/mcp/obsidian-tc.zed.settings.json +1 -1
  119. package/docs/audit-brief.md +0 -148
  120. package/scripts/README.md +0 -7
  121. package/scripts/gen-catalog.js +0 -81
  122. package/scripts/gen-plugin.js +0 -16
  123. package/scripts/record-demo.sh +0 -45
  124. package/templates/common/TASK_BUNDLE.md +0 -56
package/bin/cli.js CHANGED
@@ -1,31 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  // model-orchestrator installer.
3
- // Asks which level you want and which AIs you have access to, then writes the
4
- // matching files into a folder. It never writes a secret, never runs a vendor
3
+ // Detects available AIs and previews a stack-specific setup for confirmation.
4
+ // Writes the matching files into a folder. It never writes a secret or runs a vendor
5
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';
9
- import { spawnSync } from 'node:child_process';
10
9
  import { resolve, join } from 'node:path';
11
10
  import { which } from '../src/detect.js';
12
- import { AIS, LEVELS, TOOLS, PROVIDERS, aisForLevel, agentCandidates, byId, npmSpec } from '../src/catalog.js';
13
- import { planFiles, writeFiles, resolveSelection, resolveTools, resolveApis, dirProblems, readManifest, activationSteps, MACHINE_OWNED, RUNTIME, toPosixRel, GENERATOR_VERSION } from '../src/install.js';
14
- // npm resolves to npm.cmd on Windows; spawning that bare name with no shell
15
- // hits the same EINVAL bin/cli-run.mjs's lanes did (Node's fix for
16
- // CVE-2024-27980). windowsSpawnPlan is the same fix reused here rather than
17
- // duplicated: resolve npm's own cmd-shim and run node on it directly, no
18
- // shell, or fall back to the escaped cmd.exe path it also provides.
19
- import { windowsSpawnPlan } from './cli-run.mjs';
11
+ import { AIS, LEVELS, TOOLS, PROVIDERS, aisForLevel, agentCandidates, byId, npmSpec, summaryWithEvidence } from '../src/catalog.js';
12
+ import { planFiles, writeFiles, writePreflightProblems, resolveSelection, resolveTools, resolveApis, dirProblems, readManifest, activationSteps, MACHINE_OWNED, RUNTIME, toPosixRel, GENERATOR_VERSION } from '../src/install.js';
20
13
  import { uninstallFiles } from '../src/uninstall.js';
21
14
  import { assertSnippetPrimary, planSnippetApplication } from '../src/apply-snippets.js';
15
+ import { assignRoles, inferPrimary, roleTable } from '../src/roles.js';
16
+ import { installHealthCheck, signInStatus } from '../src/postinstall.js';
17
+ import { planCompanionApplication } from '../src/apply-companions.js';
22
18
 
23
19
  // One strict parse. Unknown flags, missing values and duplicates are usage
24
20
  // errors (exit 2) before anything is planned, so a typo like --dryy can never
25
21
  // turn a dry run into a real one.
26
22
  const SPEC = {
27
23
  level: 'value', ais: 'value', primary: 'value', dir: 'value', project: 'value', tools: 'value', apis: 'value', plans: 'value',
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'
24
+ 'apply-snippets': 'bool', 'no-apply': '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'
29
25
  };
30
26
  export function parseArgs(argv) {
31
27
  const out = {};
@@ -91,7 +87,7 @@ if (flag('version') || flag('v')) {
91
87
  }
92
88
 
93
89
  if (flag('help') || flag('h')) {
94
- console.log(`model-orchestrator: set up a model orchestrator for the AIs you actually have.
90
+ console.log(`model-orchestrator: set up a model router for the AIs you actually have.
95
91
 
96
92
  Usage
97
93
  npx model-orchestrator interactive
@@ -102,18 +98,19 @@ Usage
102
98
  Flags
103
99
  --level 1|2|3 1 beginner (one agent), 2 intermediate (many CLIs), 3 advanced (plus a VM)
104
100
  --ais a,b,c catalog ids you have access to (see --list)
105
- --primary id the agent that runs the system and receives the subagents (any level). When several qualify
101
+ --primary id the main agent that runs the system and receives the subagents (any level). When several qualify
106
102
  and --yes is set, the run picks one and says so in the plan; pass this to decide it yourself.
107
- --tools a,b companion tools to set up, all optional (default with --yes: codecalc only); --no-tools for none
103
+ --tools a,b companion guides and supported project registration, all optional (default: none)
108
104
  --apis a,b level 3 only: metered API keys you HOLD (anthropic,openai,google,xai,openrouter); --no-apis for none.
109
- Asked separately from the CLIs because a subscription is not an API key.
105
+ Select these with --apis or the edit screen; a subscription is not an API key.
110
106
  --plans a=plan,b=plan stated subscription plans for guidance; --plans none clears prior stated plans
111
107
  --effort-auto consent to write auto effort defaults for selected high or max plan cli-run lanes
112
108
  --dir path where to write the docs and protocols (default ./ai-orchestrator)
113
- --project path the project root your agent runs from; subagent definitions go here (default: current directory,
114
- so set it: a run from your home folder otherwise drops the subagent files there)
115
- --yes skip confirmations
116
- --apply-snippets apply Claude Code rules and hooks with timestamped backups (opt-in)
109
+ --project path the project root your agent runs from (default: current directory);
110
+ choose a project folder: global agent configuration is outside the installer scope
111
+ --yes skip confirmations; requires --level and --ais for a reproducible install
112
+ --apply-snippets apply main-agent rules and supported project config with backups; enables activation with --yes
113
+ --no-apply leave project rules and settings for manual activation (interactive default: apply)
117
114
  --force overwrite every file that already exists, documents included
118
115
  --upgrade-runtime replace the runtime files (cli-run, the audit job, compose, gateway config, setup script) even
119
116
  when they cannot be verified as untouched; documents are still kept
@@ -122,7 +119,8 @@ Flags
122
119
  and reported, and nothing happens without a manifest
123
120
  --uninstall remove unedited managed files recorded in MANIFEST.json; keep and list edited files
124
121
  --dry, --dry-run print the plan, write nothing
125
- --no-install never offer to run npm installs
122
+ --no-install accepted for compatibility; the installer never runs third-party installations
123
+ --no-tools accepted for compatibility; companions default to none
126
124
  --list print the catalog
127
125
  --version, -v print the version and exit
128
126
  --help, -h print this and exit
@@ -135,9 +133,10 @@ if (flag('list')) {
135
133
  console.log('');
136
134
  for (const a of AIS) {
137
135
  const here = a.bin ? (which(a.bin) ? 'installed' : 'not on PATH') : 'app';
138
- console.log(`${a.id.padEnd(13)} ${a.name}\n${''.padEnd(13)} level ${a.minLevel}+ · ${a.access} · ${here}\n${''.padEnd(13)} ${a.role}`);
136
+ const billing = { subscription: 'subscription lane', 'pay-per-token': 'pay-per-token lane', free: 'free lane', local: 'local lane' }[a.facts.billing];
137
+ console.log(`${a.id.padEnd(13)} ${a.name}\n${''.padEnd(13)} level ${a.minLevel}+ · ${billing} · ${here}\n${''.padEnd(13)} ${summaryWithEvidence(a)}`);
139
138
  const install = a.install.npm
140
- ? `npm install -g ${npmSpec(a)}`
139
+ ? 'npm install -g ' + npmSpec(a)
141
140
  : a.install.script
142
141
  ? `curl -fsSL ${a.install.script} -o /tmp/${a.id}-install.sh && less /tmp/${a.id}-install.sh && bash /tmp/${a.id}-install.sh`
143
142
  : a.install.url + (a.install.brew ? ` (or: brew install ${a.install.brew})` : '');
@@ -152,12 +151,19 @@ if (flag('list')) {
152
151
  }
153
152
 
154
153
  const yes = flag('yes');
155
- const rl = yes || flag('uninstall') ? null : makeAsker({ input: stdin, output: stdout });
154
+ let rl = null;
156
155
  const ask = (q, fallback) => (rl ? rl.ask(q, fallback) : Promise.resolve(fallback));
157
156
 
157
+ // Throws instead of exiting directly. Every call site outside the interactive
158
+ // edit screen has no handler in between, so the error rides the promise chain
159
+ // up to main().catch() below, which prints the same message and exits 2: flag
160
+ // validation and --yes keep their hard exit. Inside editSetup() a catch block
161
+ // takes the USAGE code as a signal to re-ask the same field instead of
162
+ // letting the process die on a typo (H1).
158
163
  function bad(msg) {
159
- console.error('model-orchestrator: ' + msg);
160
- process.exit(2);
164
+ const err = new Error(msg);
165
+ err.code = 'USAGE';
166
+ throw err;
161
167
  }
162
168
 
163
169
  function plansFromIds(raw, selected) {
@@ -180,7 +186,194 @@ function plansFromIds(raw, selected) {
180
186
  function plansFromManifest(manifest, selected) {
181
187
  const raw = manifest && manifest.plans;
182
188
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
183
- return plansFromIds(Object.entries(raw).map(([id, plan]) => `${id}=${plan}`).join(','), selected);
189
+ const pairs = Object.entries(raw).filter(([id]) => selected.some((ai) => ai.id === id));
190
+ return pairs.length ? plansFromIds(pairs.map(([id, plan]) => `${id}=${plan}`).join(','), selected) : {};
191
+ }
192
+
193
+ function inferLevel(selected) {
194
+ return Math.min(2, Math.max(1, selected.length >= 2 && selected.some((ai) => ai.facts.cliRun) ? 2 : 1, ...selected.map((ai) => ai.minLevel)));
195
+ }
196
+
197
+ // 0.1.35's --primary auto-pick (git show 20408a8:bin/cli.js), kept verbatim
198
+ // for --yes so a reproducible flag-only run never changes its main agent
199
+ // across a version bump (K1): claude-code first, else the first candidate
200
+ // that can load project agent definitions, else the first candidate in
201
+ // selection order. `inferPrimary`'s capability ranking is for the
202
+ // interactive default only.
203
+ export function legacyPrimary(candidates) {
204
+ if (!candidates.length) return null;
205
+ return candidates.find((ai) => ai.id === 'claude-code') || candidates.find((ai) => ai.facts.agentDefinitions) || candidates[0];
206
+ }
207
+
208
+ // Detection reads filesystem metadata only. A caller can supply the detector
209
+ // for deterministic tests; no detected vendor program is executed here.
210
+ export function inferSetup({ selected, detect = which } = {}) {
211
+ const detected = new Set(AIS.filter((ai) => ai.bin && detect(ai.bin)).map((ai) => ai.id));
212
+ const picks = selected || AIS.filter((ai) => detected.has(ai.id));
213
+ return { detected, selected: picks, level: inferLevel(picks), primary: inferPrimary(agentCandidates(picks)) || null, tools: [], apis: [], plans: {}, effortAuto: [] };
214
+ }
215
+
216
+ function selectedFromIds(raw) {
217
+ const { selected, unknown } = resolveSelection(raw.split(',').map((s) => s.trim()).filter(Boolean));
218
+ if (unknown.length) bad('unknown AI id(s): ' + unknown.join(', ') + ' (see --list)');
219
+ if (!selected.length) bad('pick at least one AI');
220
+ return selected;
221
+ }
222
+
223
+ function numberedPicks(answer, available, noun) {
224
+ if (answer.toLowerCase() === 'none') return [];
225
+ const picked = answer.split(',').map((s) => s.trim()).filter(Boolean).map((number) => {
226
+ const index = Number(number);
227
+ const item = Number.isInteger(index) && index > 0 ? available[index - 1] : null;
228
+ if (!item) bad(`no ${noun} numbered ${number}`);
229
+ return item;
230
+ });
231
+ return [...new Set(picked)];
232
+ }
233
+
234
+ async function askAis(available, detected, selected = []) {
235
+ console.log('\nWhich AIs do you have access to? (numbers, comma-separated; detected ones are marked)');
236
+ available.forEach((ai, i) => console.log(` ${String(i + 1).padStart(2)} ${detected.has(ai.id) ? '*' : ' '} ${ai.name}`));
237
+ const fallback = available.map((ai, i) => selected.includes(ai) ? i + 1 : null).filter(Boolean).join(',');
238
+ return numberedPicks(await ask(`\nYour picks${fallback ? ' [' + fallback + ']' : ''}: `, fallback), available, 'AI');
239
+ }
240
+
241
+ function eligibleEffort(selected, plans) {
242
+ return selected.filter((ai) => ai.facts.cliRun && plans[ai.id] && ['high', 'max'].includes(plans[ai.id].headroom)).map((ai) => ai.id).sort();
243
+ }
244
+
245
+ function validateSetup(state) {
246
+ if (![1, 2, 3].includes(state.level)) bad('level must be 1, 2 or 3');
247
+ if (!state.selected.length) bad('pick at least one AI');
248
+ const tooHigh = state.selected.filter((ai) => ai.minLevel > state.level);
249
+ if (tooHigh.length) bad(`${tooHigh.map((ai) => ai.id).join(', ')} need level ${Math.max(...tooHigh.map((ai) => ai.minLevel))} or higher`);
250
+ const candidates = agentCandidates(state.selected);
251
+ if (!candidates.length) bad('pick at least one agent or chat app to be the orchestrator; a local model runtime on its own cannot run the system');
252
+ if (!candidates.includes(state.primary)) bad('--primary must be one of: ' + candidates.map((ai) => ai.id).join(', '));
253
+ if (state.level < 3 && state.apis.length) bad('--apis only applies at level 3 (the gateway)');
254
+ const dirBad = dirProblems(state.dir);
255
+ if (dirBad.length) bad(dirBad.join('; '));
256
+ const projectBad = dirProblems(state.project);
257
+ if (projectBad.length) bad('--project: ' + projectBad.join('; '));
258
+ }
259
+
260
+ // A typo here must never cost the user the whole session (H1): every field
261
+ // below re-asks itself on a bad answer instead of exiting, and every earlier
262
+ // answer stays put. `bad()`'s hard exit still applies to flags and --yes;
263
+ // only a USAGE error raised while THIS function is running is caught locally.
264
+ async function editSetup(state, sources) {
265
+ while (true) {
266
+ console.log('\nChange what?\n 1 level (1, 2 or 3; 3 needs an always-on Linux machine)\n 2 which AIs\n 3 main agent\n 4 companion tools\n 5 where the files go (folder and project root)\n 6 subscription plans' + (state.level === 3 ? '\n 7 level 3 API keys' : '') + `\n 8 automatic activation (${state.applySnippets ? 'on; select to turn off' : 'off; select to turn on'})` + '\n 0 nothing, back to the summary');
267
+ const choice = await ask('\nNumber [0]: ', '0');
268
+ if (choice === '0') return;
269
+ if (choice === '1') {
270
+ LEVELS.forEach((l) => console.log(` ${l.id} ${l.name}: ${l.tagline}`));
271
+ while (true) {
272
+ const answer = Number(await ask(`Level [${state.level}]: `, String(state.level)));
273
+ if ([1, 2, 3].includes(answer)) { state.level = answer; break; }
274
+ console.log('Pick 1, 2 or 3.');
275
+ }
276
+ state.levelChosen = true;
277
+ sources.delete('level');
278
+ } else if (choice === '2') {
279
+ while (true) {
280
+ try { state.selected = await askAis(AIS, state.detected, state.selected); break; }
281
+ catch (e) { if (!e || e.code !== 'USAGE') throw e; console.log(e.message); }
282
+ }
283
+ sources.delete('ais');
284
+ if (!state.levelChosen) state.level = inferLevel(state.selected);
285
+ if (!state.primaryChosen || !agentCandidates(state.selected).includes(state.primary)) {
286
+ state.primary = inferPrimary(agentCandidates(state.selected)) || null;
287
+ state.primaryChosen = false;
288
+ sources.delete('primary');
289
+ }
290
+ state.plans = Object.fromEntries(Object.entries(state.plans).filter(([id]) => state.selected.some((ai) => ai.id === id)));
291
+ state.effortAuto = state.effortAuto.filter((id) => state.selected.some((ai) => ai.id === id && ai.facts.cliRun));
292
+ } else if (choice === '3') {
293
+ const candidates = agentCandidates(state.selected);
294
+ candidates.forEach((ai, i) => console.log(` ${i + 1} ${ai.name}`));
295
+ const fallback = String(candidates.indexOf(state.primary) + 1);
296
+ while (true) {
297
+ const answer = Number(await ask(`Main agent [${fallback}]: `, fallback));
298
+ if (Number.isInteger(answer) && answer >= 1 && answer <= candidates.length) { state.primary = candidates[answer - 1]; break; }
299
+ console.log(`Pick 1-${candidates.length}.`);
300
+ }
301
+ state.primaryChosen = true;
302
+ sources.delete('primary');
303
+ } else if (choice === '4') {
304
+ console.log('Companion tools (optional): selecting one writes guides and config snippets, and registers supported project config when activation is on. Install prerequisites yourself.');
305
+ TOOLS.forEach((tool, i) => console.log(` ${i + 1} ${tool.name}: ${tool.role}\n ${tool.requires}\n ${tool.optionalNote}`));
306
+ const fallback = TOOLS.map((tool, i) => state.tools.includes(tool) ? i + 1 : null).filter(Boolean).join(',') || 'none';
307
+ while (true) {
308
+ try { state.tools = numberedPicks(await ask(`Companions [${fallback}]: `, fallback), TOOLS, 'tool'); break; }
309
+ catch (e) { if (!e || e.code !== 'USAGE') throw e; console.log(e.message); }
310
+ }
311
+ sources.delete('tools');
312
+ sources.delete('no-tools');
313
+ } else if (choice === '5') {
314
+ while (true) {
315
+ const dir = resolve(await ask(`Docs folder [${state.dir}]: `, state.dir));
316
+ const problems = dirProblems(dir);
317
+ if (!problems.length) { state.dir = dir; break; }
318
+ console.log(problems.join('; '));
319
+ }
320
+ while (true) {
321
+ const project = resolve(await ask(`Project root [${state.project}]: `, state.project));
322
+ const problems = dirProblems(project);
323
+ if (!problems.length) { state.project = project; break; }
324
+ console.log(problems.join('; '));
325
+ }
326
+ sources.delete('dir');
327
+ sources.delete('project');
328
+ } else if (choice === '6') {
329
+ const plans = {};
330
+ for (const ai of state.selected.filter((candidate) => candidate.plans)) {
331
+ const first = ai.plans[0];
332
+ console.log(`\nWhich ${ai.vendor} plan? (checked ${first.checked}, source ${first.source})`);
333
+ ai.plans.forEach((plan, i) => console.log(` ${i + 1} ${plan.name} (${plan.headroom} headroom)`));
334
+ console.log(` ${ai.plans.length + 1} not sure`);
335
+ const fallback = String(state.plans[ai.id] ? ai.plans.indexOf(state.plans[ai.id]) + 1 : ai.plans.length + 1);
336
+ while (true) {
337
+ const answer = Number(await ask(`Plan [${fallback}]: `, fallback));
338
+ if (Number.isInteger(answer) && answer >= 1 && answer <= ai.plans.length + 1) {
339
+ if (answer <= ai.plans.length) plans[ai.id] = ai.plans[answer - 1];
340
+ break;
341
+ }
342
+ console.log(`Pick 1-${ai.plans.length + 1}.`);
343
+ }
344
+ }
345
+ state.plans = plans;
346
+ state.plansChosen = true;
347
+ state.effortChosen = true;
348
+ const eligible = eligibleEffort(state.selected, plans);
349
+ state.effortAuto = eligible.length && /^y/i.test(await ask(`Size reasoning effort per task automatically on ${eligible.join(', ')}? [y/N] `, 'n')) ? eligible : [];
350
+ sources.delete('plans');
351
+ sources.delete('effort-auto');
352
+ } else if (choice === '7' && state.level === 3) {
353
+ console.log('Which metered API keys do you HOLD? A subscription is not an API key. Only variable names are written.');
354
+ PROVIDERS.forEach((provider, i) => console.log(` ${i + 1} ${provider.name} (${provider.envName})`));
355
+ const fallback = PROVIDERS.map((provider, i) => state.apis.includes(provider) ? i + 1 : null).filter(Boolean).join(',') || 'none';
356
+ while (true) {
357
+ try { state.apis = numberedPicks(await ask(`Your keys [${fallback}]: `, fallback), PROVIDERS, 'provider'); break; }
358
+ catch (e) { if (!e || e.code !== 'USAGE') throw e; console.log(e.message); }
359
+ }
360
+ sources.delete('apis');
361
+ sources.delete('no-apis');
362
+ } else if (choice === '8') {
363
+ state.applySnippets = !state.applySnippets;
364
+ sources.delete('apply-snippets');
365
+ sources.delete('no-apply');
366
+ } else {
367
+ console.log('Pick a listed edit number.');
368
+ continue;
369
+ }
370
+ // A single field can pass its own check and still leave the whole setup
371
+ // inconsistent (e.g. a level dropped below what a still-selected AI
372
+ // needs). Catch that here too: report it and go back to the menu instead
373
+ // of exiting, so the user can fix whichever field caused it.
374
+ try { validateSetup(state); return; }
375
+ catch (e) { if (!e || e.code !== 'USAGE') throw e; console.log(e.message); }
376
+ }
184
377
  }
185
378
 
186
379
  async function main() {
@@ -194,218 +387,157 @@ async function main() {
194
387
  const actions = uninstallFiles({ dir, project, dry });
195
388
  console.log(dry ? 'Uninstall preview (--dry): nothing changed.' : 'Uninstall complete.');
196
389
  for (const action of actions) console.log(action);
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.`);
199
- console.log('For another primary agent, remove its pasted activation block from its rules file.');
390
+ console.log('\nRecorded activation entries are removed when unchanged. User edits and backups are kept. Remove any instructions you pasted by hand from their app or rules file.');
200
391
  return;
201
392
  }
202
393
  // Says what this generates, not what it guarantees. The old line promised
203
394
  // routing this package does not perform: lane choice is an instruction an
204
395
  // agent follows, never something enforced here (#11).
205
- console.log('\nmodel-orchestrator\nA model orchestrator: routing rules and a CLI runner for the AIs you actually have.\n');
396
+ console.log('\nmodel-orchestrator\nModel router for AI coding agents: installs routing rules, 8 subagents, hooks and a CLI runner so your AI picks model and effort per task and saves tokens\n');
397
+ if (flag('no-install')) console.log('--no-install is no longer needed: the installer never runs a third-party install.');
206
398
 
207
- // 1. Level
208
- let level = Number(opt('level'));
209
- if (![1, 2, 3].includes(level)) {
210
- if (yes) bad('--level must be 1, 2 or 3 when --yes is set');
211
- console.log('Which level?');
212
- for (const l of LEVELS) console.log(` ${l.id} ${l.name}: ${l.tagline}`);
213
- level = Number(await ask('\nLevel [1]: ', '1'));
214
- if (![1, 2, 3].includes(level)) bad('level must be 1, 2 or 3');
215
- }
399
+ if (!yes) rl = makeAsker({ input: stdin, output: stdout });
400
+ if (yes && ![1, 2, 3].includes(Number(opt('level')))) bad('--level must be 1, 2 or 3 when --yes is set');
401
+ if (yes && !opt('ais')) bad('--ais is required with --yes (comma-separated ids, see --list)');
402
+ if (flag('no-apis') && opt('apis')) bad('--no-apis and --apis contradict each other');
403
+ if (flag('no-apply') && flag('apply-snippets')) bad('--no-apply and --apply-snippets contradict each other');
216
404
 
217
- // 2. Access
218
- const available = aisForLevel(level);
219
- let ids;
220
- if (opt('ais')) {
221
- ids = opt('ais').split(',').map((s) => s.trim()).filter(Boolean);
222
- } else {
223
- if (yes) bad('--ais is required with --yes (comma-separated ids, see --list)');
224
- console.log('\nWhich AIs do you have access to? (numbers, comma-separated; detected ones are marked)');
225
- available.forEach((a, i) => {
226
- const mark = a.bin && which(a.bin) ? '*' : ' ';
227
- console.log(` ${String(i + 1).padStart(2)} ${mark} ${a.name}`);
228
- });
229
- const detected = available.map((a, i) => (a.bin && which(a.bin) ? i + 1 : null)).filter(Boolean);
230
- const fallback = detected.join(',');
231
- const answer = await ask(`\nYour picks${fallback ? ' [' + fallback + ']' : ''}: `, fallback);
232
- ids = answer
233
- .split(',')
234
- .map((s) => s.trim())
235
- .filter(Boolean)
236
- .map((n) => {
237
- const a = available[Number(n) - 1];
238
- if (!a) bad(`no AI numbered ${n}`);
239
- return a.id;
240
- });
405
+ console.log('Looking for AI tools on your PATH...');
406
+ const setup = inferSetup({ selected: opt('ais') ? selectedFromIds(opt('ais')) : undefined });
407
+ console.log(' found: ' + (AIS.filter((ai) => setup.detected.has(ai.id)).map((ai) => `${ai.bin} (${ai.name})`).join(', ') || 'none'));
408
+ if (!setup.selected.length && !yes) {
409
+ const available = opt('level') ? aisForLevel(Number(opt('level'))) : AIS;
410
+ setup.selected = await askAis(available, setup.detected);
241
411
  }
242
- const { selected, unknown } = resolveSelection(ids);
243
- if (unknown.length) bad('unknown AI id(s): ' + unknown.join(', ') + ' (see --list)');
244
- if (!selected.length) bad('pick at least one AI');
245
- const tooHigh = selected.filter((a) => a.minLevel > level);
246
- if (tooHigh.length) bad(`${tooHigh.map((a) => a.id).join(', ')} need level ${Math.max(...tooHigh.map((a) => a.minLevel))} or higher`);
247
-
248
- // 3. Primary agent (the one that runs the system)
249
- const candidates = agentCandidates(selected);
250
- let primary = null;
251
- let primaryAutoPicked = false;
252
- if (opt('primary')) {
253
- primary = byId[opt('primary')];
254
- if (!primary || !candidates.includes(primary)) bad('--primary must be one of: ' + candidates.map((a) => a.id).join(', '));
255
- } else if (candidates.length === 1) {
256
- primary = candidates[0];
257
- } else if (candidates.length === 0) {
258
- bad('pick at least one agent or chat app to be the orchestrator; a local model runtime on its own cannot run the system');
259
- } else if (candidates.length > 1) {
260
- // --yes picks for the user: claude-code if present, else the first agent that can load subagent
261
- // definitions (it gets five files written for it), else the first candidate. #19: codex listed
262
- // before agy used to win and nothing was written to the project root.
263
- if (yes) {
264
- primary = candidates.find((a) => a.id === 'claude-code') || candidates.find((a) => a.agentsDir) || candidates[0];
265
- primaryAutoPicked = true;
266
- }
267
- else {
268
- console.log('\nWhich one is your primary agent (the one that runs the system)?');
269
- candidates.forEach((a, i) => console.log(` ${i + 1} ${a.name}`));
270
- const n = Number(await ask('\nPrimary [1]: ', '1'));
271
- primary = candidates[n - 1];
272
- if (!primary) bad('pick a listed number');
273
- }
412
+ setup.level = opt('level') ? Number(opt('level')) : inferLevel(setup.selected);
413
+ setup.primary = opt('primary') ? byId[opt('primary')]
414
+ : (yes ? legacyPrimary(agentCandidates(setup.selected)) : inferPrimary(agentCandidates(setup.selected))) || null;
415
+ setup.levelChosen = opt('level') !== null;
416
+ setup.primaryChosen = opt('primary') !== null;
417
+ setup.plansChosen = opt('plans') !== null;
418
+ setup.effortChosen = flag('effort-auto');
419
+ setup.dir = resolve(opt('dir') || './ai-orchestrator');
420
+ setup.project = resolve(opt('project') || '.');
421
+ setup.applySnippets = flag('apply-snippets') || (!yes && !flag('no-apply'));
422
+ if (opt('tools')) {
423
+ const result = resolveTools(opt('tools').split(',').map((value) => value.trim()).filter(Boolean));
424
+ if (result.unknown.length) bad('unknown tool id(s): ' + result.unknown.join(', ') + ' (see --list)');
425
+ setup.tools = result.tools;
274
426
  }
275
-
276
- // 3b. Companion tools (not AIs: things the AIs call)
277
- let tools = [];
278
- if (flag('no-tools') && opt('tools')) bad('--no-tools and --tools contradict each other');
279
- if (!flag('no-tools')) {
280
- if (opt('tools')) {
281
- const r = resolveTools(opt('tools').split(',').map((s) => s.trim()).filter(Boolean));
282
- if (r.unknown.length) bad('unknown tool id(s): ' + r.unknown.join(', ') + ' (see --list)');
283
- tools = r.tools;
284
- } else if (yes) {
285
- tools = TOOLS.filter((t) => t.recommended);
286
- } else {
287
- console.log('\nCompanion tools (all optional): things your agents call. Selecting one writes docs and config snippets; it installs nothing.');
288
- for (const t of TOOLS) {
289
- console.log(`\n ${t.id}: ${t.role}\n ${t.repo}\n needs: ${t.requires}\n ${t.optionalNote}`);
290
- const def = t.recommended ? 'y' : 'n';
291
- const a = await ask(` Set up ${t.id}? [${t.recommended ? 'Y/n' : 'y/N'}]: `, def);
292
- if (/^y/i.test(a)) tools.push(t);
293
- }
294
- }
427
+ if (opt('apis')) {
428
+ const result = resolveApis(opt('apis').split(',').map((value) => value.trim()).filter(Boolean));
429
+ if (result.unknown.length) bad('unknown provider id(s): ' + result.unknown.join(', ') + ' (see --list)');
430
+ setup.apis = result.apis;
431
+ if (setup.level < 3) bad('--apis only applies at level 3 (the gateway)');
295
432
  }
433
+ validateSetup(setup);
296
434
 
297
- // 3c. Level 3: which metered API keys the user HOLDS. Separate from the CLI
298
- // question on purpose: a Claude Code plan is not an Anthropic API key.
299
- let apis = [];
300
- if (flag('no-apis') && opt('apis')) bad('--no-apis and --apis contradict each other');
301
- if (level >= 3 && !flag('no-apis')) {
302
- if (opt('apis')) {
303
- const r = resolveApis(opt('apis').split(',').map((s) => s.trim()).filter(Boolean));
304
- if (r.unknown.length) bad('unknown provider id(s): ' + r.unknown.join(', ') + ' (see --list)');
305
- apis = r.apis;
306
- } else if (!yes) {
307
- console.log('\nLevel 3 gateway: which metered API keys do you HOLD? (numbers, comma-separated, or none)');
308
- console.log(' This is separate from the CLIs above: a subscription is not an API key. Only variable NAMES are written; you keep the values in your secrets manager.');
309
- PROVIDERS.forEach((prov, i) => console.log(` ${String(i + 1).padStart(2)} ${prov.name} (${prov.envName})`));
310
- const a = await ask('\nYour keys [none]: ', '');
311
- apis = a
312
- .split(',')
313
- .map((s) => s.trim())
314
- .filter(Boolean)
315
- .map((n) => {
316
- const prov = PROVIDERS[Number(n) - 1];
317
- if (!prov) bad(`no provider numbered ${n}`);
318
- return prov;
319
- });
435
+ let prev = readManifest(setup.dir);
436
+ setup.plans = opt('plans') !== null ? plansFromIds(opt('plans'), setup.selected) : plansFromManifest(prev, setup.selected);
437
+ let plansKept = opt('plans') === null && Object.keys(setup.plans).length > 0;
438
+ setup.effortAuto = flag('effort-auto') ? eligibleEffort(setup.selected, setup.plans)
439
+ : Array.isArray(prev?.effortAuto) ? prev.effortAuto.filter((id) => setup.selected.some((ai) => ai.id === id && ai.facts.cliRun)) : [];
440
+ const sources = new Set(Object.keys(parsed.out));
441
+ let files;
442
+ let plannedManifest;
443
+ let registrations = [];
444
+ while (true) {
445
+ validateSetup(setup);
446
+ const { level, selected, primary, dir, project, tools, apis, plans, effortAuto, detected, applySnippets } = setup;
447
+ if (applySnippets) assertSnippetPrimary(primary);
448
+ files = planFiles({ level, selected, primary, dir, project, tools, apis, plans, effortAuto, detected, applySnippets });
449
+ plannedManifest = JSON.parse(files.find((file) => file.rel === 'MANIFEST.json').content);
450
+ if (applySnippets) files.push(...planSnippetApplication({ primary, project, files }));
451
+ registrations = applySnippets ? planCompanionApplication({ primary, tools, project, files }) : [];
452
+ files.push(...registrations);
453
+ const lvl = LEVELS.find((entry) => entry.id === level);
454
+ const candidates = agentCandidates(selected);
455
+ const agentFiles = files.filter((file) => file.root === 'project');
456
+ const projectKinds = [
457
+ [agentFiles.filter((file) => primary?.facts.agentDefinitions && toPosixRel(file.rel).startsWith(primary.facts.agentDefinitions + '/')).length, 'subagents'],
458
+ [agentFiles.filter((file) => toPosixRel(file.rel).startsWith('.claude/hooks/')).length, 'hooks']
459
+ ].filter(([count]) => count).map(([count, kind]) => `${count} ${kind}`).join(' + ');
460
+ const from = (key) => sources.has(key) ? ` (from --${key})` : '';
461
+ const autoPrimary = !setup.primaryChosen && candidates.length > 1
462
+ ? ` (chosen for you from ${candidates.map((ai) => ai.id).join(', ')}; pass --primary to decide it yourself)` : '';
463
+ const detectedAccess = !sources.has('ais') && selected.every((ai) => detected.has(ai.id)) ? ' (detected on your PATH)' : '';
464
+ const statedPlans = Object.entries(plans).map(([id, plan]) => `${id}=${plan.id}`).join(', ');
465
+ console.log(`\nPlan\n level ${lvl.id} ${lvl.name}: ${lvl.tagline}${from('level')}\n access ${selected.map((ai) => ai.id).join(', ')}${from('ais')}${detectedAccess}\n primary ${primary.id}${from('primary')}${autoPrimary}\n tools ${tools.map((tool) => tool.id).join(', ') || 'none (companions are opt-in)'}${from('tools')}${from('no-tools')}\n plans ${statedPlans || 'not stated (your tools select the model)'}${from('plans')}`
466
+ + (level >= 3 ? `\n api keys ${apis.map((provider) => provider.id).join(', ') || 'none'}${from('apis')}${from('no-apis')}` : '')
467
+ + (effortAuto.length || sources.has('effort-auto') ? `\n auto effort ${effortAuto.join(', ') || 'none eligible'}${from('effort-auto')}` : '')
468
+ + `\n folder ${dir}${from('dir')}\n project ${project}${projectKinds ? ' (' + projectKinds + ' go here)' : ''}${from('project')}\n files ${files.length}`);
469
+ if (plansKept) console.log(' plans kept from the previous run');
470
+ // Q4: automatic activation only ever means a rules file gets written for
471
+ // the primary; a primary with none (a chat app, or a CLI the catalog has
472
+ // no rules file for, such as Grok or Hermes) always keeps a manual step,
473
+ // whatever --apply-snippets or the edit screen say.
474
+ console.log(` activation ${applySnippets && assertSnippetPrimary(primary) ? 'automatic' : 'manual'}${from('apply-snippets')}${from('no-apply')}`);
475
+ for (const file of agentFiles) {
476
+ const path = join(project, file.rel);
477
+ // Q8: a rules file that does not exist yet is created, not updated, and
478
+ // there is nothing to back up. file.original is null exactly when this
479
+ // run is the first to write it.
480
+ const rulesCreated = file.applySnippet && file.rel === primary.rulesFile && file.original === null;
481
+ const action = rulesCreated ? 'create'
482
+ : file.applySnippet ? file.rel === primary.rulesFile ? 'update' : file.rel === join('.claude', 'settings.json') ? 'merge hooks into' : 'merge config into'
483
+ : 'write';
484
+ const note = rulesCreated ? 'new file, marked block'
485
+ : `${file.applySnippet && file.rel === primary.rulesFile ? 'marked block, ' : ''}backup kept if changed`;
486
+ console.log(` ${action} ${path} (${note})`);
320
487
  }
321
- } else if (level < 3 && opt('apis')) {
322
- bad('--apis only applies at level 3 (the gateway)');
323
- }
324
-
325
- // 4. Targets: the docs folder, and the project root the agent runs from
326
- const dir = resolve(opt('dir') || (await ask('\nWrite docs and protocols into [./ai-orchestrator]: ', './ai-orchestrator')));
327
- const dirBad = dirProblems(dir);
328
- if (dirBad.length) bad(dirBad.join('; '));
329
- const project = resolve(opt('project') || (primary && primary.agentsDir && !yes ? await ask(`\nProject root your agent runs from (subagents go in ${primary.agentsDir}/ there) [.]: `, '.') : '.'));
330
- const projectBad = dirProblems(project);
331
- if (projectBad.length) bad('--project: ' + projectBad.join('; '));
332
-
333
- // A prior manifest is read before planning so an omitted reconfiguration
334
- // preserves consent and stated plans instead of silently clearing either.
335
- const prev = readManifest(dir);
336
- let plans;
337
- let plansKept = false;
338
- if (opt('plans') !== null) {
339
- plans = plansFromIds(opt('plans'), selected);
340
- } else if (prev) {
341
- plans = plansFromManifest(prev, selected);
342
- plansKept = Object.keys(plans).length > 0;
343
- } else if (!yes) {
344
- plans = {};
345
- for (const ai of selected.filter((a) => a.plans)) {
346
- const first = ai.plans[0];
347
- console.log(`\nWhich ${ai.vendor} plan? (checked ${first.checked}, source ${first.source})`);
348
- ai.plans.forEach((p, i) => console.log(` ${i + 1} ${p.name} (${p.headroom} headroom)`));
349
- console.log(` ${ai.plans.length + 1} not sure`);
350
- const answer = Number(await ask(`Plan [${ai.plans.length + 1}]: `, String(ai.plans.length + 1)));
351
- if (!Number.isInteger(answer) || answer < 1 || answer > ai.plans.length + 1) bad('pick a listed plan number');
352
- if (answer <= ai.plans.length) plans[ai.id] = ai.plans[answer - 1];
488
+ if (primary.facts.agentDefinitions) console.log(` subagent location: ${join(project, primary.facts.agentDefinitions)}; launch ${primary.bin} from ${project}`);
489
+ if (agentFiles.length && !sources.has('project')) console.log(`\nNote: --project defaults to the current directory, so the ${projectKinds} go to the current directory (${project}). Pass --project to put them somewhere else.`);
490
+ const assignment = assignRoles({ selected, primary, detected, plans });
491
+ const agents = Object.fromEntries(Object.entries(plannedManifest.roles || {}).filter(([, role]) => role.agent).map(([id, role]) => [id, role.agent]));
492
+ console.log('\n' + roleTable(assignment, { selected, primary, detected, agents }));
493
+ if (level >= 2 && !selected.some((ai) => ai.facts.cliRun)) console.log('\nWarning: no executable lanes selected; delegation is inactive. Use level 1 for a single-agent setup, or add a supported CLI. Doctor will exit 13 until a lane is enabled.');
494
+ if (flag('dry') || flag('dry-run')) {
495
+ for (const file of files) console.log(' - ' + (file.root === 'project' ? '[project] ' : '') + file.rel);
496
+ if (applySnippets) {
497
+ const preview = writeFiles(files, { dir, project, dry: true, force: flag('force'), upgradeRuntime: flag('upgrade-runtime'), updateDocs: flag('update-docs'), prevManifest: prev, backupExisting: true });
498
+ for (const path of preview.written) console.log(' would write ' + path);
499
+ for (const path of [...preview.skipped, ...preview.conflicts, ...preview.unverifiable, ...preview.docsConflict, ...preview.docsUnverifiable]) console.log(' would keep ' + path);
500
+ for (const path of preview.backups) console.log(' would back up ' + path);
501
+ }
502
+ console.log('\n--dry: nothing written.');
503
+ rl && rl.close();
504
+ return;
353
505
  }
354
- } else {
355
- plans = {};
356
- }
357
-
358
- const eligibleAuto = selected.filter((a) => a.cliRun && plans[a.id] && ['high', 'max'].includes(plans[a.id].headroom)).map((a) => a.id).sort();
359
- let effortAuto;
360
- if (flag('effort-auto')) {
361
- effortAuto = eligibleAuto;
362
- } else if (prev && Array.isArray(prev.effortAuto)) {
363
- effortAuto = prev.effortAuto.filter((id) => selected.some((a) => a.id === id && a.cliRun));
364
- } else if (!yes && eligibleAuto.length) {
365
- const answer = await ask(`\nSize reasoning effort per task automatically on ${eligibleAuto.join(', ')}? [y/N] `, 'n');
366
- effortAuto = /^y/i.test(answer) ? eligibleAuto : [];
367
- } else {
368
- effortAuto = [];
369
- }
370
-
371
- // 5. Plan
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 }));
376
- const lvl = LEVELS.find((l) => l.id === level);
377
- const agentFiles = files.filter((f) => f.root === 'project');
378
- const projectKinds = [
379
- [agentFiles.filter((f) => primary?.agentsDir && toPosixRel(f.rel).startsWith(primary.agentsDir + '/')).length, 'subagents'],
380
- [agentFiles.filter((f) => toPosixRel(f.rel).startsWith('.claude/hooks/')).length, 'hooks']
381
- ].filter(([count]) => count).map(([count, kind]) => `${count} ${kind}`).join(' + ');
382
- const statedPlans = Object.entries(plans).map(([id, p]) => `${id}=${p.id}`).join(', ');
383
- console.log(`\nPlan\n level ${lvl.id} ${lvl.name}\n access ${selected.map((a) => a.id).join(', ')}\n primary ${primary ? primary.id : 'none'}${primaryAutoPicked ? ` (chosen for you from ${candidates.map((a) => a.id).join(', ')}; pass --primary to decide it yourself)` : ''}\n tools ${tools.map((t) => t.id).join(', ') || 'none'}\n plans ${statedPlans || 'none stated'}` + (level >= 3 ? `\n api keys ${apis.map((p) => p.id).join(', ') || 'none'}` : '') + `\n folder ${dir}\n project ${project}${projectKinds ? ' (' + projectKinds + ' go here)' : ''}\n files ${files.length}`);
384
- if (plansKept) console.log(' plans kept from the previous run');
385
- if (agentFiles.length && !opt('project')) {
386
- console.log(`\nNote: --project defaults to the current directory, so the ${projectKinds} go to the current directory (${project}). Pass --project to put them somewhere else.`);
387
- }
388
- if (level >= 2 && !selected.some((a) => a.cliRun)) {
389
- console.log('\nWarning: no executable lanes selected; delegation is inactive. Use level 1 for a single-agent setup, or add a supported CLI. Doctor will exit 13 until a lane is enabled.');
390
- }
391
- if (flag('dry') || flag('dry-run')) {
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);
506
+ if (yes) break;
507
+ const go = (await ask('\nWrite these files?\n[Y/n/e] (e to change anything above): ', 'y')).toLowerCase();
508
+ if (go === 'y' || go === 'yes') break;
509
+ if (go === 'e') {
510
+ const oldDir = setup.dir;
511
+ const oldPlans = setup.plans;
512
+ await editSetup(setup, sources);
513
+ if (setup.plans !== oldPlans) plansKept = false;
514
+ if (oldDir !== setup.dir) {
515
+ prev = readManifest(setup.dir);
516
+ if (!setup.plansChosen) {
517
+ setup.plans = plansFromManifest(prev, setup.selected);
518
+ plansKept = Object.keys(setup.plans).length > 0;
519
+ }
520
+ if (!setup.effortChosen) setup.effortAuto = Array.isArray(prev?.effortAuto) ? prev.effortAuto.filter((id) => setup.selected.some((ai) => ai.id === id && ai.facts.cliRun)) : [];
521
+ }
522
+ continue;
398
523
  }
399
- console.log('\n--dry: nothing written.');
400
- rl && rl.close();
401
- return;
402
- }
403
- const go = yes ? 'y' : await ask('\nWrite these files? [Y/n]: ', 'y');
404
- if (!/^y/i.test(go)) {
524
+ if (go !== 'n' && go !== 'no') bad('answer y, n or e');
405
525
  console.log('Nothing written.');
406
526
  rl && rl.close();
407
527
  return;
408
528
  }
529
+ const { level, selected, primary, dir, project, tools, apis, plans, effortAuto, applySnippets } = setup;
530
+ // Q1 safety: a doomed write (global agent config, path escape) must never
531
+ // first run a real vendor status command such as `claude auth status`.
532
+ // Check the same path safety writeFiles() will check, before any subprocess.
533
+ const earlyProblems = writePreflightProblems(files, { dir, project });
534
+ if (earlyProblems.length) bad('refusing to write:\n ' + earlyProblems.join('\n '));
535
+ const authStatuses = signInStatus(selected);
536
+ // Final remaining steps include the observed sign-in and MCP merge results.
537
+ // Render before writing so README ownership and preservation stay unchanged.
538
+ const applications = files.filter(file => file.applySnippet);
539
+ files = [...planFiles({ ...setup, authStatuses, registrations }), ...applications];
540
+ plannedManifest = JSON.parse(files.find(file => file.rel === 'MANIFEST.json').content);
409
541
 
410
542
  // Reconfiguration: compare what a previous run recorded with what was asked now.
411
543
  const changed = prev
@@ -413,11 +545,12 @@ async function main() {
413
545
  .concat(['ais', 'tools', 'apis'].filter((k) => JSON.stringify(prev[k] || []) !== JSON.stringify({ ais: selected, tools, apis }[k].map((x) => x.id))))
414
546
  .concat(JSON.stringify(prev.plans || {}) !== JSON.stringify(Object.fromEntries(Object.entries(plans).map(([id, p]) => [id, p.id]))) ? ['plans'] : [])
415
547
  .concat(JSON.stringify((prev.effortAuto || []).slice().sort()) !== JSON.stringify(effortAuto.slice().sort()) ? ['effortAuto'] : [])
548
+ .concat(JSON.stringify(prev.roles || {}) !== JSON.stringify(plannedManifest.roles || {}) ? ['roles'] : [])
416
549
  : [];
417
550
 
418
- let written, skipped, upgraded, conflicts, unverifiable, docsUpdated, docsConflict, docsUnverifiable;
551
+ let written, skipped, upgraded, conflicts, unverifiable, docsUpdated, docsConflict, docsUnverifiable, docsRenamed;
419
552
  try {
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) }));
553
+ ({ written, skipped, upgraded, conflicts, unverifiable, docsUpdated, docsConflict, docsUnverifiable, docsRenamed } = 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) }));
421
554
  } catch (e) {
422
555
  if (e && e.code === 'PREFLIGHT') bad(e.message);
423
556
  throw e;
@@ -432,13 +565,14 @@ async function main() {
432
565
  if (changed.length) {
433
566
  console.log(` selection changed: ${changed.join(', ')}`);
434
567
  console.log(` applied: ${ownedWritten.join(', ') || 'nothing'} (machine-owned files are always rewritten, so the new lanes are live)`);
568
+ if (changed.includes('roles')) console.log(' the role assignment changed; MANIFEST.json and aunx route are current. Any kept documents may still carry the previous assignment.');
435
569
  } else console.log(' selection identical.');
436
570
  }
437
571
  if (upgraded.length) console.log(` runtime upgraded: ${upgraded.join(', ')} ${flag('upgrade-runtime') ? '(--upgrade-runtime: replaced whether or not you had edited them)' : '(each installed copy matched the hash of a previous run, so nobody had edited it)'}`);
438
572
  if (conflicts.length) {
439
573
  console.log(` runtime CONFLICT, kept: ${conflicts.join(', ')}`);
440
574
  console.log(' these differ from what a previous run generated, so you edited them. The fixes in this release were NOT applied to them.');
441
- console.log(' Options: move your copy aside and re-run; or --upgrade-runtime to replace runtime files only; or --force to replace everything.');
575
+ console.log(' Options: move your copy aside and re-run; or --upgrade-runtime to replace runtime files only; or --force to replace current generated files.');
442
576
  }
443
577
  if (unverifiable.length) {
444
578
  console.log(` runtime kept, UNVERIFIABLE: ${unverifiable.join(', ')}`);
@@ -446,54 +580,39 @@ async function main() {
446
580
  console.log(' Re-run with --upgrade-runtime to replace runtime files only (documents stay), or --force to replace everything.');
447
581
  }
448
582
  if (docsUpdated.length) console.log(` documents updated: ${docsUpdated.join(', ')} (each matched the hash of a previous run, so nobody had edited them)`);
583
+ if (docsRenamed.length) console.log(` documents renamed: ${docsRenamed.join(', ')} (the old copy matched its installed hash)`);
449
584
  if (docsConflict.length) {
450
585
  console.log(` document CONFLICT, kept: ${docsConflict.join(', ')}`);
451
- console.log(' these differ from what a previous run generated, so you edited them. Edit them by hand, or --force to replace everything.');
586
+ console.log(' these differ from what a previous run generated, so you edited them. Edit them by hand; --force replaces current generated paths. Edited legacy briefs stay.');
452
587
  }
453
588
  if (docsUnverifiable.length) {
454
589
  console.log(` documents kept, UNVERIFIABLE: ${docsUnverifiable.join(', ')}`);
455
- console.log(' the previous install left no manifest, so --update-docs cannot tell your edits from generated text. --force replaces everything.');
590
+ console.log(' the previous install left no manifest, so --update-docs cannot tell your edits from generated text. --force replaces current generated paths. Unverifiable legacy briefs stay.');
456
591
  }
457
592
  if (skipped.length && prev && changed.length && !flag('update-docs')) console.log(' documents kept: they may describe the old selection. --update-docs regenerates the ones you have not edited; --force regenerates all of them (this overwrites your edits).');
458
593
  }
459
594
 
460
- // 6. Installs, opt-in per CLI, npm only. Vendor scripts are printed, never run.
595
+ // 6. Print manual installation commands for missing CLIs.
461
596
  const missing = selected.filter((a) => a.bin && !which(a.bin));
462
597
  if (missing.length) {
463
- console.log('\nNot found on this machine (presence is checked; installed versions are not validated):');
598
+ console.log('\nInstall these yourself (binary presence checked; versions need your verification):');
464
599
  for (const a of missing) {
465
- if (a.install.npm) {
466
- const spec = npmSpec(a); // the same pinned spec the table and the box script use
467
- const run = flag('no-install') || yes ? 'n' : await ask(` ${a.name}: run \`npm install -g ${spec}\` now? [y/N]: `, 'n');
468
- if (/^y/i.test(run)) {
469
- // Opt-in cmd.exe fallback: npm.cmd is not a cmd-shim, and every argument here
470
- // is the catalog's pinned spec, never user text (lanes refuse this path).
471
- const plan = windowsSpawnPlan([which('npm') || 'npm', 'install', '-g', spec], process.platform, { allowCmdFallback: true });
472
- const r = spawnSync(plan.command, plan.args, { stdio: 'inherit', ...plan.options });
473
- console.log(r.status === 0 ? ` installed ${spec}` : ` npm exited ${r.status}; install it by hand`);
474
- } else {
475
- console.log(` ${a.name}: npm install -g ${spec} (pinned to the version this installer was released with)`);
476
- }
477
- } else if (a.install.script) {
478
- console.log(` ${a.name}: the vendor installer is a shell script. Download it, read it, then run it:\n curl -fsSL ${a.install.script} -o /tmp/${a.id}-install.sh && less /tmp/${a.id}-install.sh && bash /tmp/${a.id}-install.sh`);
479
- } else {
480
- console.log(` ${a.name}: ${a.install.url}` + (a.install.brew ? ` (or: brew install ${a.install.brew})` : ''));
481
- }
482
- console.log(` sign in: ${a.auth}`);
600
+ const command = a.install.npm
601
+ ? 'npm install -g ' + npmSpec(a)
602
+ : a.install.script
603
+ ? `curl -fsSL ${a.install.script} -o /tmp/${a.id}-install.sh && less /tmp/${a.id}-install.sh && bash /tmp/${a.id}-install.sh`
604
+ : a.install.brew ? `brew install ${a.install.brew}` : 'Follow the vendor setup guide';
605
+ console.log(` ${a.name}: ${command}\n official guide: ${a.install.url || a.install.script}`);
483
606
  }
484
607
  }
485
608
 
486
- for (const t of tools) {
487
- const doc = t.id.toUpperCase() + '.md';
488
- console.log(`\n${t.name}\n note: ${t.optionalNote}\n needs: ${t.requires}\n run: ${t.install}\n one-click or self-registering for: ${t.autoClients.join(', ')}. Other agents and the details: ${dir}/${doc}`);
489
- }
490
-
491
- // 7. Activation summary: writing the folder is half the job. Say exactly what
492
- // turns it on, in order, with one command that proves it. The generated
493
- // README renders this same array, so the two surfaces cannot disagree (#20).
494
- const steps = activationSteps({ level, selected, primary, tools, dir, project, applySnippets });
495
- console.log('\nTo activate, in order:');
609
+ // 7. Run the presence check, then print only the remaining actions. The
610
+ // generated README renders the same array for a fresh installation (#20).
611
+ await installHealthCheck({ level, selected, primary, dir });
612
+ const steps = activationSteps({ level, selected, primary, tools, dir, project, applySnippets, authStatuses, registrations });
613
+ console.log("\nWhat's left for you:");
496
614
  steps.forEach((st, i) => console.log(` ${i + 1}. ${st}`));
615
+ if (!steps.length) console.log(' Nothing left to do.');
497
616
  console.log(`\nStart here: ${join(dir, 'README.md')} (written for level ${level} and the AIs you picked).`);
498
617
  console.log('\nIf this saved you time, a star helps people find it: https://github.com/aunysillyme/model-orchestrator\n');
499
618
  rl && rl.close();
@@ -510,5 +629,5 @@ function isEntryPoint() {
510
629
  }
511
630
  if (isEntryPoint()) main().catch((e) => {
512
631
  console.error('model-orchestrator: ' + (e && e.message ? e.message : e));
513
- process.exit(e && ['EOF', 'UNINSTALL', 'PREFLIGHT'].includes(e.code) ? 2 : 1);
632
+ process.exit(e && ['EOF', 'UNINSTALL', 'PREFLIGHT', 'USAGE'].includes(e.code) ? 2 : 1);
514
633
  });