model-orchestrator 0.1.34 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/AGENTS.md +31 -21
  2. package/CHANGELOG.md +51 -1
  3. package/README.md +127 -110
  4. package/bin/README.md +57 -6
  5. package/bin/aunx.js +7 -0
  6. package/bin/cli-run.mjs +21 -15
  7. package/bin/cli.js +376 -257
  8. package/docs/README.md +15 -18
  9. package/docs/catalog.md +228 -38
  10. package/docs/companions.md +28 -10
  11. package/docs/guarantees.md +21 -12
  12. package/docs/how-it-routes.md +49 -42
  13. package/docs/install.md +135 -33
  14. package/docs/part-1-beginner.md +37 -45
  15. package/docs/part-2-intermediate.md +34 -52
  16. package/docs/part-3-advanced.md +36 -26
  17. package/docs/security-review-history.md +38 -0
  18. package/llms.txt +24 -25
  19. package/package.json +16 -8
  20. package/proof/README.md +100 -0
  21. package/proof/gate-demo.cast +9 -0
  22. package/proof/gate-demo.gif +0 -0
  23. package/proof/results.json +198 -0
  24. package/proof/scripts/check-gate.js +26 -0
  25. package/proof/scripts/install-time.js +16 -0
  26. package/proof/scripts/lib.js +73 -0
  27. package/proof/scripts/measure.js +15 -0
  28. package/proof/scripts/missing-results.js +30 -0
  29. package/proof/scripts/record-gate.js +38 -0
  30. package/proof/scripts/render.js +18 -0
  31. package/proof/scripts/runner-overhead.js +21 -0
  32. package/src/README.md +9 -3
  33. package/src/activation-ownership.js +19 -0
  34. package/src/apply-companions.js +104 -0
  35. package/src/apply-snippets.js +60 -28
  36. package/src/aunx.js +262 -0
  37. package/src/catalog.js +253 -117
  38. package/src/install.js +478 -209
  39. package/src/plugin.js +13 -4
  40. package/src/postinstall.js +57 -0
  41. package/src/roles.js +184 -0
  42. package/src/uninstall.js +125 -8
  43. package/templates/README.md +19 -2
  44. package/templates/advanced/README.md +2 -2
  45. package/templates/advanced/vm/PRIVACY_GATES.md +17 -19
  46. package/templates/advanced/vm/README.md +25 -20
  47. package/templates/advanced/vm/box-CLAUDE.md +19 -18
  48. package/templates/advanced/vm/jobs/README.md +3 -1
  49. package/templates/advanced/vm/jobs/weekly-audit.service +3 -0
  50. package/templates/advanced/vm/jobs/weekly-audit.sh +2 -2
  51. package/templates/advanced/vm/setup-vm.sh +49 -2
  52. package/templates/agents/README.md +2 -2
  53. package/templates/agents/agy/README.md +20 -3
  54. package/templates/agents/agy/builder.md +11 -7
  55. package/templates/agents/agy/bulk-worker.md +9 -7
  56. package/templates/agents/agy/code-reviewer.md +13 -7
  57. package/templates/agents/agy/deep-planner.md +10 -7
  58. package/templates/agents/agy/done-verifier.md +13 -22
  59. package/templates/agents/agy/finding-verifier.md +14 -22
  60. package/templates/agents/agy/live-researcher.md +10 -7
  61. package/templates/agents/agy/reader.md +10 -12
  62. package/templates/agents/claude-code/README.md +18 -14
  63. package/templates/agents/claude-code/builder.md +10 -15
  64. package/templates/agents/claude-code/bulk-worker.md +8 -10
  65. package/templates/agents/claude-code/code-reviewer.md +11 -17
  66. package/templates/agents/claude-code/deep-planner.md +9 -11
  67. package/templates/agents/claude-code/done-verifier.md +12 -33
  68. package/templates/agents/claude-code/finding-verifier.md +13 -39
  69. package/templates/agents/claude-code/live-researcher.md +9 -11
  70. package/templates/agents/claude-code/reader.md +9 -18
  71. package/templates/agents/snippets/chat.md +9 -10
  72. package/templates/agents/snippets/claude-code.md +17 -18
  73. package/templates/agents/snippets/generic.md +9 -11
  74. package/templates/agents/snippets/route-gate.mjs +2 -2
  75. package/templates/agents/snippets/route-metrics.mjs +1 -1
  76. package/templates/agents/snippets/subagent-context.mjs +4 -4
  77. package/templates/beginner/ORCHESTRATOR.md +31 -36
  78. package/templates/beginner/README.md +1 -1
  79. package/templates/common/ACCEPTANCE_CHECKS.json +12 -0
  80. package/templates/common/CONTEXT.md +37 -0
  81. package/templates/common/DECISIONS.md +11 -0
  82. package/templates/common/README.md +24 -11
  83. package/templates/common/TASK_BRIEF.md +84 -0
  84. package/templates/common/protocols/README.md +14 -11
  85. package/templates/common/protocols/acceptance-checks.md +14 -0
  86. package/templates/common/protocols/build-protocol.md +91 -106
  87. package/templates/common/protocols/context-file.md +10 -0
  88. package/templates/common/protocols/decision-log.md +9 -0
  89. package/templates/common/protocols/deep-research.md +20 -34
  90. package/templates/common/protocols/docs-then-prove.md +13 -18
  91. package/templates/common/protocols/gap-analysis.md +15 -21
  92. package/templates/common/protocols/memory-and-record.md +21 -20
  93. package/templates/common/protocols/numbers-and-logic.md +20 -26
  94. package/templates/common/protocols/propagate.md +18 -27
  95. package/templates/intermediate/CLI-RUN.md +83 -113
  96. package/templates/intermediate/DELEGATION_MATRIX.md +9 -3
  97. package/templates/intermediate/README.md +3 -3
  98. package/templates/intermediate/RESEARCH_TRIAGE.md +23 -15
  99. package/templates/intermediate/ROUTING.md +54 -51
  100. package/templates/intermediate/TIERS.md +37 -76
  101. package/templates/tools/README.md +1 -1
  102. package/templates/tools/obsidian-tc/OBSIDIAN-TC.md +1 -1
  103. package/docs/audit-brief.md +0 -148
  104. package/scripts/README.md +0 -7
  105. package/scripts/gen-catalog.js +0 -81
  106. package/scripts/gen-plugin.js +0 -16
  107. package/scripts/record-demo.sh +0 -45
  108. package/templates/common/TASK_BUNDLE.md +0 -56
package/src/plugin.js CHANGED
@@ -19,7 +19,7 @@ export const REPO_URL = 'https://github.com/aunysillyme/model-orchestrator';
19
19
  // ROUTING.md at level 2 and 3, ORCHESTRATOR.md at level 1. Level 2 first, so
20
20
  // a project that moved up a level reads the newer file.
21
21
  export const DEFAULT_RULES = ['ai-orchestrator/ROUTING.md', 'ai-orchestrator/ORCHESTRATOR.md'];
22
- export const DEFAULT_TASK_BUNDLE = 'ai-orchestrator/TASK_BUNDLE.md';
22
+ export const DEFAULT_TASK_BRIEF = 'ai-orchestrator/TASK_BRIEF.md';
23
23
 
24
24
  // route-metrics.mjs is not here on purpose: it appends a routing log to disk,
25
25
  // and the plugin ships only hooks that read. `npx model-orchestrator` still
@@ -37,7 +37,7 @@ export function pluginVars() {
37
37
  RULES_DIR_OVERRIDE_JS: "''",
38
38
  RULES_FILE_REL: DEFAULT_RULES.join(' or '),
39
39
  RULES_FILE_REL_JSON: JSON.stringify(DEFAULT_RULES[0] + ' (' + DEFAULT_RULES[1] + ' on a level 1 install)'),
40
- TASK_BUNDLE_REL_JSON: JSON.stringify(DEFAULT_TASK_BUNDLE),
40
+ TASK_BRIEF_REL_JSON: JSON.stringify(DEFAULT_TASK_BRIEF),
41
41
  RULES_CANDIDATES_JSON: JSON.stringify(DEFAULT_RULES),
42
42
  SETUP_HINT_JSON: JSON.stringify(
43
43
  'This project has no model-orchestrator routing rules yet. Running `npx model-orchestrator` in the project root writes them (' +
@@ -59,9 +59,9 @@ export function pluginManifest() {
59
59
  name: PLUGIN_NAME,
60
60
  version: GENERATOR_VERSION,
61
61
  description:
62
- 'Routing for Claude Code: a hook injects your project\'s routing table on every prompt, so each task goes to the right subagent tier and fewer tokens go to the most expensive model. Ships ' +
62
+ 'Model router for Claude Code: injects project routing rules on each prompt and provides ' +
63
63
  n +
64
- ' subagents across three model tiers.',
64
+ ' subagents for planning, working and cheap model tiers.',
65
65
  author: { name: 'model-orchestrator maintainers', url: REPO_URL },
66
66
  homepage: REPO_URL + '#readme',
67
67
  repository: REPO_URL,
@@ -70,6 +70,15 @@ export function pluginManifest() {
70
70
  };
71
71
  }
72
72
 
73
+ export function marketplaceManifest() {
74
+ return {
75
+ name: PLUGIN_NAME,
76
+ owner: { name: 'model-orchestrator maintainers', url: REPO_URL },
77
+ description: 'Model router for Claude Code: routing hooks and subagents for planning, working and cheap model tiers.',
78
+ plugins: [{ name: PLUGIN_NAME, source: './plugin', category: 'development' }]
79
+ };
80
+ }
81
+
73
82
  // Pure: reads templates, writes nothing. Paths are posix, relative to plugin/.
74
83
  export function planPluginFiles() {
75
84
  const v = pluginVars();
@@ -0,0 +1,57 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { join } from 'node:path';
3
+ import { which } from './detect.js';
4
+ import { doctor, windowsSpawnPlan } from '../bin/cli-run.mjs';
5
+
6
+ // Q1: `trust: 'positive-only'` means a definite negative is never reported
7
+ // for this AI. Only a parsed JSON field of exactly `true` counts as signed
8
+ // in; a failed spawn, a non-zero exit, unparsable stdout or a parsed `false`
9
+ // all come back unknown (null), so the conditional "if you have not signed
10
+ // in yet" step stays instead of a wrong, confident "you are not signed in".
11
+ // Author-machine probe 2026-09-27: `claude auth status` (default JSON)
12
+ // returned {"loggedIn":false} with exit 1 inside a working, signed-in
13
+ // session, so a reported failure is not trustworthy either.
14
+ function positiveOnlyVerdict(result, jsonField) {
15
+ if (result.error || result.signal) return null;
16
+ try {
17
+ const parsed = JSON.parse(result.stdout ?? '');
18
+ return parsed && typeof parsed === 'object' && parsed[jsonField] === true ? true : null;
19
+ } catch { return null; }
20
+ }
21
+
22
+ // Only catalogued, reliable read-only status commands may run. Vendor output
23
+ // can contain account details, so return a verdict without printing it.
24
+ export function signInStatus(selected, { detect = which, spawn = spawnSync, platform = process.platform } = {}) {
25
+ const statuses = {};
26
+ for (const ai of selected) {
27
+ const status = ai.authStatus;
28
+ if (!ai.bin || !status?.reliable) continue;
29
+ const positiveOnly = status.trust === 'positive-only';
30
+ const bin = detect(ai.bin);
31
+ if (!bin) { statuses[ai.id] = positiveOnly ? null : false; continue; }
32
+ try {
33
+ const plan = windowsSpawnPlan([bin, ...status.args], platform);
34
+ if (plan.refuse) { statuses[ai.id] = null; continue; }
35
+ const result = spawn(plan.command, plan.args, {
36
+ ...plan.options, shell: false, encoding: 'utf8',
37
+ stdio: ['ignore', 'pipe', 'pipe'], timeout: 2000, maxBuffer: 8192, windowsHide: true
38
+ });
39
+ statuses[ai.id] = positiveOnly ? positiveOnlyVerdict(result, status.jsonField || 'loggedIn')
40
+ : result.error || result.signal || result.status === null ? null
41
+ : result.status === 0 ? true : result.status === 1 ? false : null;
42
+ } catch { statuses[ai.id] = null; }
43
+ }
44
+ return statuses;
45
+ }
46
+
47
+ export async function installHealthCheck({ level, selected, primary, dir }) {
48
+ console.log('\nHealth check (doctor, binary presence only):');
49
+ // Use this package's implementation, never execute a retained or user-edited
50
+ // runner in the target directory. Only its data configuration is read.
51
+ const rc = await doctor(false, {
52
+ here: join(dir, 'bin'), primary: primary?.id, compact: true,
53
+ ...(level < 2 ? { config: { enabled: selected.filter(ai => ai.facts.cliRun).map(ai => ai.id), defaults: {} } } : {})
54
+ });
55
+ if (rc && rc !== 10 && rc !== 13) console.log(` doctor could not read the installed lane configuration (exit ${rc}).`);
56
+ return rc;
57
+ }
package/src/roles.js ADDED
@@ -0,0 +1,184 @@
1
+ // Pure role assignment. The catalog supplies capabilities; this module orders
2
+ // those facts and renders the result without probing tools or touching files.
3
+
4
+ const isMain = (ai, ctx) => ai.id === ctx.primary?.id;
5
+ const mainFirst = (ai, ctx) => isMain(ai, ctx) ? 0 : 1;
6
+ const knownContext = (ai) => ai.facts.contextWindow?.tokens ?? null;
7
+ const largestContext = (ai) => -(knownContext(ai) ?? -1);
8
+ const differentFamily = (ai, ctx) => Boolean(ctx.mainFamily && ai.facts.modelFamily && ai.facts.modelFamily !== ctx.mainFamily);
9
+ const familyFirst = (ai, ctx) => differentFamily(ai, ctx) ? 0 : 1;
10
+ const callable = (ai, ctx) => isMain(ai, ctx) || ai.facts.cliRun === true;
11
+ const inputRate = (ai) => ai.facts.billing === 'pay-per-token' ? ai.facts.pricing?.inPerM ?? null : null;
12
+ const factNote = (ai, key) => ai.factNotes?.[key] ? `; ${ai.factNotes[key]}` : '';
13
+ const billingReason = (ai) => ai.facts.billing === 'pay-per-token'
14
+ ? `pay-per-token billing${ai.facts.pricing?.inPerM == null ? "; check your provider's rate" : `, stated input rate ${ai.facts.pricing.inPerM} per million tokens`}`
15
+ : `${ai.facts.billing} billing`;
16
+
17
+ export function costRank(ai) {
18
+ return { local: 0, free: 1, subscription: 2, 'pay-per-token': 3 }[ai.facts.billing] ?? Infinity;
19
+ }
20
+
21
+ const mainOrContext = (ai, ctx) => isMain(ai, ctx) ? 'main agent' : knownContext(ai) == null
22
+ ? 'selection order; context capacity is unverified'
23
+ : `largest known context among qualifying lanes (${knownContext(ai)} tokens)`;
24
+ const noReview = (ctx) => ctx.mainFamily
25
+ ? `No AI from a different model family than ${ctx.mainFamily} is selected. Review in a fresh context on your main agent and treat the result as a self-check, not an independent review.`
26
+ : 'The main agent model family is unverified, so independent review cannot be established. Review in a fresh context as a self-check.';
27
+ const noPrivate = () => 'Nothing in your stack runs on your own machine. Keep this work off every lane here.';
28
+ const noMain = () => 'No main agent is selected.';
29
+
30
+ export const ROLE_SPECS = [
31
+ {
32
+ id: 'plan', job: 'Architecture, ambiguity, unknown cause', tier: 'planning model',
33
+ requires: callable, prefer: [mainFirst, largestContext], fallback: 'main', why: mainOrContext
34
+ },
35
+ {
36
+ id: 'build', job: 'Implement a specified section', tier: 'working model',
37
+ requires: (ai, ctx) => ai.facts.writesFiles === true && (isMain(ai, ctx) || (ai.facts.headless === true && ai.facts.cliRun === true)),
38
+ prefer: [mainFirst, (ai) => ai.facts.loadsProjectRules === true ? 0 : 1, (ai) => ai.facts.agentDefinitions ? 0 : 1], fallback: 'main',
39
+ why: (ai, ctx) => [isMain(ai, ctx) ? 'main agent' : 'writes files, headless cli-run lane', ai.facts.loadsProjectRules === true ? 'loads project rules' : ai.facts.loadsProjectRules === null ? 'project rules inheritance is unverified' : null].filter(Boolean).join(', ')
40
+ },
41
+ {
42
+ id: 'review', job: 'Independent review of the final artifact', tier: 'working model',
43
+ requires: (ai, ctx) => ai.facts.cliRun === true && differentFamily(ai, ctx),
44
+ prefer: [(ai) => ai.facts.readOnlyMode === true ? 0 : 1, costRank], fallback: 'none', noneReason: noReview,
45
+ why: (ai, ctx) => `different model family from ${ctx.mainFamily}${ai.facts.readOnlyMode === true ? ', read-only mode available' : `, ${billingReason(ai)}`}`
46
+ },
47
+ {
48
+ id: 'verify', job: 'Reproduce a finding, check a definition of done', tier: 'working model',
49
+ requires: callable, prefer: [familyFirst, costRank, mainFirst], fallback: 'main',
50
+ why: (ai, ctx) => differentFamily(ai, ctx)
51
+ ? `different model family from the author (${ctx.mainFamily}), ${billingReason(ai)}`
52
+ : `${isMain(ai, ctx) ? 'main agent' : billingReason(ai)}${!ai.facts.modelFamily || !ctx.mainFamily ? '; model family is unverified' : ''}`
53
+ },
54
+ {
55
+ id: 'research', job: 'Current primary sources, live web or social', tier: 'working model',
56
+ requires: (ai) => ai.facts.liveWeb === true, prefer: [costRank], fallback: 'main',
57
+ why: (ai) => `states live web tools, ${billingReason(ai)}; ties follow selection order${factNote(ai, 'liveWeb')}`
58
+ },
59
+ {
60
+ id: 'bulk', job: 'Many similar items, cheap', tier: 'cheap model',
61
+ requires: (ai) => ai.facts.headless === true && ai.facts.cliRun === true,
62
+ prefer: [costRank, inputRate], fallback: 'main',
63
+ why: (ai) => `${billingReason(ai)}, headless, runs through cli-run; ties follow selection order`
64
+ },
65
+ {
66
+ id: 'read', job: 'Digest many files, return cited facts', tier: 'cheap model',
67
+ requires: () => true, prefer: [mainFirst, largestContext], fallback: 'main', why: mainOrContext
68
+ },
69
+ {
70
+ id: 'private', job: 'Work that must not leave the machine', tier: 'working model',
71
+ requires: (ai) => ai.facts.runsLocally === true, prefer: [], fallback: 'none', noneReason: noPrivate,
72
+ why: () => 'runs on your machine'
73
+ },
74
+ {
75
+ id: 'fan-out', job: 'N independent units in one call', tier: 'working model', conditional: true,
76
+ requires: (ai) => ai.facts.fanOut === true, prefer: [costRank], fallback: 'none',
77
+ why: (ai) => `one call starts several children, ${billingReason(ai)}${factNote(ai, 'fanOut')}`
78
+ },
79
+ {
80
+ id: 'long-context', job: 'One document larger than the main agent holds', tier: 'planning model', conditional: true,
81
+ requires: (ai, ctx) => ctx.mainContext !== null && knownContext(ai) !== null && knownContext(ai) > ctx.mainContext,
82
+ prefer: [largestContext], fallback: 'none',
83
+ why: (ai, ctx) => `known context of ${knownContext(ai)} tokens exceeds the main agent's ${ctx.mainContext}`
84
+ }
85
+ ];
86
+
87
+ function routeVia(ai, primary) {
88
+ if (ai.id === primary?.id) return 'main-agent';
89
+ if (ai.facts.runsLocally === true) return 'local';
90
+ if (ai.facts.cliRun === true) return 'cli-run';
91
+ // Agent definitions belong to their own main agent. A different selected AI
92
+ // gets no subagent route merely because it can store such definitions.
93
+ return 'manual';
94
+ }
95
+
96
+ export function assignRoles({ selected, primary, detected = new Set(), plans = {} }) {
97
+ // PATH detection and stated plan headroom are advisory, not role qualifiers.
98
+ void detected;
99
+ void plans;
100
+ const ctx = { primary, mainFamily: primary?.facts.modelFamily ?? null, mainContext: primary?.facts.contextWindow?.tokens ?? null };
101
+ const roles = {};
102
+ for (const spec of ROLE_SPECS) {
103
+ const pool = selected.filter((ai) => spec.requires(ai, ctx));
104
+ if (!pool.length) {
105
+ if (spec.conditional) continue;
106
+ if (spec.fallback === 'main' && primary) {
107
+ const why = spec.id === 'research'
108
+ ? 'No selected AI states live web tools; capability is unverified. The main agent carries it; verify it has the required tools.'
109
+ : 'No separate lane qualifies; the main agent carries it.';
110
+ roles[spec.id] = { ai: primary.id, via: 'main-agent', tier: spec.tier, why };
111
+ } else {
112
+ roles[spec.id] = { ai: null, via: 'none', tier: spec.tier, why: (spec.noneReason || noMain)(ctx) };
113
+ }
114
+ continue;
115
+ }
116
+ pool.sort((a, b) => {
117
+ for (const key of spec.prefer) {
118
+ const x = key(a, ctx), y = key(b, ctx);
119
+ if (x != null && y != null && x !== y) return x < y ? -1 : 1;
120
+ }
121
+ return selected.indexOf(a) - selected.indexOf(b);
122
+ });
123
+ const winner = pool[0];
124
+ roles[spec.id] = { ai: winner.id, via: routeVia(winner, primary), tier: spec.tier, why: spec.why(winner, ctx) };
125
+ }
126
+ return { roles, unassigned: Object.entries(roles).filter(([, role]) => role.ai === null).map(([id]) => id) };
127
+ }
128
+
129
+ // `agents` is the installer's role -> installed definition name map. Keeping
130
+ // that explicit means this pure module never guesses whether a file was written.
131
+ export function roleRoute(roleId, assignment, { selected = [], primary = null, agents = {} } = {}) {
132
+ const role = assignment.roles[roleId];
133
+ if (!role) return null;
134
+ const out = { ...role };
135
+ const ai = selected.find((item) => item.id === role.ai);
136
+ if (role.ai === null) {
137
+ out.reason = role.why;
138
+ } else if (role.via === 'cli-run' && ai?.facts.cliRun === true) {
139
+ out.command = `cli-run ${ai.id}${['review', 'verify'].includes(roleId) && ai.facts.readOnlyMode === true ? ' --audit' : ''}`;
140
+ } else if (role.ai === primary?.id && primary.facts.agentDefinitions && agents[roleId]) {
141
+ out.agent = agents[roleId];
142
+ }
143
+ return out;
144
+ }
145
+
146
+ export function manifestRoles(assignment, context = {}) {
147
+ return Object.fromEntries(Object.keys(assignment.roles).map((id) => [id, roleRoute(id, assignment, context)]));
148
+ }
149
+
150
+ export function roleHow(role, { selected = [], primary = null } = {}) {
151
+ if (role.ai === null) return role.reason?.startsWith('Nothing in your stack') ? 'keep it off every lane here' : 'fresh-context self-check on your main agent';
152
+ const ai = selected.find((item) => item.id === role.ai);
153
+ const tier = `${role.tier} tier`;
154
+ if (role.command) return `\`aunx ${role.command}\`, ${tier}`;
155
+ if (role.via === 'local') return `${ai?.bin ? `\`${ai.bin}\`` : 'local runtime'} on your machine, ${tier}`;
156
+ if (ai?.facts.kind === 'chat') return `paste the work into your ${role.ai === primary?.id ? 'main agent' : 'chat app'}, ${tier}`;
157
+ if (role.agent) return `\`${role.agent}\` on your main agent, ${tier}`;
158
+ if (role.via === 'main-agent') return `on your main agent, ${tier}`;
159
+ return `manual handoff, ${tier}`;
160
+ }
161
+
162
+ const cell = (text) => String(text).replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
163
+ export function roleTable(assignment, { selected = [], primary = null, detected = new Set(), agents = {} } = {}) {
164
+ const context = { selected, primary, agents };
165
+ const rows = ROLE_SPECS.filter((spec) => assignment.roles[spec.id]).map((spec) => {
166
+ const role = roleRoute(spec.id, assignment, context);
167
+ const ai = selected.find((item) => item.id === role.ai);
168
+ const name = ai ? `${ai.name}${detected.has(ai.id) ? ' (detected on PATH)' : ''}` : 'none selected';
169
+ return `| ${[spec.job, name, roleHow(role, context), role.why].map(cell).join(' | ')} |`;
170
+ });
171
+ return [
172
+ '## Your stack: who does what', '',
173
+ 'Assigned from the AIs you selected and what each one can do. Re-run the installer to reassign.', '',
174
+ '| Job | Goes to | How | Why this one |', '|---|---|---|---|', ...rows
175
+ ].join('\n');
176
+ }
177
+
178
+ export function inferPrimary(candidates) {
179
+ const rank = (ai) => ai.facts.loadsProjectRules === true && ai.facts.agentDefinitions ? 0
180
+ : ai.facts.agentDefinitions ? 1
181
+ : ai.facts.kind === 'agent-cli' && ai.rulesFile ? 2
182
+ : ai.facts.kind === 'agent-cli' ? 3 : 4;
183
+ return [...candidates].sort((a, b) => rank(a) - rank(b) || candidates.indexOf(a) - candidates.indexOf(b))[0];
184
+ }
package/src/uninstall.js CHANGED
@@ -1,7 +1,9 @@
1
- import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync, rmdirSync, unlinkSync } from 'node:fs';
1
+ import { closeSync, constants, existsSync, fstatSync, ftruncateSync, lstatSync, openSync, readFileSync, readdirSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32 } from 'node:path';
4
- import { dirProblems, realRoot } from './install.js';
4
+ import { dirProblems, globalConfigProblem, realRoot } from './install.js';
5
+ import { START, END } from './apply-snippets.js';
6
+ import { validateActivationOwnership } from './activation-ownership.js';
5
7
 
6
8
  const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
7
9
  const object = (value) => value && typeof value === 'object' && !Array.isArray(value);
@@ -38,6 +40,8 @@ function entry(key, roots, directory = false) {
38
40
  }
39
41
  const abs = resolve(root, rel);
40
42
  if (!isDirRoot && (abs === root || !abs.startsWith(root + sep))) throw refused(`path leaves its target root: ${key}`);
43
+ const globalProblem = globalConfigProblem(abs);
44
+ if (globalProblem) throw refused(globalProblem);
41
45
  return { key, root, abs, directory };
42
46
  }
43
47
 
@@ -79,6 +83,95 @@ function removeFile(item, expectedHash) {
79
83
  unlinkSync(item.abs);
80
84
  }
81
85
 
86
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
87
+ function activationEntry(key, ownership, roots) {
88
+ const problem = validateActivationOwnership(key, ownership);
89
+ if (problem) throw refused(problem);
90
+ const item = entry(key, roots);
91
+ return { ...item, ownership };
92
+ }
93
+
94
+ // Only the bytes/config entries recorded by activation belong to this install.
95
+ // User content around a block and unrelated settings survive later edits.
96
+ function removeActivation(item, bytes) {
97
+ const owned = item.ownership;
98
+ if (owned.kind === 'rules') {
99
+ let start = bytes.indexOf(START);
100
+ let end = bytes.indexOf(END);
101
+ if (start === -1 && end === -1) return { content: bytes, edited: false };
102
+ if (start < 0 || end < start || bytes.indexOf(START, start + START.length) !== -1 || bytes.indexOf(END, end + END.length) !== -1) return { content: bytes, edited: true };
103
+ end += Buffer.byteLength(END);
104
+ if (hash(bytes.subarray(start, end)) !== owned.blockHash) return { content: bytes, edited: true };
105
+ const prefix = Buffer.from(owned.addedPrefix);
106
+ const suffix = Buffer.from(owned.addedSuffix);
107
+ if (prefix.length && bytes.subarray(start - prefix.length, start).equals(prefix)) start -= prefix.length;
108
+ if (suffix.length && bytes.subarray(end, end + suffix.length).equals(suffix)) end += suffix.length;
109
+ const content = Buffer.concat([bytes.subarray(0, start), bytes.subarray(end)]);
110
+ return { content: owned.created && content.length === 0 ? null : content, edited: false };
111
+ }
112
+ let data;
113
+ try { data = JSON.parse(bytes.toString('utf8')); }
114
+ catch { return { content: bytes, edited: true }; }
115
+ if (!object(data)) return { content: bytes, edited: true };
116
+ let edited = false;
117
+ let changed = false;
118
+ if (owned.kind === 'hooks') {
119
+ if (data.hooks === undefined) return { content: bytes, edited: false };
120
+ if (!object(data.hooks)) return { content: bytes, edited: true };
121
+ for (const record of owned.hooks) {
122
+ const groups = data.hooks[record.event];
123
+ if (groups === undefined) continue;
124
+ if (!Array.isArray(groups) || groups.some((group) => !object(group) || !Array.isArray(group.hooks))) { edited = true; continue; }
125
+ let removed = false;
126
+ for (let index = 0; index < groups.length; index++) {
127
+ const group = groups[index];
128
+ const { hooks, ...attributes } = group;
129
+ if (!same(attributes, record.group)) continue;
130
+ const match = hooks.findIndex((hook) => same(hook, record.hook));
131
+ if (match === -1) continue;
132
+ hooks.splice(match, 1);
133
+ if (!hooks.length) groups.splice(index, 1);
134
+ changed = removed = true;
135
+ break;
136
+ }
137
+ if (!removed && groups.some((group) => group.hooks.some((hook) => object(hook) && hook.command === record.hook.command && same(hook.args || [], record.hook.args || [])))) edited = true;
138
+ if (!groups.length && !owned.originalEvents.includes(record.event)) delete data.hooks[record.event];
139
+ }
140
+ if (!owned.hadHooks && Object.keys(data.hooks).length === 0) delete data.hooks;
141
+ } else {
142
+ const servers = data[owned.key];
143
+ if (servers === undefined) return { content: bytes, edited: false };
144
+ if (!object(servers)) return { content: bytes, edited: true };
145
+ for (const [name, configuration] of Object.entries(owned.servers)) {
146
+ if (!Object.hasOwn(servers, name)) continue;
147
+ if (!same(servers[name], configuration)) { edited = true; continue; }
148
+ delete servers[name];
149
+ changed = true;
150
+ }
151
+ if (!owned.hadKey && Object.keys(servers).length === 0) delete data[owned.key];
152
+ }
153
+ return { content: !changed ? bytes : owned.created && Object.keys(data).length === 0 ? null : Buffer.from(JSON.stringify(data, null, 2) + '\n'), edited };
154
+ }
155
+
156
+ function applyRemoval(item, plan) {
157
+ const current = readRegular(item);
158
+ if (!current || !current.bytes.equals(plan.original)) throw refused(`file changed during uninstall: ${item.abs}`);
159
+ const backup = plan.backup;
160
+ writeFileSync(backup, current.bytes, { flag: 'wx', mode: current.stat.mode & 0o777 });
161
+ const last = inspect(item);
162
+ if (!last || last.dev !== current.stat.dev || last.ino !== current.stat.ino) throw refused(`file changed during uninstall: ${item.abs}`);
163
+ if (plan.content === null) unlinkSync(item.abs);
164
+ else {
165
+ const fd = openSync(item.abs, constants.O_WRONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
166
+ try {
167
+ const actual = fstatSync(fd);
168
+ if (!actual.isFile() || actual.dev !== current.stat.dev || actual.ino !== current.stat.ino) throw refused(`file changed during uninstall: ${item.abs}`);
169
+ ftruncateSync(fd, 0);
170
+ writeFileSync(fd, plan.content);
171
+ } finally { closeSync(fd); }
172
+ }
173
+ }
174
+
82
175
  // Validate every path and type before removing anything. The manifest is an
83
176
  // inventory, never authority to expand the two roots supplied by the caller.
84
177
  export function uninstallFiles({ dir, project, dry = false }) {
@@ -96,6 +189,7 @@ export function uninstallFiles({ dir, project, dry = false }) {
96
189
  }
97
190
  }
98
191
  if (data.directories !== undefined && !Array.isArray(data.directories)) throw refused('manifest directories must be an array');
192
+ if (data.activation !== undefined && !object(data.activation)) throw refused('manifest activation must be an object');
99
193
 
100
194
  const files = Object.entries(data.files).map(([key, digest]) => {
101
195
  const item = entry(key, roots);
@@ -104,17 +198,18 @@ export function uninstallFiles({ dir, project, dry = false }) {
104
198
  return { ...item, digest };
105
199
  });
106
200
  const directories = (data.directories || []).map((key) => entry(key, roots, true));
201
+ const activation = Object.entries(data.activation || {}).map(([key, ownership]) => activationEntry(key, ownership, roots));
107
202
  const seen = new Set([manifest.abs]);
108
- for (const item of [...files, ...directories]) {
203
+ for (const item of [...files, ...directories, ...activation]) {
109
204
  if (seen.has(item.abs)) throw refused(`duplicate manifest path: ${item.key}`);
110
205
  seen.add(item.abs);
111
206
  inspect(item);
112
207
  }
113
208
 
114
209
  const actions = [];
115
- const backupTargets = [...files, manifest];
210
+ const backupTargets = [...files, manifest, ...activation];
116
211
  if (data.primary === 'claude-code') backupTargets.push(entry('[project] CLAUDE.md', roots), entry('[project] .claude/settings.json', roots));
117
- for (const item of backupTargets) {
212
+ for (const item of new Map(backupTargets.map((target) => [target.abs, target])).values()) {
118
213
  const parent = dirname(item.abs);
119
214
  if (!inspect({ root: item.root, abs: parent, directory: true })) continue;
120
215
  const prefix = basename(item.abs) + '.bak-';
@@ -125,7 +220,27 @@ export function uninstallFiles({ dir, project, dry = false }) {
125
220
  }
126
221
  }
127
222
  const pending = [];
223
+ const changes = [];
128
224
  let edited = false;
225
+ for (const item of activation) {
226
+ const current = readRegular(item);
227
+ if (!current) { actions.push(' missing activation ' + item.abs); continue; }
228
+ const removal = removeActivation(item, current.bytes);
229
+ if (removal.edited) {
230
+ edited = true;
231
+ actions.push(' keep edited activation ' + item.abs);
232
+ }
233
+ if (removal.content !== null && removal.content.equals(current.bytes)) continue;
234
+ let stamp = Date.now();
235
+ let backup;
236
+ do {
237
+ backup = item.abs + '.bak-' + new Date(stamp).toISOString().replace(/[-:]/g, '').slice(0, 15);
238
+ stamp += 1000;
239
+ } while (existsSync(backup));
240
+ changes.push({ item, original: current.bytes, ...removal, backup });
241
+ actions.push(' backup ' + backup);
242
+ actions.push(' remove activation ' + item.abs);
243
+ }
129
244
  for (const item of files) {
130
245
  const current = readRegular(item);
131
246
  if (!current) actions.push(' missing file ' + item.abs);
@@ -142,13 +257,14 @@ export function uninstallFiles({ dir, project, dry = false }) {
142
257
 
143
258
  // Compute empty directories against the same plan used by the real run.
144
259
  // Foreign entries and kept edits prevent their parents from being removed.
145
- const disappearing = new Set(pending.map((item) => item.abs));
260
+ const disappearing = new Set([...pending.map((item) => item.abs), ...changes.filter((plan) => plan.content === null).map((plan) => plan.item.abs)]);
261
+ const backupParents = new Set(changes.map((plan) => dirname(plan.backup)));
146
262
  if (!edited) disappearing.add(manifest.abs);
147
263
  const empty = [];
148
264
  directories.sort((a, b) => b.abs.split(sep).length - a.abs.split(sep).length || a.abs.localeCompare(b.abs));
149
265
  for (const item of directories) {
150
266
  if (!inspect(item)) continue;
151
- if (readdirSync(item.abs).every((name) => disappearing.has(join(item.abs, name)))) {
267
+ if (!backupParents.has(item.abs) && readdirSync(item.abs).every((name) => disappearing.has(join(item.abs, name)))) {
152
268
  disappearing.add(item.abs);
153
269
  empty.push(item);
154
270
  }
@@ -156,7 +272,8 @@ export function uninstallFiles({ dir, project, dry = false }) {
156
272
 
157
273
  if (!dry) {
158
274
  // A changed type or symlink detected here still refuses the whole run.
159
- for (const item of [...files, ...directories, manifest]) inspect(item);
275
+ for (const item of [...files, ...directories, ...activation, manifest]) inspect(item);
276
+ for (const plan of changes) applyRemoval(plan.item, plan);
160
277
  for (const item of pending) removeFile(item, item.digest);
161
278
  if (!edited) removeFile(manifest, hash(saved.bytes));
162
279
  }
@@ -4,11 +4,28 @@ Everything the installer can write, organized by the level that adds it. Files a
4
4
 
5
5
  | Folder | Written at | Contents |
6
6
  |---|---|---|
7
- | `common/` | every level | the start-here README, `TASK_BUNDLE.md`, `protocols/` (build, propagate, gap analysis, deep research, numbers and logic, memory and record, docs then prove) |
7
+ | `common/` | every level | the start-here README, `TASK_BRIEF.md`, `CONTEXT.md`, `ACCEPTANCE_CHECKS.json`, `DECISIONS.md` and indexed workflow protocols |
8
8
  | `beginner/` | every level | `ORCHESTRATOR.md`, the single-agent routing rules |
9
- | `agents/` | every level, one variant | the primary agent's loading surface: Claude Code subagents (plus `.claude/hooks/route-gate.mjs` and `subagent-context.mjs`, and `settings.hooks.snippet.json` to wire them in), Antigravity custom agents, or a paste snippet |
9
+ | `agents/` | every level, one variant | the main agent's loading surface: Claude Code subagents (plus `.claude/hooks/route-gate.mjs`, `subagent-context.mjs` and `route-metrics.mjs`, and `settings.hooks.snippet.json` to wire them in), Antigravity custom agents, or a paste snippet |
10
10
  | `intermediate/` | level 2+ | `ROUTING.md`, `TIERS.md`, `DELEGATION_MATRIX.md`, `RESEARCH_TRIAGE.md`, `CLI-RUN.md` |
11
11
  | `advanced/` | level 3 | `vm/`: gateway config, compose file, box rules, privacy gates, scheduled jobs |
12
12
  | `tools/` | when selected | companion tools the AIs call: `codecalc/`, `obsidian-tc/` and `context7/` (install doc + MCP snippets each). See `tools/README.md` |
13
13
 
14
14
  Agent definitions under `agents/claude-code/` and `agents/agy/` are written to the PROJECT root (`--project`), not `--dir`, because that is where those CLIs read them. A `README.md` at the root of a tier folder (like this one) documents the repo and is not installed. `common/README.md` is the exception: it is the user's start-here file. READMEs deeper in (`protocols/`, `vm/`, `vm/jobs/`) are installed as folder indexes.
15
+
16
+ ## Workflow protocols
17
+
18
+ When editing a procedure, update its installed index in `common/protocols/README.md` too.
19
+
20
+ | Protocol | Purpose |
21
+ |---|---|
22
+ | Build | Frame, assign, implement, audit once and verify the change in use |
23
+ | Context file | Share one source of context across the run |
24
+ | Acceptance checks | Verify requirements against the final artifact |
25
+ | Decision log | Record Did / Why / Serves / Rejected |
26
+ | Propagate | Complete shared-name and interface changes |
27
+ | Gap analysis | Compare scope with the user's ask |
28
+ | Deep research | Reconcile bounded research against primary sources |
29
+ | Numbers and logic | Compute decision inputs |
30
+ | Memory and record | Keep durable information indexed |
31
+ | Docs then prove | Verify changing interfaces on the runtime |
@@ -5,10 +5,10 @@ Written at level 3 only, on top of everything below it. Everything lands under `
5
5
  | File | What it is |
6
6
  |---|---|
7
7
  | `vm/README.md` | start-here for the box: what runs where, the gateway-holds-the-keys rule, the closed loop |
8
- | `vm/setup-vm.sh` | idempotent setup for a fresh Ubuntu box: deps, the selected CLIs; prints every vendor script instead of running it |
8
+ | `vm/setup-vm.sh` | manual Ubuntu setup: dependencies and selected CLIs; prints vendor scripts for review. After sign-ins and secret injection, `--start-services` starts Compose and verifies the selected local lane |
9
9
  | `vm/docker-compose.yml` | the gateway (and a local model runtime if selected), bound to loopback |
10
10
  | `vm/gateway.config.yaml` | one lane per selected provider, keys referenced by environment variable NAME only |
11
11
  | `vm/ENVIRONMENT.md` | which variable names the gateway expects, and where to keep the values (a secrets manager, never a file in the repo) |
12
12
  | `vm/box-CLAUDE.md` | the rules a Claude Code session on the box inherits: cheapest tier that does the job, what never goes to the cheap tier, no public bind |
13
- | `vm/PRIVACY_GATES.md` | what data never leaves the machine, which lanes are barred by name |
13
+ | `vm/PRIVACY_GATES.md` | configurable data classes, allowed and barred lanes, and the authorization check before dispatch |
14
14
  | `vm/jobs/` | a systemd timer + service pair for the weekly gap-analysis audit, plus an index |
@@ -1,33 +1,31 @@
1
- # PRIVACY_GATES.md: what never leaves the machine
1
+ # PRIVACY_GATES.md: match data to authorized lanes
2
2
 
3
- A bar that is not named is not enforced. Fill in the names.
3
+ Before sending data to a lane, classify it under the project's policy and verify the lane is authorized for that class. Fill in the allowed and barred lane names before using this template with protected information.
4
4
 
5
5
  ## Data classes
6
6
 
7
- | Class | Examples | May go to |
7
+ | Class | Examples | Choose |
8
8
  |---|---|---|
9
- | Public | published posts, open docs, public repos | any lane |
10
- | Working | your own notes, drafts, code you will publish | your primary vendor's lanes, subscription CLIs you trust with it |
11
- | Confidential | client data, other people's records, contracts | your primary vendor only, or the local lane |
12
- | Personal | health, identity, private journals | the local lane only, or nowhere |
9
+ | Public | Published posts, open docs, public repositories | Any lane permitted by project policy |
10
+ | Working | Internal drafts and unpublished code | Lanes approved for this project |
11
+ | Confidential | Client records, contracts and restricted business data | Explicitly approved processors or a local runtime |
12
+ | Personal | Health, identity and private journals | Explicitly authorized processing with the required privacy boundary |
13
13
 
14
- ## Lanes barred by name for Confidential and Personal
14
+ ## Name the boundaries
15
15
 
16
- - metered third-party bulk lanes (the cheapest-tier API you use for volume)
17
- - concurrent fan-out lanes on a consumer subscription
18
- - any shared compute lane (free GPU tiers, notebook services)
19
- - any tool that stores conversation history on its own servers without a retention control you have read
20
-
21
- Write your own lane names here, in this file, so the bar is checkable:
16
+ Record the actual tool names and the reason for each rule:
22
17
 
23
18
  ```
24
- BARRED: <lane>, <lane>, <lane>
19
+ ALLOWED FOR <class>: <lane>, <lane>
20
+ BARRED FOR <class>: <lane>, <lane>
25
21
  ```
26
22
 
27
- ## The local lane is a privacy lane, not a cost lane
23
+ Review a provider's retention, training and access policy before approving a new lane for protected data. Never send protected information to an unapproved bulk, fan-out or shared-compute lane.
24
+
25
+ ## Use the local lane for confinement
28
26
 
29
- Route to a local model when confinement is the requirement. Never to save money: the accuracy gap is real, and pennies saved are not worth a wrong answer that looks right.
27
+ When data must remain on the machine, use a local runtime and verify that its tools and logging preserve that boundary. Check its task quality with the same acceptance checks used for any other lane.
30
28
 
31
- ## The check
29
+ ## Check before dispatch
32
30
 
33
- Before any bulk call: which class is this data, and is the lane in the barred list? If you cannot answer both, it is Confidential.
31
+ When a bulk call is ready, verify the data class, the selected tool and the permission that allows it. When classification or permission is unresolved, keep the data local and obtain the missing decision before dispatch.