super-backlog 0.3.4 → 0.5.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/dist/cli.js +13 -0
- package/dist/commands/init.js +4 -1
- package/dist/commands/models.js +34 -0
- package/dist/commands/uninstall.js +2 -0
- package/dist/dashboard/server.js +6 -0
- package/dist/init/execute.js +12 -0
- package/dist/init/planner.js +2 -0
- package/dist/models/claude.js +61 -0
- package/dist/models/config.js +40 -0
- package/dist/models/dashboard-api.js +30 -0
- package/dist/models/defaults.js +16 -0
- package/dist/models/discovery.js +66 -0
- package/dist/models/family.js +14 -0
- package/dist/models/install.js +18 -0
- package/dist/models/opencode.js +14 -0
- package/dist/models/resolve.js +22 -0
- package/dist/models/types.js +1 -0
- package/dist/models/uninstall.js +91 -0
- package/dist/templates/agent-sbl-worker-cheap.md +6 -0
- package/dist/templates/agent-sbl-worker.md +6 -0
- package/dist/templates/cc-session-hook.js +26 -0
- package/dist/templates/claude-agent-sbl-worker-cheap.md +6 -0
- package/dist/templates/claude-agent-sbl-worker.md +6 -0
- package/dist/templates/model-router-plugin.js +27 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -5,6 +5,7 @@ import process from 'node:process';
|
|
|
5
5
|
import { runDashboard } from './commands/dashboard.js';
|
|
6
6
|
import { runDoctor } from './commands/doctor.js';
|
|
7
7
|
import { runInit } from './commands/init.js';
|
|
8
|
+
import { runModels } from './commands/models.js';
|
|
8
9
|
import { runUninstall } from './commands/uninstall.js';
|
|
9
10
|
import { runUpdate } from './commands/update.js';
|
|
10
11
|
import { assertNode20, KIT_VERSION } from './lib/version.js';
|
|
@@ -17,12 +18,15 @@ Commands:
|
|
|
17
18
|
uninstall Remove kit-managed files (project data kept unless --with-backlog)
|
|
18
19
|
update Refresh kit-managed files and report upstream versions
|
|
19
20
|
dashboard Generate the single-file project dashboard (--serve for live mode)
|
|
21
|
+
models Manage the model router (show, enable, disable, discover)
|
|
20
22
|
doctor Check the environment (node, PowerShell policy, backlog CLI)
|
|
21
23
|
|
|
22
24
|
init options:
|
|
23
25
|
--pm <auto|npm|pnpm|bun|skip> Package manager to use (default: auto)
|
|
24
26
|
--harness <opencode|claude> Target harness; repeatable or comma-separated (default: both)
|
|
25
27
|
--guard Install the integrity pre-commit hook (opt-in)
|
|
28
|
+
--models Install the model router config during init (opt-in)
|
|
29
|
+
--no-models Explicitly opt out of the model router
|
|
26
30
|
--no-dashboard Skip generating the project dashboard
|
|
27
31
|
--no-refresh-hook Skip the post-commit dashboard freshness hook
|
|
28
32
|
--dry-run Show what would be done without writing anything
|
|
@@ -67,6 +71,8 @@ async function main(argv) {
|
|
|
67
71
|
pm: { type: 'string' },
|
|
68
72
|
harness: { type: 'string', multiple: true },
|
|
69
73
|
guard: { type: 'boolean' },
|
|
74
|
+
models: { type: 'boolean' },
|
|
75
|
+
'no-models': { type: 'boolean' },
|
|
70
76
|
'no-dashboard': { type: 'boolean' },
|
|
71
77
|
'no-refresh-hook': { type: 'boolean' },
|
|
72
78
|
'dry-run': { type: 'boolean' },
|
|
@@ -111,6 +117,13 @@ async function main(argv) {
|
|
|
111
117
|
positionals: parsed.positionals,
|
|
112
118
|
});
|
|
113
119
|
}
|
|
120
|
+
case 'models': {
|
|
121
|
+
const parsed = parseArgs({ args: rest, allowPositionals: true, options: {} });
|
|
122
|
+
return await runModels(process.cwd(), {
|
|
123
|
+
values: parsed.values,
|
|
124
|
+
positionals: parsed.positionals,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
114
127
|
case 'doctor':
|
|
115
128
|
return runDoctor(process.cwd());
|
|
116
129
|
default:
|
package/dist/commands/init.js
CHANGED
|
@@ -23,6 +23,8 @@ function describeAction(action) {
|
|
|
23
23
|
return 'write-claude-pointer CLAUDE.md';
|
|
24
24
|
case 'copy-skills':
|
|
25
25
|
return 'copy-skills (.opencode/skill + .claude/skills)';
|
|
26
|
+
case 'install-model-router':
|
|
27
|
+
return 'install-model-router .super-backlog/models.json';
|
|
26
28
|
case 'install-guard-hook':
|
|
27
29
|
return 'install-guard-hook .git/hooks/pre-commit';
|
|
28
30
|
case 'install-refresh-hook':
|
|
@@ -74,6 +76,7 @@ export async function runInit(cwd, args) {
|
|
|
74
76
|
const dashboard = args.values['no-dashboard'] !== true;
|
|
75
77
|
const refreshHook = args.values['no-refresh-hook'] !== true; // default on, opt-out flag
|
|
76
78
|
const dryRun = args.values['dry-run'] === true;
|
|
79
|
+
const models = args.values.models === true ? true : args.values['no-models'] === true ? false : undefined;
|
|
77
80
|
const projectName = args.positionals[0] ?? basename(resolve(cwd));
|
|
78
81
|
let opencodeConfig;
|
|
79
82
|
const opencodePath = join(cwd, 'opencode.json');
|
|
@@ -95,7 +98,7 @@ export async function runInit(cwd, args) {
|
|
|
95
98
|
opencodeConfig,
|
|
96
99
|
pkgExists: existsSync(join(cwd, 'package.json')),
|
|
97
100
|
};
|
|
98
|
-
const opts = { projectName, harnesses, pm, guard, dashboard, refreshHook, skipInstall: false };
|
|
101
|
+
const opts = { projectName, harnesses, pm, guard, dashboard, refreshHook, skipInstall: false, models };
|
|
99
102
|
const plan = planInit(state, opts, KIT_VERSION);
|
|
100
103
|
if (dryRun) {
|
|
101
104
|
console.log(`dry-run for "${projectName}": ${plan.actions.length} action(s) planned, nothing written`);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// src/commands/models.ts
|
|
2
|
+
import { loadConfig } from '../models/config.js';
|
|
3
|
+
import { discoverModels } from '../models/discovery.js';
|
|
4
|
+
import { writeRouterConfig } from '../models/install.js';
|
|
5
|
+
export async function runModels(cwd, args) {
|
|
6
|
+
const sub = args.positionals[0] ?? 'show';
|
|
7
|
+
switch (sub) {
|
|
8
|
+
case 'show': {
|
|
9
|
+
const cfg = loadConfig(cwd);
|
|
10
|
+
console.log(JSON.stringify(cfg, null, 2));
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
case 'enable':
|
|
14
|
+
writeRouterConfig(cwd, true);
|
|
15
|
+
console.log('model router enabled');
|
|
16
|
+
return 0;
|
|
17
|
+
case 'disable':
|
|
18
|
+
writeRouterConfig(cwd, false);
|
|
19
|
+
console.log('model router disabled');
|
|
20
|
+
return 0;
|
|
21
|
+
case 'discover': {
|
|
22
|
+
const resolved = await discoverModels(cwd);
|
|
23
|
+
if (!resolved) {
|
|
24
|
+
console.error('discovery failed');
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
console.log(JSON.stringify(resolved, null, 2));
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
default:
|
|
31
|
+
console.error(`unknown models subcommand: ${sub}`);
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -8,6 +8,7 @@ import { stripOwned } from '../lib/markers.js';
|
|
|
8
8
|
import { PLUGIN_SPEC } from '../lib/opencode.js';
|
|
9
9
|
import { isOwnedSkillFile } from '../lib/ownership.js';
|
|
10
10
|
import { WANTED_SCRIPTS } from '../lib/pkgjson.js';
|
|
11
|
+
import { uninstallModelRouter } from '../models/uninstall.js';
|
|
11
12
|
const OWNED_SKILL_DIRS = [
|
|
12
13
|
'.opencode/skill/spec-to-backlog',
|
|
13
14
|
'.opencode/skill/backlog-status-report',
|
|
@@ -265,6 +266,7 @@ export function runUninstall(cwd, args) {
|
|
|
265
266
|
label: 'backlog/ (project task data preserved - pass --with-backlog to delete)',
|
|
266
267
|
});
|
|
267
268
|
}
|
|
269
|
+
uninstallModelRouter(cwd, report);
|
|
268
270
|
console.log('super-backlog uninstall');
|
|
269
271
|
for (const line of report)
|
|
270
272
|
console.log(`${line.verdict}: ${line.label}`);
|
package/dist/dashboard/server.js
CHANGED
|
@@ -5,6 +5,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
5
5
|
import { createServer } from 'node:http';
|
|
6
6
|
import { isAbsolute, join } from 'node:path';
|
|
7
7
|
import process from 'node:process';
|
|
8
|
+
import { createModelApiHandler } from '../models/dashboard-api.js';
|
|
8
9
|
export const DASHBOARD_PORT = 6428;
|
|
9
10
|
export function recursiveWatchSupported(platform, nodeVersion) {
|
|
10
11
|
// Node 24 on Windows triggers a libuv assertion in recursive fs.watch:
|
|
@@ -70,7 +71,12 @@ export async function startServeServer(cwd, opts = {}) {
|
|
|
70
71
|
else {
|
|
71
72
|
console.warn('warning: live reload is disabled because Node 24+ on Windows cannot reliably watch directories recursively (libuv fs-event bug); use Node 22 or Linux/macOS for --serve');
|
|
72
73
|
}
|
|
74
|
+
const modelApi = createModelApiHandler();
|
|
73
75
|
const server = createServer((req, res) => {
|
|
76
|
+
if (req.url?.startsWith('/api/')) {
|
|
77
|
+
void modelApi(req, res);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
74
80
|
const url = req.url ?? '/';
|
|
75
81
|
const method = req.method ?? 'GET';
|
|
76
82
|
if (method !== 'GET' || !(url === '/' || url === '/index.html')) {
|
package/dist/init/execute.js
CHANGED
|
@@ -8,6 +8,7 @@ import { installGuardHook, installRefreshHook } from '../lib/hooks.js';
|
|
|
8
8
|
import { injectBlock } from '../lib/markers.js';
|
|
9
9
|
import { applyPluginEntry } from '../lib/opencode.js';
|
|
10
10
|
import { OwnershipError, renderSkill } from '../lib/ownership.js';
|
|
11
|
+
import { writeRouterConfig } from '../models/install.js';
|
|
11
12
|
import { addDevDependencies, mergeScripts, WANTED_DEVS, WANTED_SCRIPTS, } from '../lib/pkgjson.js';
|
|
12
13
|
import { installCmdsFor } from '../lib/pm.js';
|
|
13
14
|
import { resolveBacklogBin, runCapture } from '../lib/run.js';
|
|
@@ -216,6 +217,17 @@ export async function executeActions(cwd, actions, ctx) {
|
|
|
216
217
|
case 'generate-dashboard':
|
|
217
218
|
(await applyGenerateDashboard(cwd, warnings)) ? applied++ : skipped++;
|
|
218
219
|
break;
|
|
220
|
+
case 'install-model-router': {
|
|
221
|
+
const { installOpenCodeAdapter } = await import('../models/opencode.js');
|
|
222
|
+
const { installClaudeAdapter } = await import('../models/claude.js');
|
|
223
|
+
writeRouterConfig(cwd, action.enabled);
|
|
224
|
+
if (action.enabled) {
|
|
225
|
+
installOpenCodeAdapter(cwd);
|
|
226
|
+
installClaudeAdapter(cwd);
|
|
227
|
+
}
|
|
228
|
+
applied++;
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
219
231
|
case 'write':
|
|
220
232
|
atomicWrite(join(cwd, action.path), action.contents);
|
|
221
233
|
applied++;
|
package/dist/init/planner.js
CHANGED
|
@@ -56,5 +56,7 @@ export function planInit(state, opts, _version) {
|
|
|
56
56
|
actions.push({ kind: 'install-refresh-hook' });
|
|
57
57
|
if (opts.dashboard)
|
|
58
58
|
actions.push({ kind: 'generate-dashboard' });
|
|
59
|
+
if (opts.models === true)
|
|
60
|
+
actions.push({ kind: 'install-model-router', enabled: true });
|
|
59
61
|
return { actions, warnings };
|
|
60
62
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// src/models/claude.ts
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { atomicWrite } from '../lib/atomic.js';
|
|
6
|
+
import { loadConfig } from './config.js';
|
|
7
|
+
import { resolveTier } from './resolve.js';
|
|
8
|
+
const MODEL_RE = /^model:\s*.+$/m;
|
|
9
|
+
export function syncClaudeAgents(cwd, mainModel) {
|
|
10
|
+
const cfg = loadConfig(cwd);
|
|
11
|
+
if (!cfg.enabled)
|
|
12
|
+
return;
|
|
13
|
+
const workhorse = mainModel ? resolveTier(mainModel, 'workhorse', cfg) : cfg.resolved.workhorse || null;
|
|
14
|
+
const budget = mainModel ? resolveTier(mainModel, 'budget', cfg) : cfg.resolved.budget || null;
|
|
15
|
+
updateAgentFile(cwd, 'sbl-worker.md', workhorse);
|
|
16
|
+
updateAgentFile(cwd, 'sbl-worker-cheap.md', budget);
|
|
17
|
+
}
|
|
18
|
+
export function updateAgentFile(cwd, file, model) {
|
|
19
|
+
const path = join(cwd, '.claude', 'agents', file);
|
|
20
|
+
if (!existsSync(path))
|
|
21
|
+
return;
|
|
22
|
+
let content = readFileSync(path, 'utf8');
|
|
23
|
+
const replacement = `model: ${model ?? 'inherit'}`;
|
|
24
|
+
if (MODEL_RE.test(content)) {
|
|
25
|
+
content = content.replace(MODEL_RE, replacement);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
content = content.replace(/^---$/m, `---\n${replacement}`);
|
|
29
|
+
}
|
|
30
|
+
atomicWrite(path, content);
|
|
31
|
+
}
|
|
32
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
export function installClaudeAdapter(cwd) {
|
|
34
|
+
const workerPath = join(cwd, '.claude', 'agents', 'sbl-worker.md');
|
|
35
|
+
const cheapPath = join(cwd, '.claude', 'agents', 'sbl-worker-cheap.md');
|
|
36
|
+
atomicWrite(workerPath, readFileSync(join(__dirname, '../templates/claude-agent-sbl-worker.md'), 'utf8'));
|
|
37
|
+
atomicWrite(cheapPath, readFileSync(join(__dirname, '../templates/claude-agent-sbl-worker-cheap.md'), 'utf8'));
|
|
38
|
+
installSettingsHook(cwd);
|
|
39
|
+
}
|
|
40
|
+
function installSettingsHook(cwd) {
|
|
41
|
+
const path = join(cwd, '.claude', 'settings.json');
|
|
42
|
+
const existing = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {};
|
|
43
|
+
const hook = {
|
|
44
|
+
hooks: {
|
|
45
|
+
SessionStart: [
|
|
46
|
+
{
|
|
47
|
+
matcher: '*',
|
|
48
|
+
hooks: [
|
|
49
|
+
{
|
|
50
|
+
type: 'command',
|
|
51
|
+
command: 'node',
|
|
52
|
+
args: ['node_modules/super-backlog/dist/templates/cc-session-hook.js'],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
const next = { ...existing, hooks: { ...existing.hooks, ...hook.hooks } };
|
|
60
|
+
atomicWrite(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
61
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { DEFAULT_CONFIG, DEFAULT_FAMILIES } from './defaults.js';
|
|
4
|
+
const CONFIG_PATH = '.super-backlog/models.json';
|
|
5
|
+
export function loadConfig(cwd) {
|
|
6
|
+
const path = join(cwd, CONFIG_PATH);
|
|
7
|
+
if (!existsSync(path))
|
|
8
|
+
return { ...DEFAULT_CONFIG };
|
|
9
|
+
try {
|
|
10
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
11
|
+
if (!raw || typeof raw !== 'object')
|
|
12
|
+
return { ...DEFAULT_CONFIG };
|
|
13
|
+
return normalizeConfig(raw);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return { ...DEFAULT_CONFIG, enabled: false };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function normalizeConfig(partial) {
|
|
20
|
+
return {
|
|
21
|
+
version: typeof partial.version === 'number' ? partial.version : DEFAULT_CONFIG.version,
|
|
22
|
+
enabled: partial.enabled === true,
|
|
23
|
+
mode: ['auto', 'family', 'individual'].includes(partial.mode) ? partial.mode : 'family',
|
|
24
|
+
tiers: partial.tiers && typeof partial.tiers === 'object' ? partial.tiers : {},
|
|
25
|
+
individual: partial.individual && typeof partial.individual === 'object' ? partial.individual : {},
|
|
26
|
+
families: partial.families && typeof partial.families === 'object' ? partial.families : {},
|
|
27
|
+
resolved: partial.resolved && typeof partial.resolved === 'object' ? partial.resolved : {},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function getFamilyTable(config) {
|
|
31
|
+
const merged = {};
|
|
32
|
+
for (const [name, tiers] of Object.entries(DEFAULT_FAMILIES)) {
|
|
33
|
+
merged[name] = { ...tiers, ...(config.families[name] || {}) };
|
|
34
|
+
}
|
|
35
|
+
for (const [name, tiers] of Object.entries(config.families)) {
|
|
36
|
+
if (!merged[name])
|
|
37
|
+
merged[name] = { workhorse: '', budget: '', ...tiers };
|
|
38
|
+
}
|
|
39
|
+
return merged;
|
|
40
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import { loadConfig } from './config.js';
|
|
3
|
+
import { discoverModels } from './discovery.js';
|
|
4
|
+
function currentCwd() {
|
|
5
|
+
try {
|
|
6
|
+
return process.cwd();
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return '.';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function createModelApiHandler() {
|
|
13
|
+
return async (req, res) => {
|
|
14
|
+
const url = req.url ?? '/';
|
|
15
|
+
const method = req.method ?? 'GET';
|
|
16
|
+
if (method === 'GET' && url === '/api/models') {
|
|
17
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
18
|
+
res.end(JSON.stringify({ config: loadConfig(currentCwd()), status: 'ok' }));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (method === 'POST' && url === '/api/models/discover') {
|
|
22
|
+
const result = await discoverModels(currentCwd());
|
|
23
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
24
|
+
res.end(JSON.stringify(result ?? { error: 'discovery failed' }));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
28
|
+
res.end('not found');
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const DEFAULT_FAMILIES = {
|
|
2
|
+
kimi: { workhorse: 'opencode/kimi-k2.7-code', budget: 'opencode/kimi-k2.5' },
|
|
3
|
+
grok: { workhorse: 'xai/grok-4.5', budget: 'xai/grok-4.3' },
|
|
4
|
+
claude: { workhorse: 'opencode/claude-sonnet-4-6', budget: 'opencode/claude-haiku-4-5' },
|
|
5
|
+
gpt: { workhorse: 'opencode/gpt-5.1-codex-mini', budget: 'opencode/gpt-5-nano' },
|
|
6
|
+
gemini: { workhorse: 'opencode/gemini-3.5-flash', budget: 'opencode/gemini-3.5-flash-lite' },
|
|
7
|
+
};
|
|
8
|
+
export const DEFAULT_CONFIG = {
|
|
9
|
+
version: 1,
|
|
10
|
+
enabled: false,
|
|
11
|
+
mode: 'family',
|
|
12
|
+
tiers: {},
|
|
13
|
+
individual: {},
|
|
14
|
+
families: {},
|
|
15
|
+
resolved: {},
|
|
16
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { runCapture } from '../lib/run.js';
|
|
2
|
+
export function parseOpenCodeModels(output) {
|
|
3
|
+
return output
|
|
4
|
+
.split('\n')
|
|
5
|
+
.map((s) => s.trim())
|
|
6
|
+
.filter((s) => s.length > 0 && s.includes('/'));
|
|
7
|
+
}
|
|
8
|
+
function scoreForTier(modelId, tier) {
|
|
9
|
+
const lower = modelId.toLowerCase();
|
|
10
|
+
// Prefer flagship-ish names for workhorse, avoid 'nano/lite' for workhorse
|
|
11
|
+
if (tier === 'workhorse') {
|
|
12
|
+
if (lower.includes('opus'))
|
|
13
|
+
return 100;
|
|
14
|
+
if (lower.includes('sonnet'))
|
|
15
|
+
return 90;
|
|
16
|
+
if (lower.includes('k2.7-code') || lower.includes('k2.6-code'))
|
|
17
|
+
return 85;
|
|
18
|
+
if (lower.includes('gpt-5.1') || lower.includes('gpt-5.2') || lower.includes('k3'))
|
|
19
|
+
return 80;
|
|
20
|
+
if (lower.includes('gemini-3.1') || lower.includes('gemini-3.5') || lower.includes('gemini-3.6'))
|
|
21
|
+
return 70;
|
|
22
|
+
if (lower.includes('k2.7') || lower.includes('k2.6'))
|
|
23
|
+
return 60;
|
|
24
|
+
if (lower.includes('grok-4.6'))
|
|
25
|
+
return 55;
|
|
26
|
+
if (lower.includes('grok-4.5'))
|
|
27
|
+
return 50;
|
|
28
|
+
return 10;
|
|
29
|
+
}
|
|
30
|
+
// Budget tier: prefer nano/lite/flash names
|
|
31
|
+
if (lower.includes('nano') || lower.includes('lite'))
|
|
32
|
+
return 100;
|
|
33
|
+
if (lower.includes('flash'))
|
|
34
|
+
return 90;
|
|
35
|
+
if (lower.includes('haiku'))
|
|
36
|
+
return 85;
|
|
37
|
+
if (lower.includes('k2.5'))
|
|
38
|
+
return 80;
|
|
39
|
+
if (lower.includes('grok-4.3'))
|
|
40
|
+
return 70;
|
|
41
|
+
return 10;
|
|
42
|
+
}
|
|
43
|
+
export function rankTiers(available) {
|
|
44
|
+
const workhorse = [...available].sort((a, b) => scoreForTier(b, 'workhorse') - scoreForTier(a, 'workhorse'))[0] || '';
|
|
45
|
+
const budget = [...available].sort((a, b) => scoreForTier(b, 'budget') - scoreForTier(a, 'budget'))[0] || '';
|
|
46
|
+
return { workhorse, budget };
|
|
47
|
+
}
|
|
48
|
+
export async function discoverModels(cwd) {
|
|
49
|
+
try {
|
|
50
|
+
const result = runCapture('opencode', ['models'], cwd);
|
|
51
|
+
if (result.status !== 0)
|
|
52
|
+
return null;
|
|
53
|
+
const available = parseOpenCodeModels(result.stdout);
|
|
54
|
+
if (available.length === 0)
|
|
55
|
+
return null;
|
|
56
|
+
const ranked = rankTiers(available);
|
|
57
|
+
return {
|
|
58
|
+
discoveredAt: new Date().toISOString(),
|
|
59
|
+
workhorse: ranked.workhorse,
|
|
60
|
+
budget: ranked.budget,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function detectFamily(modelId) {
|
|
2
|
+
const lower = modelId.toLowerCase();
|
|
3
|
+
if (lower.includes('kimi'))
|
|
4
|
+
return 'kimi';
|
|
5
|
+
if (lower.includes('grok'))
|
|
6
|
+
return 'grok';
|
|
7
|
+
if (lower.includes('claude'))
|
|
8
|
+
return 'claude';
|
|
9
|
+
if (lower.includes('gemini'))
|
|
10
|
+
return 'gemini';
|
|
11
|
+
if (lower.includes('gpt'))
|
|
12
|
+
return 'gpt';
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// src/models/install.ts
|
|
2
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { atomicWrite } from '../lib/atomic.js';
|
|
5
|
+
import { loadConfig } from './config.js';
|
|
6
|
+
const CONFIG_DIR = '.super-backlog';
|
|
7
|
+
const CONFIG_FILE = 'models.json';
|
|
8
|
+
export function writeRouterConfig(cwd, enabled) {
|
|
9
|
+
const dir = join(cwd, CONFIG_DIR);
|
|
10
|
+
const path = join(dir, CONFIG_FILE);
|
|
11
|
+
if (!existsSync(dir)) {
|
|
12
|
+
mkdirSync(dir, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
const current = loadConfig(cwd);
|
|
15
|
+
const next = { ...current, enabled };
|
|
16
|
+
atomicWrite(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// src/models/opencode.ts
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { atomicWrite } from '../lib/atomic.js';
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
export function installOpenCodeAdapter(cwd) {
|
|
8
|
+
const pluginPath = join(cwd, '.opencode', 'plugins', 'sbl-model-router.js');
|
|
9
|
+
const workerPath = join(cwd, '.opencode', 'agents', 'sbl-worker.md');
|
|
10
|
+
const cheapPath = join(cwd, '.opencode', 'agents', 'sbl-worker-cheap.md');
|
|
11
|
+
atomicWrite(pluginPath, readFileSync(join(__dirname, '../templates/model-router-plugin.js'), 'utf8'));
|
|
12
|
+
atomicWrite(workerPath, readFileSync(join(__dirname, '../templates/agent-sbl-worker.md'), 'utf8'));
|
|
13
|
+
atomicWrite(cheapPath, readFileSync(join(__dirname, '../templates/agent-sbl-worker-cheap.md'), 'utf8'));
|
|
14
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getFamilyTable } from './config.js';
|
|
2
|
+
import { detectFamily } from './family.js';
|
|
3
|
+
export { detectFamily } from './family.js';
|
|
4
|
+
export function resolveTier(mainModel, tier, config) {
|
|
5
|
+
if (!config.enabled)
|
|
6
|
+
return null;
|
|
7
|
+
if (config.mode === 'individual') {
|
|
8
|
+
return config.individual[tier] || null;
|
|
9
|
+
}
|
|
10
|
+
const family = detectFamily(mainModel);
|
|
11
|
+
if (!family)
|
|
12
|
+
return null;
|
|
13
|
+
if (config.mode === 'family') {
|
|
14
|
+
const table = getFamilyTable(config);
|
|
15
|
+
const entry = table[family];
|
|
16
|
+
if (!entry)
|
|
17
|
+
return null;
|
|
18
|
+
return config.tiers[tier] || entry[tier] || null;
|
|
19
|
+
}
|
|
20
|
+
// auto mode falls back to family table when discovery is not populated
|
|
21
|
+
return config.resolved[tier] || config.tiers[tier] || null;
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { DEFAULT_CONFIG } from './defaults.js';
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// src/models/uninstall.ts
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { atomicWrite } from '../lib/atomic.js';
|
|
5
|
+
const ROUTER_FILES = [
|
|
6
|
+
'.super-backlog/models.json',
|
|
7
|
+
'.opencode/plugins/sbl-model-router.js',
|
|
8
|
+
'.opencode/agents/sbl-worker.md',
|
|
9
|
+
'.opencode/agents/sbl-worker-cheap.md',
|
|
10
|
+
'.claude/agents/sbl-worker.md',
|
|
11
|
+
'.claude/agents/sbl-worker-cheap.md',
|
|
12
|
+
];
|
|
13
|
+
export function uninstallModelRouter(cwd, report) {
|
|
14
|
+
for (const rel of ROUTER_FILES) {
|
|
15
|
+
const abs = join(cwd, rel);
|
|
16
|
+
if (!existsSync(abs)) {
|
|
17
|
+
report.push({ verdict: 'skipped', label: `${rel} (not found)` });
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
rmSync(abs, { force: true });
|
|
21
|
+
report.push({ verdict: 'removed', label: rel });
|
|
22
|
+
}
|
|
23
|
+
// Remove .super-backlog directory if it is now empty
|
|
24
|
+
const dir = join(cwd, '.super-backlog');
|
|
25
|
+
if (existsSync(dir)) {
|
|
26
|
+
try {
|
|
27
|
+
const remaining = readdirSync(dir);
|
|
28
|
+
if (remaining.length === 0) {
|
|
29
|
+
rmSync(dir, { recursive: true, force: true });
|
|
30
|
+
report.push({ verdict: 'removed', label: '.super-backlog/' });
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
report.push({ verdict: 'kept', label: '.super-backlog/ (contains other files)' });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// ignore race conditions
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
uninstallClaudeSettingsHook(cwd, report);
|
|
41
|
+
}
|
|
42
|
+
function uninstallClaudeSettingsHook(cwd, report) {
|
|
43
|
+
const path = join(cwd, '.claude', 'settings.json');
|
|
44
|
+
if (!existsSync(path)) {
|
|
45
|
+
report.push({ verdict: 'skipped', label: '.claude/settings.json model-router hook (not found)' });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
let settings;
|
|
49
|
+
try {
|
|
50
|
+
settings = JSON.parse(readFileSync(path, 'utf8'));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
report.push({ verdict: 'skipped', label: '.claude/settings.json (not valid JSON)' });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const before = JSON.stringify(settings);
|
|
57
|
+
const sessionStart = settings.hooks?.SessionStart;
|
|
58
|
+
if (!Array.isArray(sessionStart) || sessionStart.length === 0) {
|
|
59
|
+
report.push({ verdict: 'skipped', label: '.claude/settings.json SessionStart hook (none)' });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const filtered = sessionStart.filter((entry) => {
|
|
63
|
+
const hooks = entry.hooks;
|
|
64
|
+
if (!Array.isArray(hooks))
|
|
65
|
+
return true;
|
|
66
|
+
return !hooks.some((h) => typeof h === 'object' &&
|
|
67
|
+
h !== null &&
|
|
68
|
+
h.type === 'command' &&
|
|
69
|
+
Array.isArray(h.args) &&
|
|
70
|
+
String(h.args?.[0] ?? '').includes('cc-session-hook.js'));
|
|
71
|
+
});
|
|
72
|
+
if (filtered.length === sessionStart.length) {
|
|
73
|
+
report.push({ verdict: 'skipped', label: '.claude/settings.json SessionStart hook (none owned)' });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (filtered.length === 0) {
|
|
77
|
+
delete settings.hooks.SessionStart;
|
|
78
|
+
if (Object.keys(settings.hooks ?? {}).length === 0)
|
|
79
|
+
delete settings.hooks;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
settings.hooks.SessionStart = filtered;
|
|
83
|
+
}
|
|
84
|
+
if (JSON.stringify(settings) !== before) {
|
|
85
|
+
atomicWrite(path, `${JSON.stringify(settings, null, 2)}\n`);
|
|
86
|
+
report.push({ verdict: 'removed', label: '.claude/settings.json SessionStart hook' });
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
report.push({ verdict: 'skipped', label: '.claude/settings.json SessionStart hook (none owned)' });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// src/templates/cc-session-hook.js
|
|
2
|
+
import { loadConfig } from 'super-backlog/dist/models/config.js';
|
|
3
|
+
import { resolveTier } from 'super-backlog/dist/models/resolve.js';
|
|
4
|
+
import { updateAgentFile } from 'super-backlog/dist/models/claude.js';
|
|
5
|
+
|
|
6
|
+
async function main() {
|
|
7
|
+
try {
|
|
8
|
+
let input = '';
|
|
9
|
+
process.stdin.on('data', (d) => { input += d; });
|
|
10
|
+
await new Promise((resolve) => process.stdin.on('end', resolve));
|
|
11
|
+
let event = {};
|
|
12
|
+
try { event = JSON.parse(input); } catch { /* ignore */ }
|
|
13
|
+
const cwd = process.cwd();
|
|
14
|
+
const cfg = loadConfig(cwd);
|
|
15
|
+
if (!cfg.enabled) process.exit(0);
|
|
16
|
+
const mainModel = event.model;
|
|
17
|
+
if (!mainModel || typeof mainModel !== 'string') process.exit(0);
|
|
18
|
+
updateAgentFile(cwd, 'sbl-worker.md', resolveTier(mainModel, 'workhorse', cfg));
|
|
19
|
+
updateAgentFile(cwd, 'sbl-worker-cheap.md', resolveTier(mainModel, 'budget', cfg));
|
|
20
|
+
} catch {
|
|
21
|
+
// degrade silently so a broken router never blocks Claude Code startup
|
|
22
|
+
}
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
main().catch(() => process.exit(0));
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// src/templates/model-router-plugin.js
|
|
2
|
+
module.exports = async () => {
|
|
3
|
+
const { loadConfig } = await import('super-backlog/dist/models/config.js');
|
|
4
|
+
const { resolveTier } = await import('super-backlog/dist/models/resolve.js');
|
|
5
|
+
|
|
6
|
+
const TIER_BY_AGENT = {
|
|
7
|
+
'sbl-worker': 'workhorse',
|
|
8
|
+
'sbl-worker-cheap': 'budget',
|
|
9
|
+
'explore': 'budget',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
'chat.params': async (input, output) => {
|
|
14
|
+
const cfg = loadConfig(input.cwd ?? process.cwd());
|
|
15
|
+
if (!cfg.enabled) return;
|
|
16
|
+
const agent = input.agent?.name;
|
|
17
|
+
const tier = TIER_BY_AGENT[agent];
|
|
18
|
+
if (!tier) return;
|
|
19
|
+
const mainModel = input.model;
|
|
20
|
+
if (!mainModel || typeof mainModel !== 'string') return;
|
|
21
|
+
const target = resolveTier(mainModel, tier, cfg);
|
|
22
|
+
if (!target) return;
|
|
23
|
+
output.params = output.params || {};
|
|
24
|
+
output.params.model = target;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
};
|