model-orchestrator 0.1.4

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 (81) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +21 -0
  3. package/README.md +133 -0
  4. package/SECURITY.md +17 -0
  5. package/bin/README.md +10 -0
  6. package/bin/cli-run.mjs +599 -0
  7. package/bin/cli.js +372 -0
  8. package/docs/README.md +13 -0
  9. package/docs/audit-brief.md +83 -0
  10. package/docs/catalog.md +113 -0
  11. package/docs/part-1-beginner.md +65 -0
  12. package/docs/part-2-intermediate.md +65 -0
  13. package/docs/part-3-advanced.md +65 -0
  14. package/package.json +52 -0
  15. package/scripts/README.md +5 -0
  16. package/scripts/gen-catalog.js +37 -0
  17. package/src/README.md +9 -0
  18. package/src/catalog.js +277 -0
  19. package/src/detect.js +26 -0
  20. package/src/install.js +628 -0
  21. package/src/prompt.js +34 -0
  22. package/src/render.js +8 -0
  23. package/templates/README.md +14 -0
  24. package/templates/advanced/README.md +14 -0
  25. package/templates/advanced/vm/ENVIRONMENT.md +18 -0
  26. package/templates/advanced/vm/PRIVACY_GATES.md +33 -0
  27. package/templates/advanced/vm/README.md +60 -0
  28. package/templates/advanced/vm/box-CLAUDE.md +28 -0
  29. package/templates/advanced/vm/docker-compose.yml +19 -0
  30. package/templates/advanced/vm/gateway.config.yaml +12 -0
  31. package/templates/advanced/vm/jobs/README.md +39 -0
  32. package/templates/advanced/vm/jobs/weekly-audit.service +17 -0
  33. package/templates/advanced/vm/jobs/weekly-audit.sh +107 -0
  34. package/templates/advanced/vm/jobs/weekly-audit.timer +10 -0
  35. package/templates/advanced/vm/setup-vm.sh +46 -0
  36. package/templates/agents/README.md +13 -0
  37. package/templates/agents/agy/README.md +5 -0
  38. package/templates/agents/agy/builder.md +17 -0
  39. package/templates/agents/agy/bulk-worker.md +17 -0
  40. package/templates/agents/agy/code-reviewer.md +17 -0
  41. package/templates/agents/agy/deep-planner.md +17 -0
  42. package/templates/agents/agy/live-researcher.md +17 -0
  43. package/templates/agents/claude-code/README.md +13 -0
  44. package/templates/agents/claude-code/builder.md +17 -0
  45. package/templates/agents/claude-code/bulk-worker.md +18 -0
  46. package/templates/agents/claude-code/code-reviewer.md +19 -0
  47. package/templates/agents/claude-code/deep-planner.md +18 -0
  48. package/templates/agents/claude-code/live-researcher.md +18 -0
  49. package/templates/agents/snippets/chat.md +25 -0
  50. package/templates/agents/snippets/claude-code.md +27 -0
  51. package/templates/agents/snippets/generic.md +21 -0
  52. package/templates/beginner/ORCHESTRATOR.md +55 -0
  53. package/templates/beginner/README.md +3 -0
  54. package/templates/common/README.md +52 -0
  55. package/templates/common/TASK_BUNDLE.md +56 -0
  56. package/templates/common/protocols/README.md +14 -0
  57. package/templates/common/protocols/build-protocol.md +133 -0
  58. package/templates/common/protocols/deep-research.md +44 -0
  59. package/templates/common/protocols/gap-analysis.md +28 -0
  60. package/templates/common/protocols/memory-and-record.md +30 -0
  61. package/templates/common/protocols/numbers-and-logic.md +35 -0
  62. package/templates/common/protocols/propagate.md +34 -0
  63. package/templates/intermediate/CLI-RUN.md +100 -0
  64. package/templates/intermediate/DELEGATION_MATRIX.md +41 -0
  65. package/templates/intermediate/README.md +13 -0
  66. package/templates/intermediate/RESEARCH_TRIAGE.md +30 -0
  67. package/templates/intermediate/ROUTING.md +73 -0
  68. package/templates/intermediate/TIERS.md +44 -0
  69. package/templates/tools/README.md +10 -0
  70. package/templates/tools/codecalc/CODECALC.md +43 -0
  71. package/templates/tools/codecalc/mcp/agy.mcp_config.json +8 -0
  72. package/templates/tools/codecalc/mcp/codex.config.toml +4 -0
  73. package/templates/tools/codecalc/mcp/mcpServers.json +8 -0
  74. package/templates/tools/codecalc/mcp/vscode.mcp.json +8 -0
  75. package/templates/tools/codecalc/mcp/zed.settings.json +9 -0
  76. package/templates/tools/obsidian-tc/OBSIDIAN-TC.md +65 -0
  77. package/templates/tools/obsidian-tc/mcp/obsidian-tc.agy.mcp_config.json +9 -0
  78. package/templates/tools/obsidian-tc/mcp/obsidian-tc.codex.config.toml +7 -0
  79. package/templates/tools/obsidian-tc/mcp/obsidian-tc.mcpServers.json +9 -0
  80. package/templates/tools/obsidian-tc/mcp/obsidian-tc.vscode.mcp.json +10 -0
  81. package/templates/tools/obsidian-tc/mcp/obsidian-tc.zed.settings.json +9 -0
package/src/install.js ADDED
@@ -0,0 +1,628 @@
1
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync, readdirSync, statSync, lstatSync, unlinkSync, realpathSync } from 'node:fs';
2
+ import { join, dirname, relative, resolve, sep, parse as parsePath, posix } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { render } from './render.js';
5
+ import { createHash } from 'node:crypto';
6
+ import { AIS, LEVELS, TOOLS, PROVIDERS, IMAGES, byId, toolById, providerById, npmSpec } from './catalog.js';
7
+
8
+ const HERE = dirname(fileURLToPath(import.meta.url));
9
+ export const GENERATOR_VERSION = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')).version;
10
+ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
11
+ export const TEMPLATES = join(HERE, '..', 'templates');
12
+ export const CLI_RUN_SRC = join(HERE, '..', 'bin', 'cli-run.mjs');
13
+
14
+ function walk(dir, base = dir) {
15
+ const out = [];
16
+ if (!existsSync(dir)) return out;
17
+ for (const name of readdirSync(dir)) {
18
+ const p = join(dir, name);
19
+ if (statSync(p).isDirectory()) out.push(...walk(p, base));
20
+ else out.push({ abs: p, rel: relative(base, p) });
21
+ }
22
+ return out;
23
+ }
24
+
25
+ // A README.md at the ROOT of a template tier (beginner/, intermediate/,
26
+ // advanced/, agents/*) documents this repo's folder and is not installed.
27
+ // common/README.md is the exception: it is the user's start-here file.
28
+ // Deeper README.md files (protocols/, vm/) are installed, they index the
29
+ // folder they sit in.
30
+ function installable(sub, rel) {
31
+ if (rel !== 'README.md') return true;
32
+ return sub === 'common';
33
+ }
34
+
35
+ function table(rows, header) {
36
+ const line = (cells) => `| ${cells.join(' | ')} |`;
37
+ return [line(header), line(header.map(() => '---')), ...rows.map(line)].join('\n');
38
+ }
39
+
40
+ export function lanesTable(selected) {
41
+ const rows = selected.map((a) => [
42
+ a.name,
43
+ a.lane === 'A' ? 'A (subscription, $0 per call)' : a.lane === 'B' ? 'B (metered)' : a.lane === 'local' ? 'local' : 'chat',
44
+ a.role,
45
+ a.cliRun ? '`cli-run ' + a.id + '`' : a.bin ? '`' + a.bin + '`' : 'the app'
46
+ ]);
47
+ return table(rows, ['AI', 'Lane', 'Wins at', 'Call it with']);
48
+ }
49
+
50
+ export function installTable(selected) {
51
+ const rows = selected.map((a) => {
52
+ const how = a.install.npm
53
+ ? '`npm install -g ' + npmSpec(a) + '` (pinned; check for newer)'
54
+ : a.install.script
55
+ ? 'vendor script: ' + a.install.script
56
+ : a.install.url;
57
+ return [a.name, how, a.auth];
58
+ });
59
+ return table(rows, ['AI', 'Install', 'Sign in']);
60
+ }
61
+
62
+ // Gateway lanes come from the metered API keys the user said they HOLD, never
63
+ // from which subscription CLIs they selected: those are different entitlements.
64
+ // Only variable NAMES appear here. The installer never writes a value.
65
+ export function gatewayModels(selected, apis = []) {
66
+ const lines = [];
67
+ if (selected.some((a) => a.id === 'ollama')) {
68
+ lines.push(' - model_name: local-small', ' litellm_params:', ' model: ollama/llama3.2:3b', ' api_base: http://ollama:11434');
69
+ }
70
+ for (const prov of apis) {
71
+ for (const [alias, model] of prov.lanes) {
72
+ lines.push(` - model_name: ${alias}`, ' litellm_params:', ` model: ${model}`, ` api_key: os.environ/${prov.envName}`);
73
+ }
74
+ }
75
+ if (!lines.length) lines.push(' # No provider key and no local runtime selected. Add one lane per provider here; keys stay in the environment.');
76
+ return lines.join('\n');
77
+ }
78
+
79
+ export function envNames(selected, apis = []) {
80
+ const names = new Set(apis.map((prov) => prov.envName));
81
+ names.add('GATEWAY_MASTER_KEY');
82
+ return [...names];
83
+ }
84
+
85
+ export function scriptInstallers(selected) {
86
+ const lines = [];
87
+ for (const a of selected) {
88
+ if (a.install.script) lines.push(`say " ${a.name}: curl -fsSL ${a.install.script} -o /tmp/${a.id}-install.sh && less /tmp/${a.id}-install.sh && bash /tmp/${a.id}-install.sh"`);
89
+ else if (a.install.url && a.kind !== 'chat') lines.push(`say " ${a.name}: ${a.install.url}"`);
90
+ }
91
+ return lines.length ? lines.join('\n') : 'say " none"';
92
+ }
93
+
94
+ export function composeEnv(selected, apis = []) {
95
+ // Pass-through of NAMES only. Compose substitutes each from the host environment.
96
+ const rows = envNames(selected, apis)
97
+ .filter((n) => n !== 'GATEWAY_MASTER_KEY')
98
+ .map((n) => ` - ${n}=\${${n}:-}`);
99
+ return rows.length ? rows.join('\n') : ' # no metered provider keys selected';
100
+ }
101
+
102
+ export function composeOllama(selected) {
103
+ if (!selected.some((a) => a.id === 'ollama')) return ' # no local runtime selected';
104
+ return [
105
+ ' ollama:',
106
+ ` image: ${IMAGES.ollama}`,
107
+ ' container_name: ollama',
108
+ ' restart: unless-stopped',
109
+ ' ports:',
110
+ ' - "127.0.0.1:11434:11434"',
111
+ ' volumes:',
112
+ ' - ollama:/root/.ollama',
113
+ '',
114
+ 'volumes:',
115
+ ' ollama: {}'
116
+ ].join('\n');
117
+ }
118
+
119
+ // --dir is rendered into a bash script and a systemd unit. It is data there,
120
+ // never syntax: single-quoted for bash (the only character that needs care
121
+ // inside single quotes is the quote itself), and %-escaped for systemd, whose
122
+ // specifiers begin with %. Control characters and newlines are refused outright
123
+ // because no quoting convention survives them in both grammars.
124
+ export function shellQuote(v) {
125
+ return "'" + String(v).replace(/'/g, "'\\''") + "'";
126
+ }
127
+ export function systemdEscape(v) {
128
+ return String(v).replace(/%/g, '%%');
129
+ }
130
+ export function dirProblems(dir) {
131
+ const abs = resolve(dir);
132
+ const problems = [];
133
+ if (/[\x00-\x1f\x7f]/.test(abs)) problems.push('the target path contains control characters or a newline');
134
+ if (abs === parsePath(abs).root) problems.push('the target is the filesystem root; pick a folder');
135
+ return problems;
136
+ }
137
+
138
+ // The lane the generated weekly audit calls: the first ENABLED cli-run lane
139
+ // in this preference order. None enabled means the job refuses at run time
140
+ // (exit 13) instead of calling a lane the installer disabled.
141
+ export const AUDIT_LANE_ORDER = ['hermes', 'qwen', 'codex', 'agy', 'grok'];
142
+ export function auditLane(selected) {
143
+ const enabled = new Set(selected.filter((a) => a.cliRun).map((a) => a.id));
144
+ return AUDIT_LANE_ORDER.find((l) => enabled.has(l)) || null;
145
+ }
146
+
147
+ // Everything ROUTING.md and RESEARCH_TRIAGE.md say about lanes is rendered
148
+ // from the lanes the user actually has. A generated manual must never
149
+ // recommend a command its own lanes.json disables.
150
+ export function laneVars(selected) {
151
+ const has = (id) => selected.some((a) => a.id === id);
152
+ const enabled = selected.filter((a) => a.cliRun).map((a) => a.id);
153
+ const cr = (id) => '`cli-run ' + id + '`';
154
+ const step0 = [];
155
+ if (has('hermes')) step0.push(`${cr('hermes')} (the free tier) for rough drafts and divergent reads`);
156
+ if (has('qwen')) step0.push(`${cr('qwen')} (the cheapest metered lane) for structured bulk, never for anything citing a line, number or source`);
157
+ if (has('grok')) step0.push(`${cr('grok')} for X and live web reads at $0`);
158
+ if (has('codex')) step0.push(`${cr('codex --audit')} for an adversarial read by a second model family`);
159
+ if (has('agy')) step0.push(`${cr('agy')} for research sweeps and concurrent fan-out`);
160
+ const stage1 = [];
161
+ if (has('codex')) stage1.push(`${cr('codex')} for adversarial critique of the map`);
162
+ if (has('grok')) stage1.push(`${cr('grok')} to verify current API behaviour instead of trusting recall`);
163
+ if (has('hermes')) stage1.push(`${cr('hermes')} for a divergent read`);
164
+ if (has('agy')) stage1.push(`${cr('agy')} for a wide sweep of prior art`);
165
+ const examples = [];
166
+ examples.push(has('grok') ? `| "What is trending on X today" | ${cr('grok')} |` : '| "What is trending on X today" | live-researcher (standard tier with web tools) |');
167
+ examples.push(has('codex') ? `| "Audit this auth diff" | ${cr('codex --audit')} |` : '| "Audit this auth diff" | code-reviewer at deep tier, in a fresh context told to attack |');
168
+ examples.push(has('qwen') ? `| "Classify these 200 items" | bulk-worker, or ${cr('qwen')} if the items may leave the machine |` : '| "Classify these 200 items" | bulk-worker |');
169
+ examples.push(enabled.length >= 2 ? '| "Research this topic properly" | several engines in parallel, see `RESEARCH_TRIAGE.md` |' : '| "Research this topic properly" | deep tier plans, standard tier sweeps, a fresh context attacks; see `RESEARCH_TRIAGE.md` |');
170
+ const roles = [];
171
+ if (has('agy')) roles.push('| Web sweep | `cli-run agy` | widest landscape pass |');
172
+ if (has('codex')) roles.push('| Adversarial read | `cli-run codex --audit` | attack the premise, hunt for what the others would get wrong |');
173
+ if (has('grok')) roles.push('| Live data | `cli-run grok` | dated primary sources, real-time reads |');
174
+ if (has('hermes')) roles.push('| Cheap divergent read | `cli-run hermes` | another opinion at $0 |');
175
+ if (has('qwen')) roles.push('| Structured extraction | `cli-run qwen` | pull the facts into a table; never trust its citations without a check |');
176
+ roles.push('| Triage + the durable record | the orchestrator | opens primary sources, marks every claim, writes the artifact |');
177
+ const run = [];
178
+ if (has('agy')) run.push('node bin/cli-run.mjs agy --brief "$BRIEF" --timeout 900 > research/out-agy.md');
179
+ if (has('codex')) run.push('node bin/cli-run.mjs codex --audit --brief "$BRIEF" --timeout 900 > research/out-codex.md');
180
+ if (has('grok')) run.push('node bin/cli-run.mjs grok --brief "$BRIEF" --timeout 900 > research/out-grok.md');
181
+ if (has('hermes')) run.push('node bin/cli-run.mjs hermes --brief "$BRIEF" --timeout 900 > research/out-hermes.md');
182
+ if (has('qwen')) run.push('node bin/cli-run.mjs qwen --brief "$BRIEF" --timeout 900 > research/out-qwen.md');
183
+ return {
184
+ 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)',
185
+ STAGE1_LANES: stage1.length ? '; ' + stage1.join(', ') : '',
186
+ 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 attack and allowed to answer CLEAN',
187
+ LIVE_LANE: has('grok') ? '`cli-run grok` first ($0), then' : '',
188
+ BULK_LANE: has('qwen') ? ', or `cli-run qwen` if the data may leave your machine' : has('hermes') ? ', or `cli-run hermes` for a free rough pass' : '',
189
+ LANE_EXAMPLES: examples.join('\n'),
190
+ RESEARCH_ROLES: roles.join('\n'),
191
+ RESEARCH_RUN: run.length ? run.join('\n') : '# no cli-run lane selected: run the sweep on your primary agent, then a fresh adversarial turn (protocols/deep-research.md, level 1 shape)',
192
+ RESEARCH_ENGINES: String(run.length)
193
+ };
194
+ }
195
+
196
+ function vars(opts) {
197
+ const { level, selected, primary } = opts;
198
+ const tools = opts.tools || [];
199
+ const apis = opts.apis || [];
200
+ const lvl = LEVELS.find((l) => l.id === level);
201
+ const lane = auditLane(selected);
202
+ const codecalc = tools.some((t) => t.id === 'codecalc');
203
+ const dirAbs = resolve(opts.dir || 'ai-orchestrator');
204
+ const projectAbs = resolve(opts.project || process.cwd());
205
+ let rulesPath = relative(projectAbs, dirAbs).split(sep).join(posix.sep);
206
+ if (rulesPath === '') rulesPath = '.';
207
+ else if (rulesPath.startsWith('..')) rulesPath = dirAbs; // outside the project: absolute is the only honest path
208
+ const pinOf = (id) => (toolById[id] && toolById[id].pin) || 'latest';
209
+ return {
210
+ ...laneVars(selected),
211
+ RULES_PATH: rulesPath,
212
+ ROUTING_FILE: level >= 2 ? 'ROUTING.md' : 'ORCHESTRATOR.md',
213
+ PROJECT_DIR: projectAbs,
214
+ AGENTS_DIR: primary && primary.agentsDir ? join(projectAbs, primary.agentsDir) : 'none (your primary agent has no subagent folder)',
215
+ LITELLM_IMAGE: IMAGES.litellm,
216
+ OLLAMA_IMAGE: IMAGES.ollama,
217
+ CODECALC_PIN: pinOf('codecalc'),
218
+ OBSIDIAN_TC_PIN: pinOf('obsidian-tc'),
219
+ APIS_LIST: apis.length ? apis.map((prov) => '- ' + prov.name + ' (`' + prov.envName + '`)').join('\n') : '- none: no metered provider key was selected, so the gateway serves only a local lane if you picked one',
220
+ INSTALL_DIR: dirAbs,
221
+ INSTALL_DIR_SH: shellQuote(dirAbs),
222
+ INSTALL_DIR_SYSTEMD: systemdEscape(dirAbs),
223
+ AUDIT_LANE: lane || 'none',
224
+ // Enforced boundary per lane: codex has a read-only sandbox flag; the others
225
+ // run with whatever their own config allows, and the script says so.
226
+ AUDIT_LANE_FLAGS: lane === 'codex' ? '--audit' : '',
227
+ AUDIT_LANE_BOUNDARY_NOTE: lane === 'codex'
228
+ ? 'codex --audit, a read-only filesystem sandbox; commands and network follow the codex config'
229
+ : lane
230
+ ? `${lane} offers no sandbox flag cli-run can pass, so the denied-actions list is instruction-level only and enforcement is whatever ${lane}'s own permission config allows`
231
+ : 'no lane selected',
232
+ AUDIT_LANE_GUARD: lane
233
+ ? ''
234
+ : 'echo "weekly-audit: no cli-run lane was enabled at install time; enable one in bin/lanes.json and edit AUDIT_LANE" >&2; exit 13',
235
+ TOOLS_LIST: tools.length ? tools.map((t) => '- ' + t.name + ': ' + t.role).join('\n') : '- none selected (re-run the installer with --tools codecalc to add the calculator and code runner)',
236
+ CODECALC_STATUS: codecalc ? 'installed alongside this folder (see `CODECALC.md`)' : 'not selected; the rule below still binds, do the arithmetic with any tool that computes rather than guesses',
237
+ OBSIDIAN_TC_STATUS: tools.some((t) => t.id === 'obsidian-tc') ? 'selected (see `OBSIDIAN-TC.md`); the tool names below are live calls' : 'not selected; the rule below still binds against whatever store you keep (a notes folder, a wiki, a repo of markdown), the tool names are what obsidian-tc would give you',
238
+ DATE: new Date().toISOString().slice(0, 10),
239
+ LEVEL_ID: String(level),
240
+ LEVEL_NAME: lvl.name,
241
+ LEVEL_TAGLINE: lvl.tagline,
242
+ PRIMARY_ID: primary ? primary.id : 'none',
243
+ PRIMARY_NAME: primary ? primary.name : 'your agent',
244
+ PRIMARY_RULES_FILE: primary && primary.rulesFile ? primary.rulesFile : 'your agent\'s instructions file',
245
+ PRIMARY_DEEP: primary && primary.models ? primary.models.deep : 'your strongest model',
246
+ PRIMARY_STANDARD: primary && primary.models ? primary.models.standard : 'your everyday model',
247
+ PRIMARY_FAST: primary && primary.models ? primary.models.fast : 'your cheapest model',
248
+ AIS_LIST: selected.map((a) => '- ' + a.name + ': ' + a.role).join('\n'),
249
+ AI_IDS: selected.map((a) => a.id).join(','),
250
+ LANES_TABLE: lanesTable(selected),
251
+ INSTALL_TABLE: installTable(selected),
252
+ CLI_RUN_LANES: selected.filter((a) => a.cliRun).map((a) => a.id).join(', ') || 'none selected',
253
+ GATEWAY_MODELS: gatewayModels(selected, apis),
254
+ ENV_NAMES: envNames(selected, apis).map((n) => '- `' + n + '`').join('\n'),
255
+ ENV_EXPORTS: envNames(selected, apis).map((n) => n + '=').join('\n'),
256
+ NPM_PACKAGES: selected.map(npmSpec).filter(Boolean).join(' ') || '""',
257
+ SCRIPT_INSTALLERS: scriptInstallers(selected),
258
+ COMPOSE_ENV: composeEnv(selected, apis),
259
+ COMPOSE_OLLAMA: composeOllama(selected)
260
+ };
261
+ }
262
+
263
+ // Build the list of files this run would write. Pure: touches no disk except
264
+ // reading templates, so tests and --dry can inspect it.
265
+ export function planFiles(opts) {
266
+ const { level, selected, primary } = opts;
267
+ const v = vars(opts);
268
+ const files = [];
269
+ // root: 'dir' (the docs folder) or 'project' (where the agent actually looks for subagents)
270
+ const add = (rel, content, mode, root = 'dir') => files.push({ rel, content, mode: mode || 0o644, root });
271
+ const addTemplates = (sub) => {
272
+ for (const f of walk(join(TEMPLATES, sub))) {
273
+ if (!installable(sub, f.rel)) continue;
274
+ const raw = readFileSync(f.abs, 'utf8');
275
+ add(f.rel, render(raw, v));
276
+ }
277
+ };
278
+
279
+ addTemplates('common');
280
+ addTemplates('beginner');
281
+
282
+ // The primary agent's own loading surface.
283
+ if (primary && primary.id === 'claude-code') {
284
+ for (const f of walk(join(TEMPLATES, 'agents', 'claude-code'))) {
285
+ if (!installable('agents', f.rel)) continue;
286
+ add(join('.claude', 'agents', f.rel), render(readFileSync(f.abs, 'utf8'), v), 0o644, 'project');
287
+ }
288
+ add('CLAUDE.snippet.md', render(readFileSync(join(TEMPLATES, 'agents', 'snippets', 'claude-code.md'), 'utf8'), v));
289
+ } else if (primary && primary.id === 'agy') {
290
+ for (const f of walk(join(TEMPLATES, 'agents', 'agy'))) {
291
+ if (!installable('agents', f.rel)) continue;
292
+ add(join('.agents', 'agents', f.rel), render(readFileSync(f.abs, 'utf8'), v), 0o644, 'project');
293
+ }
294
+ add('GEMINI.snippet.md', render(readFileSync(join(TEMPLATES, 'agents', 'snippets', 'generic.md'), 'utf8'), v));
295
+ } else if (primary && primary.rulesFile) {
296
+ add(primary.rulesFile.replace('.md', '.snippet.md'), render(readFileSync(join(TEMPLATES, 'agents', 'snippets', 'generic.md'), 'utf8'), v));
297
+ } else if (primary) {
298
+ add('PASTE-INTO-YOUR-AGENT.md', render(readFileSync(join(TEMPLATES, 'agents', 'snippets', 'chat.md'), 'utf8'), v));
299
+ }
300
+
301
+ for (const t of opts.tools || []) {
302
+ if (existsSync(join(TEMPLATES, 'tools', t.id))) addTemplates(join('tools', t.id));
303
+ }
304
+
305
+ if (level >= 2) {
306
+ addTemplates('intermediate');
307
+ add(join('bin', 'cli-run.mjs'), readFileSync(CLI_RUN_SRC, 'utf8'), 0o755);
308
+ add(
309
+ join('bin', 'lanes.json'),
310
+ JSON.stringify(
311
+ {
312
+ enabled: selected.filter((a) => a.cliRun).map((a) => a.id),
313
+ note: 'Lanes cli-run may call. Edit to enable or disable a lane. A lane not listed here exits 13 (unavailable).'
314
+ },
315
+ null,
316
+ 2
317
+ ) + '\n'
318
+ );
319
+ }
320
+
321
+ if (level >= 3) {
322
+ addTemplates('advanced');
323
+ }
324
+
325
+ // MANIFEST.json records the choices this run was generated from, the
326
+ // generator version, and a hash of every file as generated, so a later run
327
+ // can tell an untouched generated file (safe to upgrade) from one the user
328
+ // edited (kept, reported as a conflict). Machine-owned: rewritten every run.
329
+ const fileHashes = {};
330
+ for (const f of files) fileHashes[(f.root === 'project' ? '[project] ' : '') + f.rel.split(sep).join('/')] = sha256(f.content);
331
+ files.push({
332
+ rel: 'MANIFEST.json',
333
+ mode: 0o644,
334
+ root: 'dir',
335
+ content:
336
+ JSON.stringify(
337
+ {
338
+ generator: 'model-orchestrator',
339
+ generatorVersion: GENERATOR_VERSION,
340
+ generatedAt: new Date().toISOString(),
341
+ level,
342
+ ais: selected.map((a) => a.id),
343
+ primary: primary ? primary.id : null,
344
+ tools: (opts.tools || []).map((t) => t.id),
345
+ apis: (opts.apis || []).map((p) => p.id),
346
+ dir: resolve(opts.dir || 'ai-orchestrator'),
347
+ project: resolve(opts.project || process.cwd()),
348
+ files: fileHashes,
349
+ note: 'Machine-owned. Rewritten on every run together with bin/lanes.json. Edit the docs, not this.'
350
+ },
351
+ null,
352
+ 2
353
+ ) + '\n'
354
+ });
355
+
356
+ return files;
357
+ }
358
+
359
+ // Every write is checked before any write happens:
360
+ // containment the resolved target stays inside --dir (template names are ours,
361
+ // but a check is cheaper than trusting that forever)
362
+ // no symlinks no existing path component under --dir may be a symlink, so a
363
+ // target/bin -> /elsewhere link cannot redirect a write
364
+ // parent type an existing component that must be a directory is one
365
+ // Then files are written with an exclusive create unless --force, and any file
366
+ // this run created is removed again if a later write fails.
367
+ // Resolve the target root through whatever part of it already exists. A
368
+ // symlinked ancestor (macOS /tmp -> /private/tmp, a user's ~/projects link) is
369
+ // the user's own choice and is followed; the resolved real path is what every
370
+ // containment check compares against. A root that exists and is not a
371
+ // directory is refused.
372
+ export function realRoot(dir) {
373
+ const abs = resolve(dir);
374
+ const missing = [];
375
+ let cur = abs;
376
+ while (!existsSync(cur)) {
377
+ missing.unshift(cur.slice(dirname(cur).length + (dirname(cur).endsWith(sep) ? 0 : 1)));
378
+ const up = dirname(cur);
379
+ if (up === cur) break;
380
+ cur = up;
381
+ }
382
+ const real = realpathSync(cur);
383
+ return { root: missing.length ? join(real, ...missing) : real, exists: missing.length === 0 };
384
+ }
385
+
386
+ export function preflight(files, dir) {
387
+ const problems = dirProblems(dir);
388
+ if (problems.length) return problems;
389
+ const { root, exists } = realRoot(dir);
390
+ if (exists && !statSync(root).isDirectory()) return [`the target exists and is not a directory: ${resolve(dir)}`];
391
+ for (const f of files) {
392
+ const abs = resolve(root, f.rel);
393
+ if (abs === root || !abs.startsWith(root + sep)) {
394
+ problems.push(`${f.rel}: resolves outside the target directory`);
395
+ continue;
396
+ }
397
+ const parts = relative(root, abs).split(sep);
398
+ let cur = root;
399
+ for (let i = 0; i < parts.length; i++) {
400
+ cur = join(cur, parts[i]);
401
+ let st;
402
+ try {
403
+ st = lstatSync(cur);
404
+ } catch {
405
+ break; // nothing below here exists yet
406
+ }
407
+ const last = i === parts.length - 1;
408
+ if (st.isSymbolicLink()) {
409
+ problems.push(`${f.rel}: ${relative(root, cur)} is a symlink`);
410
+ break;
411
+ }
412
+ if (!last && !st.isDirectory()) {
413
+ problems.push(`${f.rel}: ${relative(root, cur)} exists and is not a directory`);
414
+ break;
415
+ }
416
+ if (last && !st.isFile()) {
417
+ problems.push(`${f.rel}: exists and is not a regular file`);
418
+ break;
419
+ }
420
+ }
421
+ }
422
+ return problems;
423
+ }
424
+
425
+ // Three classes of generated file.
426
+ // MACHINE_OWNED structured configuration: rewritten on every run so a new
427
+ // selection applies (MANIFEST.json, bin/lanes.json).
428
+ // RUNTIME executables and units: rewritten when the installed copy is
429
+ // byte-identical to what a previous run generated (the manifest
430
+ // hash proves nobody edited it), kept and reported as a conflict
431
+ // when it was edited, kept and reported as unverifiable when no
432
+ // manifest exists. --upgrade-runtime forces this class only.
433
+ // documents everything else: the user may have edited them; kept unless --force.
434
+ export const MACHINE_OWNED = new Set(['MANIFEST.json', 'bin/lanes.json']);
435
+ export const RUNTIME = new Set([
436
+ 'bin/cli-run.mjs',
437
+ 'vm/setup-vm.sh',
438
+ 'vm/docker-compose.yml',
439
+ 'vm/gateway.config.yaml',
440
+ 'vm/jobs/weekly-audit.sh',
441
+ 'vm/jobs/weekly-audit.service',
442
+ 'vm/jobs/weekly-audit.timer'
443
+ ]);
444
+ export function fileClass(rel) {
445
+ const r = rel.split(sep).join('/');
446
+ if (MACHINE_OWNED.has(r)) return 'owned';
447
+ if (RUNTIME.has(r)) return 'runtime';
448
+ return 'document';
449
+ }
450
+
451
+ export function readManifest(dir) {
452
+ try {
453
+ const j = JSON.parse(readFileSync(join(resolve(dir), 'MANIFEST.json'), 'utf8'));
454
+ return j && typeof j === 'object' ? j : null;
455
+ } catch {
456
+ return null;
457
+ }
458
+ }
459
+
460
+ // Files carry a root: 'dir' for the docs folder, 'project' for the agent
461
+ // definitions the user's CLI reads from the project root. Each root gets its
462
+ // own preflight; one failure anywhere rolls back everything this run touched.
463
+ // Machine-owned files are always rewritten (they carry the selection); other
464
+ // existing documents are kept unless --force, or --update-docs for the ones a previous run wrote and nobody edited.
465
+ export function writeFiles(files, opts) {
466
+ const { dir, force = false, dry = false, upgradeRuntime = false, updateDocs = false } = opts;
467
+ const prevHashes = (opts.prevManifest && opts.prevManifest.files) || null;
468
+ const roots = { dir, project: opts.project || dir };
469
+ const groups = { dir: files.filter((f) => (f.root || 'dir') === 'dir'), project: files.filter((f) => f.root === 'project') };
470
+ const problems = [];
471
+ for (const k of ['dir', 'project']) {
472
+ if (!groups[k].length) continue;
473
+ problems.push(...preflight(groups[k], roots[k]).map((p) => (k === 'project' ? `[project] ${p}` : p)));
474
+ }
475
+ if (problems.length) {
476
+ const e = new Error('refusing to write:\n ' + problems.join('\n '));
477
+ e.code = 'PREFLIGHT';
478
+ throw e;
479
+ }
480
+ const written = [];
481
+ const skipped = [];
482
+ const upgraded = []; // runtime files replaced because the installed copy was an untouched generated one
483
+ const conflicts = []; // runtime files kept because the installed copy differs from what we generated
484
+ const unverifiable = []; // runtime files kept because there is no manifest to compare against
485
+ const docsUpdated = []; // --update-docs: documents regenerated because the installed copy was an untouched generated one
486
+ const docsConflict = []; // --update-docs: documents kept because you edited them
487
+ const docsUnverifiable = []; // --update-docs: documents kept because there is no manifest to compare against
488
+ const created = [];
489
+ const originals = new Map(); // abs -> {content, mode} of files --force overwrote, restored on failure
490
+ // Files that exist and were NOT rewritten this run. MANIFEST.json must record the hash of
491
+ // what is on disk for them (the previous run's hash, or nothing when there was no manifest),
492
+ // never the hash of content this run planned but did not write. Otherwise the next
493
+ // --update-docs or upgrade sees every kept file as "edited".
494
+ const keptKeys = new Set();
495
+ try {
496
+ // project first so MANIFEST.json (last in the dir group) is the final write and can
497
+ // describe every decision made above it
498
+ for (const k of ['project', 'dir']) {
499
+ if (!groups[k].length) continue;
500
+ const { root } = realRoot(roots[k]);
501
+ for (const f of groups[k]) {
502
+ const abs = resolve(root, f.rel);
503
+ const exists = existsSync(abs);
504
+ const label = k === 'project' ? '[project] ' + f.rel : f.rel;
505
+ const key = (k === 'project' ? '[project] ' : '') + f.rel.split(sep).join('/');
506
+ const cls = k === 'dir' ? fileClass(f.rel) : 'document';
507
+ if (exists && !force) {
508
+ if (cls === 'document') {
509
+ // Documents are the user's. Without --update-docs they are never touched.
510
+ // With it, the same hash rule the runtime class uses applies: regenerate
511
+ // only what a previous run wrote and nobody edited since.
512
+ if (!updateDocs) {
513
+ skipped.push(label);
514
+ keptKeys.add(key);
515
+ continue;
516
+ }
517
+ const onDisk = sha256(readFileSync(abs));
518
+ if (onDisk === sha256(f.content)) {
519
+ skipped.push(label); // already current
520
+ continue;
521
+ }
522
+ const prev = prevHashes ? prevHashes[key] : undefined;
523
+ if (!prev) {
524
+ docsUnverifiable.push(label);
525
+ keptKeys.add(key);
526
+ continue;
527
+ }
528
+ if (onDisk !== prev) {
529
+ docsConflict.push(label);
530
+ keptKeys.add(key);
531
+ continue;
532
+ }
533
+ docsUpdated.push(label);
534
+ }
535
+ if (cls === 'runtime') {
536
+ const onDisk = sha256(readFileSync(abs));
537
+ if (onDisk === sha256(f.content)) {
538
+ skipped.push(label); // already current
539
+ continue;
540
+ }
541
+ if (!upgradeRuntime) {
542
+ const prev = prevHashes ? prevHashes[key] : undefined;
543
+ if (!prev) {
544
+ unverifiable.push(label);
545
+ keptKeys.add(key);
546
+ continue;
547
+ }
548
+ if (onDisk !== prev) {
549
+ conflicts.push(label);
550
+ keptKeys.add(key);
551
+ continue;
552
+ }
553
+ }
554
+ upgraded.push(label); // untouched generated file, or --upgrade-runtime said replace it: either way it is reported
555
+ }
556
+ }
557
+ let content = f.content;
558
+ if (f.rel === 'MANIFEST.json' && keptKeys.size) {
559
+ const m = JSON.parse(content);
560
+ for (const kk of Object.keys(m.files || {})) {
561
+ if (!keptKeys.has(kk)) continue;
562
+ if (prevHashes && prevHashes[kk]) m.files[kk] = prevHashes[kk];
563
+ else delete m.files[kk]; // never recorded: stays unverifiable, which is the truth
564
+ }
565
+ content = JSON.stringify(m, null, 2) + '\n';
566
+ }
567
+ if (!dry) {
568
+ if (exists) originals.set(abs, { content: readFileSync(abs), mode: statSync(abs).mode });
569
+ mkdirSync(dirname(abs), { recursive: true });
570
+ writeFileSync(abs, content, { flag: exists ? 'w' : 'wx' });
571
+ if (!exists) created.push(abs);
572
+ chmodSync(abs, f.mode);
573
+ }
574
+ written.push(label);
575
+ }
576
+ }
577
+ } catch (e) {
578
+ for (const abs of created.reverse()) {
579
+ try {
580
+ unlinkSync(abs);
581
+ } catch {
582
+ /* best effort */
583
+ }
584
+ }
585
+ for (const [abs, o] of originals) {
586
+ try {
587
+ writeFileSync(abs, o.content);
588
+ chmodSync(abs, o.mode);
589
+ } catch {
590
+ /* best effort */
591
+ }
592
+ }
593
+ throw e;
594
+ }
595
+ return { written, skipped, upgraded, conflicts, unverifiable, docsUpdated, docsConflict, docsUnverifiable };
596
+ }
597
+
598
+ export function resolveSelection(ids) {
599
+ const selected = [];
600
+ const unknown = [];
601
+ for (const id of ids) {
602
+ if (byId[id]) selected.push(byId[id]);
603
+ else unknown.push(id);
604
+ }
605
+ return { selected, unknown };
606
+ }
607
+
608
+ export function resolveApis(ids) {
609
+ const apis = [];
610
+ const unknown = [];
611
+ for (const id of ids) {
612
+ if (providerById[id]) apis.push(providerById[id]);
613
+ else unknown.push(id);
614
+ }
615
+ return { apis, unknown };
616
+ }
617
+
618
+ export function resolveTools(ids) {
619
+ const tools = [];
620
+ const unknown = [];
621
+ for (const id of ids) {
622
+ if (toolById[id]) tools.push(toolById[id]);
623
+ else unknown.push(id);
624
+ }
625
+ return { tools, unknown };
626
+ }
627
+
628
+ export { AIS, LEVELS, TOOLS, PROVIDERS, IMAGES, npmSpec };
package/src/prompt.js ADDED
@@ -0,0 +1,34 @@
1
+ import { createInterface } from 'node:readline';
2
+
3
+ // A line-buffered asker. readline emits 'line' for piped input as soon as it
4
+ // arrives, whether or not a question is pending, so a naive question() loop
5
+ // drops answers that were typed (or piped) ahead. This queues them.
6
+ // EOF with no answer left is an abort, never a silent default: a pipe that
7
+ // ran out of lines must not confirm a write on the user's behalf.
8
+ export function makeAsker({ input, output }) {
9
+ const rl = createInterface({ input, output, terminal: false });
10
+ const queue = [];
11
+ const waiters = [];
12
+ let closed = false;
13
+ rl.on('line', (l) => (waiters.length ? waiters.shift()(l) : queue.push(l)));
14
+ rl.on('close', () => {
15
+ closed = true;
16
+ while (waiters.length) waiters.shift()(null);
17
+ });
18
+ async function ask(question, fallback) {
19
+ output.write(question);
20
+ let line;
21
+ if (queue.length) line = queue.shift();
22
+ else if (closed) line = null;
23
+ else line = await new Promise((res) => waiters.push(res));
24
+ if (line === null) {
25
+ output.write('\n');
26
+ const e = new Error('input ended before the question was answered (use --yes with --level and --ais for non-interactive runs)');
27
+ e.code = 'EOF';
28
+ throw e;
29
+ }
30
+ line = line.trim();
31
+ return line === '' ? fallback : line;
32
+ }
33
+ return { ask, close: () => rl.close() };
34
+ }
package/src/render.js ADDED
@@ -0,0 +1,8 @@
1
+ // Minimal {{key}} renderer. No logic in templates on purpose: anything that
2
+ // needs a decision is computed in install.js and handed in as a string.
3
+ export function render(text, vars) {
4
+ return text.replace(/\{\{\s*([A-Z0-9_]+)\s*\}\}/g, (m, key) => {
5
+ if (!(key in vars)) throw new Error(`template placeholder without a value: ${key}`);
6
+ return String(vars[key]);
7
+ });
8
+ }