Skip to content

Commit 9818846

Browse files
WatchAndyTWclaude
andcommitted
feat: P1/P2 — cost HUD, routing tooling, health TTL cache, commodity tags
Completes the P1/P2 items from the feature/improvement review (some groundwork was scaffolded in a prior session; this finishes + verifies it). Unit A — routing config tooling (validation + explainability): - decide() returns nearMisses[] + a confidence rating (high/medium/low); route.mjs --explain surfaces them. - validateRoster() gains two checks: unknown when.type tokens (warning, when the caller passes the tags.txt label set) and invalid agent tier (error); existing behavior and return shape preserved (knownTypes is optional). - new score.mjs knownTypes(tagsPath) exports the tags.txt label set; route.mjs --validate now passes it so unknown-type routes are flagged. Unit B — cost/token accounting in the HUD: - per-backend cost_per_1k_chars in roster.json; run.mjs computes costMicros on success and state.mjs accumulates approx_cost_micros (flat one-field- per-line contract preserved); statusline renders a ~$ figure. - new test/cost-hud.test.mjs proves it end-to-end: two calls (40k+360k micros) accumulate to 400000 and the statusline renders "$0.40". Unit C — health-check TTL cache + new commodity task types: - backends.mjs health() is now TTL-memoized (60s) per backend with invalidateHealth() + _clearHealthCache(); removes the per-fallback-hop re-probe cost. The uncached probe is preserved as _healthUncached(). - three new tags.txt types route to the agy standard-coding lane instead of wasting Sonnet: docs-write, i18n, deps-bump. Wired into the standard-coding route in roster.json. Verified: 5 true-positives route to agy, no false positives ("read the docs", "update the business logic" stay clear). Verified by execution (Opus verifier pass): new-tag routing cases pass, validator new-checks fire, health cache memoizes, full suite 88/88 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 35dff8d commit 9818846

11 files changed

Lines changed: 295 additions & 36 deletions

File tree

config/roster.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@
5151
"$HOME/AppData/Local/agy/bin/agy.exe"
5252
],
5353
"hard_timeout": "6m",
54+
"_cost_note": "tunable estimate, USD per 1000 output chars — for the HUD cost figure only",
55+
"cost_per_1k_chars": 0.0001,
5456
"quota_patterns": [
5557
"quota", "rate limit", "resource_exhausted", "429",
5658
"exceeded your current quota", "insufficient", "out of credits",
@@ -72,6 +74,8 @@
7274
"sandbox_flag": "",
7375
"use_winpty": false,
7476
"hard_timeout": "6m",
77+
"_cost_note": "tunable estimate, USD per 1000 output chars — for the HUD cost figure only",
78+
"cost_per_1k_chars": 0.0004,
7579
"quota_patterns": ["rate limit", "quota", "429", "usage limit", "insufficient_quota", "too many requests", "exceeded"],
7680
"quota_exit_codes": [],
7781
"_note": "Invoked as `codex exec <flags> <prompt>` — non-interactive, prints only the final message to stdout (no winpty). models empty = codex's own config.toml default; set per-tier model IDs here to map cheap/standard. `-s read-only` keeps it from modifying files; drop --skip-git-repo-check only if you require a git repo."
@@ -146,7 +150,7 @@
146150

147151
{ "_comment": "Standard, verifiable coding — most everyday code work.",
148152
"name": "standard-coding",
149-
"when": { "type": ["frontend", "react-component", "css", "html", "ui", "svg", "animation", "3d", "asset-gen", "mockup", "boilerplate", "scaffold", "crud", "rest-endpoint", "script", "cli-tool", "glue-code", "data-transform", "sql-query", "regex", "config-file", "dockerfile", "rename", "format", "codegen", "fixture"] },
153+
"when": { "type": ["frontend", "react-component", "css", "html", "ui", "svg", "animation", "3d", "asset-gen", "mockup", "boilerplate", "scaffold", "crud", "rest-endpoint", "script", "cli-tool", "glue-code", "data-transform", "sql-query", "regex", "config-file", "dockerfile", "rename", "format", "codegen", "fixture", "docs-write", "i18n", "deps-bump"] },
150154
"backend": "agy", "tier": "standard" },
151155

152156
{ "_comment": "Cheap/fast trivia — tiny, throwaway, zero-judgment.",

config/tags.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ format reformat|prettier|fix (the )?indentation|lint fix|run the formatt
8686
codegen code ?gen(eration)?|generate (the )?code
8787
unit-test unit test|write tests?|test cases?|\bjest\b|\bpytest\b|test coverage
8888
fixture fixture|mock data|test data|seed data|sample data
89+
docs-write (writ|updat|add|generat|creat|author).{0,30}(docs?|documentation|readme|jsdoc|docstrings?|changelog)|document (the )?(api|module|function|code)
90+
i18n \bi18n\b|internationali(z|s)ation|locali(z|s)ation|translat.{0,15}(strings?|\bui\b|text|copy)|add locale support
91+
deps-bump (bump|upgrade|update).{0,20}(the )?(version|dependenc(y|ies)|package)|upgrade .{0,20} to v?\d|bump .{0,20} to v?\d
8992

9093
# ============================ AGY: trivial ============================
9194
quick-email \bemail\b|draft (a|an) (email|message|note)|reply to

src/bin/route.mjs

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { fileURLToPath } from 'url';
99
import { dirname, resolve, join } from 'path';
1010
import { readFileSync } from 'fs';
1111
import { decide } from '../lib/router.mjs';
12+
import { validateRoster } from '../lib/validate-config.mjs';
13+
import { knownTypes } from '../lib/score.mjs';
1214
import { resolveRosterPath } from '../lib/platform.mjs';
1315

1416
// Resolve project root from this file's location (src/bin/route.mjs -> root).
@@ -19,6 +21,7 @@ const MMT_ROOT = resolve(__dirname, '..', '..');
1921
const args = process.argv.slice(2);
2022
let preset = '';
2123
let explain = false;
24+
let validate = false;
2225
let tagsPath = process.env.MMT_TAGS || join(MMT_ROOT, 'config', 'tags.txt');
2326
// Default via shared resolver: .mmt/roster.json (cwd) > ~/.claude/mmt-roster.json > plugin default.
2427
// An explicit --roster flag (below) still overrides this.
@@ -30,6 +33,7 @@ for (let i = 0; i < args.length; i++) {
3033
if (a === '--preset' && i + 1 < args.length) { preset = args[++i]; }
3134
else if (a.startsWith('--preset=')) { preset = a.slice('--preset='.length); }
3235
else if (a === '--explain') { explain = true; }
36+
else if (a === '--validate') { validate = true; }
3337
else if (a === '--tags' && i + 1 < args.length) { tagsPath = args[++i]; }
3438
else if (a.startsWith('--tags=')) { tagsPath = a.slice('--tags='.length); }
3539
else if (a === '--roster' && i + 1 < args.length) { rosterPath = args[++i]; }
@@ -56,26 +60,30 @@ async function main() {
5660
task = (await readStdin()).replace(/\r?\n$/, '');
5761
}
5862

59-
if (!task) {
60-
process.stderr.write('route.mjs: no task text (pass as arg or stdin)\n');
61-
process.exit(2);
62-
}
63-
6463
// Load roster.
6564
let roster;
6665
try {
6766
roster = JSON.parse(readFileSync(rosterPath, 'utf8'));
6867
} catch (e) {
6968
process.stderr.write(`route.mjs: could not load roster: ${e.message}\n`);
70-
// Degrade to a safe Sonnet decision.
71-
const fallback = {
72-
backend: 'native', model: 'native:sonnet', tier: 'sonnet',
73-
rule: 'no-roster', native: true,
74-
preset: preset || 'balanced',
75-
score: { chars: [...task].length, types: [] },
76-
};
77-
process.stdout.write(JSON.stringify(fallback) + '\n');
78-
process.exit(0);
69+
process.exit(1);
70+
}
71+
72+
// --validate mode: check roster schema and exit. Pass the tags.txt type labels so the validator
73+
// can flag any route that references a type not defined there.
74+
if (validate) {
75+
let known;
76+
try { known = knownTypes(tagsPath); } catch { known = undefined; }
77+
const result = validateRoster(roster, known);
78+
result.errors.forEach(err => process.stderr.write(`error: ${err}\n`));
79+
result.warnings.forEach(warn => process.stderr.write(`warning: ${warn}\n`));
80+
process.stdout.write(JSON.stringify(result) + '\n');
81+
process.exit(result.ok ? 0 : 1);
82+
}
83+
84+
if (!task) {
85+
process.stderr.write('route.mjs: no task text (pass as arg or stdin)\n');
86+
process.exit(2);
7987
}
8088

8189
let decision;
@@ -88,19 +96,26 @@ async function main() {
8896
rule: 'internal-error', native: true,
8997
preset: preset || 'balanced',
9098
score: { chars: [...task].length, types: [] },
99+
nearMisses: [],
100+
confidence: 'low'
91101
};
92102
process.stdout.write(JSON.stringify(fallback) + '\n');
93103
process.exit(0);
94104
}
95105

96106
if (explain) {
107+
const nearMissLines = decision.nearMisses.length === 0
108+
? ['<none>']
109+
: decision.nearMisses.map(nm => `${nm.rule.name} (${nm.backend}/${nm.tier})`);
97110
process.stderr.write([
98111
'── route.mjs decision ─────────────────────────────',
99-
`task chars : ${decision.score.chars}`,
100-
`task types : ${decision.score.types.join(', ') || '<none>'}`,
101-
`preset : ${decision.preset}`,
102-
`roster : ${rosterPath}`,
103-
`decision : ${JSON.stringify(decision)}`,
112+
`task chars : ${decision.score.chars}`,
113+
`task types : ${decision.score.types.join(', ') || '<none>'}`,
114+
`preset : ${decision.preset}`,
115+
`confidence : ${decision.confidence}`,
116+
`near misses : ${nearMissLines.join(', ')}`,
117+
`roster : ${rosterPath}`,
118+
`decision : ${JSON.stringify(decision)}`,
104119
'───────────────────────────────────────────────────',
105120
].join('\n') + '\n');
106121
}

src/bin/run.mjs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,8 +254,11 @@ async function main() {
254254
fallbackCount++; continue;
255255
}
256256

257-
// success.
258-
state.end({ id: callId, backend: be, model, rule: D_rule, code: 0, durMs, outChars, fallback: fallbackCount });
257+
// success. Approximate cost (USD micros) from the backend's per-1k-char rate × (in+out) chars —
258+
// a rough HUD figure only (chars, not tokens). Missing/zero rate -> 0 cost.
259+
const rate = Number(beCfg.cost_per_1k_chars) || 0;
260+
const costMicros = Math.round(rate * ((inChars + outChars) / 1000) * 1e6);
261+
state.end({ id: callId, backend: be, model, rule: D_rule, code: 0, durMs, outChars, fallback: fallbackCount, costMicros });
259262
process.stdout.write(cleanOut + '\n');
260263
process.exit(0);
261264
}

src/lib/backends.mjs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ import Module, { createRequire } from 'node:module';
1919
import { spawn, execSync } from 'node:child_process';
2020
import * as platform from './platform.mjs';
2121

22+
// Health-check TTL memoization — health() is called on EVERY fallback-chain hop in run.mjs; without
23+
// caching that re-spawns `<bin> --version` each time. Cache the boolean per backend for HEALTH_TTL_MS.
24+
const _healthCache = new Map();
25+
const HEALTH_TTL_MS = 60_000;
26+
27+
function _healthKey(backendCfg) {
28+
return (backendCfg?.kind || '') + ':' + (backendCfg?.cmd || '');
29+
}
30+
2231
// node-pty resolution: plugin-local install first, then a GLOBAL `npm install -g node-pty` via the
2332
// NODE_PATH shim (ensureGlobalNodeModules). createRequire is used instead of ESM import() because
2433
// NODE_PATH only affects CommonJS require() resolution — this is the same trick oh-my-claudecode uses
@@ -350,7 +359,7 @@ export async function invoke(backendCfg, prompt, opts = {}) {
350359
// --- health ------------------------------------------------------------------
351360
// `<bin> --version` -> non-empty output, exit 0. WITHOUT any PTY wrapper for BOTH backends:
352361
// PROBES.md confirms winpty yields empty for agy --version (not TTY-gated), and codex isn't gated.
353-
export async function health(backendCfg) {
362+
async function _healthUncached(backendCfg) {
354363
const kind = backendCfg && backendCfg.kind;
355364
let bin;
356365
if (kind === 'gemini') {
@@ -377,6 +386,28 @@ export async function health(backendCfg) {
377386
return res.code === 0 && out.length > 0;
378387
}
379388

389+
// TTL-memoized health check. Returns the cached boolean if checked within HEALTH_TTL_MS, else
390+
// re-probes and caches. Keeps the per-hop fallback-chain cost down to one probe per backend per TTL.
391+
export async function health(backendCfg) {
392+
const key = _healthKey(backendCfg);
393+
const cached = _healthCache.get(key);
394+
const now = Date.now();
395+
if (cached !== undefined && (now - cached.ts) < HEALTH_TTL_MS) return cached.ok;
396+
const ok = await _healthUncached(backendCfg);
397+
_healthCache.set(key, { ok, ts: now });
398+
return ok;
399+
}
400+
401+
// Drop a backend's cached health (e.g. after it errors mid-run) so the next call re-probes it.
402+
export function invalidateHealth(backendCfg) {
403+
_healthCache.delete(_healthKey(backendCfg));
404+
}
405+
406+
// Reset the whole health cache — for tests.
407+
export function _clearHealthCache() {
408+
_healthCache.clear();
409+
}
410+
380411
// --- clean -------------------------------------------------------------------
381412
// Parity with backends.sh mmt_clean: strip CR, drop winpty teardown assertion lines, strip stray
382413
// OSC / CSI / charset-select / 2-char ESC sequences, then trim trailing blank lines.

src/lib/router.mjs

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,20 @@ function applyPreset(preset, ruleName, backend, tier) {
4343

4444
/**
4545
* First-match-wins rule evaluation (parity with match.py match_rule).
46+
* Returns the winning rule or null; also collects near-misses for confidence.
4647
* @param {object[]} routes
4748
* @param {number} chars
4849
* @param {string[]} types
49-
* @returns {object|null}
50+
* @returns {{ rule: object|null, nearMisses: object[] }}
5051
*/
5152
function matchRule(routes, chars, types) {
5253
const tset = new Set(types.filter(Boolean));
54+
const nearMisses = [];
55+
5356
for (const r of routes) {
57+
// Skip marker objects (no name key)
58+
if (!r.name) continue;
59+
5460
const when = r.when || {};
5561
let ok = true;
5662

@@ -65,16 +71,40 @@ function matchRule(routes, chars, types) {
6571
if (ok && 'max_chars' in when) {
6672
if (chars > Number(when.max_chars)) ok = false;
6773
}
68-
if (ok) return r;
74+
75+
if (ok) return { rule: r, nearMisses };
76+
77+
// Collect near-misses: rules with type overlap (ignore char constraints)
78+
if ('type' in when) {
79+
const ruleTypes = when.type;
80+
const hasTypeOverlap = ruleTypes.some(t => tset.has(t));
81+
if (hasTypeOverlap) {
82+
nearMisses.push({ rule: r, backend: r.backend || 'native', tier: r.tier || 'sonnet' });
83+
}
84+
}
6985
}
70-
return null;
86+
87+
return { rule: null, nearMisses };
88+
}
89+
90+
/**
91+
* Compute decision confidence.
92+
* @param {string[]} types - matched task types
93+
* @param {object[]} nearMisses - rules with type overlap but failed on chars/min_chars/max_chars
94+
* @returns {"high" | "medium" | "low"}
95+
*/
96+
function computeConfidence(types, nearMisses) {
97+
if (types.length > 0 && nearMisses.length === 0) return 'high';
98+
if (nearMisses.length > 0) return 'medium';
99+
return 'low'; // catch-all only
71100
}
72101

73102
/**
74103
* Produce a routing decision.
75104
* @param {{ task: string, roster: object, tagsPath: string, preset?: string }} opts
76105
* @returns {{ backend: string, model: string, tier: string, rule: string, native: boolean,
77-
* preset: string, score: { chars: number, types: string[] } }}
106+
* preset: string, score: { chars: number, types: string[] },
107+
* nearMisses: object[], confidence: string }}
78108
*/
79109
export function decide({ task, roster, tagsPath, preset: presetArg }) {
80110
const defs = getRosterDefaults(roster);
@@ -83,15 +113,13 @@ export function decide({ task, roster, tagsPath, preset: presetArg }) {
83113
const chars = charCount(task);
84114
const types = classify(task, tagsPath);
85115

86-
// Filter out _comment marker objects; keep only real rules (those with a `name` key).
87116
const rawRoutes = getRosterRoutes(roster);
88117
const score = { chars, types };
89118

90-
let rule = matchRule(rawRoutes, chars, types);
119+
const { rule, nearMisses } = matchRule(rawRoutes, chars, types);
91120

92121
let backend, tier, ruleName;
93122
if (rule === null) {
94-
// Should be impossible given catch-all-safe with when:{}, but stay safe.
95123
backend = 'native';
96124
tier = 'sonnet';
97125
ruleName = 'catch-all-safe';
@@ -103,6 +131,7 @@ export function decide({ task, roster, tagsPath, preset: presetArg }) {
103131

104132
[backend, tier] = applyPreset(preset, ruleName, backend, tier);
105133
const { model, native } = resolveModel(roster, backend, tier);
134+
const confidence = computeConfidence(types, nearMisses);
106135

107-
return { backend, model, tier, rule: ruleName, native, preset, score };
136+
return { backend, model, tier, rule: ruleName, native, preset, score, nearMisses, confidence };
108137
}

src/lib/score.mjs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,27 @@ export function classify(task, tagsPath) {
6161

6262
return result;
6363
}
64+
65+
/**
66+
* Collect the set of task-type labels DEFINED in tags.txt (the first token of each non-comment,
67+
* non-blank line). Used by `route.mjs --validate` to flag routes that reference an unknown type.
68+
* @param {string} tagsPath absolute path to config/tags.txt
69+
* @returns {Set<string>}
70+
*/
71+
export function knownTypes(tagsPath) {
72+
const out = new Set();
73+
let raw;
74+
try {
75+
raw = readFileSync(tagsPath, 'utf8');
76+
} catch {
77+
return out;
78+
}
79+
for (const line of raw.split(/\r?\n/)) {
80+
const trimmed = line.trim();
81+
if (!trimmed || trimmed.startsWith('#')) continue;
82+
const spaceIdx = trimmed.search(/\s/);
83+
if (spaceIdx === -1) continue;
84+
out.add(trimmed.slice(0, spaceIdx));
85+
}
86+
return out;
87+
}

src/lib/state.mjs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ function loadCounters() {
4646
errors: getNum(text, 'errors', 0),
4747
approx_in_chars: getNum(text, 'approx_in_chars', 0),
4848
approx_out_chars: getNum(text, 'approx_out_chars', 0),
49+
approx_cost_micros: getNum(text, 'approx_cost_micros', 0),
4950
};
5051
}
5152

@@ -137,7 +138,8 @@ function flush(s) {
137138
` "last_dur_ms": ${intOr(s.last_dur_ms, 0)},`,
138139
` "last_out_chars": ${intOr(s.last_out_chars, 0)},`,
139140
` "approx_in_chars": ${intOr(s.approx_in_chars, 0)},`,
140-
` "approx_out_chars": ${intOr(s.approx_out_chars, 0)}`,
141+
` "approx_out_chars": ${intOr(s.approx_out_chars, 0)},`,
142+
` "approx_cost_micros": ${intOr(s.approx_cost_micros, 0)}`,
141143
'}',
142144
'',
143145
];
@@ -179,8 +181,8 @@ export function start({ id, backend, model, rule, inChars } = {}) {
179181
}
180182
}
181183

182-
// end({ id, backend, model, rule, code, durMs, outChars, fallback }) — open--, calls++, set last_*.
183-
export function end({ id, backend, model, rule, code, durMs, outChars, fallback } = {}) {
184+
// end({ id, backend, model, rule, code, durMs, outChars, fallback, costMicros }) — open--, calls++, set last_*.
185+
export function end({ id, backend, model, rule, code, durMs, outChars, fallback, costMicros } = {}) {
184186
const locked = lock();
185187
try {
186188
const s = loadCounters();
@@ -204,6 +206,7 @@ export function end({ id, backend, model, rule, code, durMs, outChars, fallback
204206
s.last_dur_ms = intOr(durMs, 0);
205207
s.last_out_chars = intOr(outChars, 0);
206208
s.approx_out_chars = (s.approx_out_chars || 0) + intOr(outChars, 0);
209+
s.approx_cost_micros = (s.approx_cost_micros || 0) + intOr(costMicros, 0);
207210
flush(s);
208211
} finally {
209212
if (locked) unlock();

0 commit comments

Comments
 (0)