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.
- package/AGENTS.md +31 -21
- package/CHANGELOG.md +51 -1
- package/README.md +127 -110
- package/bin/README.md +57 -6
- package/bin/aunx.js +7 -0
- package/bin/cli-run.mjs +21 -15
- package/bin/cli.js +376 -257
- package/docs/README.md +15 -18
- package/docs/catalog.md +228 -38
- package/docs/companions.md +28 -10
- package/docs/guarantees.md +21 -12
- package/docs/how-it-routes.md +49 -42
- package/docs/install.md +135 -33
- package/docs/part-1-beginner.md +37 -45
- package/docs/part-2-intermediate.md +34 -52
- package/docs/part-3-advanced.md +36 -26
- package/docs/security-review-history.md +38 -0
- package/llms.txt +24 -25
- package/package.json +16 -8
- package/proof/README.md +100 -0
- package/proof/gate-demo.cast +9 -0
- package/proof/gate-demo.gif +0 -0
- package/proof/results.json +198 -0
- package/proof/scripts/check-gate.js +26 -0
- package/proof/scripts/install-time.js +16 -0
- package/proof/scripts/lib.js +73 -0
- package/proof/scripts/measure.js +15 -0
- package/proof/scripts/missing-results.js +30 -0
- package/proof/scripts/record-gate.js +38 -0
- package/proof/scripts/render.js +18 -0
- package/proof/scripts/runner-overhead.js +21 -0
- package/src/README.md +9 -3
- package/src/activation-ownership.js +19 -0
- package/src/apply-companions.js +104 -0
- package/src/apply-snippets.js +60 -28
- package/src/aunx.js +262 -0
- package/src/catalog.js +253 -117
- package/src/install.js +478 -209
- package/src/plugin.js +13 -4
- package/src/postinstall.js +57 -0
- package/src/roles.js +184 -0
- package/src/uninstall.js +125 -8
- package/templates/README.md +19 -2
- package/templates/advanced/README.md +2 -2
- package/templates/advanced/vm/PRIVACY_GATES.md +17 -19
- package/templates/advanced/vm/README.md +25 -20
- package/templates/advanced/vm/box-CLAUDE.md +19 -18
- package/templates/advanced/vm/jobs/README.md +3 -1
- package/templates/advanced/vm/jobs/weekly-audit.service +3 -0
- package/templates/advanced/vm/jobs/weekly-audit.sh +2 -2
- package/templates/advanced/vm/setup-vm.sh +49 -2
- package/templates/agents/README.md +2 -2
- package/templates/agents/agy/README.md +20 -3
- package/templates/agents/agy/builder.md +11 -7
- package/templates/agents/agy/bulk-worker.md +9 -7
- package/templates/agents/agy/code-reviewer.md +13 -7
- package/templates/agents/agy/deep-planner.md +10 -7
- package/templates/agents/agy/done-verifier.md +13 -22
- package/templates/agents/agy/finding-verifier.md +14 -22
- package/templates/agents/agy/live-researcher.md +10 -7
- package/templates/agents/agy/reader.md +10 -12
- package/templates/agents/claude-code/README.md +18 -14
- package/templates/agents/claude-code/builder.md +10 -15
- package/templates/agents/claude-code/bulk-worker.md +8 -10
- package/templates/agents/claude-code/code-reviewer.md +11 -17
- package/templates/agents/claude-code/deep-planner.md +9 -11
- package/templates/agents/claude-code/done-verifier.md +12 -33
- package/templates/agents/claude-code/finding-verifier.md +13 -39
- package/templates/agents/claude-code/live-researcher.md +9 -11
- package/templates/agents/claude-code/reader.md +9 -18
- package/templates/agents/snippets/chat.md +9 -10
- package/templates/agents/snippets/claude-code.md +17 -18
- package/templates/agents/snippets/generic.md +9 -11
- package/templates/agents/snippets/route-gate.mjs +2 -2
- package/templates/agents/snippets/route-metrics.mjs +1 -1
- package/templates/agents/snippets/subagent-context.mjs +4 -4
- package/templates/beginner/ORCHESTRATOR.md +31 -36
- package/templates/beginner/README.md +1 -1
- package/templates/common/ACCEPTANCE_CHECKS.json +12 -0
- package/templates/common/CONTEXT.md +37 -0
- package/templates/common/DECISIONS.md +11 -0
- package/templates/common/README.md +24 -11
- package/templates/common/TASK_BRIEF.md +84 -0
- package/templates/common/protocols/README.md +14 -11
- package/templates/common/protocols/acceptance-checks.md +14 -0
- package/templates/common/protocols/build-protocol.md +91 -106
- package/templates/common/protocols/context-file.md +10 -0
- package/templates/common/protocols/decision-log.md +9 -0
- package/templates/common/protocols/deep-research.md +20 -34
- package/templates/common/protocols/docs-then-prove.md +13 -18
- package/templates/common/protocols/gap-analysis.md +15 -21
- package/templates/common/protocols/memory-and-record.md +21 -20
- package/templates/common/protocols/numbers-and-logic.md +20 -26
- package/templates/common/protocols/propagate.md +18 -27
- package/templates/intermediate/CLI-RUN.md +83 -113
- package/templates/intermediate/DELEGATION_MATRIX.md +9 -3
- package/templates/intermediate/README.md +3 -3
- package/templates/intermediate/RESEARCH_TRIAGE.md +23 -15
- package/templates/intermediate/ROUTING.md +54 -51
- package/templates/intermediate/TIERS.md +37 -76
- package/templates/tools/README.md +1 -1
- package/templates/tools/obsidian-tc/OBSIDIAN-TC.md +1 -1
- package/docs/audit-brief.md +0 -148
- package/scripts/README.md +0 -7
- package/scripts/gen-catalog.js +0 -81
- package/scripts/gen-plugin.js +0 -16
- package/scripts/record-demo.sh +0 -45
- package/templates/common/TASK_BUNDLE.md +0 -56
package/src/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,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
|
}
|
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 |
|
|
@@ -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.
|