model-orchestrator 0.1.35 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/AGENTS.md +31 -21
  2. package/CHANGELOG.md +58 -1
  3. package/README.md +129 -110
  4. package/SECURITY.md +7 -3
  5. package/bin/README.md +57 -6
  6. package/bin/aunx.js +7 -0
  7. package/bin/cli-run.mjs +21 -15
  8. package/bin/cli.js +376 -257
  9. package/docs/README.md +15 -18
  10. package/docs/catalog.md +236 -44
  11. package/docs/companions.md +28 -10
  12. package/docs/guarantees.md +21 -12
  13. package/docs/how-it-routes.md +49 -42
  14. package/docs/install.md +141 -33
  15. package/docs/part-1-beginner.md +37 -45
  16. package/docs/part-2-intermediate.md +34 -52
  17. package/docs/part-3-advanced.md +36 -26
  18. package/docs/security-review-history.md +39 -0
  19. package/llms.txt +24 -25
  20. package/package.json +15 -8
  21. package/proof/README.md +100 -0
  22. package/proof/gate-demo.cast +9 -0
  23. package/proof/gate-demo.gif +0 -0
  24. package/proof/results.json +198 -0
  25. package/proof/scripts/check-gate.js +26 -0
  26. package/proof/scripts/install-time.js +16 -0
  27. package/proof/scripts/lib.js +73 -0
  28. package/proof/scripts/measure.js +15 -0
  29. package/proof/scripts/missing-results.js +30 -0
  30. package/proof/scripts/record-gate.js +38 -0
  31. package/proof/scripts/render.js +18 -0
  32. package/proof/scripts/runner-overhead.js +21 -0
  33. package/src/README.md +10 -3
  34. package/src/activation-ownership.js +19 -0
  35. package/src/apply-companions.js +104 -0
  36. package/src/apply-snippets.js +60 -28
  37. package/src/aunx.js +272 -0
  38. package/src/bounded-file.js +31 -0
  39. package/src/catalog.js +257 -121
  40. package/src/install.js +483 -212
  41. package/src/plugin.js +13 -4
  42. package/src/postinstall.js +57 -0
  43. package/src/roles.js +184 -0
  44. package/src/uninstall.js +128 -10
  45. package/templates/README.md +19 -2
  46. package/templates/advanced/README.md +2 -2
  47. package/templates/advanced/vm/ENVIRONMENT.md +8 -0
  48. package/templates/advanced/vm/PRIVACY_GATES.md +17 -19
  49. package/templates/advanced/vm/README.md +25 -20
  50. package/templates/advanced/vm/box-CLAUDE.md +19 -18
  51. package/templates/advanced/vm/docker-compose.yml +2 -1
  52. package/templates/advanced/vm/jobs/README.md +31 -2
  53. package/templates/advanced/vm/jobs/weekly-audit.service +7 -2
  54. package/templates/advanced/vm/jobs/weekly-audit.sh +24 -17
  55. package/templates/advanced/vm/setup-vm.sh +49 -2
  56. package/templates/agents/README.md +2 -2
  57. package/templates/agents/agy/README.md +20 -3
  58. package/templates/agents/agy/builder.md +11 -7
  59. package/templates/agents/agy/bulk-worker.md +9 -7
  60. package/templates/agents/agy/code-reviewer.md +13 -7
  61. package/templates/agents/agy/deep-planner.md +10 -7
  62. package/templates/agents/agy/done-verifier.md +13 -22
  63. package/templates/agents/agy/finding-verifier.md +14 -22
  64. package/templates/agents/agy/live-researcher.md +10 -7
  65. package/templates/agents/agy/reader.md +10 -12
  66. package/templates/agents/claude-code/README.md +18 -14
  67. package/templates/agents/claude-code/builder.md +10 -15
  68. package/templates/agents/claude-code/bulk-worker.md +8 -10
  69. package/templates/agents/claude-code/code-reviewer.md +11 -17
  70. package/templates/agents/claude-code/deep-planner.md +9 -11
  71. package/templates/agents/claude-code/done-verifier.md +12 -33
  72. package/templates/agents/claude-code/finding-verifier.md +13 -39
  73. package/templates/agents/claude-code/live-researcher.md +9 -11
  74. package/templates/agents/claude-code/reader.md +9 -18
  75. package/templates/agents/snippets/chat.md +9 -10
  76. package/templates/agents/snippets/claude-code.md +17 -18
  77. package/templates/agents/snippets/generic.md +9 -11
  78. package/templates/agents/snippets/route-gate.mjs +2 -2
  79. package/templates/agents/snippets/route-metrics.mjs +1 -1
  80. package/templates/agents/snippets/subagent-context.mjs +4 -4
  81. package/templates/beginner/ORCHESTRATOR.md +31 -36
  82. package/templates/beginner/README.md +1 -1
  83. package/templates/common/ACCEPTANCE_CHECKS.json +12 -0
  84. package/templates/common/CONTEXT.md +37 -0
  85. package/templates/common/DECISIONS.md +11 -0
  86. package/templates/common/README.md +24 -11
  87. package/templates/common/TASK_BRIEF.md +84 -0
  88. package/templates/common/protocols/README.md +14 -11
  89. package/templates/common/protocols/acceptance-checks.md +15 -0
  90. package/templates/common/protocols/build-protocol.md +91 -106
  91. package/templates/common/protocols/context-file.md +10 -0
  92. package/templates/common/protocols/decision-log.md +9 -0
  93. package/templates/common/protocols/deep-research.md +20 -34
  94. package/templates/common/protocols/docs-then-prove.md +13 -18
  95. package/templates/common/protocols/gap-analysis.md +15 -21
  96. package/templates/common/protocols/memory-and-record.md +21 -20
  97. package/templates/common/protocols/numbers-and-logic.md +20 -26
  98. package/templates/common/protocols/propagate.md +18 -27
  99. package/templates/intermediate/CLI-RUN.md +83 -113
  100. package/templates/intermediate/DELEGATION_MATRIX.md +9 -3
  101. package/templates/intermediate/README.md +3 -3
  102. package/templates/intermediate/RESEARCH_TRIAGE.md +23 -15
  103. package/templates/intermediate/ROUTING.md +54 -51
  104. package/templates/intermediate/TIERS.md +37 -76
  105. package/templates/tools/README.md +1 -1
  106. package/templates/tools/codecalc/CODECALC.md +4 -4
  107. package/templates/tools/codecalc/mcp/agy.mcp_config.json +1 -1
  108. package/templates/tools/codecalc/mcp/codex.config.toml +1 -1
  109. package/templates/tools/codecalc/mcp/mcpServers.json +1 -1
  110. package/templates/tools/codecalc/mcp/vscode.mcp.json +1 -1
  111. package/templates/tools/codecalc/mcp/zed.settings.json +1 -1
  112. package/templates/tools/context7/CONTEXT7.md +6 -10
  113. package/templates/tools/obsidian-tc/OBSIDIAN-TC.md +3 -3
  114. package/templates/tools/obsidian-tc/mcp/obsidian-tc.agy.mcp_config.json +1 -1
  115. package/templates/tools/obsidian-tc/mcp/obsidian-tc.codex.config.toml +1 -1
  116. package/templates/tools/obsidian-tc/mcp/obsidian-tc.mcpServers.json +1 -1
  117. package/templates/tools/obsidian-tc/mcp/obsidian-tc.vscode.mcp.json +1 -1
  118. package/templates/tools/obsidian-tc/mcp/obsidian-tc.zed.settings.json +1 -1
  119. package/docs/audit-brief.md +0 -148
  120. package/scripts/README.md +0 -7
  121. package/scripts/gen-catalog.js +0 -81
  122. package/scripts/gen-plugin.js +0 -16
  123. package/scripts/record-demo.sh +0 -45
  124. package/templates/common/TASK_BUNDLE.md +0 -56
package/src/install.js CHANGED
@@ -1,15 +1,41 @@
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';
12
+ import { MANIFEST_BYTE_CAP, readRegularFile } from './bounded-file.js';
7
13
 
8
14
  const HERE = dirname(fileURLToPath(import.meta.url));
9
15
  export const GENERATOR_VERSION = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')).version;
10
16
  const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
11
17
  export const TEMPLATES = join(HERE, '..', 'templates');
12
18
  export const CLI_RUN_SRC = join(HERE, '..', 'bin', 'cli-run.mjs');
19
+ // Compatibility with the 0.1.x filename; current docs use the public brief name.
20
+ const LEGACY_BRIEF = ['TASK', 'BUN' + 'DLE.md'].join('_');
21
+ // R1: a pre-existing settings or MCP JSON file larger than this is refused
22
+ // before it is read or parsed, the same way invalid JSON is refused today.
23
+ // Generous on purpose: real settings/MCP files are kilobytes, not megabytes.
24
+ export const ACTIVATION_JSON_BYTE_CAP = 10 * 1024 * 1024; // 10 MB
25
+
26
+ function readLegacyBrief(path, dir) {
27
+ const problems = preflight([{ rel: LEGACY_BRIEF }], dir);
28
+ if (problems.length) throw Object.assign(new Error(problems.join('; ')), { code: 'PREFLIGHT' });
29
+ const expected = lstatSync(path);
30
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
31
+ try {
32
+ const actual = fstatSync(fd);
33
+ if (!actual.isFile() || actual.dev !== expected.dev || actual.ino !== expected.ino) {
34
+ throw Object.assign(new Error('legacy brief changed during inspection; re-run the installer'), { code: 'PREFLIGHT' });
35
+ }
36
+ return { content: readFileSync(fd), stat: actual };
37
+ } finally { closeSync(fd); }
38
+ }
13
39
 
14
40
  function walk(dir, base = dir) {
15
41
  const out = [];
@@ -37,23 +63,25 @@ function table(rows, header) {
37
63
  return [line(header), line(header.map(() => '---')), ...rows.map(line)].join('\n');
38
64
  }
39
65
 
40
- export function lanesTable(selected, plans = {}) {
41
- const rows = selected.map((a) => [
66
+ export function lanesTable(selected, plans = {}, primary = inferPrimary(selected)) {
67
+ const { roles } = assignRoles({ selected, primary, plans });
68
+ const rows = selected.map(a => [
42
69
  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',
70
+ a.facts.billing,
71
+ summaryWithEvidence(a),
72
+ Object.entries(roles).filter(([, role]) => role.ai === a.id).map(([id]) => id).join(', ') || 'none',
73
+ a.facts.cliRun ? '`cli-run ' + a.id + '`' : a.bin ? '`' + a.bin + '`' : 'the app',
46
74
  plans[a.id] ? `${plans[a.id].name} (${plans[a.id].headroom} headroom)` : 'not stated'
47
75
  ]);
48
- return table(rows, ['AI', 'Lane', 'Wins at', 'Call it with', 'Plan']);
76
+ return table(rows, ['AI', 'Lane', 'What it is', 'Assigned roles', 'Call it with', 'Plan']);
49
77
  }
50
78
 
51
79
  function planGuidance(selected, plans = {}) {
52
80
  const lines = selected.filter((a) => plans[a.id]).map((a) => {
53
81
  const p = plans[a.id];
54
82
  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.';
83
+ ? 'Keep this base-headroom lane for short second opinions. If it is your main agent, delegate volume to high or max headroom lanes.'
84
+ : '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
85
  return `- **${a.name}: ${p.name} (${p.headroom} headroom).** ${volume} Capability and independent-review rules are unchanged. Checked ${p.checked}.`;
58
86
  });
59
87
  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 +105,7 @@ export function installTable(selected) {
77
105
  export function gatewayModels(selected, apis = []) {
78
106
  const lines = [];
79
107
  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');
108
+ lines.push(' - model_name: local-small', ' litellm_params:', ` model: ${byId.ollama.gatewayModel}`, ' api_base: http://ollama:11434');
81
109
  }
82
110
  for (const prov of apis) {
83
111
  for (const [alias, model] of prov.lanes) {
@@ -98,7 +126,7 @@ export function scriptInstallers(selected) {
98
126
  const lines = [];
99
127
  for (const a of selected) {
100
128
  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}"`);
129
+ else if (a.install.url && a.facts.kind !== 'chat') lines.push(`say " ${a.name}: ${a.install.url}"`);
102
130
  }
103
131
  return lines.length ? lines.join('\n') : 'say " none"';
104
132
  }
@@ -147,94 +175,134 @@ export function dirProblems(dir) {
147
175
  return problems;
148
176
  }
149
177
 
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;
178
+ // The weekly job uses the independent review assignment, then an eligible
179
+ // bulk runner. A main-agent fallback without a runner keeps the exit-13 guard.
180
+ export function auditLane(selected, primary = selected[0]) {
181
+ const { roles } = assignRoles({ selected, primary });
182
+ const id = roles.review.ai ?? roles.bulk.ai ?? null;
183
+ return selected.some(a => a.id === id && a.facts.cliRun) ? id : null;
184
+ }
185
+
186
+ function stackContext(selected, primary, detected = new Set()) {
187
+ const installed = new Set(agentIds(primary));
188
+ const names = { plan: 'deep-planner', build: 'builder', review: 'code-reviewer', verify: 'finding-verifier', research: 'live-researcher', bulk: 'bulk-worker', read: 'reader' };
189
+ const agents = Object.fromEntries(Object.entries(names).filter(([, name]) => installed.has(name)));
190
+ return { selected, primary, detected, agents };
191
+ }
192
+
193
+ function rolePick(id, assignment, ctx) {
194
+ const entry = roleRoute(id, assignment, ctx);
195
+ if (!entry || !entry.ai) return `none selected: ${entry?.reason || entry?.why || 'no eligible lane'}`;
196
+ const ai = ctx.selected.find(a => a.id === entry.ai);
197
+ if (entry.command) return '`' + entry.command + '`';
198
+ if (entry.via === 'local') return `${ai.name} on your machine`;
199
+ if (entry.via === 'main-agent') {
200
+ if (ai.facts.kind === 'chat') return `paste the work into your main agent, ${entry.tier} tier`;
201
+ return `${entry.agent ? '`' + entry.agent + '` on ' : ''}your main agent, ${entry.tier} tier`;
202
+ }
203
+ return `${ai.name}, ${entry.tier} tier`;
157
204
  }
158
205
 
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);
206
+ // Routing advice uses the same assignments as the stack table and manifest.
207
+ // Capability facts determine eligibility; selection order resolves equal fits.
208
+ export function laneVars(selected, primary = selected[0]) {
209
+ const assignment = assignRoles({ selected, primary });
210
+ const ctx = stackContext(selected, primary);
211
+ const { roles } = assignment;
212
+ const rolesForPrimary = routingRoles(primary);
213
+ const pick = id => rolePick(id, assignment, ctx);
214
+ // The installed subagent labels describe only main-agent assignments.
215
+ // An external winner must reach the action instructions as well as the table.
216
+ const assignedLabel = (id, local) => roles[id]?.ai && roles[id].ai !== primary?.id ? pick(id) : local;
217
+ const assignedRoles = {
218
+ PLANNER_ROLE: assignedLabel('plan', rolesForPrimary.PLANNER_ROLE),
219
+ BUILDER_ROLE: assignedLabel('build', rolesForPrimary.BUILDER_ROLE),
220
+ REVIEW_ROLE: assignedLabel('review', rolesForPrimary.REVIEW_ROLE),
221
+ FINDING_ROLE: assignedLabel('verify', rolesForPrimary.FINDING_ROLE),
222
+ DONE_ROLE: assignedLabel('verify', rolesForPrimary.DONE_ROLE),
223
+ LIVE_ROLE: assignedLabel('research', rolesForPrimary.LIVE_ROLE),
224
+ BULK_ROLE: assignedLabel('bulk', rolesForPrimary.BULK_ROLE),
225
+ READER_ROLE: assignedLabel('read', rolesForPrimary.READER_ROLE)
226
+ };
227
+ // TIERS.md's "Role" column is the one place several rows can carry the
228
+ // SAME bare command (two different rows can both land on `cli-run grok`,
229
+ // and FINDING_ROLE/DONE_ROLE are literally the same 'verify' assignment
230
+ // shown twice): a bare command alone does not say which row it is (C2).
231
+ // ROUTING.md and ORCHESTRATOR.md keep the assignedRoles values above
232
+ // (their decision-tree and activation text are pinned to that bare
233
+ // shape), so this labels a separate set of vars for TIERS.md only.
234
+ const externalWinner = (id) => Boolean(roles[id]?.ai && roles[id].ai !== primary?.id);
235
+ const tierRole = (value, id, label) => externalWinner(id) ? `${label} (${value})` : value;
236
+ const tierRoles = {
237
+ TIER_PLANNER_ROLE: tierRole(assignedRoles.PLANNER_ROLE, 'plan', 'planning'),
238
+ TIER_REVIEW_ROLE: tierRole(assignedRoles.REVIEW_ROLE, 'review', 'code review'),
239
+ TIER_FINDING_ROLE: tierRole(assignedRoles.FINDING_ROLE, 'verify', 'reproduce a finding'),
240
+ TIER_BUILDER_ROLE: tierRole(assignedRoles.BUILDER_ROLE, 'build', 'build'),
241
+ TIER_LIVE_ROLE: tierRole(assignedRoles.LIVE_ROLE, 'research', 'live research'),
242
+ TIER_BULK_ROLE: tierRole(assignedRoles.BULK_ROLE, 'bulk', 'bulk work'),
243
+ TIER_DONE_ROLE: tierRole(assignedRoles.DONE_ROLE, 'verify', 'check definition of done'),
244
+ TIER_READER_ROLE: tierRole(assignedRoles.READER_ROLE, 'read', 'read many files')
245
+ };
246
+ const reviewer = selected.find(a => a.id === roles.review.ai);
247
+ // No reviewer: state the self-check once (dropping roles.review.why here,
248
+ // which restates the same point) and end without a period, so
249
+ // ROUTING.md's fixed "... with appropriate effort." tail reads as one
250
+ // sentence instead of a second, dangling one glued after a full stop
251
+ // (C3). "review" still ends every "Why" column via roles.review.why
252
+ // directly, so that reasoning is not lost, only not duplicated here.
253
+ const review = reviewer
254
+ ? `${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'})`
255
+ : `${rolesForPrimary.REVIEW_ROLE} in a fresh context. No different-family reviewer is selected: treat this as a self-check`;
256
+ const picks = ROLE_SPECS.filter(spec => roles[spec.id]).map(spec => [spec.job, spec.id === 'review' ? review : pick(spec.id), roles[spec.id].why]);
257
+ const metered = selected.some(a => a.facts.billing === 'pay-per-token');
258
+ const free = selected.some(a => a.facts.billing === 'free');
180
259
  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.'
260
+ 'Prompt caching where it fits: frozen prefix first, volatile text last.',
261
+ `Use the assigned bulk route for bounded volume: ${pick('bulk')}. ${roles.bulk.why}.`,
262
+ ...(metered ? ["Batch APIs where the selected provider supports them, for work that can wait. When a rate is unverified, check your provider's rate."] : []),
263
+ ...(free ? ['A free model can carry routing decisions when its tools and context fit.'] : []),
264
+ 'Select effort and scoped context before changing model tiers.',
265
+ 'Read the current model roster before choosing an explicit model.'
187
266
  ];
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');
267
+ const enabled = selected.filter(a => a.facts.cliRun);
268
+ const step0 = ROLE_SPECS.filter(spec => roles[spec.id]?.ai && roles[spec.id].ai !== primary?.id)
269
+ .map(spec => `${pick(spec.id)} for ${spec.job.toLowerCase()}; ${roles[spec.id].why}`);
270
+ const stage1 = ['research', 'review', 'fan-out'].filter(id => roles[id]?.ai)
271
+ .map(id => `${id === 'review' ? review : pick(id)} for ${id === 'research' ? 'current primary sources' : id === 'review' ? 'a critique of the context file' : 'independent research units'}`);
272
+ const examples = [
273
+ `| "What is current on this topic" | ${pick('research')}; ${roles.research.why} |`,
274
+ `| "Audit this auth diff" | ${review} |`,
275
+ `| "Classify these 200 items" | ${pick('bulk')} |`,
276
+ '| "Research this topic properly" | plan the question, collect primary sources and verify claims; see `RESEARCH_TRIAGE.md` |'
277
+ ];
278
+ const researchRoles = ['research', 'review', 'bulk', 'fan-out'].filter(id => roles[id]?.ai)
279
+ .map(id => `| ${ROLE_SPECS.find(spec => spec.id === id).job} | ${id === 'review' ? review : pick(id)} | ${roles[id].why} |`);
280
+ researchRoles.push('| Triage + the durable record | the main agent | opens primary sources, marks every claim, writes the artifact |');
281
+ const runLanes = new Map();
282
+ for (const id of ['review', 'research', 'bulk', 'fan-out']) {
283
+ const entry = roleRoute(id, assignment, ctx);
284
+ if (entry?.command && !runLanes.has(entry.ai)) runLanes.set(entry.ai, entry.command);
285
+ }
286
+ const run = [...runLanes].map(([id, command]) => `node bin/cli-run.mjs ${command.replace(/^cli-run /, '')} --brief "$BRIEF" --timeout 900 > research/out-${id}.md`);
219
287
  return {
288
+ ...assignedRoles,
289
+ ...tierRoles,
220
290
  TASK_LANES_TABLE: table(picks, ['Task type', 'Pick', 'Why']),
221
291
  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' : '',
292
+ FAN_OUT_ADVICE: roles['fan-out'] ? ` Many independent items each needing their own agent turn → ${pick('fan-out')}.` : '',
293
+ METERED_CITATION_NOTE: metered ? ' Verify every supporting number and citation returned by a pay-per-token lane.' : '',
294
+ RESEARCH_SELECTION_ADVICE: `Use ${pick('research')} for current primary sources. ${roles.research.why}. ` + (enabled.length >= 2
295
+ ? 'Send a shared task brief to selected lanes with complementary capabilities; prefer different model families for independent perspectives.'
296
+ : 'Use a fresh context to challenge the sweep; add a different model family for independent research.') + ` For review, use ${review}.`,
297
+ GAP_ANALYSIS_LANE: `${review}. Give it the same artifact and verify each finding before acting.`,
298
+ 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',
299
+ STAGE1_LANES: stage1.length ? '; ' + stage1.join('; ') : '',
300
+ ATTACK_LANE: review,
301
+ LIVE_LANE: `${pick('research')} for current sources, then`,
302
+ BULK_LANE: `; assigned bulk route: ${pick('bulk')}`,
235
303
  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)',
304
+ RESEARCH_ROLES: researchRoles.join('\n'),
305
+ 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
306
  RESEARCH_ENGINES: String(run.length)
239
307
  };
240
308
  }
@@ -246,7 +314,7 @@ export function laneVars(selected) {
246
314
  // note) is gated on this so a primary with no verified premise keeps the
247
315
  // original, more conservative wording.
248
316
  export function subagentsLoadRules(primary) {
249
- return !!(primary && primary.subagentsLoadRules);
317
+ return !!(primary && primary.facts.loadsProjectRules);
250
318
  }
251
319
 
252
320
  // Canonical agent order, tier-first. Used to render a stable, non-hardcoded
@@ -254,8 +322,9 @@ export function subagentsLoadRules(primary) {
254
322
  // shipped, so a future agent addition or removal cannot leave the sentence
255
323
  // stale the way the finding-verifier omission did.
256
324
  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');
325
+ function agentIds(primary) {
326
+ if (!primary?.facts.agentDefinitions) return [];
327
+ const dir = join(TEMPLATES, 'agents', primary.id);
259
328
  if (!existsSync(dir)) return [];
260
329
  const files = readdirSync(dir).filter((f) => f.endsWith('.md') && f !== 'README.md').map((f) => f.replace(/\.md$/, ''));
261
330
  const set = new Set(files);
@@ -263,40 +332,51 @@ export function claudeAgentIds() {
263
332
  const extra = files.filter((id) => !AGENT_ORDER.includes(id)).sort();
264
333
  return [...ordered, ...extra];
265
334
  }
335
+ export function claudeAgentIds() {
336
+ return agentIds(byId['claude-code']);
337
+ }
266
338
 
267
- // The compact "pick the lane before acting" table, rendered from the AIs the
339
+ function routingRoles(primary) {
340
+ const installed = new Set(agentIds(primary));
341
+ const role = (id, label) => installed.has(id) ? id : `${label} role on the main agent`;
342
+ return {
343
+ BULK_ROLE: role('bulk-worker', 'bulk processing'),
344
+ BUILDER_ROLE: role('builder', 'build'),
345
+ READER_ROLE: role('reader', 'reading'),
346
+ REVIEW_ROLE: role('code-reviewer', 'code review'),
347
+ FINDING_ROLE: role('finding-verifier', 'finding verification'),
348
+ DONE_ROLE: role('done-verifier', 'completion verification'),
349
+ PLANNER_ROLE: role('deep-planner', 'planning'),
350
+ LIVE_ROLE: role('live-researcher', 'live research')
351
+ };
352
+ }
353
+
354
+ // The compact "choose a route before acting" table, rendered from the AIs the
268
355
  // user actually selected and the agents actually installed, never a second
269
356
  // 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 + '`']);
357
+ export function routeGateTable(selected, primary = inferPrimary(selected)) {
358
+ const assignment = assignRoles({ selected, primary });
359
+ const ctx = stackContext(selected, primary);
360
+ const rows = ROLE_SPECS.filter(spec => assignment.roles[spec.id])
361
+ .map(spec => [spec.job, rolePick(spec.id, assignment, ctx)]);
282
362
  return table(rows, ['Task', 'Lane']);
283
363
  }
284
364
 
285
365
  // The marked block route-gate.mjs extracts at runtime. Installed only for
286
366
  // claude-code so the hook always finds a block to read; other primaries get
287
367
  // no hook and so get no block.
288
- export function routeGateSection(selected) {
368
+ export function routeGateSection(selected, primary = inferPrimary(selected)) {
289
369
  return [
290
370
  '<!-- route-gate:start -->',
291
- '## Route gate: pick the lane before acting',
371
+ '## Route gate: choose a route before acting',
292
372
  '',
293
373
  'Injected on every turn by the `route-gate` hook, so this table is read at runtime rather than recalled from memory.',
294
374
  '',
295
- routeGateTable(selected),
375
+ routeGateTable(selected, primary),
296
376
  '',
297
377
  "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
378
  '',
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.',
379
+ '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
380
  '',
301
381
  '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
382
  '<!-- route-gate:end -->'
@@ -305,45 +385,39 @@ export function routeGateSection(selected) {
305
385
 
306
386
  // ROUTING.md / ORCHESTRATOR.md decision-tree rule 5 and the "Who builds"
307
387
  // 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.
388
+ // verified premise that its subagents load CLAUDE.md. Other agents verify
389
+ // rules and tool reach during Assign before handing off a section.
311
390
  export function decisionRule5(primary) {
312
391
  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.`;
392
+ ? `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.`
393
+ : `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
394
  }
395
+ // ORCHESTRATOR.md's own decision tree already lists items 1-7 (see the
396
+ // template); this rule lands after all of them, so it continues that
397
+ // sequence as 8, not the "5" that fits ROUTING.md's shorter, differently
398
+ // ordered tree above (C4).
316
399
  export function decisionRule5Beginner(primary) {
317
400
  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.`;
401
+ ? `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.`
402
+ : `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
403
  }
321
404
  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
405
  return [
334
406
  '## Who builds',
335
407
  '',
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.',
408
+ subagentsLoadRules(primary)
409
+ ? '**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.'
410
+ : '**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
411
  '',
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).',
412
+ '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
413
  '',
340
- 'Every delegation carries `TASK_BUNDLE.md`. Its brief must restate every convention the delegate needs.'
414
+ 'When delegation costs as much as the bounded work itself, keep that work in the current session and record the reason.'
341
415
  ].join('\n');
342
416
  }
343
417
  export function addEndpointRow(primary) {
344
418
  return subagentsLoadRules(primary)
345
- ? '| "Add an endpoint" | builder, briefed and verified by the orchestrator |'
346
- : '| "Add an endpoint" | the orchestrator builds it |';
419
+ ? '| "Add an endpoint" | builder after Assign confirms its capabilities, with a task brief |'
420
+ : '| "Add an endpoint" | the main agent or another capable build lane chosen during Assign |';
347
421
  }
348
422
  export function inlineThresholdNote(primary) {
349
423
  return subagentsLoadRules(primary)
@@ -356,29 +430,24 @@ export function delegateRulesNote(primary) {
356
430
  : 'Subagents, a fresh chat, a second window: each one holds none of these rules.';
357
431
  }
358
432
 
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.
433
+ // Keep assignment guidance consistent across the routing and build protocols.
365
434
  export function planBigExecuteSmallLine(primary) {
366
435
  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.';
436
+ ? '- **Assign by job fit.** Use a planning model for architecture, assign scoped execution to builder, and use a cheap model for mechanical work.'
437
+ : '- **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
438
  }
370
439
  export function rolesBuilderRow(primary) {
371
440
  return subagentsLoadRules(primary)
372
441
  ? [
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 |"
442
+ '| Main agent | Frames, maps, assigns, combines sections, verifies, records | Keeps the whole scope and names merge conflicts |',
443
+ '| Builder | Executes the assigned section from the task brief | Hands verification to an independent reviewer |'
375
444
  ].join('\n')
376
- : '| Builder / orchestrator | Routes, maps, writes, verifies, records. Stages 0, 1, 3, 6, 7 | Hand off the main build |';
445
+ : '| Main agent / assigned builder | Executes each section whose tools and rules it holds | Gives the reviewer the combined result and acceptance checks |';
377
446
  }
378
447
  export function builderHandoffNote(primary) {
379
448
  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.`;
449
+ ? '**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.'
450
+ : '**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
451
  }
383
452
 
384
453
  // Which activation file this primary gets. ONE decision, read by three
@@ -390,6 +459,29 @@ export function snippetFor(primary) {
390
459
  return primary.rulesFile ? primary.rulesFile.replace(/\.md$/, '.snippet.md') : 'PASTE-INTO-YOUR-AGENT.md';
391
460
  }
392
461
 
462
+ // The one primary-instruction step: copy into a known rules file, paste into a
463
+ // chat app's surface, or (Q3/Q5) load the block by hand for a CLI main agent
464
+ // the catalog has no project rules file for (Grok, Hermes today). Read by the
465
+ // terminal summary line, the activation list and the generated README's
466
+ // "where things went" section, so the three surfaces cannot describe three
467
+ // different things (#20).
468
+ export function primaryActivationStep({ primary, dir, project }) {
469
+ const snippet = snippetFor(primary);
470
+ if (!primary || !snippet) return null;
471
+ const dirAbs = resolve(dir || 'ai-orchestrator');
472
+ const projectAbs = resolve(project || process.cwd());
473
+ if (primary.rulesFile) return `copy the block in ${join(dirAbs, snippet)} into ${join(projectAbs, primary.rulesFile)} (create it if missing)`;
474
+ // A chat app has no possessive that survives its catalog note: "Claude app or
475
+ // claude.ai (chat only, no CLI)'s custom instructions" was the sentence this
476
+ // replaces (#22).
477
+ 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'}`;
478
+ // A CLI with no cataloged project rules file: say plainly there is nothing
479
+ // to write automatically, and name the accurate fallback (Q3). No verified
480
+ // per-CLI loading convention is cataloged for Grok or Hermes, so this states
481
+ // the honest generic fallback rather than guessing a mechanism.
482
+ 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}`;
483
+ }
484
+
393
485
  // The activation list, in order. The terminal prints this array at the end of a
394
486
  // run and the generated README renders the same array, so the page cannot
395
487
  // describe a different first step from the one the user just read (#20).
@@ -398,25 +490,33 @@ export function activationSteps(opts) {
398
490
  const tools = opts.tools || [];
399
491
  const dirAbs = resolve(opts.dir || 'ai-orchestrator');
400
492
  const projectAbs = resolve(opts.project || process.cwd());
401
- const snippet = snippetFor(primary);
402
493
  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`);
494
+ // Automatic application only ever covers a primary with a rulesFile; every
495
+ // other case (no rulesFile, or applySnippets off) keeps the manual step.
496
+ if (primary && !(primary.rulesFile && opts.applySnippets)) {
497
+ const step = primaryActivationStep({ primary, dir: opts.dir, project: opts.project });
498
+ if (step) steps.push(step);
499
+ }
410
500
  // Only claude-code ships hooks (route-gate, subagent-context): the wiring
411
501
  // 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}`);
502
+ 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`);
503
+ for (const a of selected.filter((a) => a.bin && a.facts.kind === 'agent-cli')) {
504
+ if (opts.authStatuses?.[a.id] === true) continue;
505
+ steps.push(opts.authStatuses?.[a.id] === false
506
+ ? `sign in to ${a.name}: ${a.auth}`
507
+ : `${a.name}, if you have not signed in yet: ${a.auth}`);
508
+ }
415
509
  // A local runtime has a bin but no sign-in, so the agent-cli loop above skips it
416
510
  // 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)`);
511
+ for (const a of selected.filter((a) => a.bin && a.facts.kind === 'local-runtime')) {
512
+ // Q2/Q6: only print the install step when the runtime is not already on
513
+ // PATH, and name the configured model instead of a placeholder.
514
+ if (level < 3 && opts.detected?.has(a.id)) continue;
515
+ steps.push(level >= 3
516
+ ? `${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`
517
+ : `install ${a.name}: ${a.install.url}, then \`${a.bin} pull ${a.gatewayModel.replace(/^ollama\//, '')}\` before the local lane can answer`);
518
+ }
519
+ steps.push(...companionRegistrationSteps({ ...opts, tools, dir: dirAbs, project: projectAbs }));
420
520
  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
521
  return steps;
422
522
  }
@@ -425,15 +525,19 @@ export function activationSteps(opts) {
425
525
  // activationSteps is: level 1 writes no bin/, so a step naming cli-run.mjs or
426
526
  // lanes.json there described an install that did not happen (#27).
427
527
  export function proofSteps(opts) {
428
- const { level, primary } = opts;
528
+ const { level, primary, selected = primary ? [primary] : [] } = opts;
429
529
  const steps = [
430
530
  '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.'
531
+ '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
532
  ];
433
533
  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.');
534
+ 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
535
  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.');
536
+ if (selected.some(a => a.facts.cliRun)) {
537
+ 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.');
538
+ } else {
539
+ 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.');
540
+ }
437
541
  }
438
542
  // Only claude-code ships the route-gate hook, so only claude-code gets a
439
543
  // proof step that checks it fired: the table must come from the hook's
@@ -450,7 +554,15 @@ function vars(opts) {
450
554
  const tools = opts.tools || [];
451
555
  const apis = opts.apis || [];
452
556
  const lvl = LEVELS.find((l) => l.id === level);
453
- const lane = auditLane(selected);
557
+ const lane = auditLane(selected, primary);
558
+ const assignment = assignRoles({ selected, primary, detected: opts.detected, plans });
559
+ const stack = stackContext(selected, primary, opts.detected);
560
+ const enabled = selected.filter(a => a.facts.cliRun);
561
+ const exampleLane = enabled[0]?.id || '<lane>';
562
+ const exampleDefaults = { model: '<model-id>', ...(LANE_FLAGS[exampleLane]?.effort ? { effort: 'high' } : {}) };
563
+ const auditExample = enabled.find(a => a.facts.readOnlyMode);
564
+ 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.';
565
+ const gaps = assignment.unassigned.map(id => `${id}: ${assignment.roles[id].why}`).join(' ');
454
566
  const codecalc = tools.some((t) => t.id === 'codecalc');
455
567
  const dirAbs = resolve(opts.dir || 'ai-orchestrator');
456
568
  const projectAbs = resolve(opts.project || process.cwd());
@@ -483,46 +595,66 @@ function vars(opts) {
483
595
  : '';
484
596
  const pinOf = (id) => (toolById[id] && toolById[id].pin) || 'latest';
485
597
  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 });
598
+ 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 });
599
+ const proofs = proofSteps({ level, primary, selected });
488
600
  const routingFile = level >= 2 ? 'ROUTING.md' : 'ORCHESTRATOR.md';
489
601
  // The path route-gate.mjs and subagent-context.mjs resolve at runtime,
490
602
  // relative to CLAUDE_PROJECT_DIR. Mirrors the RULES_PATH fallback below:
491
603
  // outside the project, the honest path is absolute, never a hardcoded one.
492
604
  const relJoin = (name) => (rulesPath === dirPosix ? posix.join(dirPosix, name) : rulesPath === '.' ? name : rulesPath + '/' + name);
493
605
  const rulesFileRel = relJoin(routingFile);
494
- const taskBundleRel = relJoin('TASK_BUNDLE.md');
606
+ const taskBriefRel = relJoin('TASK_BRIEF.md');
495
607
  // Only claude-code and agy put files under the project root. A chat primary
496
608
  // puts nothing there, so naming a project root would name a folder this run
497
609
  // never created (#21).
498
- const writesProject = !!(primary && primary.agentsDir);
610
+ const writesProject = !!(primary && primary.facts.agentDefinitions);
499
611
  const readsProjectRules = !!(primary && primary.rulesFile);
500
612
  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');
613
+ if (writesProject) whereThingsWent.push(`- Project root (where your agent reads rules and subagents): \`${projectAbs}\``, `- Subagent definitions: \`${join(projectAbs, primary.facts.agentDefinitions)}\``);
614
+ 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');
615
+ // Q5: a CLI with no cataloged project rules file (Grok, Hermes) is not a
616
+ // chat app, so it gets its own accurate sentence instead of borrowing theirs.
617
+ 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
618
  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
619
  whereThingsWent.push(`- The rules path your snippets use: \`${rulesPath}\``);
505
620
  whereThingsWent.push(rulesPathNote
506
621
  ? '- Rules location: absolute, because this folder is outside the project. ' + rulesPathNote
507
622
  : '- Rules location: project-relative, so moving the project and its rules folder together preserves the paths.');
508
623
  return {
509
- ...laneVars(selected),
510
- ACTIVATION_STEPS: steps.map((st, i) => `${i + 1}. ${st}`).join('\n'),
624
+ ...laneVars(selected, primary),
625
+ STACK_TABLE: roleTable(assignment, stack),
626
+ STACK_FALLBACK_NOTE: fallbackNote,
627
+ STACK_GAPS: gaps,
628
+ // The "Full assignments" pointer is its own paragraph, not a clause
629
+ // glued onto the end of the gaps sentence (C5): when gaps is non-empty
630
+ // it already ends its own sentence ("...off every lane here."), and
631
+ // running the pointer straight on read as a continuation of it.
632
+ STACK_SUMMARY: ['## Your stack: who does what', fallbackNote, gaps].filter(Boolean).join('\n')
633
+ + '\n\nFull assignments: [README.md](README.md#your-stack-who-does-what).',
634
+ EXAMPLE_LANE: exampleLane,
635
+ EXAMPLE_EFFORT_FLAGS: LANE_FLAGS[exampleLane]?.effort ? ' --effort high' : '',
636
+ EXAMPLE_AUDIT_LANE: auditExample?.id || '',
637
+ 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.',
638
+ EXAMPLE_LANES_JSON: JSON.stringify({ enabled: enabled.map(a => a.id), defaults: { [exampleLane]: exampleDefaults } }, null, 2),
639
+ // Renders only when Qwen is actually selected: the sentence names a flag
640
+ // that is a usage error on every other lane (C1).
641
+ QWEN_SAFE_MODE_NOTE: selected.some(a => a.id === 'qwen') ? "When Qwen's safe mode is required, pass `--safe-mode` to that lane. " : '',
642
+ ACTIVATION_STEPS: steps.length ? steps.map((st, i) => `${i + 1}. ${st}`).join('\n') : 'Nothing left to do.',
511
643
  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.'
644
+ LOAD_IT: opts.applySnippets && readsProjectRules
645
+ ? `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
646
  : 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.`
647
+ ? `${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
648
  : 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.',
649
+ ? `${primary.name} has no cataloged project rules file. Follow the load step under "What's left for you" using \`${snippet}\` next to this README.`
650
+ : 'No main agent was selected, so no activation file was written. Re-run the installer and pick one.',
519
651
  CLAUDE_SNIPPET_INTRO: opts.applySnippets
520
652
  ? '# Model orchestrator activation\n\nThe installer applied these rules to the marked block in `CLAUDE.md` at your project root.'
521
653
  : "# 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
654
  CLAUDE_HOOKS_ACTIVATION: opts.applySnippets
523
655
  ? 'The installer merged the hook entries into `.claude/settings.json` to wire all three in.'
524
656
  : '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.' : '',
657
+ 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
658
  WHERE_THINGS_WENT: whereThingsWent.join('\n'),
527
659
  RULES_PATH: rulesPath,
528
660
  RULES_PATH_NOTE: rulesPathNote,
@@ -530,7 +662,7 @@ function vars(opts) {
530
662
  RULES_DIR_OVERRIDE_JS: 'process.env.MODEL_ORCHESTRATOR_RULES_DIR',
531
663
  ROUTING_FILE: level >= 2 ? 'ROUTING.md' : 'ORCHESTRATOR.md',
532
664
  PROJECT_DIR: projectAbs,
533
- AGENTS_DIR: primary && primary.agentsDir ? join(projectAbs, primary.agentsDir) : 'none (your primary agent has no subagent folder)',
665
+ AGENTS_DIR: primary && primary.facts.agentDefinitions ? join(projectAbs, primary.facts.agentDefinitions) : 'none (your main agent has no subagent folder)',
534
666
  LITELLM_IMAGE: IMAGES.litellm,
535
667
  OLLAMA_IMAGE: IMAGES.ollama,
536
668
  CODECALC_PIN: pinOf('codecalc'),
@@ -542,16 +674,21 @@ function vars(opts) {
542
674
  INSTALL_DIR_SYSTEMD: systemdEscape(dirPosix),
543
675
  // vm/README.md step 3 named `grok login` and `agy` whatever you picked (#26).
544
676
  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>\`.`);
677
+ const lines = selected.filter((a) => a.bin && a.facts.kind === 'agent-cli').map((a) => ` - ${a.name}: ${a.auth}`);
678
+ 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
679
  return lines.length ? lines.join('\n') : ' - none: no CLI you selected needs a sign-in on the box.';
548
680
  })(),
681
+ VM_LOCAL_MODEL_SH: shellQuote(selected.some((a) => a.id === 'ollama') ? byId.ollama.gatewayModel.replace(/^ollama\//, '') : ''),
682
+ VM_SCRIPT_INSTALLERS: scriptInstallers(selected.filter((a) => a.facts.kind !== 'local-runtime')),
683
+ VM_LOCAL_SETUP: selected.some((a) => a.id === 'ollama')
684
+ ? `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.`
685
+ : 'No local runtime was selected. The command starts the configured services; verify any configured provider lanes separately.',
549
686
  AUDIT_LANE: lane || 'none',
550
687
  // Enforced boundary per lane: codex has a read-only sandbox flag; the others
551
688
  // 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'
689
+ AUDIT_LANE_FLAGS: selected.find(a => a.id === lane)?.facts.readOnlyMode ? '--audit' : '',
690
+ AUDIT_LANE_BOUNDARY_NOTE: selected.find(a => a.id === lane)?.facts.readOnlyMode
691
+ ? `${lane} --audit, a read-only filesystem sandbox; commands and network follow the ${lane} config`
555
692
  : lane
556
693
  ? `${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
694
  : 'no lane selected',
@@ -559,9 +696,9 @@ function vars(opts) {
559
696
  ? ''
560
697
  : '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
698
  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',
699
+ 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',
700
+ 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',
701
+ 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
702
  DATE: new Date().toISOString().slice(0, 10),
566
703
  LEVEL_ID: String(level),
567
704
  LEVEL_NAME: lvl.name,
@@ -569,15 +706,15 @@ function vars(opts) {
569
706
  PRIMARY_ID: primary ? primary.id : 'none',
570
707
  PRIMARY_NAME: primary ? primary.name : 'your agent',
571
708
  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'),
709
+ PRIMARY_DEEP: 'the planning model available in your configuration',
710
+ PRIMARY_STANDARD: 'the working model available in your configuration',
711
+ PRIMARY_FAST: 'the cheap model available in your configuration',
712
+ AIS_LIST: selected.map((a) => '- ' + a.name + ': ' + summaryWithEvidence(a)).join('\n'),
576
713
  AI_IDS: selected.map((a) => a.id).join(','),
577
- LANES_TABLE: lanesTable(selected, plans),
714
+ LANES_TABLE: lanesTable(selected, plans, primary),
578
715
  PLAN_GUIDANCE: planGuidance(selected, plans),
579
716
  INSTALL_TABLE: installTable(selected),
580
- CLI_RUN_LANES: selected.filter((a) => a.cliRun).map((a) => a.id).join(', ') || 'none selected',
717
+ CLI_RUN_LANES: selected.filter((a) => a.facts.cliRun).map((a) => a.id).join(', ') || 'none selected',
581
718
  GATEWAY_MODELS: gatewayModels(selected, apis),
582
719
  ENV_NAMES: envNames(selected, apis).map((n) => '- `' + n + '`').join('\n'),
583
720
  ENV_EXPORTS: envNames(selected, apis).map((n) => n + '=').join('\n'),
@@ -585,9 +722,8 @@ function vars(opts) {
585
722
  SCRIPT_INSTALLERS: scriptInstallers(selected),
586
723
  COMPOSE_ENV: composeEnv(selected, apis),
587
724
  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.
725
+ // Delegate-by-default wording uses the verified subagent loading surface.
726
+ // Every other agent confirms tool and rule reach during Assign.
591
727
  DECISION_RULE5: decisionRule5(primary),
592
728
  DECISION_RULE5_L1: decisionRule5Beginner(primary),
593
729
  WHO_BUILDS: whoBuildsSection(primary),
@@ -597,11 +733,11 @@ function vars(opts) {
597
733
  PLAN_BIG_LINE: planBigExecuteSmallLine(primary),
598
734
  ROLES_BUILDER_ROW: rolesBuilderRow(primary),
599
735
  BUILDER_HANDOFF_NOTE: builderHandoffNote(primary),
600
- ROUTE_GATE_SECTION: subagentsLoadRules(primary) ? '\n' + routeGateSection(selected) + '\n' : '',
736
+ ROUTE_GATE_SECTION: subagentsLoadRules(primary) ? '\n' + routeGateSection(selected, primary) + '\n' : '',
601
737
  AGENTS_LIST_LINE: claudeAgentIds().map((id) => '`' + id + '`').join(', '),
602
738
  RULES_FILE_REL: rulesFileRel,
603
739
  RULES_FILE_REL_JSON: JSON.stringify(rulesFileRel),
604
- TASK_BUNDLE_REL_JSON: JSON.stringify(taskBundleRel),
740
+ TASK_BRIEF_REL_JSON: JSON.stringify(taskBriefRel),
605
741
  // route-gate.mjs takes a candidate list so the plugin bundle (src/plugin.js)
606
742
  // can render the installer's default locations from the same template. An
607
743
  // install knows its one rules file, and wrote it, so it needs no hint.
@@ -620,6 +756,18 @@ export function planFiles(opts) {
620
756
  const files = [];
621
757
  // root: 'dir' (the docs folder) or 'project' (where the agent actually looks for subagents)
622
758
  const add = (rel, content, mode, root = 'dir') => files.push({ rel, content, mode: mode || 0o644, root });
759
+ const renderAgent = (raw) => {
760
+ let content = render(raw, v);
761
+ const tier = raw.match(/^Tier: ((?:planning|working|cheap) model)\./m)?.[1];
762
+ const model = opts.plans?.[primary?.id]?.tierModels?.[tier];
763
+ // Mappings belong to a dated, verified plan entry. Every shipped mapping
764
+ // is null; an unstated plan leaves vendor resolution entirely intact.
765
+ if (model != null) {
766
+ if (typeof model !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.:/-]*$/.test(model)) throw new Error('invalid tierModels model identifier');
767
+ content = content.replace(/^---\n/, `---\nmodel: ${model}\n`);
768
+ }
769
+ return content;
770
+ };
623
771
  const addTemplates = (sub) => {
624
772
  for (const f of walk(join(TEMPLATES, sub))) {
625
773
  if (!installable(sub, f.rel)) continue;
@@ -631,11 +779,11 @@ export function planFiles(opts) {
631
779
  addTemplates('common');
632
780
  addTemplates('beginner');
633
781
 
634
- // The primary agent's own loading surface.
782
+ // The main agent's own loading surface.
635
783
  if (primary && primary.id === 'claude-code') {
636
784
  for (const f of walk(join(TEMPLATES, 'agents', 'claude-code'))) {
637
785
  if (!installable('agents', f.rel)) continue;
638
- add(join('.claude', 'agents', f.rel), render(readFileSync(f.abs, 'utf8'), v), 0o644, 'project');
786
+ add(join('.claude', 'agents', f.rel), renderAgent(readFileSync(f.abs, 'utf8')), 0o644, 'project');
639
787
  }
640
788
  add('CLAUDE.snippet.md', render(readFileSync(join(TEMPLATES, 'agents', 'snippets', 'claude-code.md'), 'utf8'), v));
641
789
  // Delegate-by-default hooks (0.1.15), claude-code only: route-gate.mjs (UserPromptSubmit)
@@ -652,7 +800,7 @@ export function planFiles(opts) {
652
800
  } else if (primary && primary.id === 'agy') {
653
801
  for (const f of walk(join(TEMPLATES, 'agents', 'agy'))) {
654
802
  if (!installable('agents', f.rel)) continue;
655
- add(join('.agents', 'agents', f.rel), render(readFileSync(f.abs, 'utf8'), v), 0o644, 'project');
803
+ add(join('.agents', 'agents', f.rel), renderAgent(readFileSync(f.abs, 'utf8')), 0o644, 'project');
656
804
  }
657
805
  add('GEMINI.snippet.md', render(readFileSync(join(TEMPLATES, 'agents', 'snippets', 'generic.md'), 'utf8'), v));
658
806
  } else if (primary && primary.rulesFile) {
@@ -672,10 +820,10 @@ export function planFiles(opts) {
672
820
  join('bin', 'lanes.json'),
673
821
  JSON.stringify(
674
822
  {
675
- enabled: selected.filter((a) => a.cliRun).map((a) => a.id),
823
+ enabled: selected.filter((a) => a.facts.cliRun).map((a) => a.id),
676
824
  defaults: Object.fromEntries((opts.effortAuto || []).map((lane) => [lane, { effort: 'auto' }])),
677
825
  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.'
826
+ 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
827
  },
680
828
  null,
681
829
  2
@@ -706,6 +854,8 @@ export function planFiles(opts) {
706
854
  level,
707
855
  ais: selected.map((a) => a.id),
708
856
  primary: primary ? primary.id : null,
857
+ detected: selected.filter(a => opts.detected?.has(a.id)).map(a => a.id),
858
+ roles: manifestRoles(assignRoles({ selected, primary, detected: opts.detected, plans: opts.plans }), stackContext(selected, primary, opts.detected)),
709
859
  tools: (opts.tools || []).map((t) => t.id),
710
860
  apis: (opts.apis || []).map((p) => p.id),
711
861
  ...(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 +900,28 @@ export function realRoot(dir) {
750
900
  return { root: missing.length ? join(real, ...missing) : real, exists: missing.length === 0 };
751
901
  }
752
902
 
903
+ // Project activation must never become a machine-wide agent configuration.
904
+ // Resolve the user's home as well as the requested root to cover system aliases.
905
+ export function globalConfigProblem(path) {
906
+ const home = realRoot(homedir()).root;
907
+ const globalFolders = new Set(['.claude', '.codex', '.grok', '.qwen', '.gemini', '.agents', '.antigravity', '.hermes']);
908
+ for (const ai of AIS) {
909
+ if (ai.facts?.agentDefinitions) globalFolders.add(ai.facts.agentDefinitions.split('/')[0]);
910
+ }
911
+ const rules = new Set(AIS.map((ai) => ai.rulesFile).filter(Boolean));
912
+ rules.add('.mcp.json');
913
+ const relativePath = relative(home, path);
914
+ const globalFolder = [...globalFolders].some((folder) => {
915
+ const target = realRoot(join(home, folder)).root;
916
+ const rel = relative(target, path);
917
+ return rel === '' || rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel);
918
+ });
919
+ if (rules.has(relativePath) || globalFolder) {
920
+ return `${path}: global agent configuration is outside the installer scope; choose a project folder below your home directory`;
921
+ }
922
+ return null;
923
+ }
924
+
753
925
  export function preflight(files, dir) {
754
926
  const problems = dirProblems(dir);
755
927
  if (problems.length) return problems;
@@ -761,6 +933,11 @@ export function preflight(files, dir) {
761
933
  problems.push(`${f.rel}: resolves outside the target directory`);
762
934
  continue;
763
935
  }
936
+ const globalProblem = globalConfigProblem(abs);
937
+ if (globalProblem) {
938
+ problems.push(globalProblem);
939
+ continue;
940
+ }
764
941
  const parts = relative(root, abs).split(sep);
765
942
  let cur = root;
766
943
  for (let i = 0; i < parts.length; i++) {
@@ -831,11 +1008,30 @@ export function fileClass(rel, separator = sep) {
831
1008
 
832
1009
  export function readManifest(dir) {
833
1010
  try {
834
- const j = JSON.parse(readFileSync(join(resolve(dir), 'MANIFEST.json'), 'utf8'));
1011
+ const j = JSON.parse(readRegularFile(join(resolve(dir), 'MANIFEST.json'), MANIFEST_BYTE_CAP).toString('utf8'));
835
1012
  return j && typeof j === 'object' ? j : null;
836
- } catch {
837
- return null;
1013
+ } catch (error) {
1014
+ if (error.code === 'ENOENT' || error instanceof SyntaxError) return null;
1015
+ throw error;
1016
+ }
1017
+ }
1018
+
1019
+ // Path-safety-only preflight: global agent config, path escape, a non-directory
1020
+ // target. Read-only, no side effects, and independent of any previous
1021
+ // manifest. writeFiles() below runs the same check again before it writes
1022
+ // anything; bin/cli.js calls this copy earlier, so a doomed install (global
1023
+ // config, path escape) never reaches a step that can run a real vendor status
1024
+ // command as a side effect (Q1 safety: signInStatus can execute
1025
+ // `claude auth status` etc. before writeFiles is ever called).
1026
+ export function writePreflightProblems(files, { dir, project }) {
1027
+ const roots = { dir, project: project || dir };
1028
+ const groups = { dir: files.filter((f) => (f.root || 'dir') === 'dir'), project: files.filter((f) => f.root === 'project') };
1029
+ const problems = [];
1030
+ for (const k of ['dir', 'project']) {
1031
+ if (!groups[k].length) continue;
1032
+ problems.push(...preflight(groups[k], roots[k]).map((p) => (k === 'project' ? `[project] ${p}` : p)));
838
1033
  }
1034
+ return problems;
839
1035
  }
840
1036
 
841
1037
  // Files carry a root: 'dir' for the docs folder, 'project' for the agent
@@ -856,12 +1052,52 @@ export function writeFiles(files, opts) {
856
1052
  const belongsHere = (key) => typeof key === 'string' && sameRoots[key.startsWith('[project] ') ? 'project' : 'dir'];
857
1053
  // A hash or directory from another project cannot establish ownership here.
858
1054
  const prevHashes = previous?.files ? Object.fromEntries(Object.entries(previous.files).filter(([key]) => belongsHere(key))) : null;
1055
+ if (sameRoots.project && previous?.activation !== undefined) {
1056
+ if (!previous.activation || typeof previous.activation !== 'object' || Array.isArray(previous.activation)) {
1057
+ throw Object.assign(new Error('invalid activation ownership in previous manifest'), { code: 'PREFLIGHT' });
1058
+ }
1059
+ for (const [key, ownership] of Object.entries(previous.activation)) {
1060
+ const problem = validateActivationOwnership(key, ownership);
1061
+ if (problem) throw Object.assign(new Error(problem), { code: 'PREFLIGHT' });
1062
+ }
1063
+ }
1064
+ const activation = previous?.activation && typeof previous.activation === 'object' && !Array.isArray(previous.activation)
1065
+ ? Object.fromEntries(Object.entries(previous.activation).filter(([key]) => belongsHere(key))) : {};
1066
+ for (const file of files.filter((item) => item.activation)) {
1067
+ const key = '[project] ' + toPosixRel(file.rel);
1068
+ const prior = activation[key];
1069
+ const next = { ...file.activation, created: file.original === null };
1070
+ if (prior?.kind === next.kind) {
1071
+ next.created = prior.created;
1072
+ if (next.kind === 'rules') {
1073
+ next.addedPrefix = prior.addedPrefix;
1074
+ next.addedSuffix = prior.addedSuffix;
1075
+ } else if (next.kind === 'hooks') {
1076
+ next.hadHooks = prior.hadHooks;
1077
+ next.originalEvents = prior.originalEvents;
1078
+ next.hooks = [...new Map([...(prior.hooks || []), ...next.hooks].map((hook) => [JSON.stringify(hook), hook])).values()];
1079
+ } else if (next.kind === 'mcp') {
1080
+ next.servers = { ...prior.servers, ...next.servers };
1081
+ next.hadKey = prior.hadKey;
1082
+ }
1083
+ }
1084
+ activation[key] = next;
1085
+ }
859
1086
  const groups = { dir: files.filter((f) => (f.root || 'dir') === 'dir'), project: files.filter((f) => f.root === 'project') };
860
1087
  const problems = [];
861
1088
  for (const k of ['dir', 'project']) {
862
1089
  if (!groups[k].length) continue;
863
1090
  problems.push(...preflight(groups[k], roots[k]).map((p) => (k === 'project' ? `[project] ${p}` : p)));
864
1091
  }
1092
+ // A legacy brief is a read and possible deletion target, so validate it with
1093
+ // the same containment, regular-file and symlink checks as every write.
1094
+ const currentBrief = groups.dir.find((f) => f.rel === 'TASK_BRIEF.md');
1095
+ const legacyPath = resolve(realRoot(dir).root, LEGACY_BRIEF);
1096
+ let hasLegacy = false;
1097
+ if (currentBrief) {
1098
+ try { lstatSync(legacyPath); hasLegacy = true; } catch { /* absent */ }
1099
+ if (hasLegacy) problems.push(...preflight([{ rel: LEGACY_BRIEF }], dir));
1100
+ }
865
1101
  if (!problems.length) {
866
1102
  for (const f of files.filter((file) => file.applySnippet)) {
867
1103
  const abs = resolve(roots[f.root], f.rel);
@@ -884,6 +1120,7 @@ export function writeFiles(files, opts) {
884
1120
  const docsUpdated = []; // --update-docs: documents regenerated because the installed copy was an untouched generated one
885
1121
  const docsConflict = []; // --update-docs: documents kept because you edited them
886
1122
  const docsUnverifiable = []; // --update-docs: documents kept because there is no manifest to compare against
1123
+ const docsRenamed = [];
887
1124
  const backups = [];
888
1125
  const created = [];
889
1126
  // Only directories actually created by this install are owned. Preserve the
@@ -895,6 +1132,7 @@ export function writeFiles(files, opts) {
895
1132
  // never the hash of content this run planned but did not write. Otherwise the next
896
1133
  // --update-docs or upgrade sees every kept file as "edited".
897
1134
  const keptKeys = new Set();
1135
+ const removedKeys = new Set();
898
1136
  try {
899
1137
  // project first so MANIFEST.json (last in the dir group) is the final write and can
900
1138
  // describe every decision made above it
@@ -969,7 +1207,40 @@ export function writeFiles(files, opts) {
969
1207
  }
970
1208
  let content = f.content;
971
1209
  if (f.rel === 'MANIFEST.json') {
1210
+ if (hasLegacy) {
1211
+ const previousHash = prevHashes?.[LEGACY_BRIEF];
1212
+ const original = readLegacyBrief(legacyPath, dir);
1213
+ const unchanged = previousHash && sha256(original.content) === previousHash;
1214
+ if ((updateDocs || force) && unchanged) {
1215
+ // The replacement has already been written (or preserved) by this
1216
+ // point. Keep deletion in this transaction and restore on failure.
1217
+ const current = readLegacyBrief(legacyPath, dir);
1218
+ const last = lstatSync(legacyPath);
1219
+ if (!current.content.equals(original.content) || current.stat.ino !== original.stat.ino || current.stat.dev !== original.stat.dev
1220
+ || last.ino !== current.stat.ino || last.dev !== current.stat.dev || !last.isFile()) {
1221
+ const e = new Error('legacy brief changed during upgrade; re-run the installer');
1222
+ e.code = 'PREFLIGHT';
1223
+ throw e;
1224
+ }
1225
+ if (!dry) {
1226
+ originals.set(legacyPath, { content: original.content, mode: original.stat.mode });
1227
+ unlinkSync(legacyPath);
1228
+ }
1229
+ removedKeys.add(LEGACY_BRIEF);
1230
+ docsRenamed.push(`${LEGACY_BRIEF} -> TASK_BRIEF.md`);
1231
+ } else if ((updateDocs || force) && !previousHash) {
1232
+ docsUnverifiable.push(LEGACY_BRIEF);
1233
+ } else if ((updateDocs || force) && !unchanged) {
1234
+ docsConflict.push(LEGACY_BRIEF);
1235
+ } else skipped.push(LEGACY_BRIEF);
1236
+ }
972
1237
  const m = JSON.parse(content);
1238
+ // Preserve ownership of retained 0.1.x companion files and other
1239
+ // formerly selected files, so uninstall still checks their original
1240
+ // installed hashes. New defaults do not erase a previous selection.
1241
+ m.files = { ...prevHashes, ...m.files };
1242
+ if (Object.keys(activation).length) m.activation = activation;
1243
+ for (const removed of removedKeys) delete m.files[removed];
973
1244
  for (const kk of Object.keys(m.files || {})) {
974
1245
  if (!keptKeys.has(kk)) continue;
975
1246
  if (prevHashes && prevHashes[kk]) m.files[kk] = prevHashes[kk];
@@ -977,7 +1248,7 @@ export function writeFiles(files, opts) {
977
1248
  }
978
1249
  content = JSON.stringify(m, null, 2) + '\n';
979
1250
  }
980
- if (exists && opts.backupExisting) {
1251
+ if (exists && (opts.backupExisting || k === 'project')) {
981
1252
  let stamp = Date.now();
982
1253
  let backup;
983
1254
  do {
@@ -1033,7 +1304,7 @@ export function writeFiles(files, opts) {
1033
1304
  }
1034
1305
  throw e;
1035
1306
  }
1036
- return { written, skipped, upgraded, conflicts, unverifiable, docsUpdated, docsConflict, docsUnverifiable, backups };
1307
+ return { written, skipped, upgraded, conflicts, unverifiable, docsUpdated, docsConflict, docsUnverifiable, docsRenamed, backups };
1037
1308
  }
1038
1309
 
1039
1310
  export function resolveSelection(ids) {