model-orchestrator 0.1.35 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +31 -21
- package/CHANGELOG.md +58 -1
- package/README.md +129 -110
- package/SECURITY.md +7 -3
- package/bin/README.md +57 -6
- package/bin/aunx.js +7 -0
- package/bin/cli-run.mjs +21 -15
- package/bin/cli.js +376 -257
- package/docs/README.md +15 -18
- package/docs/catalog.md +236 -44
- package/docs/companions.md +28 -10
- package/docs/guarantees.md +21 -12
- package/docs/how-it-routes.md +49 -42
- package/docs/install.md +141 -33
- package/docs/part-1-beginner.md +37 -45
- package/docs/part-2-intermediate.md +34 -52
- package/docs/part-3-advanced.md +36 -26
- package/docs/security-review-history.md +39 -0
- package/llms.txt +24 -25
- package/package.json +15 -8
- package/proof/README.md +100 -0
- package/proof/gate-demo.cast +9 -0
- package/proof/gate-demo.gif +0 -0
- package/proof/results.json +198 -0
- package/proof/scripts/check-gate.js +26 -0
- package/proof/scripts/install-time.js +16 -0
- package/proof/scripts/lib.js +73 -0
- package/proof/scripts/measure.js +15 -0
- package/proof/scripts/missing-results.js +30 -0
- package/proof/scripts/record-gate.js +38 -0
- package/proof/scripts/render.js +18 -0
- package/proof/scripts/runner-overhead.js +21 -0
- package/src/README.md +10 -3
- package/src/activation-ownership.js +19 -0
- package/src/apply-companions.js +104 -0
- package/src/apply-snippets.js +60 -28
- package/src/aunx.js +272 -0
- package/src/bounded-file.js +31 -0
- package/src/catalog.js +257 -121
- package/src/install.js +483 -212
- package/src/plugin.js +13 -4
- package/src/postinstall.js +57 -0
- package/src/roles.js +184 -0
- package/src/uninstall.js +128 -10
- package/templates/README.md +19 -2
- package/templates/advanced/README.md +2 -2
- package/templates/advanced/vm/ENVIRONMENT.md +8 -0
- package/templates/advanced/vm/PRIVACY_GATES.md +17 -19
- package/templates/advanced/vm/README.md +25 -20
- package/templates/advanced/vm/box-CLAUDE.md +19 -18
- package/templates/advanced/vm/docker-compose.yml +2 -1
- package/templates/advanced/vm/jobs/README.md +31 -2
- package/templates/advanced/vm/jobs/weekly-audit.service +7 -2
- package/templates/advanced/vm/jobs/weekly-audit.sh +24 -17
- package/templates/advanced/vm/setup-vm.sh +49 -2
- package/templates/agents/README.md +2 -2
- package/templates/agents/agy/README.md +20 -3
- package/templates/agents/agy/builder.md +11 -7
- package/templates/agents/agy/bulk-worker.md +9 -7
- package/templates/agents/agy/code-reviewer.md +13 -7
- package/templates/agents/agy/deep-planner.md +10 -7
- package/templates/agents/agy/done-verifier.md +13 -22
- package/templates/agents/agy/finding-verifier.md +14 -22
- package/templates/agents/agy/live-researcher.md +10 -7
- package/templates/agents/agy/reader.md +10 -12
- package/templates/agents/claude-code/README.md +18 -14
- package/templates/agents/claude-code/builder.md +10 -15
- package/templates/agents/claude-code/bulk-worker.md +8 -10
- package/templates/agents/claude-code/code-reviewer.md +11 -17
- package/templates/agents/claude-code/deep-planner.md +9 -11
- package/templates/agents/claude-code/done-verifier.md +12 -33
- package/templates/agents/claude-code/finding-verifier.md +13 -39
- package/templates/agents/claude-code/live-researcher.md +9 -11
- package/templates/agents/claude-code/reader.md +9 -18
- package/templates/agents/snippets/chat.md +9 -10
- package/templates/agents/snippets/claude-code.md +17 -18
- package/templates/agents/snippets/generic.md +9 -11
- package/templates/agents/snippets/route-gate.mjs +2 -2
- package/templates/agents/snippets/route-metrics.mjs +1 -1
- package/templates/agents/snippets/subagent-context.mjs +4 -4
- package/templates/beginner/ORCHESTRATOR.md +31 -36
- package/templates/beginner/README.md +1 -1
- package/templates/common/ACCEPTANCE_CHECKS.json +12 -0
- package/templates/common/CONTEXT.md +37 -0
- package/templates/common/DECISIONS.md +11 -0
- package/templates/common/README.md +24 -11
- package/templates/common/TASK_BRIEF.md +84 -0
- package/templates/common/protocols/README.md +14 -11
- package/templates/common/protocols/acceptance-checks.md +15 -0
- package/templates/common/protocols/build-protocol.md +91 -106
- package/templates/common/protocols/context-file.md +10 -0
- package/templates/common/protocols/decision-log.md +9 -0
- package/templates/common/protocols/deep-research.md +20 -34
- package/templates/common/protocols/docs-then-prove.md +13 -18
- package/templates/common/protocols/gap-analysis.md +15 -21
- package/templates/common/protocols/memory-and-record.md +21 -20
- package/templates/common/protocols/numbers-and-logic.md +20 -26
- package/templates/common/protocols/propagate.md +18 -27
- package/templates/intermediate/CLI-RUN.md +83 -113
- package/templates/intermediate/DELEGATION_MATRIX.md +9 -3
- package/templates/intermediate/README.md +3 -3
- package/templates/intermediate/RESEARCH_TRIAGE.md +23 -15
- package/templates/intermediate/ROUTING.md +54 -51
- package/templates/intermediate/TIERS.md +37 -76
- package/templates/tools/README.md +1 -1
- package/templates/tools/codecalc/CODECALC.md +4 -4
- package/templates/tools/codecalc/mcp/agy.mcp_config.json +1 -1
- package/templates/tools/codecalc/mcp/codex.config.toml +1 -1
- package/templates/tools/codecalc/mcp/mcpServers.json +1 -1
- package/templates/tools/codecalc/mcp/vscode.mcp.json +1 -1
- package/templates/tools/codecalc/mcp/zed.settings.json +1 -1
- package/templates/tools/context7/CONTEXT7.md +6 -10
- package/templates/tools/obsidian-tc/OBSIDIAN-TC.md +3 -3
- package/templates/tools/obsidian-tc/mcp/obsidian-tc.agy.mcp_config.json +1 -1
- package/templates/tools/obsidian-tc/mcp/obsidian-tc.codex.config.toml +1 -1
- package/templates/tools/obsidian-tc/mcp/obsidian-tc.mcpServers.json +1 -1
- package/templates/tools/obsidian-tc/mcp/obsidian-tc.vscode.mcp.json +1 -1
- package/templates/tools/obsidian-tc/mcp/obsidian-tc.zed.settings.json +1 -1
- package/docs/audit-brief.md +0 -148
- package/scripts/README.md +0 -7
- package/scripts/gen-catalog.js +0 -81
- package/scripts/gen-plugin.js +0 -16
- package/scripts/record-demo.sh +0 -45
- package/templates/common/TASK_BUNDLE.md +0 -56
package/src/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
|
|
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
|
-
|
|
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
|
-
'
|
|
62
|
+
'Model router for Claude Code: injects project routing rules on each prompt and provides ' +
|
|
63
63
|
n +
|
|
64
|
-
' subagents
|
|
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,10 @@
|
|
|
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';
|
|
7
|
+
import { MANIFEST_BYTE_CAP, readBounded } from './bounded-file.js';
|
|
5
8
|
|
|
6
9
|
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
7
10
|
const object = (value) => value && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -38,6 +41,8 @@ function entry(key, roots, directory = false) {
|
|
|
38
41
|
}
|
|
39
42
|
const abs = resolve(root, rel);
|
|
40
43
|
if (!isDirRoot && (abs === root || !abs.startsWith(root + sep))) throw refused(`path leaves its target root: ${key}`);
|
|
44
|
+
const globalProblem = globalConfigProblem(abs);
|
|
45
|
+
if (globalProblem) throw refused(globalProblem);
|
|
41
46
|
return { key, root, abs, directory };
|
|
42
47
|
}
|
|
43
48
|
|
|
@@ -66,7 +71,7 @@ function readRegular(item) {
|
|
|
66
71
|
try {
|
|
67
72
|
const actual = fstatSync(fd);
|
|
68
73
|
if (!actual.isFile() || actual.dev !== expected.dev || actual.ino !== expected.ino) throw refused(`file changed during inspection: ${item.abs}`);
|
|
69
|
-
return { bytes: readFileSync(fd), stat: actual };
|
|
74
|
+
return { bytes: item.maxBytes ? readBounded(fd, item.maxBytes) : readFileSync(fd), stat: actual };
|
|
70
75
|
} finally { closeSync(fd); }
|
|
71
76
|
}
|
|
72
77
|
|
|
@@ -79,11 +84,100 @@ function removeFile(item, expectedHash) {
|
|
|
79
84
|
unlinkSync(item.abs);
|
|
80
85
|
}
|
|
81
86
|
|
|
87
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
88
|
+
function activationEntry(key, ownership, roots) {
|
|
89
|
+
const problem = validateActivationOwnership(key, ownership);
|
|
90
|
+
if (problem) throw refused(problem);
|
|
91
|
+
const item = entry(key, roots);
|
|
92
|
+
return { ...item, ownership };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Only the bytes/config entries recorded by activation belong to this install.
|
|
96
|
+
// User content around a block and unrelated settings survive later edits.
|
|
97
|
+
function removeActivation(item, bytes) {
|
|
98
|
+
const owned = item.ownership;
|
|
99
|
+
if (owned.kind === 'rules') {
|
|
100
|
+
let start = bytes.indexOf(START);
|
|
101
|
+
let end = bytes.indexOf(END);
|
|
102
|
+
if (start === -1 && end === -1) return { content: bytes, edited: false };
|
|
103
|
+
if (start < 0 || end < start || bytes.indexOf(START, start + START.length) !== -1 || bytes.indexOf(END, end + END.length) !== -1) return { content: bytes, edited: true };
|
|
104
|
+
end += Buffer.byteLength(END);
|
|
105
|
+
if (hash(bytes.subarray(start, end)) !== owned.blockHash) return { content: bytes, edited: true };
|
|
106
|
+
const prefix = Buffer.from(owned.addedPrefix);
|
|
107
|
+
const suffix = Buffer.from(owned.addedSuffix);
|
|
108
|
+
if (prefix.length && bytes.subarray(start - prefix.length, start).equals(prefix)) start -= prefix.length;
|
|
109
|
+
if (suffix.length && bytes.subarray(end, end + suffix.length).equals(suffix)) end += suffix.length;
|
|
110
|
+
const content = Buffer.concat([bytes.subarray(0, start), bytes.subarray(end)]);
|
|
111
|
+
return { content: owned.created && content.length === 0 ? null : content, edited: false };
|
|
112
|
+
}
|
|
113
|
+
let data;
|
|
114
|
+
try { data = JSON.parse(bytes.toString('utf8')); }
|
|
115
|
+
catch { return { content: bytes, edited: true }; }
|
|
116
|
+
if (!object(data)) return { content: bytes, edited: true };
|
|
117
|
+
let edited = false;
|
|
118
|
+
let changed = false;
|
|
119
|
+
if (owned.kind === 'hooks') {
|
|
120
|
+
if (data.hooks === undefined) return { content: bytes, edited: false };
|
|
121
|
+
if (!object(data.hooks)) return { content: bytes, edited: true };
|
|
122
|
+
for (const record of owned.hooks) {
|
|
123
|
+
const groups = data.hooks[record.event];
|
|
124
|
+
if (groups === undefined) continue;
|
|
125
|
+
if (!Array.isArray(groups) || groups.some((group) => !object(group) || !Array.isArray(group.hooks))) { edited = true; continue; }
|
|
126
|
+
let removed = false;
|
|
127
|
+
for (let index = 0; index < groups.length; index++) {
|
|
128
|
+
const group = groups[index];
|
|
129
|
+
const { hooks, ...attributes } = group;
|
|
130
|
+
if (!same(attributes, record.group)) continue;
|
|
131
|
+
const match = hooks.findIndex((hook) => same(hook, record.hook));
|
|
132
|
+
if (match === -1) continue;
|
|
133
|
+
hooks.splice(match, 1);
|
|
134
|
+
if (!hooks.length) groups.splice(index, 1);
|
|
135
|
+
changed = removed = true;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
if (!removed && groups.some((group) => group.hooks.some((hook) => object(hook) && hook.command === record.hook.command && same(hook.args || [], record.hook.args || [])))) edited = true;
|
|
139
|
+
if (!groups.length && !owned.originalEvents.includes(record.event)) delete data.hooks[record.event];
|
|
140
|
+
}
|
|
141
|
+
if (!owned.hadHooks && Object.keys(data.hooks).length === 0) delete data.hooks;
|
|
142
|
+
} else {
|
|
143
|
+
const servers = data[owned.key];
|
|
144
|
+
if (servers === undefined) return { content: bytes, edited: false };
|
|
145
|
+
if (!object(servers)) return { content: bytes, edited: true };
|
|
146
|
+
for (const [name, configuration] of Object.entries(owned.servers)) {
|
|
147
|
+
if (!Object.hasOwn(servers, name)) continue;
|
|
148
|
+
if (!same(servers[name], configuration)) { edited = true; continue; }
|
|
149
|
+
delete servers[name];
|
|
150
|
+
changed = true;
|
|
151
|
+
}
|
|
152
|
+
if (!owned.hadKey && Object.keys(servers).length === 0) delete data[owned.key];
|
|
153
|
+
}
|
|
154
|
+
return { content: !changed ? bytes : owned.created && Object.keys(data).length === 0 ? null : Buffer.from(JSON.stringify(data, null, 2) + '\n'), edited };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function applyRemoval(item, plan) {
|
|
158
|
+
const current = readRegular(item);
|
|
159
|
+
if (!current || !current.bytes.equals(plan.original)) throw refused(`file changed during uninstall: ${item.abs}`);
|
|
160
|
+
const backup = plan.backup;
|
|
161
|
+
writeFileSync(backup, current.bytes, { flag: 'wx', mode: current.stat.mode & 0o777 });
|
|
162
|
+
const last = inspect(item);
|
|
163
|
+
if (!last || last.dev !== current.stat.dev || last.ino !== current.stat.ino) throw refused(`file changed during uninstall: ${item.abs}`);
|
|
164
|
+
if (plan.content === null) unlinkSync(item.abs);
|
|
165
|
+
else {
|
|
166
|
+
const fd = openSync(item.abs, constants.O_WRONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
|
|
167
|
+
try {
|
|
168
|
+
const actual = fstatSync(fd);
|
|
169
|
+
if (!actual.isFile() || actual.dev !== current.stat.dev || actual.ino !== current.stat.ino) throw refused(`file changed during uninstall: ${item.abs}`);
|
|
170
|
+
ftruncateSync(fd, 0);
|
|
171
|
+
writeFileSync(fd, plan.content);
|
|
172
|
+
} finally { closeSync(fd); }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
82
176
|
// Validate every path and type before removing anything. The manifest is an
|
|
83
177
|
// inventory, never authority to expand the two roots supplied by the caller.
|
|
84
178
|
export function uninstallFiles({ dir, project, dry = false }) {
|
|
85
179
|
const roots = { dir: targetRoot(dir), project: targetRoot(project) };
|
|
86
|
-
const manifest = entry('MANIFEST.json', roots);
|
|
180
|
+
const manifest = { ...entry('MANIFEST.json', roots), maxBytes: MANIFEST_BYTE_CAP };
|
|
87
181
|
const saved = readRegular(manifest);
|
|
88
182
|
if (!saved) throw refused(`missing manifest: ${join(resolve(dir), 'MANIFEST.json')}`);
|
|
89
183
|
let data;
|
|
@@ -96,6 +190,7 @@ export function uninstallFiles({ dir, project, dry = false }) {
|
|
|
96
190
|
}
|
|
97
191
|
}
|
|
98
192
|
if (data.directories !== undefined && !Array.isArray(data.directories)) throw refused('manifest directories must be an array');
|
|
193
|
+
if (data.activation !== undefined && !object(data.activation)) throw refused('manifest activation must be an object');
|
|
99
194
|
|
|
100
195
|
const files = Object.entries(data.files).map(([key, digest]) => {
|
|
101
196
|
const item = entry(key, roots);
|
|
@@ -104,17 +199,18 @@ export function uninstallFiles({ dir, project, dry = false }) {
|
|
|
104
199
|
return { ...item, digest };
|
|
105
200
|
});
|
|
106
201
|
const directories = (data.directories || []).map((key) => entry(key, roots, true));
|
|
202
|
+
const activation = Object.entries(data.activation || {}).map(([key, ownership]) => activationEntry(key, ownership, roots));
|
|
107
203
|
const seen = new Set([manifest.abs]);
|
|
108
|
-
for (const item of [...files, ...directories]) {
|
|
204
|
+
for (const item of [...files, ...directories, ...activation]) {
|
|
109
205
|
if (seen.has(item.abs)) throw refused(`duplicate manifest path: ${item.key}`);
|
|
110
206
|
seen.add(item.abs);
|
|
111
207
|
inspect(item);
|
|
112
208
|
}
|
|
113
209
|
|
|
114
210
|
const actions = [];
|
|
115
|
-
const backupTargets = [...files, manifest];
|
|
211
|
+
const backupTargets = [...files, manifest, ...activation];
|
|
116
212
|
if (data.primary === 'claude-code') backupTargets.push(entry('[project] CLAUDE.md', roots), entry('[project] .claude/settings.json', roots));
|
|
117
|
-
for (const item of backupTargets) {
|
|
213
|
+
for (const item of new Map(backupTargets.map((target) => [target.abs, target])).values()) {
|
|
118
214
|
const parent = dirname(item.abs);
|
|
119
215
|
if (!inspect({ root: item.root, abs: parent, directory: true })) continue;
|
|
120
216
|
const prefix = basename(item.abs) + '.bak-';
|
|
@@ -125,7 +221,27 @@ export function uninstallFiles({ dir, project, dry = false }) {
|
|
|
125
221
|
}
|
|
126
222
|
}
|
|
127
223
|
const pending = [];
|
|
224
|
+
const changes = [];
|
|
128
225
|
let edited = false;
|
|
226
|
+
for (const item of activation) {
|
|
227
|
+
const current = readRegular(item);
|
|
228
|
+
if (!current) { actions.push(' missing activation ' + item.abs); continue; }
|
|
229
|
+
const removal = removeActivation(item, current.bytes);
|
|
230
|
+
if (removal.edited) {
|
|
231
|
+
edited = true;
|
|
232
|
+
actions.push(' keep edited activation ' + item.abs);
|
|
233
|
+
}
|
|
234
|
+
if (removal.content !== null && removal.content.equals(current.bytes)) continue;
|
|
235
|
+
let stamp = Date.now();
|
|
236
|
+
let backup;
|
|
237
|
+
do {
|
|
238
|
+
backup = item.abs + '.bak-' + new Date(stamp).toISOString().replace(/[-:]/g, '').slice(0, 15);
|
|
239
|
+
stamp += 1000;
|
|
240
|
+
} while (existsSync(backup));
|
|
241
|
+
changes.push({ item, original: current.bytes, ...removal, backup });
|
|
242
|
+
actions.push(' backup ' + backup);
|
|
243
|
+
actions.push(' remove activation ' + item.abs);
|
|
244
|
+
}
|
|
129
245
|
for (const item of files) {
|
|
130
246
|
const current = readRegular(item);
|
|
131
247
|
if (!current) actions.push(' missing file ' + item.abs);
|
|
@@ -142,13 +258,14 @@ export function uninstallFiles({ dir, project, dry = false }) {
|
|
|
142
258
|
|
|
143
259
|
// Compute empty directories against the same plan used by the real run.
|
|
144
260
|
// Foreign entries and kept edits prevent their parents from being removed.
|
|
145
|
-
const disappearing = new Set(pending.map((item) => item.abs));
|
|
261
|
+
const disappearing = new Set([...pending.map((item) => item.abs), ...changes.filter((plan) => plan.content === null).map((plan) => plan.item.abs)]);
|
|
262
|
+
const backupParents = new Set(changes.map((plan) => dirname(plan.backup)));
|
|
146
263
|
if (!edited) disappearing.add(manifest.abs);
|
|
147
264
|
const empty = [];
|
|
148
265
|
directories.sort((a, b) => b.abs.split(sep).length - a.abs.split(sep).length || a.abs.localeCompare(b.abs));
|
|
149
266
|
for (const item of directories) {
|
|
150
267
|
if (!inspect(item)) continue;
|
|
151
|
-
if (readdirSync(item.abs).every((name) => disappearing.has(join(item.abs, name)))) {
|
|
268
|
+
if (!backupParents.has(item.abs) && readdirSync(item.abs).every((name) => disappearing.has(join(item.abs, name)))) {
|
|
152
269
|
disappearing.add(item.abs);
|
|
153
270
|
empty.push(item);
|
|
154
271
|
}
|
|
@@ -156,7 +273,8 @@ export function uninstallFiles({ dir, project, dry = false }) {
|
|
|
156
273
|
|
|
157
274
|
if (!dry) {
|
|
158
275
|
// A changed type or symlink detected here still refuses the whole run.
|
|
159
|
-
for (const item of [...files, ...directories, manifest]) inspect(item);
|
|
276
|
+
for (const item of [...files, ...directories, ...activation, manifest]) inspect(item);
|
|
277
|
+
for (const plan of changes) applyRemoval(plan.item, plan);
|
|
160
278
|
for (const item of pending) removeFile(item, item.digest);
|
|
161
279
|
if (!edited) removeFile(manifest, hash(saved.bytes));
|
|
162
280
|
}
|
package/templates/README.md
CHANGED
|
@@ -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, `
|
|
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
|
|
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` |
|
|
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` |
|
|
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 |
|
|
@@ -10,6 +10,14 @@ These are variable **names**. The values live in a secrets manager and are injec
|
|
|
10
10
|
|
|
11
11
|
`GATEWAY_MASTER_KEY` is the bearer every client presents to the gateway. Generate it once (`openssl rand -hex 32`), store it in the manager, never paste it into a file here. It must be a single token matching `^[A-Za-z0-9._-]+$`: the audit job interpolates it into curl's config grammar and refuses anything else.
|
|
12
12
|
|
|
13
|
+
## Gateway and scheduled audit environments
|
|
14
|
+
|
|
15
|
+
Inject the provider names above only into the environment used to launch the gateway with Compose. The weekly audit service instead reads `~/.config/ai-orchestrator/weekly-audit.env`, outside the installation and mode 600, containing only `GATEWAY_MASTER_KEY`. Do not point that service at the gateway's provider-key file. Existing installations must update and reload their copied systemd unit when adopting this template.
|
|
16
|
+
|
|
17
|
+
The audit script removes `GATEWAY_MASTER_KEY`, `LITELLM_MASTER_KEY`, and the gateway provider names `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `XAI_API_KEY`, and `OPENROUTER_API_KEY` from child environments. It supplies the gateway header to curl through stdin, without a credential temp file or an argv value. Newlines anywhere in the key are rejected before collection.
|
|
18
|
+
|
|
19
|
+
Vendor version probes and the report worker retain `HOME`, `PATH`, stored vendor sign-ins, and unrelated authentication variables. Configure the selected lane's own sign-in in the systemd user's account, such as `hermes auth add <provider>` for Hermes. An API-only lane relying solely on one of the removed provider variables needs vendor-supported stored authentication before this scheduled job can run; gateway keys are not a substitute for that setup.
|
|
20
|
+
|
|
13
21
|
## Rules
|
|
14
22
|
|
|
15
23
|
- Never print a value in a terminal or a log. Verify by length or by a hash prefix.
|
|
@@ -1,33 +1,31 @@
|
|
|
1
|
-
# PRIVACY_GATES.md:
|
|
1
|
+
# PRIVACY_GATES.md: match data to authorized lanes
|
|
2
2
|
|
|
3
|
-
|
|
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 |
|
|
7
|
+
| Class | Examples | Choose |
|
|
8
8
|
|---|---|---|
|
|
9
|
-
| Public |
|
|
10
|
-
| Working |
|
|
11
|
-
| Confidential |
|
|
12
|
-
| Personal |
|
|
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
|
-
##
|
|
14
|
+
## Name the boundaries
|
|
15
15
|
|
|
16
|
-
|
|
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
|
-
|
|
19
|
+
ALLOWED FOR <class>: <lane>, <lane>
|
|
20
|
+
BARRED FOR <class>: <lane>, <lane>
|
|
25
21
|
```
|
|
26
22
|
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
29
|
+
## Check before dispatch
|
|
32
30
|
|
|
33
|
-
|
|
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.
|