tiergear 0.1.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.
@@ -0,0 +1,27 @@
1
+ // All three speak TypeSafe's /v1/systemone contract; only where and how to reach them differs.
2
+ export const JUDGE_PRESETS = {
3
+ jev: { baseUrl: 'https://api.typesafe.ai', model: 'jev-latest', keyEnv: 'TYPESAFE_API_KEY', keyRequired: true, firstTurnTimeoutMs: 2000, turnTimeoutMs: 1200 },
4
+ // Local models vary a lot by machine, so their budgets are wider.
5
+ laya: { baseUrl: 'http://localhost:11435', model: 'laya', keyEnv: 'OLLAYA_API_KEY', keyRequired: false, firstTurnTimeoutMs: 3000, turnTimeoutMs: 2500 },
6
+ kev: { baseUrl: 'http://localhost:8009', model: 'kev-latest', keyEnv: 'KEV_API_KEY', keyRequired: false, firstTurnTimeoutMs: 3000, turnTimeoutMs: 2000 },
7
+ };
8
+ export function isJudgeName(value) {
9
+ return value === 'jev' || value === 'laya' || value === 'kev';
10
+ }
11
+ // Prompts travel in the request, so plain http is only for a judge on this machine.
12
+ export function isAllowedJudgeUrl(url) {
13
+ let parsed;
14
+ try {
15
+ parsed = new URL(url);
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ if (parsed.protocol === 'https:')
21
+ return true;
22
+ return parsed.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname);
23
+ }
24
+ // A preset's key belongs to the preset's address; another address gets only a key set for it.
25
+ export function presetKeyApplies(preset, baseUrl) {
26
+ return baseUrl.replace(/\/+$/, '') === preset.baseUrl.replace(/\/+$/, '');
27
+ }
@@ -0,0 +1,85 @@
1
+ import { guarded } from '../judge.js';
2
+ import { TIER_ORDER, isTier } from '../tiers.js';
3
+ const TIER_CRITERIA = {
4
+ trivial: 'A lookup or mechanical edit: read a value, rename, format, run one known command. No reasoning needed.',
5
+ quick: 'A small, clear change in one place with an obvious approach.',
6
+ standard: 'Ordinary engineering: implement or fix something with a clear spec across a few files.',
7
+ deep: 'Hard work: unclear cause, design decisions, many files, or high stakes such as production, money, credentials or data loss.',
8
+ max: 'The hardest problems: repeated failed attempts, debugging across systems, or architecture with lasting consequences.',
9
+ };
10
+ export function endpoint(baseUrl) {
11
+ return `${baseUrl.replace(/\/+$/, '')}/v1/systemone`;
12
+ }
13
+ export function buildQuestions(withStuck) {
14
+ const questions = {
15
+ tier: {
16
+ type: 'choice',
17
+ instructions: 'Which tier of model and reasoning effort does the next step of this coding-agent session need? ' +
18
+ 'Judge the work the next step requires within the whole task, not how short the latest message is.',
19
+ criteria: Object.fromEntries(TIER_ORDER.map((tier) => [tier, TIER_CRITERIA[tier]])),
20
+ },
21
+ };
22
+ if (withStuck) {
23
+ questions['stuck'] = {
24
+ type: 'noul',
25
+ instructions: 'Is the agent stuck on this task?',
26
+ criteria: {
27
+ true: 'The same approach keeps failing, or the user says it still does not work.',
28
+ false: 'Work is progressing, or it has just started.',
29
+ },
30
+ };
31
+ }
32
+ return questions;
33
+ }
34
+ function readTier(answer) {
35
+ if (!answer || typeof answer !== 'object')
36
+ return null;
37
+ const { choice, confidence } = answer;
38
+ if (!isTier(choice) || typeof confidence !== 'number' || !Number.isFinite(confidence))
39
+ return null;
40
+ return { tier: choice, confidence };
41
+ }
42
+ function readNoul(answer) {
43
+ if (!answer || typeof answer !== 'object')
44
+ return null;
45
+ const { noul } = answer;
46
+ return typeof noul === 'number' && Number.isFinite(noul) ? noul : null;
47
+ }
48
+ export function parseVerdict(text) {
49
+ let parsed;
50
+ try {
51
+ parsed = JSON.parse(text);
52
+ }
53
+ catch {
54
+ throw new Error('judge returned malformed JSON');
55
+ }
56
+ const answers = parsed?.answers;
57
+ if (!answers || typeof answers !== 'object')
58
+ throw new Error('judge response is missing answers');
59
+ const record = answers;
60
+ return { tier: readTier(record['tier']), stuck: readNoul(record['stuck']) };
61
+ }
62
+ // An answer is a few hundred bytes; anything far larger is not one, and parsing it would only cost time.
63
+ export const MAX_RESPONSE_CHARS = 1_000_000;
64
+ /** Jev, Laya (Ollaya) and Kev all serve this contract; a preset only changes the address, model and key. */
65
+ export function createSystemOneJudge(options) {
66
+ return {
67
+ name: options.name,
68
+ async ask({ state, withStuck, timeoutMs }) {
69
+ if (options.keyRequired && !options.apiKey)
70
+ return { ok: false, reason: `no API key for ${options.name}` };
71
+ const headers = { 'content-type': 'application/json' };
72
+ if (options.apiKey)
73
+ headers['authorization'] = `Bearer ${options.apiKey}`;
74
+ const body = JSON.stringify({ model: options.model, state, questions: buildQuestions(withStuck) });
75
+ return guarded(async () => {
76
+ const response = await options.transport(endpoint(options.baseUrl), { method: 'POST', headers, body });
77
+ if (!response.ok)
78
+ throw new Error(`${options.name} responded ${response.status}`);
79
+ if (response.text.length > MAX_RESPONSE_CHARS)
80
+ throw new Error('judge response too large');
81
+ return parseVerdict(response.text);
82
+ }, options.sleep, timeoutMs);
83
+ },
84
+ };
85
+ }
@@ -0,0 +1,50 @@
1
+ import { appliedText } from './decide.js';
2
+ export const MAX_LOG_LINES = 1000;
3
+ export function decisionsDir(home) {
4
+ return `${home}/.local/state/tiergear/decisions`;
5
+ }
6
+ export function decisionLogPath(home, name) {
7
+ return `${decisionsDir(home)}/${name.replace(/[^A-Za-z0-9._-]/g, '_')}.jsonl`;
8
+ }
9
+ export function appendLogLine(existing, line, maxLines = MAX_LOG_LINES) {
10
+ const lines = existing.split('\n').filter((l) => l.length > 0);
11
+ lines.push(line);
12
+ return `${lines.slice(-maxLines).join('\n')}\n`;
13
+ }
14
+ export function parseLogLines(text) {
15
+ const entries = [];
16
+ for (const line of text.split('\n')) {
17
+ if (!line)
18
+ continue;
19
+ try {
20
+ const parsed = JSON.parse(line);
21
+ if (typeof parsed.at === 'number')
22
+ entries.push(parsed);
23
+ }
24
+ catch {
25
+ // A torn write leaves a partial line; skip it.
26
+ }
27
+ }
28
+ return entries;
29
+ }
30
+ function clockTime(at) {
31
+ const d = new Date(at);
32
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
33
+ }
34
+ function judgeText(entry) {
35
+ if (entry.outcome !== 'ok')
36
+ return entry.outcome;
37
+ const said = entry.confidence === null ? 'n/d' : entry.confidence.toFixed(2);
38
+ return `${entry.proposed === undefined ? '?' : (entry.proposed ?? '-')} ${said}`;
39
+ }
40
+ /** One line per decision, newest first: what the judge said, what tiergear did, and what ran. */
41
+ export function recentDecisionLines(text, limit, time = clockTime) {
42
+ return parseLogLines(text)
43
+ .slice(-limit)
44
+ .reverse()
45
+ .map((e) => {
46
+ const ran = e.applied ? appliedText(e.applied) : 'session';
47
+ const who = e.phase === 'manual' ? 'manual' : `judge ${judgeText(e)}`;
48
+ return `${time(e.at)} ${who} → ${e.change} ${e.tier ?? 'unset'} (${e.reason}) · ${ran}`;
49
+ });
50
+ }
@@ -0,0 +1,72 @@
1
+ // Exchanges, not raw messages: a tool call is two messages, so a busy turn would fill any message window.
2
+ export const RECENT_EXCHANGES = 3;
3
+ // The reply the next prompt answers gets the most room, its end most of all, where a question sits.
4
+ export const LAST_REPLY_CHARS = 1500;
5
+ export const LAST_REPLY_HEAD_SHARE = 0.3;
6
+ export const OLDER_TEXT_CHARS = 400;
7
+ // The first prompt says less about the current work the longer a session runs.
8
+ export const TASK_CHARS = 500;
9
+ const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
10
+ export function abridge(text, max, headShare = 0.6) {
11
+ if (text.length <= max)
12
+ return text;
13
+ const head = Math.floor(max * headShare);
14
+ const tail = max - head;
15
+ return `${text.slice(0, head)} […${text.length - max} chars omitted…] ${text.slice(text.length - tail)}`;
16
+ }
17
+ export function countChangedFiles(messages) {
18
+ const paths = new Set();
19
+ for (const message of messages) {
20
+ for (const use of message.toolUses ?? []) {
21
+ if (!EDIT_TOOLS.has(use.tool))
22
+ continue;
23
+ const input = use.input;
24
+ const path = input?.file_path ?? input?.notebook_path;
25
+ if (typeof path === 'string')
26
+ paths.add(path);
27
+ }
28
+ }
29
+ return paths.size;
30
+ }
31
+ export function firstTurnState(prompt) {
32
+ return { task: abridge(prompt, 4000) };
33
+ }
34
+ // Each prompt with the assistant's last words before the next one, and the tools it used on the way.
35
+ export function toExchanges(messages) {
36
+ const exchanges = [];
37
+ for (const message of messages) {
38
+ if (message.role === 'user') {
39
+ if (!message.isToolResult && message.text.trim())
40
+ exchanges.push({ prompt: message.text, reply: '', tools: [] });
41
+ continue;
42
+ }
43
+ if (exchanges.length === 0)
44
+ exchanges.push({ prompt: '', reply: '', tools: [] });
45
+ const current = exchanges[exchanges.length - 1];
46
+ if (message.text.trim())
47
+ current.reply = message.text;
48
+ for (const use of message.toolUses ?? [])
49
+ if (!current.tools.includes(use.tool))
50
+ current.tools.push(use.tool);
51
+ }
52
+ return exchanges;
53
+ }
54
+ function recentMessages(messages) {
55
+ const exchanges = toExchanges(messages).slice(-RECENT_EXCHANGES);
56
+ return exchanges.flatMap((exchange, i) => {
57
+ const isLast = i === exchanges.length - 1;
58
+ const reply = isLast ? abridge(exchange.reply, LAST_REPLY_CHARS, LAST_REPLY_HEAD_SHARE) : abridge(exchange.reply, OLDER_TEXT_CHARS);
59
+ const prompt = exchange.prompt ? [{ role: 'user', text: abridge(exchange.prompt, OLDER_TEXT_CHARS), tools: [] }] : [];
60
+ return [...prompt, { role: 'assistant', text: reply, tools: exchange.tools }];
61
+ });
62
+ }
63
+ // The task and the recent exchanges travel with every question, so a terse follow-up is judged in context.
64
+ export function nextTurnState(input) {
65
+ return {
66
+ task: abridge(input.firstPrompt, TASK_CHARS),
67
+ recent: recentMessages(input.messages),
68
+ next_prompt: abridge(input.prompt, 2000),
69
+ stats: { files_changed: countChangedFiles(input.messages), repeated_failures: input.repeatedFailures },
70
+ current: { tier: input.tier, effort: input.effort },
71
+ };
72
+ }
@@ -0,0 +1,65 @@
1
+ import { claudeModelId, isTier } from './tiers.js';
2
+ export function statusDir(home) {
3
+ return `${home}/.local/state/tiergear/status`;
4
+ }
5
+ export function statusPath(home, session) {
6
+ return `${statusDir(home)}/${session.replace(/[^A-Za-z0-9._-]/g, '_')}.json`;
7
+ }
8
+ export function parseStatus(text) {
9
+ let parsed;
10
+ try {
11
+ parsed = JSON.parse(text);
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ if (!parsed || typeof parsed !== 'object')
17
+ return null;
18
+ const r = parsed;
19
+ const { session, tier, model, effort, paused, reason, line, updatedAt } = r;
20
+ if (typeof session !== 'string' || typeof reason !== 'string' || typeof line !== 'string' || typeof updatedAt !== 'number')
21
+ return null;
22
+ if ((tier !== null && !isTier(tier)) || (model !== null && typeof model !== 'string') || typeof paused !== 'boolean')
23
+ return null;
24
+ if (effort !== null && typeof effort !== 'string' && typeof effort !== 'number')
25
+ return null;
26
+ return { session, tier, model, effort, paused, reason, line, updatedAt };
27
+ }
28
+ export const STATUS_FIELDS = ['tier', 'state', 'model', 'effort'];
29
+ export function isStatusField(value) {
30
+ return typeof value === 'string' && STATUS_FIELDS.includes(value);
31
+ }
32
+ // One value for a widget of its own; empty when not known, so the widget hides.
33
+ export function statusField(status, field) {
34
+ if (field === 'state')
35
+ return status.paused ? 'paused' : 'auto';
36
+ const value = status[field];
37
+ return value === null ? '' : String(value);
38
+ }
39
+ // As Claude Code names a model: claude-opus-5-5 is "Opus 5.5"; anything else keeps its own name.
40
+ function modelName(model) {
41
+ const match = /^claude-([a-z]+)-(\d+)-(\d+)$/.exec(claudeModelId(model));
42
+ return match ? `${match[1][0].toUpperCase()}${match[1].slice(1)} ${match[2]}.${match[3]}` : model;
43
+ }
44
+ // Placeholders: {tier} {model} {modelName} (as Claude Code names it) {effort} {state} (auto or paused) {line} (the band's text).
45
+ export function formatStatus(status, template) {
46
+ if (template !== undefined) {
47
+ const values = {
48
+ tier: status.tier ?? 'unset',
49
+ model: status.model ?? '-',
50
+ modelName: status.model === null ? '-' : modelName(status.model),
51
+ effort: status.effort === null ? '-' : String(status.effort),
52
+ state: status.paused ? 'paused' : 'auto',
53
+ line: status.line,
54
+ };
55
+ const known = { tier: status.tier !== null, model: status.model !== null, modelName: status.model !== null, effort: status.effort !== null, state: true, line: true };
56
+ const keys = [...template.matchAll(/\{(tier|modelName|model|effort|state|line)\}/g)].map((m) => m[1]);
57
+ // A template with nothing known in it prints nothing, so its widget hides.
58
+ if (keys.length > 0 && keys.every((key) => !known[key]))
59
+ return '';
60
+ return template.replace(/\{(tier|modelName|model|effort|state|line)\}/g, (_, key) => values[key]);
61
+ }
62
+ const effort = status.effort === null ? null : String(status.effort);
63
+ const ran = status.model ? `${status.model}/${effort ?? '-'}` : effort;
64
+ return [status.paused ? 'paused' : status.tier, ran].filter((part) => part !== null).join(' · ');
65
+ }
@@ -0,0 +1,85 @@
1
+ import { claudeAlias, isEffort, isTier } from './tiers.js';
2
+ function column(trivial, quick, standard, deep, max) {
3
+ return { trivial, quick, standard, deep, max };
4
+ }
5
+ export const DEFAULT_TABLES = {
6
+ claude: {
7
+ // Not haiku for trivial: auto mode does not run on it, so every command waits for approval.
8
+ // The haiku column stays so a bypass-permissions user can restore it with one models cell.
9
+ models: { trivial: 'sonnet', quick: 'sonnet', standard: 'sonnet', deep: 'opus', max: 'fable' },
10
+ effort: {
11
+ haiku: column(null, null, null, null, null),
12
+ sonnet: column('low', 'low', 'medium', 'high', 'max'),
13
+ opus: column('low', 'low', 'medium', 'xhigh', 'max'),
14
+ fable: column('low', 'low', 'medium', 'high', 'xhigh'),
15
+ },
16
+ },
17
+ codex: {
18
+ models: { trivial: 'gpt-5.6-luna', quick: 'gpt-5.6-terra', standard: 'gpt-5.6-terra', deep: 'gpt-5.6-terra', max: 'gpt-5.6-terra' },
19
+ effort: {
20
+ 'gpt-5.6-luna': column('low', 'low', 'medium', 'high', 'high'),
21
+ 'gpt-5.6-terra': column('low', 'low', 'medium', 'xhigh', 'max'),
22
+ },
23
+ },
24
+ };
25
+ export function effortFor(tables, harness, model, tier) {
26
+ const table = tables[harness];
27
+ const found = table.effort[model] ?? table.effort[claudeAlias(model)] ?? table.effort[table.models[tier]];
28
+ return found ? found[tier] : null;
29
+ }
30
+ export function firstTarget(tables, harness, tier) {
31
+ const model = tables[harness].models[tier];
32
+ return { model, effort: effortFor(tables, harness, model, tier) };
33
+ }
34
+ // A model id ends up in a launch command a shell reads, so only id characters pass (brackets as in `opus[1m]`).
35
+ const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:/[\]-]*$/;
36
+ function isObject(value) {
37
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
38
+ }
39
+ export function mergeTables(base, override) {
40
+ if (!isObject(override))
41
+ return null;
42
+ const result = JSON.parse(JSON.stringify(base));
43
+ for (const [harness, value] of Object.entries(override)) {
44
+ if ((harness !== 'claude' && harness !== 'codex') || !isObject(value))
45
+ return null;
46
+ const target = result[harness];
47
+ const { models, effort } = value;
48
+ if (models !== undefined) {
49
+ if (!isObject(models))
50
+ return null;
51
+ for (const [tier, model] of Object.entries(models)) {
52
+ if (!isTier(tier) || typeof model !== 'string' || !MODEL_ID.test(model))
53
+ return null;
54
+ target.models[tier] = model;
55
+ }
56
+ }
57
+ if (effort !== undefined) {
58
+ if (!isObject(effort))
59
+ return null;
60
+ for (const [model, cells] of Object.entries(effort)) {
61
+ if (!isObject(cells))
62
+ return null;
63
+ const merged = { ...(target.effort[model] ?? column(null, null, null, null, null)) };
64
+ for (const [tier, level] of Object.entries(cells)) {
65
+ if (!isTier(tier) || (level !== null && !isEffort(level)))
66
+ return null;
67
+ merged[tier] = level;
68
+ }
69
+ target.effort[model] = merged;
70
+ }
71
+ }
72
+ }
73
+ return result;
74
+ }
75
+ export function parseTablesFile(text) {
76
+ try {
77
+ return mergeTables(DEFAULT_TABLES, JSON.parse(text));
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ export function tablesPath(home) {
84
+ return `${home}/.config/tiergear/tables.json`;
85
+ }
@@ -0,0 +1,53 @@
1
+ export const TIER_ORDER = ['trivial', 'quick', 'standard', 'deep', 'max'];
2
+ export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
3
+ // turn.step names the request's model by id; the CLI flags take aliases.
4
+ export const CLAUDE_MODEL_IDS = {
5
+ haiku: 'claude-haiku-4-5',
6
+ sonnet: 'claude-sonnet-5-5',
7
+ opus: 'claude-opus-5-5',
8
+ fable: 'claude-fable-5-1',
9
+ };
10
+ export function claudeModelId(model) {
11
+ return CLAUDE_MODEL_IDS[model] ?? model;
12
+ }
13
+ // A dated (`claude-haiku-4-5-20251001`) or suffixed (`claude-opus-5-5[1m]`) id names the same model.
14
+ export function claudeAlias(model) {
15
+ for (const [alias, id] of Object.entries(CLAUDE_MODEL_IDS)) {
16
+ if (model === id || model.startsWith(`${id}-`) || model.startsWith(`${id}[`))
17
+ return alias;
18
+ }
19
+ return model;
20
+ }
21
+ export function isTier(value) {
22
+ return typeof value === 'string' && TIER_ORDER.includes(value);
23
+ }
24
+ export function isEffort(value) {
25
+ return typeof value === 'string' && EFFORTS.includes(value);
26
+ }
27
+ export function tierRank(tier) {
28
+ return TIER_ORDER.indexOf(tier);
29
+ }
30
+ export function stepUp(tier) {
31
+ return TIER_ORDER[Math.min(tierRank(tier) + 1, TIER_ORDER.length - 1)];
32
+ }
33
+ export function stepDown(tier) {
34
+ return TIER_ORDER[Math.max(tierRank(tier) - 1, 0)];
35
+ }
36
+ export function maxTier(a, b) {
37
+ return b !== null && tierRank(b) > tierRank(a) ? b : a;
38
+ }
39
+ export function clampTier(tier, range) {
40
+ if (range.min && tierRank(tier) < tierRank(range.min))
41
+ return range.min;
42
+ if (range.max && tierRank(tier) > tierRank(range.max))
43
+ return range.max;
44
+ return tier;
45
+ }
46
+ export function launchCommand(harness, target) {
47
+ // Tables admit only id characters, so quoting is needed just to keep a shell from globbing `[1m]`.
48
+ const model = /^[A-Za-z0-9._:/-]+$/.test(target.model) ? target.model : `'${target.model}'`;
49
+ if (harness === 'claude') {
50
+ return target.effort ? `claude --model ${model} --effort ${target.effort}` : `claude --model ${model}`;
51
+ }
52
+ return target.effort ? `codex --model ${model} -c model_reasoning_effort="${target.effort}"` : `codex --model ${model}`;
53
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "tiergear",
3
+ "version": "0.1.0",
4
+ "description": "Let a decision model pick the model and reasoning effort for Claude Code sessions and Orca workers",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "jang2162",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/jang2162/tiergear.git"
11
+ },
12
+ "homepage": "https://github.com/jang2162/tiergear#readme",
13
+ "bugs": "https://github.com/jang2162/tiergear/issues",
14
+ "keywords": ["claude-code", "claude", "plugin", "reasoning-effort", "model-routing", "orca"],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "bin": {
22
+ "tiergear": "dist/cli/bin.js"
23
+ },
24
+ "scripts": {
25
+ "build": "tsc -p tsconfig.json",
26
+ "prepack": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\" && npm run build",
27
+ "test": "vitest run",
28
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.hooks.json",
29
+ "validate:plugin": "claude plugin validate ."
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^22.10.2",
33
+ "tsx": "^4.19.2",
34
+ "typescript": "^5.7.2",
35
+ "vite": "^8.3.2",
36
+ "vitest": "^5.0.3"
37
+ }
38
+ }