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.
Files changed (108) hide show
  1. package/AGENTS.md +31 -21
  2. package/CHANGELOG.md +43 -1
  3. package/README.md +127 -110
  4. package/bin/README.md +57 -6
  5. package/bin/aunx.js +7 -0
  6. package/bin/cli-run.mjs +21 -15
  7. package/bin/cli.js +376 -257
  8. package/docs/README.md +15 -18
  9. package/docs/catalog.md +228 -38
  10. package/docs/companions.md +28 -10
  11. package/docs/guarantees.md +21 -12
  12. package/docs/how-it-routes.md +49 -42
  13. package/docs/install.md +135 -33
  14. package/docs/part-1-beginner.md +37 -45
  15. package/docs/part-2-intermediate.md +34 -52
  16. package/docs/part-3-advanced.md +36 -26
  17. package/docs/security-review-history.md +38 -0
  18. package/llms.txt +24 -25
  19. package/package.json +15 -8
  20. package/proof/README.md +100 -0
  21. package/proof/gate-demo.cast +9 -0
  22. package/proof/gate-demo.gif +0 -0
  23. package/proof/results.json +198 -0
  24. package/proof/scripts/check-gate.js +26 -0
  25. package/proof/scripts/install-time.js +16 -0
  26. package/proof/scripts/lib.js +73 -0
  27. package/proof/scripts/measure.js +15 -0
  28. package/proof/scripts/missing-results.js +30 -0
  29. package/proof/scripts/record-gate.js +38 -0
  30. package/proof/scripts/render.js +18 -0
  31. package/proof/scripts/runner-overhead.js +21 -0
  32. package/src/README.md +9 -3
  33. package/src/activation-ownership.js +19 -0
  34. package/src/apply-companions.js +104 -0
  35. package/src/apply-snippets.js +60 -28
  36. package/src/aunx.js +262 -0
  37. package/src/catalog.js +253 -117
  38. package/src/install.js +478 -209
  39. package/src/plugin.js +13 -4
  40. package/src/postinstall.js +57 -0
  41. package/src/roles.js +184 -0
  42. package/src/uninstall.js +125 -8
  43. package/templates/README.md +19 -2
  44. package/templates/advanced/README.md +2 -2
  45. package/templates/advanced/vm/PRIVACY_GATES.md +17 -19
  46. package/templates/advanced/vm/README.md +25 -20
  47. package/templates/advanced/vm/box-CLAUDE.md +19 -18
  48. package/templates/advanced/vm/jobs/README.md +3 -1
  49. package/templates/advanced/vm/jobs/weekly-audit.service +3 -0
  50. package/templates/advanced/vm/jobs/weekly-audit.sh +2 -2
  51. package/templates/advanced/vm/setup-vm.sh +49 -2
  52. package/templates/agents/README.md +2 -2
  53. package/templates/agents/agy/README.md +20 -3
  54. package/templates/agents/agy/builder.md +11 -7
  55. package/templates/agents/agy/bulk-worker.md +9 -7
  56. package/templates/agents/agy/code-reviewer.md +13 -7
  57. package/templates/agents/agy/deep-planner.md +10 -7
  58. package/templates/agents/agy/done-verifier.md +13 -22
  59. package/templates/agents/agy/finding-verifier.md +14 -22
  60. package/templates/agents/agy/live-researcher.md +10 -7
  61. package/templates/agents/agy/reader.md +10 -12
  62. package/templates/agents/claude-code/README.md +18 -14
  63. package/templates/agents/claude-code/builder.md +10 -15
  64. package/templates/agents/claude-code/bulk-worker.md +8 -10
  65. package/templates/agents/claude-code/code-reviewer.md +11 -17
  66. package/templates/agents/claude-code/deep-planner.md +9 -11
  67. package/templates/agents/claude-code/done-verifier.md +12 -33
  68. package/templates/agents/claude-code/finding-verifier.md +13 -39
  69. package/templates/agents/claude-code/live-researcher.md +9 -11
  70. package/templates/agents/claude-code/reader.md +9 -18
  71. package/templates/agents/snippets/chat.md +9 -10
  72. package/templates/agents/snippets/claude-code.md +17 -18
  73. package/templates/agents/snippets/generic.md +9 -11
  74. package/templates/agents/snippets/route-gate.mjs +2 -2
  75. package/templates/agents/snippets/route-metrics.mjs +1 -1
  76. package/templates/agents/snippets/subagent-context.mjs +4 -4
  77. package/templates/beginner/ORCHESTRATOR.md +31 -36
  78. package/templates/beginner/README.md +1 -1
  79. package/templates/common/ACCEPTANCE_CHECKS.json +12 -0
  80. package/templates/common/CONTEXT.md +37 -0
  81. package/templates/common/DECISIONS.md +11 -0
  82. package/templates/common/README.md +24 -11
  83. package/templates/common/TASK_BRIEF.md +84 -0
  84. package/templates/common/protocols/README.md +14 -11
  85. package/templates/common/protocols/acceptance-checks.md +14 -0
  86. package/templates/common/protocols/build-protocol.md +91 -106
  87. package/templates/common/protocols/context-file.md +10 -0
  88. package/templates/common/protocols/decision-log.md +9 -0
  89. package/templates/common/protocols/deep-research.md +20 -34
  90. package/templates/common/protocols/docs-then-prove.md +13 -18
  91. package/templates/common/protocols/gap-analysis.md +15 -21
  92. package/templates/common/protocols/memory-and-record.md +21 -20
  93. package/templates/common/protocols/numbers-and-logic.md +20 -26
  94. package/templates/common/protocols/propagate.md +18 -27
  95. package/templates/intermediate/CLI-RUN.md +83 -113
  96. package/templates/intermediate/DELEGATION_MATRIX.md +9 -3
  97. package/templates/intermediate/README.md +3 -3
  98. package/templates/intermediate/RESEARCH_TRIAGE.md +23 -15
  99. package/templates/intermediate/ROUTING.md +54 -51
  100. package/templates/intermediate/TIERS.md +37 -76
  101. package/templates/tools/README.md +1 -1
  102. package/templates/tools/obsidian-tc/OBSIDIAN-TC.md +1 -1
  103. package/docs/audit-brief.md +0 -148
  104. package/scripts/README.md +0 -7
  105. package/scripts/gen-catalog.js +0 -81
  106. package/scripts/gen-plugin.js +0 -16
  107. package/scripts/record-demo.sh +0 -45
  108. package/templates/common/TASK_BUNDLE.md +0 -56
@@ -1,17 +1,30 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
2
3
  import { join } from 'node:path';
3
- import { preflight, snippetFor } from './install.js';
4
+ import { preflight, snippetFor, ACTIVATION_JSON_BYTE_CAP } from './install.js';
4
5
 
5
6
  export const START = '<!-- model-orchestrator:start -->';
6
7
  export const END = '<!-- model-orchestrator:end -->';
7
8
  const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
8
9
  const refuse = (message) => Object.assign(new Error(message), { code: 'PREFLIGHT' });
10
+ const CRLF = Buffer.from('\r\n');
11
+ // R2: match the file's own line ending for the block this run inserts or
12
+ // replaces, so a CRLF file stays CRLF instead of picking up a mixed file.
13
+ const eolOf = (original) => (original.indexOf(CRLF) !== -1 ? '\r\n' : '\n');
14
+ // R1: refuse a pre-existing JSON file over the stated cap before it is read,
15
+ // the same way invalid JSON is refused today, without ever loading its bytes.
16
+ function refuseIfOversized(path) {
17
+ if (existsSync(path) && statSync(path).size > ACTIVATION_JSON_BYTE_CAP) {
18
+ throw refuse(`${path}: larger than the ${ACTIVATION_JSON_BYTE_CAP} byte (10 MB) cap on a pre-existing settings/MCP JSON file; nothing written`);
19
+ }
20
+ }
9
21
 
10
22
  function mergeHooks(settings, incoming, path) {
11
23
  if (!object(settings) || (settings.hooks !== undefined && !object(settings.hooks))) {
12
24
  throw refuse(`${path}: expected a JSON object with an optional hooks object`);
13
25
  }
14
26
  const hooks = { ...settings.hooks };
27
+ const ownership = [];
15
28
  for (const [event, groups] of Object.entries(incoming)) {
16
29
  const existing = hooks[event] === undefined ? [] : hooks[event];
17
30
  if (!Array.isArray(existing) || existing.some((group) => !object(group) || !Array.isArray(group.hooks))) {
@@ -28,20 +41,26 @@ function mergeHooks(settings, incoming, path) {
28
41
  seen.add(key(group, hook));
29
42
  return true;
30
43
  });
31
- if (missing.length) added.push({ ...group, hooks: missing });
44
+ if (missing.length) {
45
+ added.push({ ...group, hooks: missing });
46
+ const { hooks: ignored, ...attributes } = group;
47
+ for (const hook of missing) ownership.push({ event, group: attributes, hook });
48
+ }
32
49
  }
33
50
  hooks[event] = [...existing, ...added];
34
51
  }
35
- return { ...settings, hooks };
52
+ return { settings: { ...settings, hooks }, ownership };
36
53
  }
37
54
 
38
55
  function markedContent(original, snippet, path) {
39
56
  const start = original.indexOf(START);
40
57
  const end = original.indexOf(END);
41
- const block = Buffer.from(`${START}\n${snippet.trimEnd()}\n${END}`);
58
+ const nl = eolOf(original);
59
+ const body = snippet.trimEnd().split('\n').join(nl);
60
+ const block = Buffer.from(`${START}${nl}${body}${nl}${END}`);
42
61
  if (start === -1 && end === -1) {
43
- const separator = original.length && original.at(-1) !== 10 ? '\n\n' : original.length ? '\n' : '';
44
- return Buffer.concat([original, Buffer.from(separator), block, Buffer.from('\n')]);
62
+ const separator = original.length && original.at(-1) !== 10 ? nl + nl : original.length ? nl : '';
63
+ return Buffer.concat([original, Buffer.from(separator), block, Buffer.from(nl)]);
45
64
  }
46
65
  if (start < 0 || end < start || original.indexOf(START, start + START.length) !== -1 || original.indexOf(END, end + END.length) !== -1) {
47
66
  throw refuse(`${path}: expected one matching ${START} / ${END} block`);
@@ -50,32 +69,45 @@ function markedContent(original, snippet, path) {
50
69
  }
51
70
 
52
71
  export function assertSnippetPrimary(primary) {
53
- if (primary?.id !== 'claude-code') {
54
- throw refuse(`--apply-snippets requires claude-code as primary; paste ${snippetFor(primary) || 'PASTE-INTO-YOUR-AGENT.md'} by hand`);
55
- }
72
+ // Chat apps have no project rules target; their paste step remains manual.
73
+ return !!primary?.rulesFile;
56
74
  }
57
75
 
58
- // Read and validate both user files before the installer writes anything.
59
- // These entries deliberately stay outside the uninstall manifest.
76
+ // Read and validate every user file before the installer writes anything.
77
+ // Activation ownership records only the inserted block and added hook entries.
60
78
  export function planSnippetApplication({ primary, project, files }) {
61
- assertSnippetPrimary(primary);
62
- const targets = ['CLAUDE.md', join('.claude', 'settings.json')];
79
+ if (!assertSnippetPrimary(primary)) return [];
80
+ const targets = [primary.rulesFile];
81
+ if (primary.id === 'claude-code') targets.push(join('.claude', 'settings.json'));
63
82
  const problems = preflight(targets.map((rel) => ({ rel })), project);
64
83
  if (problems.length) throw refuse(problems.join('; '));
65
- const settingsPath = join(project, targets[1]);
66
- const priorSettings = existsSync(settingsPath) ? readFileSync(settingsPath) : null;
67
- let settings = {};
68
- if (priorSettings) {
69
- try { settings = JSON.parse(priorSettings.toString('utf8')); }
70
- catch { throw refuse(`${settingsPath}: invalid JSON; nothing written`); }
71
- }
72
- const incoming = JSON.parse(files.find((f) => f.rel === 'settings.hooks.snippet.json').content);
73
- const merged = mergeHooks(settings, incoming.hooks, settingsPath);
74
84
  const rulesPath = join(project, targets[0]);
75
85
  const priorRules = existsSync(rulesPath) ? readFileSync(rulesPath) : null;
76
- const snippet = files.find((f) => f.rel === 'CLAUDE.snippet.md').content;
77
- return [
78
- { rel: targets[0], original: priorRules, content: markedContent(priorRules || Buffer.alloc(0), snippet, rulesPath) },
79
- { rel: targets[1], original: priorSettings, content: priorSettings && JSON.stringify(settings) === JSON.stringify(merged) ? priorSettings : JSON.stringify(merged, null, 2) + '\n' }
80
- ].map((file) => ({ ...file, root: 'project', mode: 0o644, applySnippet: true }));
86
+ const snippet = files.find((f) => f.rel === snippetFor(primary))?.content;
87
+ if (snippet === undefined) throw refuse(`${rulesPath}: missing generated rules snippet`);
88
+ const content = markedContent(priorRules || Buffer.alloc(0), snippet, rulesPath);
89
+ const block = content.subarray(content.indexOf(START), content.indexOf(END) + Buffer.byteLength(END));
90
+ const appended = !priorRules?.includes(START);
91
+ const entries = [{ rel: targets[0], original: priorRules, content, activation: {
92
+ kind: 'rules', blockHash: createHash('sha256').update(block).digest('hex'), created: priorRules === null,
93
+ addedPrefix: appended && priorRules?.length ? (priorRules.at(-1) === 10 ? '\n' : '\n\n') : '',
94
+ addedSuffix: appended ? '\n' : ''
95
+ } }];
96
+ if (primary.id === 'claude-code') {
97
+ const settingsPath = join(project, targets[1]);
98
+ refuseIfOversized(settingsPath);
99
+ const priorSettings = existsSync(settingsPath) ? readFileSync(settingsPath) : null;
100
+ let settings = {};
101
+ if (priorSettings) {
102
+ try { settings = JSON.parse(priorSettings.toString('utf8')); }
103
+ catch { throw refuse(`${settingsPath}: invalid JSON; nothing written`); }
104
+ }
105
+ const incoming = JSON.parse(files.find((f) => f.rel === 'settings.hooks.snippet.json').content);
106
+ const merged = mergeHooks(settings, incoming.hooks, settingsPath);
107
+ entries.push({ rel: targets[1], original: priorSettings,
108
+ content: priorSettings && JSON.stringify(settings) === JSON.stringify(merged.settings) ? priorSettings : JSON.stringify(merged.settings, null, 2) + '\n',
109
+ activation: { kind: 'hooks', hooks: merged.ownership, created: priorSettings === null, hadHooks: Object.hasOwn(settings, 'hooks'), originalEvents: Object.keys(settings.hooks || {}) }
110
+ });
111
+ }
112
+ return entries.map((file) => ({ ...file, root: 'project', mode: 0o644, applySnippet: true }));
81
113
  }
package/src/aunx.js ADDED
@@ -0,0 +1,262 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, parse, resolve, sep } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { windowsSpawnPlan } from '../bin/cli-run.mjs';
6
+ import { byId } from './catalog.js';
7
+ import { ROLE_SPECS } from './roles.js';
8
+
9
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
10
+ const COMMON = join(ROOT, 'templates', 'common');
11
+ const HELP = `aunx: model router tools for AI coding agents
12
+
13
+ aunx [installer flags] Run the model-orchestrator installer
14
+ aunx cli-run [--dir PATH] <args> Run a lane; --dir names a project's own runner
15
+ aunx route-metrics [--summary] Read the local routing summary
16
+ aunx brief [PATH] Print the task brief template, or scaffold it at PATH
17
+ aunx brief new [PATH] Create TASK_BRIEF.md
18
+ aunx context [new] [PATH] Create CONTEXT.md
19
+ aunx checks [new] [PATH] Create ACCEPTANCE_CHECKS.json
20
+ aunx checks run [PATH] Run local checks; exit 1 on any FAIL. checks run executes
21
+ the commands in your checks file, so run it only on files you trust.
22
+ aunx route [--dir PATH] "<task>" Suggest a stack role, tier and effort
23
+
24
+ Route reads MANIFEST.json from --dir, then ./ai-orchestrator, then the current
25
+ directory. It reads regular JSON files of at most 1 MiB and executes no project code.
26
+
27
+ Scaffolds preserve existing files. Check commands run only with checks run.
28
+ Use aunx install --help for the installer flags (or model-orchestrator --help).
29
+ Use aunx cli-run --help for the lane runner flags.
30
+ `;
31
+
32
+ function regular(path) {
33
+ try { return lstatSync(path).isFile(); } catch { return false; }
34
+ }
35
+
36
+ // Run Node entry points directly, including on Windows. No command shell parses lane prompts.
37
+ export function runNode(path, args) {
38
+ return new Promise(resolveExit => {
39
+ const child = spawn(process.execPath, [path, ...args], { stdio: 'inherit' });
40
+ const handlers = new Map();
41
+ for (const signal of ['SIGINT', 'SIGTERM']) {
42
+ const handler = () => { try { child.kill(signal); } catch {} };
43
+ handlers.set(signal, handler);
44
+ process.on(signal, handler);
45
+ }
46
+ const cleanup = () => { for (const [signal, handler] of handlers) process.removeListener(signal, handler); };
47
+ child.once('error', error => { cleanup(); console.error(`aunx: ${error.message}`); resolveExit(2); });
48
+ child.once('close', (code, signal) => { cleanup(); resolveExit(code ?? (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 1)); });
49
+ });
50
+ }
51
+
52
+ // dir stays undefined unless --dir is given explicitly: the project runner is
53
+ // opt-in, never a default guessed from the current directory (R1).
54
+ function runnerArgs(args) {
55
+ const rest = [];
56
+ let dir;
57
+ for (let i = 0; i < args.length; i++) {
58
+ if (args[i] === '--') { rest.push(...args.slice(i)); break; }
59
+ if (args[i] === '--dir' || args[i].startsWith('--dir=')) {
60
+ if (dir !== undefined) throw new Error('duplicate --dir');
61
+ dir = args[i] === '--dir' ? args[++i] : args[i].slice(6);
62
+ if (!dir || dir.startsWith('--')) throw new Error('--dir needs a path');
63
+ } else rest.push(args[i]);
64
+ }
65
+ return { dir: dir === undefined ? undefined : resolve(dir), rest };
66
+ }
67
+
68
+ export function scaffold(template, target) {
69
+ const path = resolve(target);
70
+ const parts = dirname(path).slice(parse(path).root.length).split(sep).filter(Boolean);
71
+ let parent = parse(path).root;
72
+ for (const part of parts) {
73
+ parent = join(parent, part);
74
+ try {
75
+ const stat = lstatSync(parent);
76
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`scaffold parent must be a real directory: ${parent}`);
77
+ } catch (error) {
78
+ if (error.code !== 'ENOENT') throw error;
79
+ mkdirSync(parent);
80
+ }
81
+ }
82
+ writeFileSync(path, readFileSync(join(COMMON, template)), { flag: 'wx', mode: 0o600 });
83
+ console.log(`Created ${path}`);
84
+ return 0;
85
+ }
86
+
87
+ function readChecks(path) {
88
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
89
+ let config;
90
+ try {
91
+ const stat = fstatSync(fd);
92
+ if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error('checks file must be a regular JSON file of at most 1 MiB');
93
+ config = JSON.parse(readFileSync(fd, 'utf8'));
94
+ } finally { closeSync(fd); }
95
+ if (!config || config.version !== 1 || !Array.isArray(config.checks) || !config.checks.length) throw new Error('expected version: 1 and a non-empty checks array');
96
+ const ids = new Set();
97
+ for (const check of config.checks) {
98
+ if (!check || typeof check.id !== 'string' || !/^[A-Za-z0-9_.-]{1,100}$/.test(check.id) || ids.has(check.id)) throw new Error('each check needs a unique id (letters, digits, _, . or -)');
99
+ ids.add(check.id);
100
+ if (check.manual !== true && !(typeof check.command === 'string' && check.command.trim()) && !(Array.isArray(check.command) && check.command.length && check.command.every(s => typeof s === 'string' && s.length > 0 && !s.includes('\0')))) throw new Error(`${check.id}: command must be a string or a non-empty argv array`);
101
+ if (typeof check.command === 'string' && check.command.includes('\0')) throw new Error(`${check.id}: command contains a null byte`);
102
+ if (check.cwd !== undefined && (typeof check.cwd !== 'string' || !check.cwd)) throw new Error(`${check.id}: cwd must be a path`);
103
+ if (check.timeoutMs !== undefined && (!Number.isInteger(check.timeoutMs) || check.timeoutMs < 1 || check.timeoutMs > 3600000)) throw new Error(`${check.id}: timeoutMs must be between 1 and 3600000`);
104
+ }
105
+ return config.checks;
106
+ }
107
+
108
+ export function runChecks(file) {
109
+ const path = resolve(file);
110
+ // Validate the whole file before the first command can mutate anything.
111
+ const checks = readChecks(path);
112
+ let failed = false;
113
+ for (const check of checks) {
114
+ if (check.manual === true) {
115
+ console.log(`FAIL ${check.id}: manual check is UNVERIFIED`);
116
+ failed = true;
117
+ continue;
118
+ }
119
+ const cwd = resolve(dirname(path), check.cwd || '.');
120
+ let command, args, options = {};
121
+ if (typeof check.command === 'string') {
122
+ command = check.command;
123
+ args = [];
124
+ options.shell = true; // Explicit local shell program supplied by the checks file's author.
125
+ } else {
126
+ const argv = [...check.command];
127
+ if (argv[0] === 'node') argv[0] = process.execPath;
128
+ const plan = windowsSpawnPlan(argv);
129
+ if (plan.refuse) {
130
+ console.log(`FAIL ${check.id}: ${plan.refuse}`);
131
+ failed = true;
132
+ continue;
133
+ }
134
+ ({ command, args, options = {} } = plan);
135
+ }
136
+ const result = spawnSync(command, args, { ...options, cwd, stdio: 'inherit', timeout: check.timeoutMs || 30000, killSignal: 'SIGKILL' });
137
+ const pass = !result.error && result.status === 0;
138
+ failed ||= !pass;
139
+ console.log(`${pass ? 'PASS' : 'FAIL'} ${check.id}: ${result.error ? result.error.code : result.signal ? `signal ${result.signal}` : `exit ${result.status}`}`);
140
+ }
141
+ return failed ? 1 : 0;
142
+ }
143
+
144
+ // Unlike a runner lookup, this discovers data only. lstat plus O_NOFOLLOW
145
+ // refuses symlinks; an identity check and bounded read cover replacement/growth.
146
+ export function readManifestRoles({ dir, cwd = process.cwd() } = {}) {
147
+ const candidates = [
148
+ ...(dir === undefined ? [] : [join(resolve(cwd, dir), 'MANIFEST.json')]),
149
+ join(cwd, 'ai-orchestrator', 'MANIFEST.json'),
150
+ join(cwd, 'MANIFEST.json')
151
+ ];
152
+ const maxBytes = 1024 * 1024;
153
+ const roleIds = new Set(ROLE_SPECS.map(spec => spec.id));
154
+ for (const path of new Set(candidates)) {
155
+ let fd;
156
+ try {
157
+ const before = lstatSync(path);
158
+ if (!before.isFile() || before.isSymbolicLink() || before.size > maxBytes) continue;
159
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
160
+ const after = fstatSync(fd);
161
+ if (!after.isFile() || after.size > maxBytes || after.dev !== before.dev || after.ino !== before.ino) continue;
162
+ const buffer = Buffer.alloc(maxBytes + 1);
163
+ let length = 0;
164
+ while (length < buffer.length) {
165
+ const count = readSync(fd, buffer, length, buffer.length - length, null);
166
+ if (!count) break;
167
+ length += count;
168
+ }
169
+ if (length > maxBytes) continue;
170
+ const manifest = JSON.parse(buffer.toString('utf8', 0, length));
171
+ const roles = manifest?.roles;
172
+ if (!roles || typeof roles !== 'object' || Array.isArray(roles) || !Object.keys(roles).length) continue;
173
+ if (Object.entries(roles).some(([id, role]) => !roleIds.has(id) || !role || typeof role !== 'object' || Array.isArray(role)
174
+ || (role.ai !== null && (typeof role.ai !== 'string' || !role.ai))
175
+ || !['main-agent', 'cli-run', 'subagent', 'manual', 'local', 'none'].includes(role.via)
176
+ || ['agent', 'tier', 'command', 'why', 'reason'].some(key => role[key] !== undefined && typeof role[key] !== 'string'))) continue;
177
+ return roles;
178
+ } catch {
179
+ // Missing, invalid, non-regular or unreadable JSON is an absent manifest.
180
+ } finally {
181
+ if (fd !== undefined) closeSync(fd);
182
+ }
183
+ }
184
+ return null;
185
+ }
186
+
187
+ function stackSuggestion(assignment) {
188
+ if (!assignment) return 'Your stack: none selected. Re-run the installer to assign this role.';
189
+ const clean = value => String(value).replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ');
190
+ const reason = assignment.why || assignment.reason;
191
+ if (!assignment.ai) return `Your stack: none selected${reason ? `. ${clean(reason)}` : '.'}`;
192
+ const name = clean(byId[assignment.ai]?.name || assignment.ai);
193
+ let via;
194
+ if (assignment.command) via = '`' + clean(assignment.command.startsWith('cli-run ') ? `aunx ${assignment.command}` : assignment.command) + '`';
195
+ else if (assignment.agent) via = '`' + clean(assignment.agent) + '` on your main agent';
196
+ else via = { 'main-agent': 'your main agent', local: 'your local runtime', manual: 'manual handoff', subagent: 'a subagent', 'cli-run': 'cli-run' }[assignment.via];
197
+ return `Your stack: ${name}, via ${via || 'your selected tool'}${reason ? ` (${clean(reason)})` : ''}.`;
198
+ }
199
+
200
+ // Ordered by the action requested. These are suggestions, so the project rules remain authoritative.
201
+ export function suggestRoute(task) {
202
+ const text = task.toLowerCase();
203
+ const routes = [
204
+ [/\b(verify|reproduce|validate|confirm|check)\b.*\b(findings?|reports?|bugs?)\b/, 'finding-verifier', 'working model', 'high', 'Reproduce each claim against the named evidence.', 'verify'],
205
+ [/\b(definition[- ]of[- ]done|acceptance checks?|completion|finished|done)\b.*\b(check|verify|met|audit)\b|\b(check|verify)\b.*\b(done|complete|completion|acceptance)\b/, 'done-verifier', 'cheap model', 'low', 'Probe the stated definition of done.', 'verify'],
206
+ [/\b(review|audit)\b/, 'code-reviewer', 'working model', 'high', 'Review the change and reproduce potential defects.', 'review'],
207
+ [/\b(latest|current|live|news|search|browse|research|look up)\b/, 'live-researcher', 'working model', 'medium', 'Fetch current evidence before drawing a conclusion.', 'research'],
208
+ [/\b(read(?:ing)?|summari[sz]e|digest|scan)\b.*\b(files|notes|documents|codebase|repo|folder)\b/, 'reader', 'cheap model', 'low', 'Read the requested files and return cited facts.', 'read'],
209
+ [/\b(design|architect(?:ure)?|ambiguous|tradeoffs?|plan|strategy|unknown cause|find why|debug|diagnose|investigate)\b/, 'deep-planner', 'planning model', 'xhigh', 'Resolve the design and interfaces before building; use equivalent effort where xhigh is unavailable.', 'plan'],
210
+ [/\b(rename|format|sort|classify|tag|bulk|mechanical|replace|lint)\b/, 'bulk-worker', 'cheap model', 'low', 'Apply a repeatable mechanical change.', 'bulk'],
211
+ [/\b(build(?:ing)?|implement|code|fix|create|write|add|refactor)\b/, 'builder', 'working model', 'high', 'Build within a task brief and run its acceptance checks.', 'build']
212
+ ];
213
+ for (const [pattern, agent, tier, effort, reason, role] of routes) if (pattern.test(text)) return { agent, tier, effort, reason, role };
214
+ return null;
215
+ }
216
+
217
+ export async function main(args) {
218
+ if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(HELP); return 0; }
219
+ const [command, ...rest] = args;
220
+ if (command === 'install') return runNode(join(ROOT, 'bin', 'cli.js'), rest);
221
+ if (command === 'cli-run') {
222
+ const parsed = runnerArgs(rest);
223
+ // The packaged runner is the default. A project's own runner only runs
224
+ // when --dir names it explicitly (R1); this is the one place cli-run
225
+ // dispatch can execute code from outside the package.
226
+ if (parsed.dir !== undefined) {
227
+ const local = join(parsed.dir, 'bin', 'cli-run.mjs');
228
+ if (regular(local)) {
229
+ console.error(`aunx: using project runner ${local}`);
230
+ return runNode(local, parsed.rest);
231
+ }
232
+ }
233
+ return runNode(join(ROOT, 'bin', 'cli-run.mjs'), parsed.rest);
234
+ }
235
+ if (command === 'route-metrics') {
236
+ const local = join(process.cwd(), '.claude', 'hooks', 'route-metrics.mjs');
237
+ return runNode(regular(local) ? local : join(ROOT, 'templates', 'agents', 'snippets', 'route-metrics.mjs'), rest.length ? rest : ['--summary']);
238
+ }
239
+ if (command === 'brief' && rest.length === 0) { process.stdout.write(readFileSync(join(COMMON, 'TASK_BRIEF.md'), 'utf8')); return 0; }
240
+ if (['brief', 'context', 'checks'].includes(command)) {
241
+ if (rest.length === 1 && ['--help', '-h'].includes(rest[0])) { console.log(HELP); return 0; }
242
+ if (command === 'checks' && rest[0] === 'run') {
243
+ if (rest.length > 2) throw new Error('usage: aunx checks run [PATH]');
244
+ return runChecks(rest[1] || 'ACCEPTANCE_CHECKS.json');
245
+ }
246
+ const tail = rest[0] === 'new' ? rest.slice(1) : rest;
247
+ if (tail.length > 1 || tail[0]?.startsWith('-')) throw new Error(`usage: aunx ${command} [new] [PATH]`);
248
+ const template = { brief: 'TASK_BRIEF.md', context: 'CONTEXT.md', checks: 'ACCEPTANCE_CHECKS.json' }[command];
249
+ return scaffold(template, tail[0] || template);
250
+ }
251
+ if (command === 'route') {
252
+ if (rest.length === 1 && ['--help', '-h'].includes(rest[0])) { console.log(HELP); return 0; }
253
+ const parsed = runnerArgs(rest);
254
+ if (parsed.rest.length !== 1 || !parsed.rest[0].trim()) throw new Error('usage: aunx route [--dir PATH] "<task>"');
255
+ const route = suggestRoute(parsed.rest[0]);
256
+ const roles = readManifestRoles({ dir: parsed.dir });
257
+ console.log(route ? `Suggestion: ${roles ? route.role : route.agent} | tier: ${route.tier} | effort: ${route.effort}\n${roles ? stackSuggestion(roles[route.role]) + '\n' : ''}${route.reason} Confirm against your ROUTING.md.` : 'Suggestion: unknown task category. Read your ROUTING.md and choose a route for the task.');
258
+ if (!roles) console.log('No install found; run the installer or pass --dir to see who your stack assigns.');
259
+ return 0;
260
+ }
261
+ return runNode(join(ROOT, 'bin', 'cli.js'), args);
262
+ }