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