model-orchestrator 0.1.34 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/AGENTS.md +31 -21
  2. package/CHANGELOG.md +51 -1
  3. package/README.md +127 -110
  4. package/bin/README.md +57 -6
  5. package/bin/aunx.js +7 -0
  6. package/bin/cli-run.mjs +21 -15
  7. package/bin/cli.js +376 -257
  8. package/docs/README.md +15 -18
  9. package/docs/catalog.md +228 -38
  10. package/docs/companions.md +28 -10
  11. package/docs/guarantees.md +21 -12
  12. package/docs/how-it-routes.md +49 -42
  13. package/docs/install.md +135 -33
  14. package/docs/part-1-beginner.md +37 -45
  15. package/docs/part-2-intermediate.md +34 -52
  16. package/docs/part-3-advanced.md +36 -26
  17. package/docs/security-review-history.md +38 -0
  18. package/llms.txt +24 -25
  19. package/package.json +16 -8
  20. package/proof/README.md +100 -0
  21. package/proof/gate-demo.cast +9 -0
  22. package/proof/gate-demo.gif +0 -0
  23. package/proof/results.json +198 -0
  24. package/proof/scripts/check-gate.js +26 -0
  25. package/proof/scripts/install-time.js +16 -0
  26. package/proof/scripts/lib.js +73 -0
  27. package/proof/scripts/measure.js +15 -0
  28. package/proof/scripts/missing-results.js +30 -0
  29. package/proof/scripts/record-gate.js +38 -0
  30. package/proof/scripts/render.js +18 -0
  31. package/proof/scripts/runner-overhead.js +21 -0
  32. package/src/README.md +9 -3
  33. package/src/activation-ownership.js +19 -0
  34. package/src/apply-companions.js +104 -0
  35. package/src/apply-snippets.js +60 -28
  36. package/src/aunx.js +262 -0
  37. package/src/catalog.js +253 -117
  38. package/src/install.js +478 -209
  39. package/src/plugin.js +13 -4
  40. package/src/postinstall.js +57 -0
  41. package/src/roles.js +184 -0
  42. package/src/uninstall.js +125 -8
  43. package/templates/README.md +19 -2
  44. package/templates/advanced/README.md +2 -2
  45. package/templates/advanced/vm/PRIVACY_GATES.md +17 -19
  46. package/templates/advanced/vm/README.md +25 -20
  47. package/templates/advanced/vm/box-CLAUDE.md +19 -18
  48. package/templates/advanced/vm/jobs/README.md +3 -1
  49. package/templates/advanced/vm/jobs/weekly-audit.service +3 -0
  50. package/templates/advanced/vm/jobs/weekly-audit.sh +2 -2
  51. package/templates/advanced/vm/setup-vm.sh +49 -2
  52. package/templates/agents/README.md +2 -2
  53. package/templates/agents/agy/README.md +20 -3
  54. package/templates/agents/agy/builder.md +11 -7
  55. package/templates/agents/agy/bulk-worker.md +9 -7
  56. package/templates/agents/agy/code-reviewer.md +13 -7
  57. package/templates/agents/agy/deep-planner.md +10 -7
  58. package/templates/agents/agy/done-verifier.md +13 -22
  59. package/templates/agents/agy/finding-verifier.md +14 -22
  60. package/templates/agents/agy/live-researcher.md +10 -7
  61. package/templates/agents/agy/reader.md +10 -12
  62. package/templates/agents/claude-code/README.md +18 -14
  63. package/templates/agents/claude-code/builder.md +10 -15
  64. package/templates/agents/claude-code/bulk-worker.md +8 -10
  65. package/templates/agents/claude-code/code-reviewer.md +11 -17
  66. package/templates/agents/claude-code/deep-planner.md +9 -11
  67. package/templates/agents/claude-code/done-verifier.md +12 -33
  68. package/templates/agents/claude-code/finding-verifier.md +13 -39
  69. package/templates/agents/claude-code/live-researcher.md +9 -11
  70. package/templates/agents/claude-code/reader.md +9 -18
  71. package/templates/agents/snippets/chat.md +9 -10
  72. package/templates/agents/snippets/claude-code.md +17 -18
  73. package/templates/agents/snippets/generic.md +9 -11
  74. package/templates/agents/snippets/route-gate.mjs +2 -2
  75. package/templates/agents/snippets/route-metrics.mjs +1 -1
  76. package/templates/agents/snippets/subagent-context.mjs +4 -4
  77. package/templates/beginner/ORCHESTRATOR.md +31 -36
  78. package/templates/beginner/README.md +1 -1
  79. package/templates/common/ACCEPTANCE_CHECKS.json +12 -0
  80. package/templates/common/CONTEXT.md +37 -0
  81. package/templates/common/DECISIONS.md +11 -0
  82. package/templates/common/README.md +24 -11
  83. package/templates/common/TASK_BRIEF.md +84 -0
  84. package/templates/common/protocols/README.md +14 -11
  85. package/templates/common/protocols/acceptance-checks.md +14 -0
  86. package/templates/common/protocols/build-protocol.md +91 -106
  87. package/templates/common/protocols/context-file.md +10 -0
  88. package/templates/common/protocols/decision-log.md +9 -0
  89. package/templates/common/protocols/deep-research.md +20 -34
  90. package/templates/common/protocols/docs-then-prove.md +13 -18
  91. package/templates/common/protocols/gap-analysis.md +15 -21
  92. package/templates/common/protocols/memory-and-record.md +21 -20
  93. package/templates/common/protocols/numbers-and-logic.md +20 -26
  94. package/templates/common/protocols/propagate.md +18 -27
  95. package/templates/intermediate/CLI-RUN.md +83 -113
  96. package/templates/intermediate/DELEGATION_MATRIX.md +9 -3
  97. package/templates/intermediate/README.md +3 -3
  98. package/templates/intermediate/RESEARCH_TRIAGE.md +23 -15
  99. package/templates/intermediate/ROUTING.md +54 -51
  100. package/templates/intermediate/TIERS.md +37 -76
  101. package/templates/tools/README.md +1 -1
  102. package/templates/tools/obsidian-tc/OBSIDIAN-TC.md +1 -1
  103. package/docs/audit-brief.md +0 -148
  104. package/scripts/README.md +0 -7
  105. package/scripts/gen-catalog.js +0 -81
  106. package/scripts/gen-plugin.js +0 -16
  107. package/scripts/record-demo.sh +0 -45
  108. package/templates/common/TASK_BUNDLE.md +0 -56
package/src/install.js CHANGED
@@ -1,15 +1,40 @@
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';
1
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync, readdirSync, statSync, lstatSync, unlinkSync, realpathSync, openSync, closeSync, fstatSync, constants } from 'node:fs';
2
+ import { join, dirname, isAbsolute, relative, resolve, sep, parse as parsePath, posix } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { homedir } from 'node:os';
4
5
  import { render } from './render.js';
6
+ import { validateActivationOwnership } from './activation-ownership.js';
5
7
  import { createHash } from 'node:crypto';
6
- import { AIS, LEVELS, TOOLS, PROVIDERS, IMAGES, byId, toolById, providerById, npmSpec } from './catalog.js';
8
+ import { ROLE_SPECS, assignRoles, roleTable, roleRoute, manifestRoles, inferPrimary } from './roles.js';
9
+ import { LANE_FLAGS } from '../bin/cli-run.mjs';
10
+ import { AIS, LEVELS, TOOLS, PROVIDERS, IMAGES, byId, toolById, providerById, npmSpec, summaryWithEvidence } from './catalog.js';
11
+ import { companionRegistrationSteps } from './apply-companions.js';
7
12
 
8
13
  const HERE = dirname(fileURLToPath(import.meta.url));
9
14
  export const GENERATOR_VERSION = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')).version;
10
15
  const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
11
16
  export const TEMPLATES = join(HERE, '..', 'templates');
12
17
  export const CLI_RUN_SRC = join(HERE, '..', 'bin', 'cli-run.mjs');
18
+ // Compatibility with the 0.1.x filename; current docs use the public brief name.
19
+ const LEGACY_BRIEF = ['TASK', 'BUN' + 'DLE.md'].join('_');
20
+ // R1: a pre-existing settings or MCP JSON file larger than this is refused
21
+ // before it is read or parsed, the same way invalid JSON is refused today.
22
+ // Generous on purpose: real settings/MCP files are kilobytes, not megabytes.
23
+ export const ACTIVATION_JSON_BYTE_CAP = 10 * 1024 * 1024; // 10 MB
24
+
25
+ function readLegacyBrief(path, dir) {
26
+ const problems = preflight([{ rel: LEGACY_BRIEF }], dir);
27
+ if (problems.length) throw Object.assign(new Error(problems.join('; ')), { code: 'PREFLIGHT' });
28
+ const expected = lstatSync(path);
29
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
30
+ try {
31
+ const actual = fstatSync(fd);
32
+ if (!actual.isFile() || actual.dev !== expected.dev || actual.ino !== expected.ino) {
33
+ throw Object.assign(new Error('legacy brief changed during inspection; re-run the installer'), { code: 'PREFLIGHT' });
34
+ }
35
+ return { content: readFileSync(fd), stat: actual };
36
+ } finally { closeSync(fd); }
37
+ }
13
38
 
14
39
  function walk(dir, base = dir) {
15
40
  const out = [];
@@ -37,23 +62,25 @@ function table(rows, header) {
37
62
  return [line(header), line(header.map(() => '---')), ...rows.map(line)].join('\n');
38
63
  }
39
64
 
40
- export function lanesTable(selected, plans = {}) {
41
- const rows = selected.map((a) => [
65
+ export function lanesTable(selected, plans = {}, primary = inferPrimary(selected)) {
66
+ const { roles } = assignRoles({ selected, primary, plans });
67
+ const rows = selected.map(a => [
42
68
  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',
69
+ a.facts.billing,
70
+ summaryWithEvidence(a),
71
+ Object.entries(roles).filter(([, role]) => role.ai === a.id).map(([id]) => id).join(', ') || 'none',
72
+ a.facts.cliRun ? '`cli-run ' + a.id + '`' : a.bin ? '`' + a.bin + '`' : 'the app',
46
73
  plans[a.id] ? `${plans[a.id].name} (${plans[a.id].headroom} headroom)` : 'not stated'
47
74
  ]);
48
- return table(rows, ['AI', 'Lane', 'Wins at', 'Call it with', 'Plan']);
75
+ return table(rows, ['AI', 'Lane', 'What it is', 'Assigned roles', 'Call it with', 'Plan']);
49
76
  }
50
77
 
51
78
  function planGuidance(selected, plans = {}) {
52
79
  const lines = selected.filter((a) => plans[a.id]).map((a) => {
53
80
  const p = plans[a.id];
54
81
  const volume = p.headroom === 'base'
55
- ? 'Keep this base-headroom lane for short second opinions. If it is primary, delegate volume to high or max headroom lanes.'
56
- : 'Use this high or max headroom lane for volume: scoped well-specified builds, pre-ship second-family checks through cli-run, and first-pass research.';
82
+ ? 'Keep this base-headroom lane for short second opinions. If it is your main agent, delegate volume to high or max headroom lanes.'
83
+ : 'Use this high or max headroom lane for volume: scoped well-specified builds, first-pass research, and pre-ship reviews through cli-run when its configured model family differs from the author\'s.';
57
84
  return `- **${a.name}: ${p.name} (${p.headroom} headroom).** ${volume} Capability and independent-review rules are unchanged. Checked ${p.checked}.`;
58
85
  });
59
86
  return lines.length ? lines.join('\n') : 'State subscription plans with `--plans` to receive volume-allocation guidance. Capability and independent-review rules stay unchanged.';
@@ -77,7 +104,7 @@ export function installTable(selected) {
77
104
  export function gatewayModels(selected, apis = []) {
78
105
  const lines = [];
79
106
  if (selected.some((a) => a.id === 'ollama')) {
80
- lines.push(' - model_name: local-small', ' litellm_params:', ' model: ollama/llama3.2:3b', ' api_base: http://ollama:11434');
107
+ lines.push(' - model_name: local-small', ' litellm_params:', ` model: ${byId.ollama.gatewayModel}`, ' api_base: http://ollama:11434');
81
108
  }
82
109
  for (const prov of apis) {
83
110
  for (const [alias, model] of prov.lanes) {
@@ -98,7 +125,7 @@ export function scriptInstallers(selected) {
98
125
  const lines = [];
99
126
  for (const a of selected) {
100
127
  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"`);
101
- else if (a.install.url && a.kind !== 'chat') lines.push(`say " ${a.name}: ${a.install.url}"`);
128
+ else if (a.install.url && a.facts.kind !== 'chat') lines.push(`say " ${a.name}: ${a.install.url}"`);
102
129
  }
103
130
  return lines.length ? lines.join('\n') : 'say " none"';
104
131
  }
@@ -147,94 +174,134 @@ export function dirProblems(dir) {
147
174
  return problems;
148
175
  }
149
176
 
150
- // The lane the generated weekly audit calls: the first ENABLED cli-run lane
151
- // in this preference order. None enabled means the job refuses at run time
152
- // (exit 13) instead of calling a lane the installer disabled.
153
- export const AUDIT_LANE_ORDER = ['hermes', 'qwen', 'codex', 'agy', 'grok'];
154
- export function auditLane(selected) {
155
- const enabled = new Set(selected.filter((a) => a.cliRun).map((a) => a.id));
156
- return AUDIT_LANE_ORDER.find((l) => enabled.has(l)) || null;
177
+ // The weekly job uses the independent review assignment, then an eligible
178
+ // bulk runner. A main-agent fallback without a runner keeps the exit-13 guard.
179
+ export function auditLane(selected, primary = selected[0]) {
180
+ const { roles } = assignRoles({ selected, primary });
181
+ const id = roles.review.ai ?? roles.bulk.ai ?? null;
182
+ return selected.some(a => a.id === id && a.facts.cliRun) ? id : null;
183
+ }
184
+
185
+ function stackContext(selected, primary, detected = new Set()) {
186
+ const installed = new Set(agentIds(primary));
187
+ const names = { plan: 'deep-planner', build: 'builder', review: 'code-reviewer', verify: 'finding-verifier', research: 'live-researcher', bulk: 'bulk-worker', read: 'reader' };
188
+ const agents = Object.fromEntries(Object.entries(names).filter(([, name]) => installed.has(name)));
189
+ return { selected, primary, detected, agents };
190
+ }
191
+
192
+ function rolePick(id, assignment, ctx) {
193
+ const entry = roleRoute(id, assignment, ctx);
194
+ if (!entry || !entry.ai) return `none selected: ${entry?.reason || entry?.why || 'no eligible lane'}`;
195
+ const ai = ctx.selected.find(a => a.id === entry.ai);
196
+ if (entry.command) return '`' + entry.command + '`';
197
+ if (entry.via === 'local') return `${ai.name} on your machine`;
198
+ if (entry.via === 'main-agent') {
199
+ if (ai.facts.kind === 'chat') return `paste the work into your main agent, ${entry.tier} tier`;
200
+ return `${entry.agent ? '`' + entry.agent + '` on ' : ''}your main agent, ${entry.tier} tier`;
201
+ }
202
+ return `${ai.name}, ${entry.tier} tier`;
157
203
  }
158
204
 
159
- // Everything ROUTING.md and RESEARCH_TRIAGE.md say about lanes is rendered
160
- // from the lanes the user actually has. A generated manual must never
161
- // recommend a command its own lanes.json disables.
162
- export function laneVars(selected) {
163
- const has = (id) => selected.some((a) => a.id === id);
164
- const categories = new Set(selected.flatMap((a) => a.laneCategories || []));
165
- const supplies = (category) => categories.has(category);
166
- const picks = [
167
- ['cheapest-metered', 'Bulk classify / extract / summarize, data may leave the machine', 'the cheapest metered lane, then the fast tier', 'use the selected bulk lane and verify its output'],
168
- ['local', 'Bulk work on data that must stay local', 'the local lane', 'a privacy lane; route here for confinement'],
169
- ['fan-out', 'Many independent items each needing its own agent turn', 'a concurrent fan-out lane', 'one call, N children, on a subscription'],
170
- ['live-data', 'Live web or social reads', 'the live-data CLI', 'subscription-covered; the same search on the API bills per call'],
171
- ['second-coder', 'Code review, no changes', 'standard tier, or the second-coder CLI', 'a different model family catches what one misses'],
172
- ['second-coder', 'Second-opinion audit of a security-shaped diff', 'the second-coder CLI in read-only audit mode', 'a second family challenges, the orchestrator reproduces'],
173
- [null, 'Deep architecture / planning', 'deep tier', 'expensive to get wrong'],
174
- [null, 'Well-specified execution', 'the orchestrator', 'execution does not need the top tier'],
175
- ['largest-context', 'Long-document analysis', 'the largest-context lane, or caching on the primary', 'window size vs re-query cost'],
176
- [null, 'Routing decisions themselves', 'the cheapest lane you have, or none', 'spend only the tokens the routing decision needs'],
177
- ['free', 'Rough drafts, divergent reads, first-pass summaries', 'the free tier', '$0, and disagreement with the primary is information'],
178
- ['cheapest-metered', 'Anything citing a line, a number, or a source', 'the cheapest metered lane with a full verification pass', 'verify every supporting number and citation']
179
- ].filter(([category]) => !category || supplies(category)).map(([, ...row]) => row);
205
+ // Routing advice uses the same assignments as the stack table and manifest.
206
+ // Capability facts determine eligibility; selection order resolves equal fits.
207
+ export function laneVars(selected, primary = selected[0]) {
208
+ const assignment = assignRoles({ selected, primary });
209
+ const ctx = stackContext(selected, primary);
210
+ const { roles } = assignment;
211
+ const rolesForPrimary = routingRoles(primary);
212
+ const pick = id => rolePick(id, assignment, ctx);
213
+ // The installed subagent labels describe only main-agent assignments.
214
+ // An external winner must reach the action instructions as well as the table.
215
+ const assignedLabel = (id, local) => roles[id]?.ai && roles[id].ai !== primary?.id ? pick(id) : local;
216
+ const assignedRoles = {
217
+ PLANNER_ROLE: assignedLabel('plan', rolesForPrimary.PLANNER_ROLE),
218
+ BUILDER_ROLE: assignedLabel('build', rolesForPrimary.BUILDER_ROLE),
219
+ REVIEW_ROLE: assignedLabel('review', rolesForPrimary.REVIEW_ROLE),
220
+ FINDING_ROLE: assignedLabel('verify', rolesForPrimary.FINDING_ROLE),
221
+ DONE_ROLE: assignedLabel('verify', rolesForPrimary.DONE_ROLE),
222
+ LIVE_ROLE: assignedLabel('research', rolesForPrimary.LIVE_ROLE),
223
+ BULK_ROLE: assignedLabel('bulk', rolesForPrimary.BULK_ROLE),
224
+ READER_ROLE: assignedLabel('read', rolesForPrimary.READER_ROLE)
225
+ };
226
+ // TIERS.md's "Role" column is the one place several rows can carry the
227
+ // SAME bare command (two different rows can both land on `cli-run grok`,
228
+ // and FINDING_ROLE/DONE_ROLE are literally the same 'verify' assignment
229
+ // shown twice): a bare command alone does not say which row it is (C2).
230
+ // ROUTING.md and ORCHESTRATOR.md keep the assignedRoles values above
231
+ // (their decision-tree and activation text are pinned to that bare
232
+ // shape), so this labels a separate set of vars for TIERS.md only.
233
+ const externalWinner = (id) => Boolean(roles[id]?.ai && roles[id].ai !== primary?.id);
234
+ const tierRole = (value, id, label) => externalWinner(id) ? `${label} (${value})` : value;
235
+ const tierRoles = {
236
+ TIER_PLANNER_ROLE: tierRole(assignedRoles.PLANNER_ROLE, 'plan', 'planning'),
237
+ TIER_REVIEW_ROLE: tierRole(assignedRoles.REVIEW_ROLE, 'review', 'code review'),
238
+ TIER_FINDING_ROLE: tierRole(assignedRoles.FINDING_ROLE, 'verify', 'reproduce a finding'),
239
+ TIER_BUILDER_ROLE: tierRole(assignedRoles.BUILDER_ROLE, 'build', 'build'),
240
+ TIER_LIVE_ROLE: tierRole(assignedRoles.LIVE_ROLE, 'research', 'live research'),
241
+ TIER_BULK_ROLE: tierRole(assignedRoles.BULK_ROLE, 'bulk', 'bulk work'),
242
+ TIER_DONE_ROLE: tierRole(assignedRoles.DONE_ROLE, 'verify', 'check definition of done'),
243
+ TIER_READER_ROLE: tierRole(assignedRoles.READER_ROLE, 'read', 'read many files')
244
+ };
245
+ const reviewer = selected.find(a => a.id === roles.review.ai);
246
+ // No reviewer: state the self-check once (dropping roles.review.why here,
247
+ // which restates the same point) and end without a period, so
248
+ // ROUTING.md's fixed "... with appropriate effort." tail reads as one
249
+ // sentence instead of a second, dangling one glued after a full stop
250
+ // (C3). "review" still ends every "Why" column via roles.review.why
251
+ // directly, so that reasoning is not lost, only not duplicated here.
252
+ const review = reviewer
253
+ ? `${pick('review')} (different model family from the main agent by default; verify the current models before dispatch${reviewer.facts.readOnlyMode ? '; read-only filesystem sandbox' : '; request review only and check the CLI permissions'})`
254
+ : `${rolesForPrimary.REVIEW_ROLE} in a fresh context. No different-family reviewer is selected: treat this as a self-check`;
255
+ const picks = ROLE_SPECS.filter(spec => roles[spec.id]).map(spec => [spec.job, spec.id === 'review' ? review : pick(spec.id), roles[spec.id].why]);
256
+ const metered = selected.some(a => a.facts.billing === 'pay-per-token');
257
+ const free = selected.some(a => a.facts.billing === 'free');
180
258
  const cost = [
181
- 'Prompt caching everywhere it fits: frozen prefix first, volatile text last.',
182
- 'Cascade: cheapest capable tier first, escalate on signal.',
183
- ...(supplies('cheapest-metered') ? ['Batch APIs where the selected provider supports them, for work that can wait.'] : []),
184
- ...(supplies('free') ? ['A free model for routing decisions.'] : []),
185
- 'Effort and reasoning knobs before model swaps; often the bigger lever.',
186
- 'Alias-based config so a vendor rename is a one-line repoint.'
259
+ 'Prompt caching where it fits: frozen prefix first, volatile text last.',
260
+ `Use the assigned bulk route for bounded volume: ${pick('bulk')}. ${roles.bulk.why}.`,
261
+ ...(metered ? ["Batch APIs where the selected provider supports them, for work that can wait. When a rate is unverified, check your provider's rate."] : []),
262
+ ...(free ? ['A free model can carry routing decisions when its tools and context fit.'] : []),
263
+ 'Select effort and scoped context before changing model tiers.',
264
+ 'Read the current model roster before choosing an explicit model.'
187
265
  ];
188
- const enabled = selected.filter((a) => a.cliRun).map((a) => a.id);
189
- const cr = (id) => '`cli-run ' + id + '`';
190
- const step0 = [];
191
- if (has('hermes')) step0.push(`${cr('hermes')} (the free tier) for rough drafts and divergent reads`);
192
- if (has('qwen')) step0.push(`${cr('qwen')} (the cheapest metered lane) for structured bulk, never for anything citing a line, number or source`);
193
- if (has('grok')) step0.push(`${cr('grok')} for X and live web reads at $0`);
194
- if (has('codex')) step0.push(`${cr('codex --audit')} for a second-opinion read by a second model family`);
195
- if (has('agy')) step0.push(`${cr('agy')} for research sweeps and concurrent fan-out`);
196
- const stage1 = [];
197
- if (has('codex')) stage1.push(`${cr('codex')} for a second-opinion critique of the map`);
198
- if (has('grok')) stage1.push(`${cr('grok')} to verify current API behaviour instead of trusting recall`);
199
- if (has('hermes')) stage1.push(`${cr('hermes')} for a divergent read`);
200
- if (has('agy')) stage1.push(`${cr('agy')} for a wide sweep of prior art`);
201
- const examples = [];
202
- 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) |');
203
- 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 challenge |');
204
- 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 |');
205
- 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 challenges; see `RESEARCH_TRIAGE.md` |');
206
- const roles = [];
207
- if (has('agy')) roles.push('| Web sweep | `cli-run agy` | widest landscape pass |');
208
- if (has('codex')) roles.push('| Second-opinion read | `cli-run codex --audit` | question the premise, hunt for what the others would get wrong |');
209
- if (has('grok')) roles.push('| Live data | `cli-run grok` | dated primary sources, real-time reads |');
210
- if (has('hermes')) roles.push('| Cheap divergent read | `cli-run hermes` | another opinion at $0 |');
211
- if (has('qwen')) roles.push('| Structured extraction | `cli-run qwen` | pull the facts into a table; never trust its citations without a check |');
212
- roles.push('| Triage + the durable record | the orchestrator | opens primary sources, marks every claim, writes the artifact |');
213
- const run = [];
214
- if (has('agy')) run.push('node bin/cli-run.mjs agy --brief "$BRIEF" --timeout 900 > research/out-agy.md');
215
- if (has('codex')) run.push('node bin/cli-run.mjs codex --audit --brief "$BRIEF" --timeout 900 > research/out-codex.md');
216
- if (has('grok')) run.push('node bin/cli-run.mjs grok --brief "$BRIEF" --timeout 900 > research/out-grok.md');
217
- if (has('hermes')) run.push('node bin/cli-run.mjs hermes --brief "$BRIEF" --timeout 900 > research/out-hermes.md');
218
- if (has('qwen')) run.push('node bin/cli-run.mjs qwen --brief "$BRIEF" --timeout 900 > research/out-qwen.md');
266
+ const enabled = selected.filter(a => a.facts.cliRun);
267
+ const step0 = ROLE_SPECS.filter(spec => roles[spec.id]?.ai && roles[spec.id].ai !== primary?.id)
268
+ .map(spec => `${pick(spec.id)} for ${spec.job.toLowerCase()}; ${roles[spec.id].why}`);
269
+ const stage1 = ['research', 'review', 'fan-out'].filter(id => roles[id]?.ai)
270
+ .map(id => `${id === 'review' ? review : pick(id)} for ${id === 'research' ? 'current primary sources' : id === 'review' ? 'a critique of the context file' : 'independent research units'}`);
271
+ const examples = [
272
+ `| "What is current on this topic" | ${pick('research')}; ${roles.research.why} |`,
273
+ `| "Audit this auth diff" | ${review} |`,
274
+ `| "Classify these 200 items" | ${pick('bulk')} |`,
275
+ '| "Research this topic properly" | plan the question, collect primary sources and verify claims; see `RESEARCH_TRIAGE.md` |'
276
+ ];
277
+ const researchRoles = ['research', 'review', 'bulk', 'fan-out'].filter(id => roles[id]?.ai)
278
+ .map(id => `| ${ROLE_SPECS.find(spec => spec.id === id).job} | ${id === 'review' ? review : pick(id)} | ${roles[id].why} |`);
279
+ researchRoles.push('| Triage + the durable record | the main agent | opens primary sources, marks every claim, writes the artifact |');
280
+ const runLanes = new Map();
281
+ for (const id of ['review', 'research', 'bulk', 'fan-out']) {
282
+ const entry = roleRoute(id, assignment, ctx);
283
+ if (entry?.command && !runLanes.has(entry.ai)) runLanes.set(entry.ai, entry.command);
284
+ }
285
+ const run = [...runLanes].map(([id, command]) => `node bin/cli-run.mjs ${command.replace(/^cli-run /, '')} --brief "$BRIEF" --timeout 900 > research/out-${id}.md`);
219
286
  return {
287
+ ...assignedRoles,
288
+ ...tierRoles,
220
289
  TASK_LANES_TABLE: table(picks, ['Task type', 'Pick', 'Why']),
221
290
  COST_PLAYBOOK: cost.map((line, i) => `${i + 1}. ${line}`).join('\n'),
222
- FAN_OUT_ADVICE: supplies('fan-out') ? ' Many independent items each needing its own agent turn → the selected concurrent fan-out lane.' : '',
223
- METERED_CITATION_NOTE: supplies('cheapest-metered') ? " A lane's figure is re-derived before it is repeated: verify every supporting number and citation from the cheapest metered lane." : '',
224
- RESEARCH_SELECTION_ADVICE: enabled.length >= 2
225
- ? 'Send the same PLAN to your selected CLI lanes, preferring different model families. Run each through `cli-run` so a run that produced nothing exits 10 and is treated as a missing engine.'
226
- : 'Use the primary agent for the sweep, then a fresh-context second-opinion turn. Add CLI lanes from different model families for independent research passes.',
227
- GAP_ANALYSIS_LANE: supplies('second-coder')
228
- ? 'a **different model family** reading the same artifact. Use the selected second-opinion coder lane in read-only mode; verify each finding before acting.'
229
- : 'a fresh-context second pass reading the same artifact. Use a different model family when one is available; verify each finding before acting.',
230
- LANE_STEP0: step0.length ? step0.map((l) => ' - ' + l).join('\n') : ' - none selected yet: every task stays on your primary agent\'s tiers until you add a lane (re-run the installer with more AIs)',
231
- STAGE1_LANES: stage1.length ? '; ' + stage1.join(', ') : '',
232
- ATTACK_LANE: has('codex') ? '`cli-run codex --audit` (a second model family in a read-only sandbox)' : 'code-reviewer at deep tier, in a fresh context told to challenge and allowed to answer CLEAN',
233
- LIVE_LANE: has('grok') ? '`cli-run grok` first ($0), then' : '',
234
- 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' : '',
291
+ FAN_OUT_ADVICE: roles['fan-out'] ? ` Many independent items each needing their own agent turn → ${pick('fan-out')}.` : '',
292
+ METERED_CITATION_NOTE: metered ? ' Verify every supporting number and citation returned by a pay-per-token lane.' : '',
293
+ RESEARCH_SELECTION_ADVICE: `Use ${pick('research')} for current primary sources. ${roles.research.why}. ` + (enabled.length >= 2
294
+ ? 'Send a shared task brief to selected lanes with complementary capabilities; prefer different model families for independent perspectives.'
295
+ : 'Use a fresh context to challenge the sweep; add a different model family for independent research.') + ` For review, use ${review}.`,
296
+ GAP_ANALYSIS_LANE: `${review}. Give it the same artifact and verify each finding before acting.`,
297
+ LANE_STEP0: step0.length ? step0.map(line => ' - ' + line).join('\n') : ' - no separate lane selected yet: use your main agent\'s tiers; keep local-only work off cloud lanes and arrange independent review separately',
298
+ STAGE1_LANES: stage1.length ? '; ' + stage1.join('; ') : '',
299
+ ATTACK_LANE: review,
300
+ LIVE_LANE: `${pick('research')} for current sources, then`,
301
+ BULK_LANE: `; assigned bulk route: ${pick('bulk')}`,
235
302
  LANE_EXAMPLES: examples.join('\n'),
236
- RESEARCH_ROLES: roles.join('\n'),
237
- RESEARCH_RUN: run.length ? run.join('\n') : '# no cli-run lane selected: run the sweep on your primary agent, then a fresh second-opinion turn (protocols/deep-research.md, level 1 shape)',
303
+ RESEARCH_ROLES: researchRoles.join('\n'),
304
+ RESEARCH_RUN: run.length ? run.flatMap(command => ['# Or: ' + command.replace('node bin/cli-run.mjs', 'aunx cli-run'), command]).join('\n') : '# no separate cli-run assignment: run the sweep on your main agent, then a fresh-context self-check',
238
305
  RESEARCH_ENGINES: String(run.length)
239
306
  };
240
307
  }
@@ -246,7 +313,7 @@ export function laneVars(selected) {
246
313
  // note) is gated on this so a primary with no verified premise keeps the
247
314
  // original, more conservative wording.
248
315
  export function subagentsLoadRules(primary) {
249
- return !!(primary && primary.subagentsLoadRules);
316
+ return !!(primary && primary.facts.loadsProjectRules);
250
317
  }
251
318
 
252
319
  // Canonical agent order, tier-first. Used to render a stable, non-hardcoded
@@ -254,8 +321,9 @@ export function subagentsLoadRules(primary) {
254
321
  // shipped, so a future agent addition or removal cannot leave the sentence
255
322
  // stale the way the finding-verifier omission did.
256
323
  const AGENT_ORDER = ['deep-planner', 'builder', 'code-reviewer', 'finding-verifier', 'live-researcher', 'bulk-worker', 'done-verifier', 'reader'];
257
- export function claudeAgentIds() {
258
- const dir = join(TEMPLATES, 'agents', 'claude-code');
324
+ function agentIds(primary) {
325
+ if (!primary?.facts.agentDefinitions) return [];
326
+ const dir = join(TEMPLATES, 'agents', primary.id);
259
327
  if (!existsSync(dir)) return [];
260
328
  const files = readdirSync(dir).filter((f) => f.endsWith('.md') && f !== 'README.md').map((f) => f.replace(/\.md$/, ''));
261
329
  const set = new Set(files);
@@ -263,40 +331,51 @@ export function claudeAgentIds() {
263
331
  const extra = files.filter((id) => !AGENT_ORDER.includes(id)).sort();
264
332
  return [...ordered, ...extra];
265
333
  }
334
+ export function claudeAgentIds() {
335
+ return agentIds(byId['claude-code']);
336
+ }
266
337
 
267
- // The compact "pick the lane before acting" table, rendered from the AIs the
338
+ function routingRoles(primary) {
339
+ const installed = new Set(agentIds(primary));
340
+ const role = (id, label) => installed.has(id) ? id : `${label} role on the main agent`;
341
+ return {
342
+ BULK_ROLE: role('bulk-worker', 'bulk processing'),
343
+ BUILDER_ROLE: role('builder', 'build'),
344
+ READER_ROLE: role('reader', 'reading'),
345
+ REVIEW_ROLE: role('code-reviewer', 'code review'),
346
+ FINDING_ROLE: role('finding-verifier', 'finding verification'),
347
+ DONE_ROLE: role('done-verifier', 'completion verification'),
348
+ PLANNER_ROLE: role('deep-planner', 'planning'),
349
+ LIVE_ROLE: role('live-researcher', 'live research')
350
+ };
351
+ }
352
+
353
+ // The compact "choose a route before acting" table, rendered from the AIs the
268
354
  // user actually selected and the agents actually installed, never a second
269
355
  // hand-typed copy of ROUTING.md's decision tree.
270
- export function routeGateTable(selected) {
271
- const rows = [
272
- ['Bulk or mechanical, many similar items', 'bulk-worker'],
273
- ['Needs live data', 'live-researcher'],
274
- ['Review without changing', 'code-reviewer'],
275
- ['Findings from a review or a scanner', 'finding-verifier, before any repair'],
276
- ['Reading or digesting many files or notes', 'reader'],
277
- ['Checking a tracker item against its stated done-signal', 'done-verifier'],
278
- ['Ambiguous, architectural, expensive to get wrong', 'deep-planner'],
279
- ['Everything else that changes files', 'builder, by default']
280
- ];
281
- for (const a of selected.filter((x) => x.cliRun)) rows.push([a.role, '`cli-run ' + a.id + '`']);
356
+ export function routeGateTable(selected, primary = inferPrimary(selected)) {
357
+ const assignment = assignRoles({ selected, primary });
358
+ const ctx = stackContext(selected, primary);
359
+ const rows = ROLE_SPECS.filter(spec => assignment.roles[spec.id])
360
+ .map(spec => [spec.job, rolePick(spec.id, assignment, ctx)]);
282
361
  return table(rows, ['Task', 'Lane']);
283
362
  }
284
363
 
285
364
  // The marked block route-gate.mjs extracts at runtime. Installed only for
286
365
  // claude-code so the hook always finds a block to read; other primaries get
287
366
  // no hook and so get no block.
288
- export function routeGateSection(selected) {
367
+ export function routeGateSection(selected, primary = inferPrimary(selected)) {
289
368
  return [
290
369
  '<!-- route-gate:start -->',
291
- '## Route gate: pick the lane before acting',
370
+ '## Route gate: choose a route before acting',
292
371
  '',
293
372
  'Injected on every turn by the `route-gate` hook, so this table is read at runtime rather than recalled from memory.',
294
373
  '',
295
- routeGateTable(selected),
374
+ routeGateTable(selected, primary),
296
375
  '',
297
376
  "Stay inline only when: (a) the brief would cost as much as the work itself, (b) the task needs this conversation's own context, (c) it is the human's decision or the final verification of delegated work (a delegate never verifies itself).",
298
377
  '',
299
- 'Never: the built-in Explore or Plan agents for rule-bound work (they skip CLAUDE.md). general-purpose taking work a named agent already owns.',
378
+ 'When work depends on project rules, use a named agent that loads those rules. The built-in Explore and Plan agents skip CLAUDE.md; give their rule-bound work to the matching named agent.',
300
379
  '',
301
380
  'End every reply with a hidden marker: `<!-- route: <lane> | <why, a few words> -->`. The route-metrics hook reads only the lane out of it, so routing coverage can be measured instead of assumed.',
302
381
  '<!-- route-gate:end -->'
@@ -305,45 +384,39 @@ export function routeGateSection(selected) {
305
384
 
306
385
  // ROUTING.md / ORCHESTRATOR.md decision-tree rule 5 and the "Who builds"
307
386
  // section read differently for claude-code, because only claude-code has the
308
- // verified premise that its subagents load CLAUDE.md. Every other primary
309
- // keeps the original wording: the orchestrator builds the main line directly
310
- // and a subagent or second CLI is assumed to hold none of these rules.
387
+ // verified premise that its subagents load CLAUDE.md. Other agents verify
388
+ // rules and tool reach during Assign before handing off a section.
311
389
  export function decisionRule5(primary) {
312
390
  return subagentsLoadRules(primary)
313
- ? `5. **Everything else that changes files** → builder executes by default. The orchestrator plans, briefs, verifies and talks to the human; it stays inline only when (a) the brief would cost as much as the work, (b) the task needs this conversation's own context, or (c) it is the human's decision, or the final verification of delegated work (a delegate never verifies itself). Never route rule-bound work to the built-in Explore or Plan agents: both skip CLAUDE.md. general-purpose should not take work a named agent already owns.`
314
- : `5. **Everything else that changes files** → the orchestrator builds it directly. Bounded sub-parts go to cheaper tiers; the main build is never handed off whole.`;
391
+ ? `5. **When the task changes files**, builder executes by default after Assign confirms its tools, rules and context fit. The main agent briefs, combines sections, verifies and talks to the human. Keep conversation-dependent decisions and final verification with the main agent. When rules matter, use the matching named agent; the built-in Explore and Plan agents skip CLAUDE.md.`
392
+ : `5. **When the task changes files**, the main agent builds it directly until Assign verifies another lane can carry the required tools, context and rules. Give a suitable delegate the whole scope and its bounded section in a task brief.`;
315
393
  }
394
+ // ORCHESTRATOR.md's own decision tree already lists items 1-7 (see the
395
+ // template); this rule lands after all of them, so it continues that
396
+ // sequence as 8, not the "5" that fits ROUTING.md's shorter, differently
397
+ // ordered tree above (C4).
316
398
  export function decisionRule5Beginner(primary) {
317
399
  return subagentsLoadRules(primary)
318
- ? `5. **Everything else that changes files or executes a known plan** → builder executes by default, at standard tier. The orchestrator plans, briefs, verifies and talks to you; it stays inline only when (a) the brief would cost as much as the work, (b) the task needs this conversation's own context, or (c) it is your decision, or the final verification of delegated work. Never route rule-bound work to the built-in Explore or Plan agents: both skip CLAUDE.md.`
319
- : `5. **Everything else that changes files or executes a known plan** → you build it directly, at standard tier. The main build is never handed off whole; bounded sub-parts (a bulk pass, a wide search, a long audit loop) can go to cheaper tiers.`;
400
+ ? `8. **When the task changes files or executes a known plan**, builder executes by default after checking its tools and rules. Use the working model tier for well-specified work and a planning model for architecture. Keep conversation-dependent decisions and final verification with the main agent.`
401
+ : `8. **When the task changes files or executes a known plan**, use the main agent's working model tier. If another lane has the required tools and rules, give it a bounded section and a task brief.`;
320
402
  }
321
403
  export function whoBuildsSection(primary) {
322
- if (subagentsLoadRules(primary)) {
323
- return [
324
- '## Who builds',
325
- '',
326
- `**Builder executes by default.** A Claude Code subagent loads this project's CLAUDE.md hierarchy at start (verified: code.claude.com/docs/en/sub-agents), so it already carries the standing rules; the orchestrator's job is to plan, brief, verify and talk to the human, not to hold work a delegate can do. Stay inline only when: (a) the brief would cost as much as the work itself, (b) the task needs this conversation's own context, or (c) it is the human's decision to make, or the final verification of delegated work (a delegate never verifies its own output as final). Never route rule-bound work to the built-in Explore or Plan agents: both skip CLAUDE.md and the git status the router depends on. general-purpose should not take work a named agent already owns.`,
327
- '',
328
- `Delegate: the main build, background and long-running tasks, small tasks, scoping, verification, research, bounded sub-parts. Never delegate: the human's own decision, or the final sign-off on a delegate's work.`,
329
- '',
330
- `Every delegation carries \`TASK_BUNDLE.md\`. Its brief must restate this task's scope: a Claude Code subagent already has the standing rules, just not that.`
331
- ].join('\n');
332
- }
333
404
  return [
334
405
  '## Who builds',
335
406
  '',
336
- '**The orchestrator owns the main build.** It is the only surface that holds these rules: a subagent or a second CLI starts with none of them and cannot route. Handing the main build to one hands it to something the router cannot reach.',
407
+ subagentsLoadRules(primary)
408
+ ? '**Builder executes by default when its capabilities fit.** A Claude Code subagent loads the project CLAUDE.md hierarchy. Give it the context file, acceptance checks and whole scope in `TASK_BRIEF.md`. When the task depends on conversation context, keep that section with the main agent.'
409
+ : '**Assign each section by tools, context and rules.** The main agent already holds the session context. When another lane can carry the required context and permissions, give it the whole scope and its section in `TASK_BRIEF.md`; otherwise build that section in the main agent.',
337
410
  '',
338
- 'Delegate: background and long-running tasks, small tasks, scoping, verification, research, bounded sub-parts. Never delegate: the main build, or any step that must carry a house rule (secrets handling, the loud-negative verification, the durable record).',
411
+ 'When a decision belongs to the human, return it to them. When a section finishes, the main agent combines it with the other sections and gives the final artifact to the independent reviewer.',
339
412
  '',
340
- 'Every delegation carries `TASK_BUNDLE.md`. Its brief must restate every convention the delegate needs.'
413
+ 'When delegation costs as much as the bounded work itself, keep that work in the current session and record the reason.'
341
414
  ].join('\n');
342
415
  }
343
416
  export function addEndpointRow(primary) {
344
417
  return subagentsLoadRules(primary)
345
- ? '| "Add an endpoint" | builder, briefed and verified by the orchestrator |'
346
- : '| "Add an endpoint" | the orchestrator builds it |';
418
+ ? '| "Add an endpoint" | builder after Assign confirms its capabilities, with a task brief |'
419
+ : '| "Add an endpoint" | the main agent or another capable build lane chosen during Assign |';
347
420
  }
348
421
  export function inlineThresholdNote(primary) {
349
422
  return subagentsLoadRules(primary)
@@ -356,29 +429,24 @@ export function delegateRulesNote(primary) {
356
429
  : 'Subagents, a fresh chat, a second window: each one holds none of these rules.';
357
430
  }
358
431
 
359
- // Pre-release audit finding 3: the delegate-by-default gate reached the
360
- // decision tree and "Who builds" but missed three other generated surfaces
361
- // stating the same old premise (the orchestrator writes the main build
362
- // itself; a delegate inherits none of the session's rules). These three
363
- // close that gap the same way: gated on subagentsLoadRules(primary), every
364
- // other primary keeps the original wording unchanged.
432
+ // Keep assignment guidance consistent across the routing and build protocols.
365
433
  export function planBigExecuteSmallLine(primary) {
366
434
  return subagentsLoadRules(primary)
367
- ? `- **Plan big, execute small**, within a build: deep tier plans at Checkpoint 1, builder executes from the orchestrator's brief, bulk and wide searches go down.`
368
- : '- **Plan big, execute small**, within a build: deep tier plans at Checkpoint 1, the orchestrator executes, bulk and wide searches go down.';
435
+ ? '- **Assign by job fit.** Use a planning model for architecture, assign scoped execution to builder, and use a cheap model for mechanical work.'
436
+ : '- **Assign by job fit.** Match reach, context window and headroom to each section. When delegation cannot carry its required rules, the main agent executes that section.';
369
437
  }
370
438
  export function rolesBuilderRow(primary) {
371
439
  return subagentsLoadRules(primary)
372
440
  ? [
373
- '| Orchestrator | Routes, maps, briefs, verifies, records. Stages 0, 1, 2, 4, 5b, 6, 7 | Write the build |',
374
- "| Builder | Executes Stage 3 from the orchestrator's brief | Route further, or verify its own work as final |"
441
+ '| Main agent | Frames, maps, assigns, combines sections, verifies, records | Keeps the whole scope and names merge conflicts |',
442
+ '| Builder | Executes the assigned section from the task brief | Hands verification to an independent reviewer |'
375
443
  ].join('\n')
376
- : '| Builder / orchestrator | Routes, maps, writes, verifies, records. Stages 0, 1, 3, 6, 7 | Hand off the main build |';
444
+ : '| Main agent / assigned builder | Executes each section whose tools and rules it holds | Gives the reviewer the combined result and acceptance checks |';
377
445
  }
378
446
  export function builderHandoffNote(primary) {
379
447
  return subagentsLoadRules(primary)
380
- ? `**Why Stage 3 goes to builder by default:** a Claude Code subagent loads this project's CLAUDE.md hierarchy at start, so it already carries the standing rules; the orchestrator's brief only has to restate this task's scope (see \`TASK_BUNDLE.md\`). The orchestrator keeps Stage 3 for itself only when the brief would cost as much as the work, the task needs this conversation's own context, or it is the human's decision or the final verification of delegated work.`
381
- : `**Why the builder does not hand off the main build:** a delegated agent does not inherit the session's standing rules and usually cannot delegate further. Any brief must restate every convention it needs (see \`TASK_BUNDLE.md\`), and that cost is itself a reason to build directly when the work fits.`;
448
+ ? '**Assign the build:** when a Claude Code subagent has the needed tools and rules, send it the scoped task brief from `TASK_BRIEF.md`. Keep conversation-dependent decisions and the final verification with the main agent.'
449
+ : '**Assign the build:** when another lane can hold the required context, tools and rules, give it the whole scope and its section in `TASK_BRIEF.md`. When that transfer is impractical, build that section in the main agent.';
382
450
  }
383
451
 
384
452
  // Which activation file this primary gets. ONE decision, read by three
@@ -390,6 +458,29 @@ export function snippetFor(primary) {
390
458
  return primary.rulesFile ? primary.rulesFile.replace(/\.md$/, '.snippet.md') : 'PASTE-INTO-YOUR-AGENT.md';
391
459
  }
392
460
 
461
+ // The one primary-instruction step: copy into a known rules file, paste into a
462
+ // chat app's surface, or (Q3/Q5) load the block by hand for a CLI main agent
463
+ // the catalog has no project rules file for (Grok, Hermes today). Read by the
464
+ // terminal summary line, the activation list and the generated README's
465
+ // "where things went" section, so the three surfaces cannot describe three
466
+ // different things (#20).
467
+ export function primaryActivationStep({ primary, dir, project }) {
468
+ const snippet = snippetFor(primary);
469
+ if (!primary || !snippet) return null;
470
+ const dirAbs = resolve(dir || 'ai-orchestrator');
471
+ const projectAbs = resolve(project || process.cwd());
472
+ if (primary.rulesFile) return `copy the block in ${join(dirAbs, snippet)} into ${join(projectAbs, primary.rulesFile)} (create it if missing)`;
473
+ // A chat app has no possessive that survives its catalog note: "Claude app or
474
+ // claude.ai (chat only, no CLI)'s custom instructions" was the sentence this
475
+ // replaces (#22).
476
+ if (primary.facts.kind === 'chat') return `open ${primary.chatName || primary.name} and paste the block in ${join(dirAbs, snippet)} into its ${primary.chatSurface || 'custom instructions'}`;
477
+ // A CLI with no cataloged project rules file: say plainly there is nothing
478
+ // to write automatically, and name the accurate fallback (Q3). No verified
479
+ // per-CLI loading convention is cataloged for Grok or Hermes, so this states
480
+ // the honest generic fallback rather than guessing a mechanism.
481
+ return `${primary.name} has no cataloged project rules file, so the installer has nothing to write for it; load the block in ${join(dirAbs, snippet)} at the start of a session with ${primary.name}`;
482
+ }
483
+
393
484
  // The activation list, in order. The terminal prints this array at the end of a
394
485
  // run and the generated README renders the same array, so the page cannot
395
486
  // describe a different first step from the one the user just read (#20).
@@ -398,25 +489,33 @@ export function activationSteps(opts) {
398
489
  const tools = opts.tools || [];
399
490
  const dirAbs = resolve(opts.dir || 'ai-orchestrator');
400
491
  const projectAbs = resolve(opts.project || process.cwd());
401
- const snippet = snippetFor(primary);
402
492
  const steps = [];
403
- if (opts.applySnippets) steps.push(`applied ${join(dirAbs, snippet)} to the model-orchestrator marked block in ${join(projectAbs, 'CLAUDE.md')}`);
404
- else if (snippet && primary.rulesFile) steps.push(`copy the block in ${join(dirAbs, snippet)} into ${join(projectAbs, primary.rulesFile)} (create it if missing)`);
405
- // A chat app has no possessive that survives its catalog note: "Claude app or
406
- // claude.ai (chat only, no CLI)'s custom instructions" was the sentence this
407
- // replaces (#22).
408
- else if (snippet) steps.push(`open ${primary.chatName || primary.name} and paste the block in ${join(dirAbs, snippet)} into its ${primary.chatSurface || 'custom instructions'}`);
409
- if (primary && primary.agentsDir) steps.push(`subagents are in ${join(projectAbs, primary.agentsDir)}; run ${primary.bin} from ${projectAbs} to pick them up`);
493
+ // Automatic application only ever covers a primary with a rulesFile; every
494
+ // other case (no rulesFile, or applySnippets off) keeps the manual step.
495
+ if (primary && !(primary.rulesFile && opts.applySnippets)) {
496
+ const step = primaryActivationStep({ primary, dir: opts.dir, project: opts.project });
497
+ if (step) steps.push(step);
498
+ }
410
499
  // Only claude-code ships hooks (route-gate, subagent-context): the wiring
411
500
  // lives in a snippet, applied only when the user opts in.
412
- if (opts.applySnippets) steps.push(`applied hooks to ${join(projectAbs, '.claude', 'settings.json')}, preserving existing settings and hooks`);
413
- else if (subagentsLoadRules(primary)) steps.push(`merge the hooks in ${join(dirAbs, 'settings.hooks.snippet.json')} into ${join(projectAbs, '.claude', 'settings.json')} (create it if missing) to wire the route-gate, subagent-context and route-metrics hooks`);
414
- for (const a of selected.filter((a) => a.bin && a.kind === 'agent-cli')) steps.push(`sign in to ${a.name}: ${a.auth}`);
501
+ if (!opts.applySnippets && subagentsLoadRules(primary)) steps.push(`merge the hooks in ${join(dirAbs, 'settings.hooks.snippet.json')} into ${join(projectAbs, '.claude', 'settings.json')} (create it if missing) to wire the route-gate, subagent-context and route-metrics hooks`);
502
+ for (const a of selected.filter((a) => a.bin && a.facts.kind === 'agent-cli')) {
503
+ if (opts.authStatuses?.[a.id] === true) continue;
504
+ steps.push(opts.authStatuses?.[a.id] === false
505
+ ? `sign in to ${a.name}: ${a.auth}`
506
+ : `${a.name}, if you have not signed in yet: ${a.auth}`);
507
+ }
415
508
  // A local runtime has a bin but no sign-in, so the agent-cli loop above skips it
416
509
  // and before this it appeared in no ordered list at any level (#26).
417
- for (const a of selected.filter((a) => a.bin && a.kind === 'local')) steps.push(`install ${a.name}: ${a.install.url}, then \`${a.bin} pull <model>\` before the local lane can answer`);
418
- for (const t of tools) steps.push(`${t.id}: ${t.install}`);
419
- if (level >= 2) steps.push(`smoke test: node ${join(dirAbs, 'bin', 'cli-run.mjs')} --doctor (add --run to send each lane one tiny prompt)`);
510
+ for (const a of selected.filter((a) => a.bin && a.facts.kind === 'local-runtime')) {
511
+ // Q2/Q6: only print the install step when the runtime is not already on
512
+ // PATH, and name the configured model instead of a placeholder.
513
+ if (level < 3 && opts.detected?.has(a.id)) continue;
514
+ steps.push(level >= 3
515
+ ? `${a.name}: follow vm/README.md, then run \`bash setup-vm.sh --start-services\` in vm/ to pull the configured model into its Compose service and verify local-small`
516
+ : `install ${a.name}: ${a.install.url}, then \`${a.bin} pull ${a.gatewayModel.replace(/^ollama\//, '')}\` before the local lane can answer`);
517
+ }
518
+ steps.push(...companionRegistrationSteps({ ...opts, tools, dir: dirAbs, project: projectAbs }));
420
519
  if (level >= 3) steps.push(`box: read ${join(dirAbs, 'vm', 'README.md')}; keys named in vm/ENVIRONMENT.md go in your secrets manager, never a file`);
421
520
  return steps;
422
521
  }
@@ -425,15 +524,19 @@ export function activationSteps(opts) {
425
524
  // activationSteps is: level 1 writes no bin/, so a step naming cli-run.mjs or
426
525
  // lanes.json there described an install that did not happen (#27).
427
526
  export function proofSteps(opts) {
428
- const { level, primary } = opts;
527
+ const { level, primary, selected = primary ? [primary] : [] } = opts;
429
528
  const steps = [
430
529
  'Start a fresh agent session and ask: "Read the orchestrator instructions. Quote the routing rule you will use, then sort pear, apple, banana alphabetically. Name the tier and whether you delegated."',
431
- 'Expect the fast tier and `apple, banana, pear`. If the agent cannot quote the routing rule, check the snippet location or chat instructions before continuing. This is a manual activation check, not proof that every future task follows the rules.'
530
+ 'Expect the cheap model tier and `apple, banana, pear`. If the agent cannot quote the routing rule, check the snippet location or chat instructions before continuing. This is a manual activation check, not proof that every future task follows the rules.'
432
531
  ];
433
532
  if (level >= 2) {
434
- steps.push('Run `node bin/cli-run.mjs --doctor` from this folder. It checks binary presence, not authentication or loaded instructions, and prints the model and effort each lane is pinned to. `--doctor --run` additionally uses a little quota to test live responses. No enabled lanes means delegation is inactive.');
533
+ steps.push('Run `node bin/cli-run.mjs --doctor` from this folder, or `aunx cli-run --doctor` from your project root. It checks binary presence, not authentication or loaded instructions, and prints the model and effort each lane is pinned to. `--doctor --run` additionally uses a little quota to test live responses. No enabled lanes means delegation is inactive.');
435
534
  steps.push('Decide whether the route matters to you. Every lane starts unpinned, which means it runs on whatever its own config file says: a CLI configured months ago at a low reasoning effort will keep auditing at that effort while your docs describe something stronger. Pin it in `bin/lanes.json` under `defaults`, or per call with `--model` and `--effort`. Either way the run is recorded in the log with the value requested and where it came from.');
436
- steps.push('To test a real output contract, choose an enabled lane from `bin/lanes.json` and run `node bin/cli-run.mjs <lane> \'Return only {"sorted":["apple","banana","pear"]}\' --expect-json`. This uses quota. Expect JSON and exit 0; inspect the array yourself. A non-JSON response exits 10, a missing binary exits 13, and an authentication failure reports the vendor error. The explicit lane tests execution; your primary agent still makes delegation decisions.');
535
+ if (selected.some(a => a.facts.cliRun)) {
536
+ steps.push('To test a real output contract, choose an enabled lane from `bin/lanes.json` and run `node bin/cli-run.mjs <lane> \'Return only {"sorted":["apple","banana","pear"]}\' --expect-json`. The same command is available as `aunx cli-run <lane>` with those arguments. This uses quota. Expect JSON and exit 0; inspect the array yourself. A non-JSON response exits 10, a missing binary exits 13, and an authentication failure reports the vendor error. The explicit lane tests execution; your main agent still makes delegation decisions.');
537
+ } else {
538
+ steps.push('Delegation is inactive: no supported CLI lane is selected, so `--doctor` will exit 13. Defer the output-contract test until you select a supported CLI lane: re-run the installer with that lane in `--ais` and `--update-docs`, then install it and sign in using the printed instructions.');
539
+ }
437
540
  }
438
541
  // Only claude-code ships the route-gate hook, so only claude-code gets a
439
542
  // proof step that checks it fired: the table must come from the hook's
@@ -450,7 +553,15 @@ function vars(opts) {
450
553
  const tools = opts.tools || [];
451
554
  const apis = opts.apis || [];
452
555
  const lvl = LEVELS.find((l) => l.id === level);
453
- const lane = auditLane(selected);
556
+ const lane = auditLane(selected, primary);
557
+ const assignment = assignRoles({ selected, primary, detected: opts.detected, plans });
558
+ const stack = stackContext(selected, primary, opts.detected);
559
+ const enabled = selected.filter(a => a.facts.cliRun);
560
+ const exampleLane = enabled[0]?.id || '<lane>';
561
+ const exampleDefaults = { model: '<model-id>', ...(LANE_FLAGS[exampleLane]?.effort ? { effort: 'high' } : {}) };
562
+ const auditExample = enabled.find(a => a.facts.readOnlyMode);
563
+ const fallbackNote = 'When no separate lane qualifies, your main agent carries the job at its stated tier. Independent review and local-only work require an eligible lane.';
564
+ const gaps = assignment.unassigned.map(id => `${id}: ${assignment.roles[id].why}`).join(' ');
454
565
  const codecalc = tools.some((t) => t.id === 'codecalc');
455
566
  const dirAbs = resolve(opts.dir || 'ai-orchestrator');
456
567
  const projectAbs = resolve(opts.project || process.cwd());
@@ -483,46 +594,66 @@ function vars(opts) {
483
594
  : '';
484
595
  const pinOf = (id) => (toolById[id] && toolById[id].pin) || 'latest';
485
596
  const snippet = snippetFor(primary);
486
- const steps = activationSteps({ level, selected, primary, tools, dir: opts.dir, project: opts.project, applySnippets: opts.applySnippets });
487
- const proofs = proofSteps({ level, primary });
597
+ const steps = activationSteps({ level, selected, primary, tools, dir: opts.dir, project: opts.project, applySnippets: opts.applySnippets, authStatuses: opts.authStatuses, registrations: opts.registrations, detected: opts.detected });
598
+ const proofs = proofSteps({ level, primary, selected });
488
599
  const routingFile = level >= 2 ? 'ROUTING.md' : 'ORCHESTRATOR.md';
489
600
  // The path route-gate.mjs and subagent-context.mjs resolve at runtime,
490
601
  // relative to CLAUDE_PROJECT_DIR. Mirrors the RULES_PATH fallback below:
491
602
  // outside the project, the honest path is absolute, never a hardcoded one.
492
603
  const relJoin = (name) => (rulesPath === dirPosix ? posix.join(dirPosix, name) : rulesPath === '.' ? name : rulesPath + '/' + name);
493
604
  const rulesFileRel = relJoin(routingFile);
494
- const taskBundleRel = relJoin('TASK_BUNDLE.md');
605
+ const taskBriefRel = relJoin('TASK_BRIEF.md');
495
606
  // Only claude-code and agy put files under the project root. A chat primary
496
607
  // puts nothing there, so naming a project root would name a folder this run
497
608
  // never created (#21).
498
- const writesProject = !!(primary && primary.agentsDir);
609
+ const writesProject = !!(primary && primary.facts.agentDefinitions);
499
610
  const readsProjectRules = !!(primary && primary.rulesFile);
500
611
  const whereThingsWent = [`- This folder: \`${dirAbs}\``];
501
- if (writesProject) whereThingsWent.push(`- Project root (where your agent reads rules and subagents): \`${projectAbs}\``, `- Subagent definitions: \`${join(projectAbs, primary.agentsDir)}\``);
502
- else if (readsProjectRules) whereThingsWent.push(`- Project root (where ${primary.name} reads \`${primary.rulesFile}\`): \`${projectAbs}\`` + (existsSync(projectAbs) ? '' : ' (this run wrote nothing there; create the folder before you copy the snippet in)'), '- Subagent definitions: none, this agent has no subagent folder');
612
+ if (writesProject) whereThingsWent.push(`- Project root (where your agent reads rules and subagents): \`${projectAbs}\``, `- Subagent definitions: \`${join(projectAbs, primary.facts.agentDefinitions)}\``);
613
+ else if (readsProjectRules) whereThingsWent.push(`- Project root (where ${primary.name} reads \`${primary.rulesFile}\`): \`${projectAbs}\`` + (opts.applySnippets || existsSync(projectAbs) ? '' : ' (this run wrote nothing there; create the folder before you copy the snippet in)'), '- Subagent definitions: none, this agent has no subagent folder');
614
+ // Q5: a CLI with no cataloged project rules file (Grok, Hermes) is not a
615
+ // chat app, so it gets its own accurate sentence instead of borrowing theirs.
616
+ else if (primary && primary.facts.kind !== 'chat') whereThingsWent.push(`- Project root: none. ${primary.name} has no cataloged project rules file, so this install wrote nothing to a project folder; load the block by hand each session.`, '- Subagent definitions: none');
503
617
  else whereThingsWent.push('- Project root: none. A chat app reads pasted instructions, not files, so this install wrote nothing to a project folder.', '- Subagent definitions: none');
504
618
  whereThingsWent.push(`- The rules path your snippets use: \`${rulesPath}\``);
505
619
  whereThingsWent.push(rulesPathNote
506
620
  ? '- Rules location: absolute, because this folder is outside the project. ' + rulesPathNote
507
621
  : '- Rules location: project-relative, so moving the project and its rules folder together preserves the paths.');
508
622
  return {
509
- ...laneVars(selected),
510
- ACTIVATION_STEPS: steps.map((st, i) => `${i + 1}. ${st}`).join('\n'),
623
+ ...laneVars(selected, primary),
624
+ STACK_TABLE: roleTable(assignment, stack),
625
+ STACK_FALLBACK_NOTE: fallbackNote,
626
+ STACK_GAPS: gaps,
627
+ // The "Full assignments" pointer is its own paragraph, not a clause
628
+ // glued onto the end of the gaps sentence (C5): when gaps is non-empty
629
+ // it already ends its own sentence ("...off every lane here."), and
630
+ // running the pointer straight on read as a continuation of it.
631
+ STACK_SUMMARY: ['## Your stack: who does what', fallbackNote, gaps].filter(Boolean).join('\n')
632
+ + '\n\nFull assignments: [README.md](README.md#your-stack-who-does-what).',
633
+ EXAMPLE_LANE: exampleLane,
634
+ EXAMPLE_EFFORT_FLAGS: LANE_FLAGS[exampleLane]?.effort ? ' --effort high' : '',
635
+ EXAMPLE_AUDIT_LANE: auditExample?.id || '',
636
+ EXAMPLE_AUDIT_BLOCK: auditExample ? `When reviewing with an available read-only mode, select its audit shape:\n\n\x60\x60\x60bash\naunx cli-run ${auditExample.id} --audit --brief REVIEW.md\nnode bin/cli-run.mjs ${auditExample.id} --audit --brief REVIEW.md\n\x60\x60\x60` : 'When reviewing, verify the chosen lane permissions and request review-only work.',
637
+ EXAMPLE_LANES_JSON: JSON.stringify({ enabled: enabled.map(a => a.id), defaults: { [exampleLane]: exampleDefaults } }, null, 2),
638
+ // Renders only when Qwen is actually selected: the sentence names a flag
639
+ // that is a usage error on every other lane (C1).
640
+ QWEN_SAFE_MODE_NOTE: selected.some(a => a.id === 'qwen') ? "When Qwen's safe mode is required, pass `--safe-mode` to that lane. " : '',
641
+ ACTIVATION_STEPS: steps.length ? steps.map((st, i) => `${i + 1}. ${st}`).join('\n') : 'Nothing left to do.',
511
642
  PROOF_STEPS: proofs.map((st, i) => `${i + 1}. ${st}`).join('\n'),
512
- LOAD_IT: opts.applySnippets
513
- ? 'The installer applied the generated rules to the model-orchestrator marked block in `CLAUDE.md` and merged the hooks into `.claude/settings.json`. Existing files changed by this run have timestamped backups beside them; their paths were printed in the terminal.'
643
+ LOAD_IT: opts.applySnippets && readsProjectRules
644
+ ? `The installer applied the generated rules to the model-orchestrator marked block in \`${primary.rulesFile}\`${subagentsLoadRules(primary) ? ' and merged the hooks into `.claude/settings.json`' : ''}. Existing files changed by this run have timestamped backups beside them; their paths were printed in the terminal.`
514
645
  : readsProjectRules
515
- ? `${primary.name} reads its rules from \`${primary.rulesFile}\` in the project root. The installer wrote \`${snippet}\` next to this README; copy its contents into \`${join(projectAbs, primary.rulesFile)}\`, creating that file if it does not exist. Nothing was appended to a file you already had.`
646
+ ? `${primary.name} reads its rules from \`${primary.rulesFile}\` in the project root. The installer wrote \`${snippet}\` next to this README; copy its contents into \`${join(projectAbs, primary.rulesFile)}\`, creating that file if it does not exist. Nothing was appended to a file you already had.${subagentsLoadRules(primary) ? ` Also merge \`settings.hooks.snippet.json\`, written next to this README, into \`.claude/settings.json\` (create it if missing) to wire the route-gate, subagent-context and route-metrics hooks.` : ''}`
516
647
  : snippet
517
- ? `${primary.name} has no project rules file, so the rules travel by paste. The installer wrote \`${snippet}\` next to this README; open ${primary.chatName || primary.name} and paste its contents into ${primary.chatSurface || 'custom instructions'}. Nothing was appended to a file you already had.`
518
- : 'No primary agent was selected, so no activation file was written. Re-run the installer and pick one.',
648
+ ? `${primary.name} has no cataloged project rules file. Follow the load step under "What's left for you" using \`${snippet}\` next to this README.`
649
+ : 'No main agent was selected, so no activation file was written. Re-run the installer and pick one.',
519
650
  CLAUDE_SNIPPET_INTRO: opts.applySnippets
520
651
  ? '# Model orchestrator activation\n\nThe installer applied these rules to the marked block in `CLAUDE.md` at your project root.'
521
652
  : "# Add this to your project's CLAUDE.md\n\nCopy the block below into `CLAUDE.md` at your project root (create the file if it does not exist). The installer did not modify any file you already had.",
522
653
  CLAUDE_HOOKS_ACTIVATION: opts.applySnippets
523
654
  ? 'The installer merged the hook entries into `.claude/settings.json` to wire all three in.'
524
655
  : 'Merge `settings.hooks.snippet.json`, written next to this file, into `.claude/settings.json` to wire all three in.',
525
- CHAT_UPLOAD_NOTE: primary && primary.kind === 'chat' ? ' A chat app cannot open a local path: upload or paste any protocol file you want it to read.' : '',
656
+ CHAT_UPLOAD_NOTE: primary && primary.facts.kind === 'chat' ? ' A chat app cannot open a local path: upload or paste any protocol file you want it to read.' : '',
526
657
  WHERE_THINGS_WENT: whereThingsWent.join('\n'),
527
658
  RULES_PATH: rulesPath,
528
659
  RULES_PATH_NOTE: rulesPathNote,
@@ -530,7 +661,7 @@ function vars(opts) {
530
661
  RULES_DIR_OVERRIDE_JS: 'process.env.MODEL_ORCHESTRATOR_RULES_DIR',
531
662
  ROUTING_FILE: level >= 2 ? 'ROUTING.md' : 'ORCHESTRATOR.md',
532
663
  PROJECT_DIR: projectAbs,
533
- AGENTS_DIR: primary && primary.agentsDir ? join(projectAbs, primary.agentsDir) : 'none (your primary agent has no subagent folder)',
664
+ AGENTS_DIR: primary && primary.facts.agentDefinitions ? join(projectAbs, primary.facts.agentDefinitions) : 'none (your main agent has no subagent folder)',
534
665
  LITELLM_IMAGE: IMAGES.litellm,
535
666
  OLLAMA_IMAGE: IMAGES.ollama,
536
667
  CODECALC_PIN: pinOf('codecalc'),
@@ -542,16 +673,21 @@ function vars(opts) {
542
673
  INSTALL_DIR_SYSTEMD: systemdEscape(dirPosix),
543
674
  // vm/README.md step 3 named `grok login` and `agy` whatever you picked (#26).
544
675
  VM_SIGNIN: (() => {
545
- const lines = selected.filter((a) => a.bin && a.kind === 'agent-cli').map((a) => ` - ${a.name}: ${a.auth}`);
546
- for (const a of selected.filter((a) => a.bin && a.kind === 'local')) lines.push(` - ${a.name}: no sign-in. Install it from ${a.install.url}, then \`${a.bin} pull <model>\`.`);
676
+ const lines = selected.filter((a) => a.bin && a.facts.kind === 'agent-cli').map((a) => ` - ${a.name}: ${a.auth}`);
677
+ for (const a of selected.filter((a) => a.bin && a.facts.kind === 'local-runtime')) lines.push(` - ${a.name}: no sign-in. Step 5 initializes the model in its Compose service.`);
547
678
  return lines.length ? lines.join('\n') : ' - none: no CLI you selected needs a sign-in on the box.';
548
679
  })(),
680
+ VM_LOCAL_MODEL_SH: shellQuote(selected.some((a) => a.id === 'ollama') ? byId.ollama.gatewayModel.replace(/^ollama\//, '') : ''),
681
+ VM_SCRIPT_INSTALLERS: scriptInstallers(selected.filter((a) => a.facts.kind !== 'local-runtime')),
682
+ VM_LOCAL_SETUP: selected.some((a) => a.id === 'ollama')
683
+ ? `The command waits for Ollama, pulls \`${byId.ollama.gatewayModel.replace(/^ollama\//, '')}\` inside its Compose service, then requires a nonempty chat completion through the gateway alias \`local-small\`. The container uses its own volume; a host Ollama installation is separate. This check sends one short prompt to the local model.`
684
+ : 'No local runtime was selected. The command starts the configured services; verify any configured provider lanes separately.',
549
685
  AUDIT_LANE: lane || 'none',
550
686
  // Enforced boundary per lane: codex has a read-only sandbox flag; the others
551
687
  // run with whatever their own config allows, and the script says so.
552
- AUDIT_LANE_FLAGS: lane === 'codex' ? '--audit' : '',
553
- AUDIT_LANE_BOUNDARY_NOTE: lane === 'codex'
554
- ? 'codex --audit, a read-only filesystem sandbox; commands and network follow the codex config'
688
+ AUDIT_LANE_FLAGS: selected.find(a => a.id === lane)?.facts.readOnlyMode ? '--audit' : '',
689
+ AUDIT_LANE_BOUNDARY_NOTE: selected.find(a => a.id === lane)?.facts.readOnlyMode
690
+ ? `${lane} --audit, a read-only filesystem sandbox; commands and network follow the ${lane} config`
555
691
  : lane
556
692
  ? `${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`
557
693
  : 'no lane selected',
@@ -559,9 +695,9 @@ function vars(opts) {
559
695
  ? ''
560
696
  : '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',
561
697
  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)',
562
- 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',
563
- 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',
564
- CONTEXT7_STATUS: tools.some((t) => t.id === 'context7') ? 'selected (see `CONTEXT7.md`); the tool names below are live calls' : 'not selected; the rule below still binds, read the vendor docs or source by hand before trusting them',
698
+ CODECALC_STATUS: codecalc ? 'setup instructions selected (see `CODECALC.md`); verify your own installation before calling it' : 'use a calculator or the project runtime to compute and verify arithmetic',
699
+ OBSIDIAN_TC_STATUS: tools.some((t) => t.id === 'obsidian-tc') ? 'setup instructions selected (see `OBSIDIAN-TC.md`); verify server access before calling these tools' : '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',
700
+ CONTEXT7_STATUS: tools.some((t) => t.id === 'context7') ? 'setup instructions selected (see `CONTEXT7.md`); verify server access before calling these tools' : 'not selected; the rule below still binds, read the vendor docs or source by hand before trusting them',
565
701
  DATE: new Date().toISOString().slice(0, 10),
566
702
  LEVEL_ID: String(level),
567
703
  LEVEL_NAME: lvl.name,
@@ -569,15 +705,15 @@ function vars(opts) {
569
705
  PRIMARY_ID: primary ? primary.id : 'none',
570
706
  PRIMARY_NAME: primary ? primary.name : 'your agent',
571
707
  PRIMARY_RULES_FILE: primary && primary.rulesFile ? primary.rulesFile : 'your agent\'s instructions file',
572
- PRIMARY_DEEP: primary && primary.models ? primary.models.deep : 'your strongest model',
573
- PRIMARY_STANDARD: primary && primary.models ? primary.models.standard : 'your everyday model',
574
- PRIMARY_FAST: primary && primary.models ? primary.models.fast : 'your cheapest model',
575
- AIS_LIST: selected.map((a) => '- ' + a.name + ': ' + a.role).join('\n'),
708
+ PRIMARY_DEEP: 'the planning model available in your configuration',
709
+ PRIMARY_STANDARD: 'the working model available in your configuration',
710
+ PRIMARY_FAST: 'the cheap model available in your configuration',
711
+ AIS_LIST: selected.map((a) => '- ' + a.name + ': ' + summaryWithEvidence(a)).join('\n'),
576
712
  AI_IDS: selected.map((a) => a.id).join(','),
577
- LANES_TABLE: lanesTable(selected, plans),
713
+ LANES_TABLE: lanesTable(selected, plans, primary),
578
714
  PLAN_GUIDANCE: planGuidance(selected, plans),
579
715
  INSTALL_TABLE: installTable(selected),
580
- CLI_RUN_LANES: selected.filter((a) => a.cliRun).map((a) => a.id).join(', ') || 'none selected',
716
+ CLI_RUN_LANES: selected.filter((a) => a.facts.cliRun).map((a) => a.id).join(', ') || 'none selected',
581
717
  GATEWAY_MODELS: gatewayModels(selected, apis),
582
718
  ENV_NAMES: envNames(selected, apis).map((n) => '- `' + n + '`').join('\n'),
583
719
  ENV_EXPORTS: envNames(selected, apis).map((n) => n + '=').join('\n'),
@@ -585,9 +721,8 @@ function vars(opts) {
585
721
  SCRIPT_INSTALLERS: scriptInstallers(selected),
586
722
  COMPOSE_ENV: composeEnv(selected, apis),
587
723
  COMPOSE_OLLAMA: composeOllama(selected),
588
- // Delegate by default (0.1.15): gated on subagentsLoadRules(primary), currently
589
- // claude-code only. Every other primary keeps the original, more
590
- // conservative wording these replace.
724
+ // Delegate-by-default wording uses the verified subagent loading surface.
725
+ // Every other agent confirms tool and rule reach during Assign.
591
726
  DECISION_RULE5: decisionRule5(primary),
592
727
  DECISION_RULE5_L1: decisionRule5Beginner(primary),
593
728
  WHO_BUILDS: whoBuildsSection(primary),
@@ -597,11 +732,11 @@ function vars(opts) {
597
732
  PLAN_BIG_LINE: planBigExecuteSmallLine(primary),
598
733
  ROLES_BUILDER_ROW: rolesBuilderRow(primary),
599
734
  BUILDER_HANDOFF_NOTE: builderHandoffNote(primary),
600
- ROUTE_GATE_SECTION: subagentsLoadRules(primary) ? '\n' + routeGateSection(selected) + '\n' : '',
735
+ ROUTE_GATE_SECTION: subagentsLoadRules(primary) ? '\n' + routeGateSection(selected, primary) + '\n' : '',
601
736
  AGENTS_LIST_LINE: claudeAgentIds().map((id) => '`' + id + '`').join(', '),
602
737
  RULES_FILE_REL: rulesFileRel,
603
738
  RULES_FILE_REL_JSON: JSON.stringify(rulesFileRel),
604
- TASK_BUNDLE_REL_JSON: JSON.stringify(taskBundleRel),
739
+ TASK_BRIEF_REL_JSON: JSON.stringify(taskBriefRel),
605
740
  // route-gate.mjs takes a candidate list so the plugin bundle (src/plugin.js)
606
741
  // can render the installer's default locations from the same template. An
607
742
  // install knows its one rules file, and wrote it, so it needs no hint.
@@ -620,6 +755,18 @@ export function planFiles(opts) {
620
755
  const files = [];
621
756
  // root: 'dir' (the docs folder) or 'project' (where the agent actually looks for subagents)
622
757
  const add = (rel, content, mode, root = 'dir') => files.push({ rel, content, mode: mode || 0o644, root });
758
+ const renderAgent = (raw) => {
759
+ let content = render(raw, v);
760
+ const tier = raw.match(/^Tier: ((?:planning|working|cheap) model)\./m)?.[1];
761
+ const model = opts.plans?.[primary?.id]?.tierModels?.[tier];
762
+ // Mappings belong to a dated, verified plan entry. Every shipped mapping
763
+ // is null; an unstated plan leaves vendor resolution entirely intact.
764
+ if (model != null) {
765
+ if (typeof model !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.:/-]*$/.test(model)) throw new Error('invalid tierModels model identifier');
766
+ content = content.replace(/^---\n/, `---\nmodel: ${model}\n`);
767
+ }
768
+ return content;
769
+ };
623
770
  const addTemplates = (sub) => {
624
771
  for (const f of walk(join(TEMPLATES, sub))) {
625
772
  if (!installable(sub, f.rel)) continue;
@@ -631,11 +778,11 @@ export function planFiles(opts) {
631
778
  addTemplates('common');
632
779
  addTemplates('beginner');
633
780
 
634
- // The primary agent's own loading surface.
781
+ // The main agent's own loading surface.
635
782
  if (primary && primary.id === 'claude-code') {
636
783
  for (const f of walk(join(TEMPLATES, 'agents', 'claude-code'))) {
637
784
  if (!installable('agents', f.rel)) continue;
638
- add(join('.claude', 'agents', f.rel), render(readFileSync(f.abs, 'utf8'), v), 0o644, 'project');
785
+ add(join('.claude', 'agents', f.rel), renderAgent(readFileSync(f.abs, 'utf8')), 0o644, 'project');
639
786
  }
640
787
  add('CLAUDE.snippet.md', render(readFileSync(join(TEMPLATES, 'agents', 'snippets', 'claude-code.md'), 'utf8'), v));
641
788
  // Delegate-by-default hooks (0.1.15), claude-code only: route-gate.mjs (UserPromptSubmit)
@@ -652,7 +799,7 @@ export function planFiles(opts) {
652
799
  } else if (primary && primary.id === 'agy') {
653
800
  for (const f of walk(join(TEMPLATES, 'agents', 'agy'))) {
654
801
  if (!installable('agents', f.rel)) continue;
655
- add(join('.agents', 'agents', f.rel), render(readFileSync(f.abs, 'utf8'), v), 0o644, 'project');
802
+ add(join('.agents', 'agents', f.rel), renderAgent(readFileSync(f.abs, 'utf8')), 0o644, 'project');
656
803
  }
657
804
  add('GEMINI.snippet.md', render(readFileSync(join(TEMPLATES, 'agents', 'snippets', 'generic.md'), 'utf8'), v));
658
805
  } else if (primary && primary.rulesFile) {
@@ -672,10 +819,10 @@ export function planFiles(opts) {
672
819
  join('bin', 'lanes.json'),
673
820
  JSON.stringify(
674
821
  {
675
- enabled: selected.filter((a) => a.cliRun).map((a) => a.id),
822
+ enabled: selected.filter((a) => a.facts.cliRun).map((a) => a.id),
676
823
  defaults: Object.fromEntries((opts.effortAuto || []).map((lane) => [lane, { effort: 'auto' }])),
677
824
  note: 'Lanes cli-run may call. Edit to enable or disable a lane. A lane not listed here exits 13 (unavailable).',
678
- defaultsNote: 'Pin what a lane runs with, so the route in your docs is the route that runs: "defaults": {"codex": {"model": "gpt-6-astra", "effort": "high"}}. Left empty, a lane inherits its own config file, which cli-run cannot see and does not guess. `--model` and `--effort` override this per call, and `--doctor` prints what each lane is pinned to. Every lane takes a model; every lane except qwen takes an effort.'
825
+ defaultsNote: 'Pin what a lane runs with, so the route in your docs is the route that runs: "defaults": {"' + (selected.find(a => a.facts.cliRun)?.id || '<lane>') + '": ' + JSON.stringify({ model: '<model-id>', ...(LANE_FLAGS[selected.find(a => a.facts.cliRun)?.id]?.effort ? { effort: 'high' } : {}) }) + '}. Left empty, a lane inherits its own config file, which cli-run cannot see and does not guess. `--model` and `--effort` override this per call, and `--doctor` prints what each lane is pinned to. Every enabled lane takes a model; the runner reports which lanes support an effort flag.'
679
826
  },
680
827
  null,
681
828
  2
@@ -706,6 +853,8 @@ export function planFiles(opts) {
706
853
  level,
707
854
  ais: selected.map((a) => a.id),
708
855
  primary: primary ? primary.id : null,
856
+ detected: selected.filter(a => opts.detected?.has(a.id)).map(a => a.id),
857
+ roles: manifestRoles(assignRoles({ selected, primary, detected: opts.detected, plans: opts.plans }), stackContext(selected, primary, opts.detected)),
709
858
  tools: (opts.tools || []).map((t) => t.id),
710
859
  apis: (opts.apis || []).map((p) => p.id),
711
860
  ...(Object.keys(opts.plans || {}).length ? { plans: Object.fromEntries(Object.entries(opts.plans).sort(([a], [b]) => a.localeCompare(b)).map(([id, p]) => [id, p.id])) } : {}),
@@ -750,6 +899,28 @@ export function realRoot(dir) {
750
899
  return { root: missing.length ? join(real, ...missing) : real, exists: missing.length === 0 };
751
900
  }
752
901
 
902
+ // Project activation must never become a machine-wide agent configuration.
903
+ // Resolve the user's home as well as the requested root to cover system aliases.
904
+ export function globalConfigProblem(path) {
905
+ const home = realRoot(homedir()).root;
906
+ const globalFolders = new Set(['.claude', '.codex', '.grok', '.qwen', '.gemini', '.agents', '.antigravity', '.hermes']);
907
+ for (const ai of AIS) {
908
+ if (ai.facts?.agentDefinitions) globalFolders.add(ai.facts.agentDefinitions.split('/')[0]);
909
+ }
910
+ const rules = new Set(AIS.map((ai) => ai.rulesFile).filter(Boolean));
911
+ rules.add('.mcp.json');
912
+ const relativePath = relative(home, path);
913
+ const globalFolder = [...globalFolders].some((folder) => {
914
+ const target = realRoot(join(home, folder)).root;
915
+ const rel = relative(target, path);
916
+ return rel === '' || rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel);
917
+ });
918
+ if (rules.has(relativePath) || globalFolder) {
919
+ return `${path}: global agent configuration is outside the installer scope; choose a project folder below your home directory`;
920
+ }
921
+ return null;
922
+ }
923
+
753
924
  export function preflight(files, dir) {
754
925
  const problems = dirProblems(dir);
755
926
  if (problems.length) return problems;
@@ -761,6 +932,11 @@ export function preflight(files, dir) {
761
932
  problems.push(`${f.rel}: resolves outside the target directory`);
762
933
  continue;
763
934
  }
935
+ const globalProblem = globalConfigProblem(abs);
936
+ if (globalProblem) {
937
+ problems.push(globalProblem);
938
+ continue;
939
+ }
764
940
  const parts = relative(root, abs).split(sep);
765
941
  let cur = root;
766
942
  for (let i = 0; i < parts.length; i++) {
@@ -838,6 +1014,24 @@ export function readManifest(dir) {
838
1014
  }
839
1015
  }
840
1016
 
1017
+ // Path-safety-only preflight: global agent config, path escape, a non-directory
1018
+ // target. Read-only, no side effects, and independent of any previous
1019
+ // manifest. writeFiles() below runs the same check again before it writes
1020
+ // anything; bin/cli.js calls this copy earlier, so a doomed install (global
1021
+ // config, path escape) never reaches a step that can run a real vendor status
1022
+ // command as a side effect (Q1 safety: signInStatus can execute
1023
+ // `claude auth status` etc. before writeFiles is ever called).
1024
+ export function writePreflightProblems(files, { dir, project }) {
1025
+ const roots = { dir, project: project || dir };
1026
+ const groups = { dir: files.filter((f) => (f.root || 'dir') === 'dir'), project: files.filter((f) => f.root === 'project') };
1027
+ const problems = [];
1028
+ for (const k of ['dir', 'project']) {
1029
+ if (!groups[k].length) continue;
1030
+ problems.push(...preflight(groups[k], roots[k]).map((p) => (k === 'project' ? `[project] ${p}` : p)));
1031
+ }
1032
+ return problems;
1033
+ }
1034
+
841
1035
  // Files carry a root: 'dir' for the docs folder, 'project' for the agent
842
1036
  // definitions the user's CLI reads from the project root. Each root gets its
843
1037
  // own preflight; one failure anywhere rolls back everything this run touched.
@@ -856,12 +1050,52 @@ export function writeFiles(files, opts) {
856
1050
  const belongsHere = (key) => typeof key === 'string' && sameRoots[key.startsWith('[project] ') ? 'project' : 'dir'];
857
1051
  // A hash or directory from another project cannot establish ownership here.
858
1052
  const prevHashes = previous?.files ? Object.fromEntries(Object.entries(previous.files).filter(([key]) => belongsHere(key))) : null;
1053
+ if (sameRoots.project && previous?.activation !== undefined) {
1054
+ if (!previous.activation || typeof previous.activation !== 'object' || Array.isArray(previous.activation)) {
1055
+ throw Object.assign(new Error('invalid activation ownership in previous manifest'), { code: 'PREFLIGHT' });
1056
+ }
1057
+ for (const [key, ownership] of Object.entries(previous.activation)) {
1058
+ const problem = validateActivationOwnership(key, ownership);
1059
+ if (problem) throw Object.assign(new Error(problem), { code: 'PREFLIGHT' });
1060
+ }
1061
+ }
1062
+ const activation = previous?.activation && typeof previous.activation === 'object' && !Array.isArray(previous.activation)
1063
+ ? Object.fromEntries(Object.entries(previous.activation).filter(([key]) => belongsHere(key))) : {};
1064
+ for (const file of files.filter((item) => item.activation)) {
1065
+ const key = '[project] ' + toPosixRel(file.rel);
1066
+ const prior = activation[key];
1067
+ const next = { ...file.activation, created: file.original === null };
1068
+ if (prior?.kind === next.kind) {
1069
+ next.created = prior.created;
1070
+ if (next.kind === 'rules') {
1071
+ next.addedPrefix = prior.addedPrefix;
1072
+ next.addedSuffix = prior.addedSuffix;
1073
+ } else if (next.kind === 'hooks') {
1074
+ next.hadHooks = prior.hadHooks;
1075
+ next.originalEvents = prior.originalEvents;
1076
+ next.hooks = [...new Map([...(prior.hooks || []), ...next.hooks].map((hook) => [JSON.stringify(hook), hook])).values()];
1077
+ } else if (next.kind === 'mcp') {
1078
+ next.servers = { ...prior.servers, ...next.servers };
1079
+ next.hadKey = prior.hadKey;
1080
+ }
1081
+ }
1082
+ activation[key] = next;
1083
+ }
859
1084
  const groups = { dir: files.filter((f) => (f.root || 'dir') === 'dir'), project: files.filter((f) => f.root === 'project') };
860
1085
  const problems = [];
861
1086
  for (const k of ['dir', 'project']) {
862
1087
  if (!groups[k].length) continue;
863
1088
  problems.push(...preflight(groups[k], roots[k]).map((p) => (k === 'project' ? `[project] ${p}` : p)));
864
1089
  }
1090
+ // A legacy brief is a read and possible deletion target, so validate it with
1091
+ // the same containment, regular-file and symlink checks as every write.
1092
+ const currentBrief = groups.dir.find((f) => f.rel === 'TASK_BRIEF.md');
1093
+ const legacyPath = resolve(realRoot(dir).root, LEGACY_BRIEF);
1094
+ let hasLegacy = false;
1095
+ if (currentBrief) {
1096
+ try { lstatSync(legacyPath); hasLegacy = true; } catch { /* absent */ }
1097
+ if (hasLegacy) problems.push(...preflight([{ rel: LEGACY_BRIEF }], dir));
1098
+ }
865
1099
  if (!problems.length) {
866
1100
  for (const f of files.filter((file) => file.applySnippet)) {
867
1101
  const abs = resolve(roots[f.root], f.rel);
@@ -884,6 +1118,7 @@ export function writeFiles(files, opts) {
884
1118
  const docsUpdated = []; // --update-docs: documents regenerated because the installed copy was an untouched generated one
885
1119
  const docsConflict = []; // --update-docs: documents kept because you edited them
886
1120
  const docsUnverifiable = []; // --update-docs: documents kept because there is no manifest to compare against
1121
+ const docsRenamed = [];
887
1122
  const backups = [];
888
1123
  const created = [];
889
1124
  // Only directories actually created by this install are owned. Preserve the
@@ -895,6 +1130,7 @@ export function writeFiles(files, opts) {
895
1130
  // never the hash of content this run planned but did not write. Otherwise the next
896
1131
  // --update-docs or upgrade sees every kept file as "edited".
897
1132
  const keptKeys = new Set();
1133
+ const removedKeys = new Set();
898
1134
  try {
899
1135
  // project first so MANIFEST.json (last in the dir group) is the final write and can
900
1136
  // describe every decision made above it
@@ -969,7 +1205,40 @@ export function writeFiles(files, opts) {
969
1205
  }
970
1206
  let content = f.content;
971
1207
  if (f.rel === 'MANIFEST.json') {
1208
+ if (hasLegacy) {
1209
+ const previousHash = prevHashes?.[LEGACY_BRIEF];
1210
+ const original = readLegacyBrief(legacyPath, dir);
1211
+ const unchanged = previousHash && sha256(original.content) === previousHash;
1212
+ if ((updateDocs || force) && unchanged) {
1213
+ // The replacement has already been written (or preserved) by this
1214
+ // point. Keep deletion in this transaction and restore on failure.
1215
+ const current = readLegacyBrief(legacyPath, dir);
1216
+ const last = lstatSync(legacyPath);
1217
+ if (!current.content.equals(original.content) || current.stat.ino !== original.stat.ino || current.stat.dev !== original.stat.dev
1218
+ || last.ino !== current.stat.ino || last.dev !== current.stat.dev || !last.isFile()) {
1219
+ const e = new Error('legacy brief changed during upgrade; re-run the installer');
1220
+ e.code = 'PREFLIGHT';
1221
+ throw e;
1222
+ }
1223
+ if (!dry) {
1224
+ originals.set(legacyPath, { content: original.content, mode: original.stat.mode });
1225
+ unlinkSync(legacyPath);
1226
+ }
1227
+ removedKeys.add(LEGACY_BRIEF);
1228
+ docsRenamed.push(`${LEGACY_BRIEF} -> TASK_BRIEF.md`);
1229
+ } else if ((updateDocs || force) && !previousHash) {
1230
+ docsUnverifiable.push(LEGACY_BRIEF);
1231
+ } else if ((updateDocs || force) && !unchanged) {
1232
+ docsConflict.push(LEGACY_BRIEF);
1233
+ } else skipped.push(LEGACY_BRIEF);
1234
+ }
972
1235
  const m = JSON.parse(content);
1236
+ // Preserve ownership of retained 0.1.x companion files and other
1237
+ // formerly selected files, so uninstall still checks their original
1238
+ // installed hashes. New defaults do not erase a previous selection.
1239
+ m.files = { ...prevHashes, ...m.files };
1240
+ if (Object.keys(activation).length) m.activation = activation;
1241
+ for (const removed of removedKeys) delete m.files[removed];
973
1242
  for (const kk of Object.keys(m.files || {})) {
974
1243
  if (!keptKeys.has(kk)) continue;
975
1244
  if (prevHashes && prevHashes[kk]) m.files[kk] = prevHashes[kk];
@@ -977,7 +1246,7 @@ export function writeFiles(files, opts) {
977
1246
  }
978
1247
  content = JSON.stringify(m, null, 2) + '\n';
979
1248
  }
980
- if (exists && opts.backupExisting) {
1249
+ if (exists && (opts.backupExisting || k === 'project')) {
981
1250
  let stamp = Date.now();
982
1251
  let backup;
983
1252
  do {
@@ -1033,7 +1302,7 @@ export function writeFiles(files, opts) {
1033
1302
  }
1034
1303
  throw e;
1035
1304
  }
1036
- return { written, skipped, upgraded, conflicts, unverifiable, docsUpdated, docsConflict, docsUnverifiable, backups };
1305
+ return { written, skipped, upgraded, conflicts, unverifiable, docsUpdated, docsConflict, docsUnverifiable, docsRenamed, backups };
1037
1306
  }
1038
1307
 
1039
1308
  export function resolveSelection(ids) {