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 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:
@@ -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}`);
@@ -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')) {
@@ -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++;
@@ -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,6 @@
1
+ ---
2
+ name: sbl-worker-cheap
3
+ description: Runs spikes, trivial edits, and mechanical searches. Use for fast, cheap work.
4
+ mode: subagent
5
+ ---
6
+ Complete the requested lightweight task as cheaply and correctly as possible.
@@ -0,0 +1,6 @@
1
+ ---
2
+ name: sbl-worker
3
+ description: Runs bounded implementation tasks. Use via the task tool for well-scoped code changes.
4
+ mode: subagent
5
+ ---
6
+ Implement the requested bounded change following project conventions and the existing codebase patterns.
@@ -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,6 @@
1
+ ---
2
+ name: sbl-worker-cheap
3
+ description: Runs spikes, trivial edits, and mechanical searches. Use for fast, cheap work.
4
+ model: TIER_PLACEHOLDER
5
+ ---
6
+ Complete the requested lightweight task as cheaply and correctly as possible.
@@ -0,0 +1,6 @@
1
+ ---
2
+ name: sbl-worker
3
+ description: Runs bounded implementation tasks. Use via the Agent tool for well-scoped code changes.
4
+ model: TIER_PLACEHOLDER
5
+ ---
6
+ Implement the requested bounded change following project conventions and the existing codebase patterns.
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "super-backlog",
3
- "version": "0.3.4",
3
+ "version": "0.5.0",
4
4
  "description": "One command to equip any project with Backlog.md + Superpowers, plus a Project Dashboard.",
5
5
  "license": "MIT",
6
6
  "repository": {