@sayansr26/agent-os 0.5.3 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.claude-plugin/marketplace.json +12 -1
  2. package/CHANGELOG.md +77 -0
  3. package/README.md +49 -7
  4. package/package.json +2 -2
  5. package/plugins/agent-os/.claude-plugin/icon.png +0 -0
  6. package/plugins/agent-os/.claude-plugin/plugin.json +3 -1
  7. package/plugins/agent-os/agents/architect.md +5 -0
  8. package/plugins/agent-os/agents/builder.md +7 -2
  9. package/plugins/agent-os/agents/documenter.md +6 -1
  10. package/plugins/agent-os/agents/feature-cartographer.md +18 -1
  11. package/plugins/agent-os/agents/orchestrator.md +5 -0
  12. package/plugins/agent-os/agents/reviewer.md +5 -0
  13. package/plugins/agent-os/agents/tester.md +5 -0
  14. package/plugins/agent-os/hooks/hooks.json +14 -1
  15. package/plugins/agent-os/hooks/pre-edit.mjs +83 -0
  16. package/plugins/agent-os/hooks/session-resume.mjs +75 -18
  17. package/plugins/agent-os/lib/features.mjs +154 -0
  18. package/plugins/agent-os/skills/init/SKILL.md +72 -32
  19. package/plugins/agent-os/skills/init/references/changing-a-feature.md +7 -0
  20. package/plugins/agent-os/skills/init/references/establishing.md +28 -0
  21. package/plugins/agent-os/skills/init/references/git-permissions.md +5 -2
  22. package/plugins/agent-os/skills/init/scripts/audit.mjs +73 -2
  23. package/plugins/agent-os/skills/init/scripts/settings.mjs +186 -0
  24. package/plugins/agent-os/skills/init/scripts/state.mjs +133 -0
  25. package/plugins/auto-route/.claude-plugin/plugin.json +18 -0
  26. package/plugins/auto-route/hooks/hooks.json +1 -0
  27. package/plugins/auto-route/hooks/register.ts +143 -0
  28. package/plugins/auto-route/tests/route.test.ts +87 -0
  29. package/plugins/auto-route/tsconfig.json +3 -0
  30. package/plugins/auto-route/types/index.d.ts +12 -0
  31. package/src/claude-setup.mjs +9 -78
  32. package/src/cli.mjs +81 -30
  33. package/src/plugin.mjs +91 -35
  34. package/src/selftest.mjs +67 -3
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * agent-os settings — write the permission and task-tool setup, not describe it.
4
+ *
5
+ * Three things, at project scope, user scope, or both:
6
+ *
7
+ * settings.json permissions.deny += the git write-protection rules
8
+ * env.CLAUDE_CODE_ENABLE_TODO_TOOLS = "1"
9
+ * CLAUDE.md the task-tracking rule
10
+ *
11
+ * Merges, never replaces: existing keys, allow rules, deny rules and a value
12
+ * the user set for the env flag all survive. Idempotent. Before changing a
13
+ * file under ~/.claude it copies it to `<file>.agent-os.bak`; project files
14
+ * are left to git, so a backup does not show up as an untracked file.
15
+ *
16
+ * Previews by default. `--apply` writes. The preview is what /agent-os:init
17
+ * shows the user before asking once; the CLI's `init` asks the same question.
18
+ *
19
+ * Usage: node settings.mjs [--root <dir>] [--scope project|user|both] [--apply]
20
+ */
21
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { dirname, join } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ export const TODO_ENV = "CLAUDE_CODE_ENABLE_TODO_TOOLS";
27
+
28
+ export const TASK_RULE = `- **Always track work with the task tool (TaskCreate / TaskUpdate).** Any request
29
+ with more than one step gets a task list before work starts: one task per
30
+ deliverable, marked \`in_progress\` when started and \`completed\` only when
31
+ verified. Keep it current as scope changes, including work delegated to
32
+ subagents, so I can see what is done, running and left at any moment.`;
33
+
34
+ /**
35
+ * Every git command that changes the repository. Read-only inspection
36
+ * (status, log, diff, show, blame, …) stays available. The first five close
37
+ * the flag forms that would otherwise walk past a per-subcommand rule —
38
+ * references/git-permissions.md explains each judgment call.
39
+ */
40
+ export const GIT_DENY = [
41
+ "Bash(git -C*)", "Bash(git -c*)", "Bash(git --git-dir*)", "Bash(git --work-tree*)", "Bash(git --exec-path*)",
42
+ "Bash(git add *)", "Bash(git am *)", "Bash(git apply *)", "Bash(git bisect *)", "Bash(git branch *)",
43
+ "Bash(git checkout *)", "Bash(git cherry-pick *)", "Bash(git clean *)", "Bash(git clone *)", "Bash(git commit *)",
44
+ "Bash(git config *)", "Bash(git fast-import *)", "Bash(git filter-branch *)", "Bash(git gc *)", "Bash(git init *)",
45
+ "Bash(git merge *)", "Bash(git mv *)", "Bash(git notes *)", "Bash(git prune *)", "Bash(git pull *)",
46
+ "Bash(git push *)", "Bash(git rebase *)", "Bash(git reflog *)", "Bash(git remote *)", "Bash(git repack *)",
47
+ "Bash(git replace *)", "Bash(git reset *)", "Bash(git restore *)", "Bash(git revert *)", "Bash(git rm *)",
48
+ "Bash(git stash *)", "Bash(git submodule *)", "Bash(git switch *)", "Bash(git symbolic-ref *)", "Bash(git tag *)",
49
+ "Bash(git update-ref *)", "Bash(git worktree *)",
50
+ ];
51
+
52
+ export const userDir = () => process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
53
+
54
+ export function paths(scope, root) {
55
+ return scope === "user"
56
+ ? { settings: join(userDir(), "settings.json"), claudeMd: join(userDir(), "CLAUDE.md"), label: "USER (~/.claude)" }
57
+ : { settings: join(root, ".claude/settings.json"), claudeMd: join(root, "CLAUDE.md"), label: "PROJECT" };
58
+ }
59
+
60
+ const put = (p, text, keep) => {
61
+ if (keep && existsSync(p)) copyFileSync(p, `${p}.agent-os.bak`);
62
+ mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, text); };
63
+
64
+ // "Bash(git commit *)" and "Bash(git commit:*)" are the same rule to Claude Code.
65
+ const norm = (r) => r.replace(/:\*\)$/, " *)").replace(/\s+/g, " ");
66
+
67
+ /**
68
+ * Merge the deny rules and the env flag into one settings.json.
69
+ * Returns { status: "changed"|"unchanged"|"invalid", file, denyAdded, env, current }
70
+ * where env is "added" | "present" | "conflict".
71
+ */
72
+ export function mergeSettings(file, { dry = false, deny = GIT_DENY, todo = true, keep = false } = {}) {
73
+ let settings = {};
74
+ if (existsSync(file)) {
75
+ try { settings = JSON.parse(readFileSync(file, "utf8")); }
76
+ catch { return { status: "invalid", file, denyAdded: [], env: null }; }
77
+ }
78
+ const perms = settings.permissions || {};
79
+ const have = new Set((perms.deny || []).map(norm));
80
+ const denyAdded = deny.filter((r) => !have.has(norm(r)));
81
+
82
+ let env = null, current;
83
+ if (todo) {
84
+ current = (settings.env || {})[TODO_ENV];
85
+ // "true" and "1" are both truthy to Claude Code; an existing value is the
86
+ // user's choice and is left alone rather than normalised.
87
+ if (current === undefined) env = "added";
88
+ else env = current === "1" || current === "true" || current === true ? "present" : "conflict";
89
+ }
90
+
91
+ if (!denyAdded.length && env !== "added") return { status: "unchanged", file, denyAdded, env, current };
92
+
93
+ if (denyAdded.length) settings.permissions = { ...perms, deny: [...(perms.deny || []), ...denyAdded] };
94
+ if (env === "added") settings.env = { ...(settings.env || {}), [TODO_ENV]: "1" };
95
+ if (!dry) put(file, JSON.stringify(settings, null, 2) + "\n", keep);
96
+ return { status: "changed", file, denyAdded, env, current };
97
+ }
98
+
99
+ /**
100
+ * Put the task-tracking rule in a CLAUDE.md. Returns "added" | "present" | "no-file".
101
+ * A project CLAUDE.md is never created — that is the project's own
102
+ * always-loaded context, and a CLI inventing one leaves a stub nobody owns.
103
+ * The user's ~/.claude/CLAUDE.md is created when `create` is set.
104
+ */
105
+ export function addTaskRule(file, { dry = false, create = false, keep = false } = {}) {
106
+ if (!existsSync(file)) {
107
+ if (!create) return { status: "no-file", file };
108
+ if (!dry) put(file, `# Personal working agreement\n\n${TASK_RULE}\n`);
109
+ return { status: "added", file, section: "new file" };
110
+ }
111
+ const text = readFileSync(file, "utf8");
112
+ if (/TaskCreate/.test(text)) return { status: "present", file };
113
+
114
+ // Prefer an operator-preferences section so the rule lands with the other
115
+ // instructions about how to work rather than orphaned at the bottom.
116
+ const heading = text.match(/^##\s+Operator preferences\s*$/m);
117
+ let next;
118
+ if (heading) {
119
+ const start = heading.index + heading[0].length;
120
+ const rest = text.slice(start);
121
+ const nextHeading = rest.search(/^##\s+/m);
122
+ const end = nextHeading === -1 ? text.length : start + nextHeading;
123
+ next = `${text.slice(0, end).replace(/\s*$/, "")}\n\n${TASK_RULE}\n\n${text.slice(end)}`;
124
+ } else {
125
+ next = `${text.replace(/\s*$/, "")}\n\n## Working agreement\n\n${TASK_RULE}\n`;
126
+ }
127
+ if (!dry) put(file, next.replace(/\n{4,}/g, "\n\n\n"), keep);
128
+ return { status: "added", file, section: heading ? "Operator preferences" : "Working agreement" };
129
+ }
130
+
131
+ /** Everything for one scope. */
132
+ export function applyScope(scope, root, { dry = false } = {}) {
133
+ const p = paths(scope, root);
134
+ return {
135
+ scope,
136
+ label: p.label,
137
+ settings: mergeSettings(p.settings, { dry, keep: scope === "user" }),
138
+ rule: addTaskRule(p.claudeMd, { dry, create: scope === "user", keep: scope === "user" }),
139
+ };
140
+ }
141
+
142
+ export function describe(r, { dry }) {
143
+ const s = r.settings, lines = [];
144
+ const verb = dry ? "would add" : "added";
145
+ lines.push(`${r.label} ${s.file}`);
146
+ if (s.status === "invalid") lines.push(" not valid JSON — left alone; fix it and re-run");
147
+ else {
148
+ lines.push(s.denyAdded.length
149
+ ? ` permissions.deny ${verb} ${s.denyAdded.length} git write rule(s)${s.denyAdded.length < GIT_DENY.length ? ` (${GIT_DENY.length - s.denyAdded.length} already there)` : ""}`
150
+ : ` permissions.deny git write protection already complete`);
151
+ lines.push({
152
+ added: ` env.${TODO_ENV} ${verb} "1"`,
153
+ present: ` env.${TODO_ENV} already on`,
154
+ conflict: ` env.${TODO_ENV} is "${s.current}" — left as set`,
155
+ }[s.env]);
156
+ if (s.status === "changed" && !dry && r.scope === "user" && existsSync(`${s.file}.agent-os.bak`)) lines.push(` backup ${s.file}.agent-os.bak`);
157
+ }
158
+ lines.push({
159
+ added: ` ${r.rule.file} ${dry ? "would add" : "added"} the task-tracking rule (${r.rule.section})`,
160
+ present: ` ${r.rule.file} task-tracking rule already there`,
161
+ "no-file": ` ${r.rule.file} absent — /agent-os:init builds it`,
162
+ }[r.rule.status]);
163
+ return lines;
164
+ }
165
+
166
+ function main(argv) {
167
+ const arg = (k, d) => { const i = argv.indexOf(k); return i === -1 ? d : argv[i + 1]; };
168
+ const root = arg("--root", process.cwd());
169
+ const scope = arg("--scope", "both");
170
+ const dry = !argv.includes("--apply");
171
+ const scopes = scope === "both" ? ["project", "user"] : [scope];
172
+ if (!scopes.every((s) => s === "project" || s === "user")) {
173
+ console.error(`--scope must be project, user or both (got "${scope}")`);
174
+ process.exit(2);
175
+ }
176
+ const out = [`AGENT-OS SETTINGS ${dry ? "preview — nothing written; re-run with --apply" : "applied"}`, ""];
177
+ for (const s of scopes) out.push(...describe(applyScope(s, root, { dry }), { dry }), "");
178
+ if (scopes.includes("user")) out.push("~/.claude applies to every project on this machine.");
179
+ process.stdout.write(out.join("\n") + "\n");
180
+ }
181
+
182
+ const isMain = () => {
183
+ try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); }
184
+ catch { return false; }
185
+ };
186
+ if (process.argv[1] && isMain()) main(process.argv.slice(2));
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * agent-os setup state — is this a fresh setup, a repair, or already healthy?
4
+ *
5
+ * Both `npx agent-os init` and `/agent-os:init` start here, so neither
6
+ * re-scaffolds a project that is set up, and both name exactly what a repair
7
+ * has to touch. Read-only: every check is a file read, and the settings checks
8
+ * are dry runs of what settings.mjs would write.
9
+ *
10
+ * The plugin check reads Claude Code's own records (installed_plugins.json,
11
+ * enabledPlugins, the marketplace cache) instead of calling the `claude` CLI,
12
+ * so it also works from inside a session.
13
+ *
14
+ * Usage: node state.mjs [projectDir]
15
+ */
16
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { addTaskRule, GIT_DENY, mergeSettings, paths, userDir } from "./settings.mjs";
20
+
21
+ export const PLUGIN_ID = "agent-os@sayan-plugins";
22
+ export const MARKETPLACE_NAME = "sayan-plugins";
23
+
24
+ const HERE = dirname(fileURLToPath(import.meta.url));
25
+ const readJson = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
26
+ const real = (p) => { try { return realpathSync(p); } catch { return p; } };
27
+
28
+ /** -1, 0, 1 for dotted numeric versions; unknown sorts lowest. */
29
+ export function cmpVersion(a, b) {
30
+ const pa = String(a || "0").split(/[.-]/).map((n) => parseInt(n, 10) || 0);
31
+ const pb = String(b || "0").split(/[.-]/).map((n) => parseInt(n, 10) || 0);
32
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
33
+ const d = (pa[i] || 0) - (pb[i] || 0);
34
+ if (d) return d > 0 ? 1 : -1;
35
+ }
36
+ return 0;
37
+ }
38
+
39
+ /** The version of the plugin this script ships in. */
40
+ export const bundledVersion = () => readJson(join(HERE, "../../../.claude-plugin/plugin.json"))?.version || null;
41
+
42
+ /**
43
+ * What Claude Code has installed for this project.
44
+ * { installed, scope, version, enabled, latest } — latest is the version in the
45
+ * local marketplace cache, which is only as new as the last marketplace update.
46
+ */
47
+ export function pluginState(root) {
48
+ const home = userDir();
49
+ const rootReal = real(root);
50
+ const records = readJson(join(home, "plugins/installed_plugins.json"))?.plugins?.[PLUGIN_ID] || [];
51
+ // Project and local installs belong to one directory; user installs to all.
52
+ const mine = records.filter((r) => r.scope === "user" || (r.projectPath && real(r.projectPath) === rootReal));
53
+ const pick = mine.find((r) => r.scope === "project" || r.scope === "local") || mine[0] || null;
54
+
55
+ const enabledIn = [
56
+ join(root, ".claude/settings.local.json"),
57
+ join(root, ".claude/settings.json"),
58
+ join(home, "settings.json"),
59
+ ].map((p) => readJson(p)?.enabledPlugins?.[PLUGIN_ID]).find((v) => v !== undefined);
60
+
61
+ const latest = readJson(join(home, "plugins/marketplaces", MARKETPLACE_NAME, "plugins/agent-os/.claude-plugin/plugin.json"))?.version || null;
62
+ return {
63
+ installed: !!pick,
64
+ scope: pick?.scope || null,
65
+ version: pick?.version || null,
66
+ enabled: pick ? enabledIn !== false : false,
67
+ latest,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Every check, then the verdict:
73
+ * fresh nothing of agent-os is here yet — run the whole setup
74
+ * repair some of it is — fix only the items marked missing
75
+ * healthy all of it is
76
+ * `expect` is the plugin version the caller wants at minimum (the CLI passes
77
+ * the version it bundles).
78
+ */
79
+ export function setupState(root, { expect = bundledVersion() } = {}) {
80
+ const items = [];
81
+ const add = (key, label, ok, fix) => items.push({ key, label, ok, fix });
82
+
83
+ const hasSource = existsSync(join(root, ".agent-os/config.json"));
84
+ const claudeMd = (() => { try { return readFileSync(join(root, "CLAUDE.md"), "utf8"); } catch { return null; } })();
85
+ const hasMemory = existsSync(join(root, ".claude/agent-memory"));
86
+
87
+ add("source", ".agent-os/ source", hasSource, "npx @sayansr26/agent-os init");
88
+ add("claude-md", "CLAUDE.md", claudeMd !== null, "/init in Claude Code, then /agent-os:init");
89
+
90
+ for (const scope of ["project", "user"]) {
91
+ const p = paths(scope, root);
92
+ const s = mergeSettings(p.settings, { dry: true });
93
+ const where = scope === "user" ? "~/.claude/settings.json" : ".claude/settings.json";
94
+ if (s.status === "invalid") { add(`${scope}-settings`, `${where} is valid JSON`, false, `fix ${where} by hand`); continue; }
95
+ const have = GIT_DENY.length - s.denyAdded.length;
96
+ add(`${scope}-git`, `${where} git write protection (${have}/${GIT_DENY.length})`, !s.denyAdded.length, `agent-os settings --scope ${scope} --apply`);
97
+ add(`${scope}-todo`, `${where} task tools`, s.env !== "added", `agent-os settings --scope ${scope} --apply`);
98
+ }
99
+ if (claudeMd !== null) {
100
+ add("task-rule", "CLAUDE.md task-tracking rule", addTaskRule(join(root, "CLAUDE.md"), { dry: true }).status === "present", "agent-os settings --scope project --apply");
101
+ add("agents-section", "CLAUDE.md says when to use each agent", /agent-os:(feature-cartographer|builder|reviewer)/.test(claudeMd), "/agent-os:init (establishing.md, Step 4b)");
102
+ }
103
+
104
+ const pl = pluginState(root);
105
+ const want = [expect, pl.latest].filter(Boolean).sort((a, b) => cmpVersion(b, a))[0] || null;
106
+ add("plugin", "Claude Code plugin installed", pl.installed, "npx @sayansr26/agent-os init");
107
+ if (pl.installed) {
108
+ add("plugin-enabled", "plugin enabled", pl.enabled, `claude plugin enable ${PLUGIN_ID}`);
109
+ add("plugin-version", `plugin version ${pl.version}${want && cmpVersion(pl.version, want) < 0 ? ` (latest ${want})` : ""}`,
110
+ !want || cmpVersion(pl.version, want) >= 0, "npx @sayansr26/agent-os init (or /plugin update, then /reload-plugins)");
111
+ }
112
+
113
+ // Settings alone do not count: deny rules may be the user's own.
114
+ const anything = hasSource || hasMemory || pl.installed || /agent-os/.test(claudeMd || "");
115
+ const missing = items.filter((i) => !i.ok);
116
+ const status = !anything ? "fresh" : missing.length ? "repair" : "healthy";
117
+ return { status, items, missing, plugin: pl };
118
+ }
119
+
120
+ export function describeState(st) {
121
+ const lines = [`SETUP ${st.status.toUpperCase()}${st.status === "repair" ? ` — ${st.missing.length} item(s) to fix` : ""}`];
122
+ for (const i of st.items) lines.push(` ${i.ok ? "ok " : "MISSING"} ${i.label}${i.ok ? "" : ` → ${i.fix}`}`);
123
+ return lines;
124
+ }
125
+
126
+ const isMain = () => {
127
+ try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); }
128
+ catch { return false; }
129
+ };
130
+ if (process.argv[1] && isMain()) {
131
+ const root = process.argv[2] || process.cwd();
132
+ process.stdout.write(describeState(setupState(root)).join("\n") + "\n");
133
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "auto-route",
3
+ "version": "0.1.0",
4
+ "description": "Picks the model and effort level for each turn from what the prompt asks for",
5
+ "types": "./types/index.d.ts",
6
+ "author": {
7
+ "name": "Sayan Choudhury",
8
+ "email": "sayan.choudhury.in@gmail.com"
9
+ },
10
+ "homepage": "https://github.com/sayansr26/agent-os",
11
+ "repository": "https://github.com/sayansr26/agent-os",
12
+ "license": "MIT",
13
+ "keywords": [
14
+ "model-routing",
15
+ "effort",
16
+ "mod"
17
+ ]
18
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.ts"] }
@@ -0,0 +1,143 @@
1
+ import { atom, read, update } from 'claude-code'
2
+ import type { ModelEffort, Register } from 'claude-code'
3
+
4
+ import type { Mode, Route, Tier } from '../types'
5
+
6
+ // Edit this table to change what each tier runs on.
7
+ const TIERS: Record<Tier, { model: string; effort: ModelEffort }> = {
8
+ light: { model: 'claude-haiku-5-5', effort: 'low' },
9
+ standard: { model: 'claude-sonnet-5-5', effort: 'medium' },
10
+ deep: { model: 'claude-opus-5-5', effort: 'high' },
11
+ max: { model: 'claude-opus-5-5', effort: 'max' },
12
+ }
13
+
14
+ // Checked top to bottom; the first tier whose pattern matches wins.
15
+ const RULES: { tier: Tier; reason: string; pattern: RegExp }[] = [
16
+ {
17
+ tier: 'max',
18
+ reason: 'asked to think hard',
19
+ pattern: /\b(ultrathink|think (really )?hard|think harder|deep dive)\b/i,
20
+ },
21
+ {
22
+ tier: 'max',
23
+ reason: 'security or concurrency',
24
+ pattern:
25
+ /\b(security (audit|review)|vulnerabilit|race condition|deadlock|memory leak|incident|data loss)/i,
26
+ },
27
+ {
28
+ tier: 'deep',
29
+ reason: 'design or debugging',
30
+ pattern:
31
+ /\b(architect|design|refactor|migrat|debug|root cause|investigate|why (is|does|doesn't|isn't)|plan|implement|build (a|the|an)|new feature|end[- ]to[- ]end|across|whole (repo|codebase|app)|optimi[sz]e|performance|review)/i,
32
+ },
33
+ {
34
+ tier: 'light',
35
+ reason: 'small or lookup task',
36
+ pattern:
37
+ /^(what|which|where|who|when|is|are|does|do|can|show|list|print|explain briefly|rename|fix (the )?typo|format|lint|bump|commit message|thanks|thank you|ok|yes|no)\b/i,
38
+ },
39
+ ]
40
+
41
+ export const classify = (text: string): Route => {
42
+ const words = text.trim().split(/\s+/).filter(Boolean).length
43
+ const rule = RULES.find(one => one.pattern.test(text))
44
+
45
+ if (rule && !(rule.tier === 'light' && words > 40)) {
46
+ return { tier: rule.tier, reason: rule.reason }
47
+ }
48
+
49
+ if (words > 150) {
50
+ return { tier: 'deep', reason: 'long, detailed prompt' }
51
+ }
52
+
53
+ return words <= 8
54
+ ? { tier: 'light', reason: 'short prompt' }
55
+ : { tier: 'standard', reason: 'regular coding task' }
56
+ }
57
+
58
+ const MODES: readonly Mode[] = ['auto', 'off', 'light', 'standard', 'deep', 'max']
59
+
60
+ const mode = atom({ plugin: 'auto-route', key: 'mode' } as const, 'auto')
61
+ const route = atom({ plugin: 'auto-route', key: 'route' } as const, null)
62
+
63
+ const describe = (current: Mode, last: Route | null) => {
64
+ if (current === 'off') {
65
+ return 'auto-route: off'
66
+ }
67
+
68
+ const pinned = current === 'auto' ? '' : ' (pinned)'
69
+ const tier = current === 'auto' ? last?.tier : current
70
+
71
+ if (!tier) {
72
+ return 'auto-route: waiting for a prompt'
73
+ }
74
+
75
+ const { model, effort } = TIERS[tier]
76
+
77
+ return `auto-route: ${tier}${pinned} · ${model.replace(/^claude-/, '')} / ${effort}`
78
+ }
79
+
80
+ export const register: Register = on => {
81
+ on('session.start', async ($, e, next) => {
82
+ await $.command.register({
83
+ name: 'route',
84
+ description: 'Auto model routing: auto, off, or pin light | standard | deep | max',
85
+ })
86
+
87
+ return next(e)
88
+ })
89
+
90
+ on('command.run', { command: 'route' }, async ($, e) => {
91
+ const asked = e.args.trim().toLowerCase()
92
+
93
+ if (asked !== '') {
94
+ const next = MODES.find(one => one === asked)
95
+
96
+ if (!next) {
97
+ return { text: `auto-route: unknown mode "${asked}". Use ${MODES.join(', ')}.` }
98
+ }
99
+
100
+ await update($, mode, () => next)
101
+ }
102
+
103
+ const text = describe(await read($, mode), await read($, route))
104
+ $.ui.status(text)
105
+
106
+ return { text: text.replace(/^auto-route: /, '') }
107
+ })
108
+
109
+ on('prompt.submit', async ($, e, next) => {
110
+ // A prompt delivered into a running turn keeps that turn's route.
111
+ const current = await read($, mode)
112
+ const isRouted = e.turnId === undefined && !e.command && current === 'auto'
113
+ $.ui.log(`auto-route: prompt.submit mode=${current} turnId=${e.turnId ?? '-'} command=${e.command ? 'yes' : 'no'} routed=${isRouted}`)
114
+
115
+ if (isRouted) {
116
+ const picked = classify(e.text)
117
+ await update($, route, () => picked)
118
+ $.ui.status(describe('auto', picked))
119
+ }
120
+
121
+ return next(e)
122
+ }).catch(($, e, next) => {
123
+ $.ui.log('auto-route: prompt.submit hook failed')
124
+
125
+ return next(e)
126
+ })
127
+
128
+ on('turn.step', async function* ($, e, next) {
129
+ const current = await read($, mode)
130
+ const last = await read($, route)
131
+ const tier = current === 'auto' ? last?.tier : current === 'off' ? undefined : current
132
+
133
+ // Subagents choose their own model; leave their requests alone.
134
+ if (e.agentId !== undefined || !tier) {
135
+ return yield* next(e)
136
+ }
137
+
138
+ const { model, effort } = TIERS[tier]
139
+ $.ui.log(`auto-route: turn.step ${e.model}/${e.effort ?? '-'} -> ${model}/${effort}`)
140
+
141
+ return yield* next({ ...e, model, effort })
142
+ })
143
+ }
@@ -0,0 +1,87 @@
1
+ import { expect, test } from 'claude-code/testing'
2
+ import type { HookStream, TurnStepChunk, TurnStepInput, TurnStepResult } from 'claude-code'
3
+
4
+ import { classify } from '../hooks/register'
5
+
6
+ const STEP: TurnStepInput = {
7
+ turnId: 't1',
8
+ index: 0,
9
+ model: 'claude-opus-5-5',
10
+ effort: 'high',
11
+ messageCount: 1,
12
+ }
13
+
14
+ const ORIGIN = { kind: 'composer' } as const
15
+
16
+ const prompt = (text: string) => ({ text, wait: false, origin: ORIGIN })
17
+
18
+ const route = (args: string) => ({
19
+ command: 'route',
20
+ args,
21
+ origin: ORIGIN,
22
+ presentation: { isFullscreen: false, columns: 120 },
23
+ })
24
+
25
+ // Reads the request's stream to its end, as the engine would.
26
+ type Steps = { turn: { step: (e: TurnStepInput) => HookStream<TurnStepChunk, TurnStepResult> } }
27
+
28
+ const step = async ($: Steps, e: TurnStepInput) => {
29
+ const stream = $.turn.step(e)
30
+ for await (const _ of stream) {
31
+ }
32
+ return stream.result
33
+ }
34
+
35
+ test('prompts land in the expected tier', async () => {
36
+ expect(classify('what does this function return?').tier).toBe('light')
37
+ expect(classify('ok').tier).toBe('light')
38
+ expect(
39
+ classify('add a loading spinner to the submit button on the ask form and disable it while saving').tier,
40
+ ).toBe('standard')
41
+ expect(classify('refactor the fix ranking so it weighs environment matches').tier).toBe('deep')
42
+ expect(classify('debug why the MCP search is slow').tier).toBe('deep')
43
+ expect(classify('ultrathink about the verification model').tier).toBe('max')
44
+ expect(classify('is there a race condition in the release watcher?').tier).toBe('max')
45
+ })
46
+
47
+ test('the routed model and effort reach the request, subagents untouched', async ($, on) => {
48
+ const sent: TurnStepInput[] = []
49
+ on('ui.status', () => ({ value: undefined }))
50
+ on('prompt.submit', (_, e) => ({ text: e.text }))
51
+ on('turn.step', async function* (_, e) {
52
+ sent.push(e)
53
+ return { turnId: e.turnId, index: e.index, answer: '', toolUses: [], stopReason: 'end_turn', usage: null }
54
+ })
55
+
56
+ await $.prompt.submit(prompt('what is in package.json?'))
57
+ await step($, STEP)
58
+ await step($, { ...STEP, agentId: 'a1' })
59
+
60
+ expect(sent[0]?.model).toBe('claude-haiku-5-5')
61
+ expect(sent[0]?.effort).toBe('low')
62
+ expect(sent[1]?.model).toBe('claude-opus-5-5')
63
+ expect(sent[1]?.effort).toBe('high')
64
+ })
65
+
66
+ test('/route pins a tier and off stops routing', async ($, on) => {
67
+ const sent: TurnStepInput[] = []
68
+ on('command.register', () => ({ value: { command: 'route' } }))
69
+ on('session.start', (_, e) => ({ cwd: e.cwd }))
70
+ on('ui.status', () => ({ value: undefined }))
71
+ on('prompt.submit', (_, e) => ({ text: e.text }))
72
+ on('turn.step', async function* (_, e) {
73
+ sent.push(e)
74
+ return { turnId: e.turnId, index: e.index, answer: '', toolUses: [], stopReason: 'end_turn', usage: null }
75
+ })
76
+
77
+ await $.session.start({ cwd: '/', surface: 'terminal', isInteractive: true })
78
+ await $.command.run(route('max'))
79
+ await $.prompt.submit(prompt('ok'))
80
+ await step($, STEP)
81
+ expect(sent[0]?.effort).toBe('max')
82
+
83
+ await $.command.run(route('off'))
84
+ await step($, { ...STEP, model: 'claude-sonnet-5-5', effort: 'medium' })
85
+ expect(sent[1]?.model).toBe('claude-sonnet-5-5')
86
+ expect(sent[1]?.effort).toBe('medium')
87
+ })
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "./.claude-plugin/types/tsconfig.json"
3
+ }
@@ -0,0 +1,12 @@
1
+ export type Tier = 'light' | 'standard' | 'deep' | 'max'
2
+
3
+ // 'auto' classifies each prompt; a tier pins it; 'off' leaves the session's own.
4
+ export type Mode = 'auto' | 'off' | Tier
5
+
6
+ export type Route = { tier: Tier; reason: string }
7
+
8
+ declare module 'claude-code' {
9
+ interface PluginState {
10
+ 'auto-route': { mode: Mode; route: Route | null }
11
+ }
12
+ }