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.
- package/AGENTS.md +31 -21
- package/CHANGELOG.md +58 -1
- package/README.md +129 -110
- package/SECURITY.md +7 -3
- package/bin/README.md +57 -6
- package/bin/aunx.js +7 -0
- package/bin/cli-run.mjs +21 -15
- package/bin/cli.js +376 -257
- package/docs/README.md +15 -18
- package/docs/catalog.md +236 -44
- package/docs/companions.md +28 -10
- package/docs/guarantees.md +21 -12
- package/docs/how-it-routes.md +49 -42
- package/docs/install.md +141 -33
- package/docs/part-1-beginner.md +37 -45
- package/docs/part-2-intermediate.md +34 -52
- package/docs/part-3-advanced.md +36 -26
- package/docs/security-review-history.md +39 -0
- package/llms.txt +24 -25
- package/package.json +15 -8
- package/proof/README.md +100 -0
- package/proof/gate-demo.cast +9 -0
- package/proof/gate-demo.gif +0 -0
- package/proof/results.json +198 -0
- package/proof/scripts/check-gate.js +26 -0
- package/proof/scripts/install-time.js +16 -0
- package/proof/scripts/lib.js +73 -0
- package/proof/scripts/measure.js +15 -0
- package/proof/scripts/missing-results.js +30 -0
- package/proof/scripts/record-gate.js +38 -0
- package/proof/scripts/render.js +18 -0
- package/proof/scripts/runner-overhead.js +21 -0
- package/src/README.md +10 -3
- package/src/activation-ownership.js +19 -0
- package/src/apply-companions.js +104 -0
- package/src/apply-snippets.js +60 -28
- package/src/aunx.js +272 -0
- package/src/bounded-file.js +31 -0
- package/src/catalog.js +257 -121
- package/src/install.js +483 -212
- package/src/plugin.js +13 -4
- package/src/postinstall.js +57 -0
- package/src/roles.js +184 -0
- package/src/uninstall.js +128 -10
- package/templates/README.md +19 -2
- package/templates/advanced/README.md +2 -2
- package/templates/advanced/vm/ENVIRONMENT.md +8 -0
- package/templates/advanced/vm/PRIVACY_GATES.md +17 -19
- package/templates/advanced/vm/README.md +25 -20
- package/templates/advanced/vm/box-CLAUDE.md +19 -18
- package/templates/advanced/vm/docker-compose.yml +2 -1
- package/templates/advanced/vm/jobs/README.md +31 -2
- package/templates/advanced/vm/jobs/weekly-audit.service +7 -2
- package/templates/advanced/vm/jobs/weekly-audit.sh +24 -17
- package/templates/advanced/vm/setup-vm.sh +49 -2
- package/templates/agents/README.md +2 -2
- package/templates/agents/agy/README.md +20 -3
- package/templates/agents/agy/builder.md +11 -7
- package/templates/agents/agy/bulk-worker.md +9 -7
- package/templates/agents/agy/code-reviewer.md +13 -7
- package/templates/agents/agy/deep-planner.md +10 -7
- package/templates/agents/agy/done-verifier.md +13 -22
- package/templates/agents/agy/finding-verifier.md +14 -22
- package/templates/agents/agy/live-researcher.md +10 -7
- package/templates/agents/agy/reader.md +10 -12
- package/templates/agents/claude-code/README.md +18 -14
- package/templates/agents/claude-code/builder.md +10 -15
- package/templates/agents/claude-code/bulk-worker.md +8 -10
- package/templates/agents/claude-code/code-reviewer.md +11 -17
- package/templates/agents/claude-code/deep-planner.md +9 -11
- package/templates/agents/claude-code/done-verifier.md +12 -33
- package/templates/agents/claude-code/finding-verifier.md +13 -39
- package/templates/agents/claude-code/live-researcher.md +9 -11
- package/templates/agents/claude-code/reader.md +9 -18
- package/templates/agents/snippets/chat.md +9 -10
- package/templates/agents/snippets/claude-code.md +17 -18
- package/templates/agents/snippets/generic.md +9 -11
- package/templates/agents/snippets/route-gate.mjs +2 -2
- package/templates/agents/snippets/route-metrics.mjs +1 -1
- package/templates/agents/snippets/subagent-context.mjs +4 -4
- package/templates/beginner/ORCHESTRATOR.md +31 -36
- package/templates/beginner/README.md +1 -1
- package/templates/common/ACCEPTANCE_CHECKS.json +12 -0
- package/templates/common/CONTEXT.md +37 -0
- package/templates/common/DECISIONS.md +11 -0
- package/templates/common/README.md +24 -11
- package/templates/common/TASK_BRIEF.md +84 -0
- package/templates/common/protocols/README.md +14 -11
- package/templates/common/protocols/acceptance-checks.md +15 -0
- package/templates/common/protocols/build-protocol.md +91 -106
- package/templates/common/protocols/context-file.md +10 -0
- package/templates/common/protocols/decision-log.md +9 -0
- package/templates/common/protocols/deep-research.md +20 -34
- package/templates/common/protocols/docs-then-prove.md +13 -18
- package/templates/common/protocols/gap-analysis.md +15 -21
- package/templates/common/protocols/memory-and-record.md +21 -20
- package/templates/common/protocols/numbers-and-logic.md +20 -26
- package/templates/common/protocols/propagate.md +18 -27
- package/templates/intermediate/CLI-RUN.md +83 -113
- package/templates/intermediate/DELEGATION_MATRIX.md +9 -3
- package/templates/intermediate/README.md +3 -3
- package/templates/intermediate/RESEARCH_TRIAGE.md +23 -15
- package/templates/intermediate/ROUTING.md +54 -51
- package/templates/intermediate/TIERS.md +37 -76
- package/templates/tools/README.md +1 -1
- package/templates/tools/codecalc/CODECALC.md +4 -4
- package/templates/tools/codecalc/mcp/agy.mcp_config.json +1 -1
- package/templates/tools/codecalc/mcp/codex.config.toml +1 -1
- package/templates/tools/codecalc/mcp/mcpServers.json +1 -1
- package/templates/tools/codecalc/mcp/vscode.mcp.json +1 -1
- package/templates/tools/codecalc/mcp/zed.settings.json +1 -1
- package/templates/tools/context7/CONTEXT7.md +6 -10
- package/templates/tools/obsidian-tc/OBSIDIAN-TC.md +3 -3
- package/templates/tools/obsidian-tc/mcp/obsidian-tc.agy.mcp_config.json +1 -1
- package/templates/tools/obsidian-tc/mcp/obsidian-tc.codex.config.toml +1 -1
- package/templates/tools/obsidian-tc/mcp/obsidian-tc.mcpServers.json +1 -1
- package/templates/tools/obsidian-tc/mcp/obsidian-tc.vscode.mcp.json +1 -1
- package/templates/tools/obsidian-tc/mcp/obsidian-tc.zed.settings.json +1 -1
- package/docs/audit-brief.md +0 -148
- package/scripts/README.md +0 -7
- package/scripts/gen-catalog.js +0 -81
- package/scripts/gen-plugin.js +0 -16
- package/scripts/record-demo.sh +0 -45
- 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 {
|
|
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
|
|
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.
|
|
44
|
-
a
|
|
45
|
-
|
|
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', '
|
|
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
|
|
56
|
-
: 'Use this high or max headroom lane for volume: scoped well-specified builds, pre-ship
|
|
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:',
|
|
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
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
182
|
-
|
|
183
|
-
...(
|
|
184
|
-
...(
|
|
185
|
-
'
|
|
186
|
-
'
|
|
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(
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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:
|
|
223
|
-
METERED_CITATION_NOTE:
|
|
224
|
-
RESEARCH_SELECTION_ADVICE: enabled.length >= 2
|
|
225
|
-
? 'Send
|
|
226
|
-
: 'Use
|
|
227
|
-
GAP_ANALYSIS_LANE:
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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:
|
|
237
|
-
RESEARCH_RUN: run.length ? run.join('\n') : '# no cli-run
|
|
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.
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
[
|
|
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:
|
|
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
|
-
'
|
|
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.
|
|
309
|
-
//
|
|
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. **
|
|
314
|
-
: `5. **
|
|
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
|
-
? `
|
|
319
|
-
: `
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
|
346
|
-
: '| "Add an endpoint" | the
|
|
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
|
-
//
|
|
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
|
-
?
|
|
368
|
-
: '- **
|
|
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
|
-
'|
|
|
374
|
-
|
|
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
|
-
: '|
|
|
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
|
-
?
|
|
381
|
-
:
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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(`
|
|
413
|
-
|
|
414
|
-
|
|
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'))
|
|
418
|
-
|
|
419
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
|
518
|
-
: 'No
|
|
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.
|
|
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.
|
|
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:
|
|
553
|
-
AUDIT_LANE_BOUNDARY_NOTE:
|
|
554
|
-
?
|
|
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 ? '
|
|
563
|
-
OBSIDIAN_TC_STATUS: tools.some((t) => t.id === 'obsidian-tc') ? 'selected (see `OBSIDIAN-TC.md`);
|
|
564
|
-
CONTEXT7_STATUS: tools.some((t) => t.id === 'context7') ? 'selected (see `CONTEXT7.md`);
|
|
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:
|
|
573
|
-
PRIMARY_STANDARD:
|
|
574
|
-
PRIMARY_FAST:
|
|
575
|
-
AIS_LIST: selected.map((a) => '- ' + a.name + ': ' + a
|
|
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
|
|
589
|
-
//
|
|
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
|
-
|
|
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
|
|
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),
|
|
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),
|
|
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": {"
|
|
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(
|
|
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) {
|