sprag-cli 3.69.0 → 3.71.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/NOTICE +5 -2
- package/README.ko.md +16 -0
- package/README.md +16 -0
- package/bin/cli.js +53 -4
- package/docs/CODEX.ko.md +3 -2
- package/docs/CODEX.md +5 -4
- package/docs/TEAM.ko.md +174 -0
- package/docs/TEAM.md +175 -0
- package/package.json +5 -1
- package/presets/agents.json +118 -0
- package/presets/doc2md/convert.py +1 -1
- package/presets/ratchet-rules.json +12 -2
- package/src/agents.js +3 -1
- package/src/cli-entry.js +40 -0
- package/src/codex/delegation.js +8 -5
- package/src/codex/hooks.js +25 -12
- package/src/codex/panel-setup.js +2 -1
- package/src/codex/panel.js +2 -1
- package/src/codex/route-scan.js +6 -4
- package/src/commands/brief.js +2 -0
- package/src/commands/codex-delegate.js +3 -3
- package/src/commands/codex.js +6 -5
- package/src/commands/harness.js +3 -4
- package/src/commands/install.js +19 -18
- package/src/commands/presets.js +158 -0
- package/src/commands/profile-map.js +2 -2
- package/src/commands/route-scan.js +25 -10
- package/src/commands/team.js +334 -0
- package/src/commands/update-check.js +2 -2
- package/src/cost.js +6 -2
- package/src/cowork-sync.js +5 -4
- package/src/debug.js +3 -2
- package/src/delegation-agents.js +49 -49
- package/src/delegation-guard.js +3 -2
- package/src/doc2md.cjs +4 -3
- package/src/env.cjs +7 -0
- package/src/env.js +19 -0
- package/src/first-run-note.js +3 -2
- package/src/harness-templates.js +10 -0
- package/src/harness.js +4 -14
- package/src/hook.cjs +4 -3
- package/src/model-rules.js +58 -29
- package/src/preset-sign.js +106 -0
- package/src/preset-sync.js +133 -0
- package/src/presets-source.js +209 -0
- package/src/prompt.js +2 -1
- package/src/route-caps.js +3 -3
- package/src/route-inject.js +24 -15
- package/src/route-scan.js +39 -15
- package/src/seed-rules.js +14 -16
- package/src/shared-model-rules.js +1 -1
- package/src/star-note.js +3 -2
- package/src/statusline-guard.js +144 -0
- package/src/stdin-payload.js +33 -4
- package/src/team-prompts.js +353 -0
- package/src/team-ratchet.js +276 -0
- package/src/team-sync.js +625 -0
- package/src/update-check.js +3 -2
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* presets-source — which presets this machine uses: the centrally published
|
|
3
|
+
* bundle or the copy shipped in the npm package.
|
|
4
|
+
*
|
|
5
|
+
* The superuser publishes ratchet presets, model-routing presets and the
|
|
6
|
+
* delegation agent definitions from the /ops console as one signed bundle.
|
|
7
|
+
* preset-sync.js fetches it (read-only, at most daily, in a detached child) into
|
|
8
|
+
* <userDataDir>/presets-remote.json. Everything that reads presets comes through
|
|
9
|
+
* here, so a newer bundle takes effect at the next session start after a fetch.
|
|
10
|
+
*
|
|
11
|
+
* The remote bundle wins only while it is valid, checked again on every read:
|
|
12
|
+
* its signature verifies against a key in PRESET_SIGNING_KEYS, its format is
|
|
13
|
+
* one this CLI understands, its min_cli is not newer than this CLI, its shape is
|
|
14
|
+
* right and it is at most 200 KB. Anything else falls back to the bundled
|
|
15
|
+
* presets in presets/*.json, which have the same shape. So a blocked network, a
|
|
16
|
+
* server outage or a tampered response leaves the machine exactly where a fresh
|
|
17
|
+
* install of this version would be.
|
|
18
|
+
*
|
|
19
|
+
* SPRAG_NO_PRESET_SYNC=1 (or SPRAG_NO_UPDATE_CHECK=1) turns the remote source
|
|
20
|
+
* off entirely: no fetch, and a cached bundle is ignored.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
24
|
+
import { join, dirname } from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import { createHash } from 'node:crypto';
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
import { envVar } from './env.js';
|
|
29
|
+
import { userDataDir } from './paths.js';
|
|
30
|
+
import {
|
|
31
|
+
PRESET_BUNDLE_FORMAT, PRESET_MAX_BYTES, PRESET_SIGNING_KEYS, verifyBundleSignature,
|
|
32
|
+
} from './preset-sign.js';
|
|
33
|
+
|
|
34
|
+
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
35
|
+
const require = createRequire(import.meta.url);
|
|
36
|
+
|
|
37
|
+
export function cliVersion() {
|
|
38
|
+
try { return require('../package.json').version; } catch { return '0.0.0'; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function presetSyncDisabled(env = process.env) {
|
|
42
|
+
return envVar('NO_PRESET_SYNC', env) === '1' || envVar('NO_UPDATE_CHECK', env) === '1';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function remoteCachePath(dir = userDataDir()) { return join(dir, 'presets-remote.json'); }
|
|
46
|
+
|
|
47
|
+
/** -1, 0 or 1. Pre-release suffixes are ignored; unparsable versions compare as 0.0.0. */
|
|
48
|
+
export function compareVersions(a, b) {
|
|
49
|
+
const p = (v) => (String(v || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)/) || [0, 0, 0, 0]).slice(1, 4).map(Number);
|
|
50
|
+
const x = p(a), y = p(b);
|
|
51
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i] ? 1 : -1;
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
56
|
+
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
57
|
+
const MODEL_RE = /^[A-Za-z0-9._-]{1,80}$/;
|
|
58
|
+
const LINE_RE = /^[^\r\n]{1,600}$/;
|
|
59
|
+
const TOOL_RE = /^[A-Za-z0-9_*().: -]{1,80}$/;
|
|
60
|
+
const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
61
|
+
const TIERS = ['T1', 'T2', 'T1H'];
|
|
62
|
+
const str = (v) => typeof v === 'string' && v.trim() !== '';
|
|
63
|
+
|
|
64
|
+
function uniq(list, key) {
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
for (const x of list) { if (seen.has(x[key])) return false; seen.add(x[key]); }
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Shape check for a bundle. Returns null when valid, else a short reason code.
|
|
72
|
+
* Rejected here rather than when rendering: an agent name with a slash would be
|
|
73
|
+
* a path outside ~/.claude/agents, and a newline in a frontmatter field would
|
|
74
|
+
* let the description write its own `tools:` line.
|
|
75
|
+
*/
|
|
76
|
+
export function bundleShapeError(b) {
|
|
77
|
+
if (!b || typeof b !== 'object' || Array.isArray(b)) return 'shape';
|
|
78
|
+
let bytes;
|
|
79
|
+
try { bytes = Buffer.byteLength(JSON.stringify(b), 'utf8'); } catch { return 'shape'; }
|
|
80
|
+
if (bytes > PRESET_MAX_BYTES) return 'too-large';
|
|
81
|
+
if (!Number.isInteger(b.format) || b.format < 1) return 'shape';
|
|
82
|
+
if (b.format > PRESET_BUNDLE_FORMAT) return 'format';
|
|
83
|
+
if (!Number.isInteger(b.version) || b.version < 1) return 'shape';
|
|
84
|
+
if (b.min_cli != null && typeof b.min_cli !== 'string') return 'shape';
|
|
85
|
+
const { ratchet_rules: rr, model_rules: mr, agents: ag } = b;
|
|
86
|
+
if (!Array.isArray(rr) || !Array.isArray(mr) || !Array.isArray(ag)) return 'shape';
|
|
87
|
+
for (const r of rr) {
|
|
88
|
+
if (!r || !ID_RE.test(r.id) || !str(r.ko) || !str(r.en)) return 'shape';
|
|
89
|
+
if (r.agents != null && (!Array.isArray(r.agents) || r.agents.some((a) => a !== 'claude' && a !== 'codex'))) return 'shape';
|
|
90
|
+
}
|
|
91
|
+
for (const m of mr) {
|
|
92
|
+
if (!m || !ID_RE.test(m.id) || !TIERS.includes(m.tier) || !ID_RE.test(m.category)) return 'shape';
|
|
93
|
+
if (!str(m.label) || !str(m.labelEn) || !str(m.agent) || !LINE_RE.test(m.agent)) return 'shape';
|
|
94
|
+
if (m.budget != null && (typeof m.budget !== 'object' || Array.isArray(m.budget))) return 'shape';
|
|
95
|
+
}
|
|
96
|
+
for (const a of ag) {
|
|
97
|
+
if (!a || !NAME_RE.test(a.name) || !MODEL_RE.test(a.model) || !EFFORTS.includes(a.effort)) return 'shape';
|
|
98
|
+
if (!Array.isArray(a.tools) || a.tools.length === 0 || a.tools.some((t) => typeof t !== 'string' || !TOOL_RE.test(t))) return 'shape';
|
|
99
|
+
if (!a.description || !LINE_RE.test(a.description.en || '') || (a.description.ko != null && !LINE_RE.test(a.description.ko))) return 'shape';
|
|
100
|
+
if (!a.body || !str(a.body.en)) return 'shape';
|
|
101
|
+
}
|
|
102
|
+
if (!uniq(rr, 'id') || !uniq(mr, 'id') || !uniq(ag, 'name')) return 'shape';
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether this CLI may apply a bundle. { ok, reason?, kid? } with reason one of
|
|
108
|
+
* shape, too-large, format, min-cli, no-keys, unsigned, unknown-kid, bad-sig.
|
|
109
|
+
* No keys is checked before the signature: it is the more basic answer.
|
|
110
|
+
*/
|
|
111
|
+
export function evaluateBundle(b, { version = cliVersion(), keys = PRESET_SIGNING_KEYS } = {}) {
|
|
112
|
+
const shape = bundleShapeError(b);
|
|
113
|
+
if (shape) return { ok: false, reason: shape };
|
|
114
|
+
if (!Array.isArray(keys) || keys.length === 0) return { ok: false, reason: 'no-keys' };
|
|
115
|
+
if (b.min_cli && compareVersions(b.min_cli, version) > 0) return { ok: false, reason: 'min-cli' };
|
|
116
|
+
const sig = verifyBundleSignature(b, keys);
|
|
117
|
+
return sig.ok ? { ok: true, kid: sig.kid } : { ok: false, reason: sig.reason };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function readRemoteCache(dir = userDataDir()) {
|
|
121
|
+
try {
|
|
122
|
+
const c = JSON.parse(readFileSync(remoteCachePath(dir), 'utf8'));
|
|
123
|
+
return c && typeof c === 'object' && !Array.isArray(c) ? c : {};
|
|
124
|
+
} catch {
|
|
125
|
+
return {};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// One evaluation per cache-file revision: the session-start path reads the
|
|
130
|
+
// presets several times (seed offer, preset file, agents, route-scan).
|
|
131
|
+
let memo = null;
|
|
132
|
+
export function resetPresetSourceMemo() { memo = null; }
|
|
133
|
+
|
|
134
|
+
function activeRemote({ env = process.env, dir = userDataDir(), keys = PRESET_SIGNING_KEYS, version = cliVersion() } = {}) {
|
|
135
|
+
if (presetSyncDisabled(env)) return { bundle: null, reason: 'disabled', cache: {} };
|
|
136
|
+
const file = remoteCachePath(dir);
|
|
137
|
+
let sig;
|
|
138
|
+
try { const s = statSync(file); sig = `${file}|${s.mtimeMs}|${s.size}|${keys.length}|${version}`; } catch { return { bundle: null, reason: 'no-cache', cache: {} }; }
|
|
139
|
+
if (memo && memo.sig === sig && memo.keys === keys) return memo.value;
|
|
140
|
+
const cache = readRemoteCache(dir);
|
|
141
|
+
let value;
|
|
142
|
+
if (!cache.bundle) value = { bundle: null, reason: 'no-cache', cache };
|
|
143
|
+
else {
|
|
144
|
+
const ev = evaluateBundle(cache.bundle, { version, keys });
|
|
145
|
+
value = ev.ok ? { bundle: cache.bundle, kid: ev.kid, cache } : { bundle: null, reason: ev.reason, cache };
|
|
146
|
+
}
|
|
147
|
+
memo = { sig, keys, value };
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** { kind: 'remote'|'bundled', version, fetchedAt, kid?, reason? } */
|
|
152
|
+
export function presetSource(opts = {}) {
|
|
153
|
+
const r = activeRemote(opts);
|
|
154
|
+
if (r.bundle) return { kind: 'remote', version: r.bundle.version, fetchedAt: r.cache.fetchedAt || null, kid: r.kid };
|
|
155
|
+
return { kind: 'bundled', version: null, fetchedAt: r.cache.fetchedAt || null, reason: r.reason };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function readBundled(name) {
|
|
159
|
+
try { return JSON.parse(readFileSync(join(packageRoot, 'presets', name), 'utf8')); } catch { return null; }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const derivedId = (ko) => `fix-${createHash('sha1').update(ko).digest('hex').slice(0, 6)}`;
|
|
163
|
+
|
|
164
|
+
function normaliseRatchet(list) {
|
|
165
|
+
return (Array.isArray(list) ? list : [])
|
|
166
|
+
.filter((r) => r && typeof r.ko === 'string' && r.ko.trim())
|
|
167
|
+
.map((r) => ({
|
|
168
|
+
// Explicit ids are stable across rewording. A rule without one (an older
|
|
169
|
+
// file) gets the id the Korean text always produced.
|
|
170
|
+
id: typeof r.id === 'string' && r.id ? r.id : derivedId(r.ko.trim()),
|
|
171
|
+
ko: r.ko.trim(), en: (r.en || r.ko).trim(),
|
|
172
|
+
...(Array.isArray(r.agents) ? { agents: r.agents.filter((a) => typeof a === 'string') } : {}),
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Ratchet presets: [{ id, ko, en, agents? }] */
|
|
177
|
+
export function ratchetPresetEntries(opts = {}) {
|
|
178
|
+
const r = activeRemote(opts);
|
|
179
|
+
if (r.bundle) return normaliseRatchet(r.bundle.ratchet_rules);
|
|
180
|
+
return normaliseRatchet(readBundled('ratchet-rules.json')?.rules);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Model-routing presets, the objects of presets/model-rules.json `presets`. */
|
|
184
|
+
export function modelPresets(opts = {}) {
|
|
185
|
+
const r = activeRemote(opts);
|
|
186
|
+
if (r.bundle) return r.bundle.model_rules.map((p) => ({ ...p }));
|
|
187
|
+
const data = readBundled('model-rules.json');
|
|
188
|
+
return Array.isArray(data?.presets) ? data.presets : [];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Delegation agents: [{ name, model, effort, tools[], description{ko,en}, body{ko,en} }] */
|
|
192
|
+
export function agentDefinitions(opts = {}) {
|
|
193
|
+
const r = activeRemote(opts);
|
|
194
|
+
if (r.bundle) return r.bundle.agents.map((a) => ({ ...a }));
|
|
195
|
+
const data = readBundled('agents.json');
|
|
196
|
+
return Array.isArray(data?.agents) ? data.agents : [];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The bundle the bundled files make: the shape the server seeds as version 1. */
|
|
200
|
+
export function bundledBundle({ version = 1, minCli = null } = {}) {
|
|
201
|
+
return {
|
|
202
|
+
format: PRESET_BUNDLE_FORMAT,
|
|
203
|
+
version,
|
|
204
|
+
min_cli: minCli,
|
|
205
|
+
ratchet_rules: normaliseRatchet(readBundled('ratchet-rules.json')?.rules),
|
|
206
|
+
model_rules: readBundled('model-rules.json')?.presets || [],
|
|
207
|
+
agents: readBundled('agents.json')?.agents || [],
|
|
208
|
+
};
|
|
209
|
+
}
|
package/src/prompt.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* decide-it-for-them behavior untouched.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { envVar } from './env.js';
|
|
12
13
|
import { createInterface } from 'node:readline';
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -19,7 +20,7 @@ import { createInterface } from 'node:readline';
|
|
|
19
20
|
* checked because build agents deadlock rather than answer.
|
|
20
21
|
*/
|
|
21
22
|
export function canPrompt({ env = process.env, stdin = process.stdin, stdout = process.stdout } = {}) {
|
|
22
|
-
if (env
|
|
23
|
+
if (envVar('NO_INPUT', env) === '1') return false;
|
|
23
24
|
if (env.CI && env.CI !== 'false') return false;
|
|
24
25
|
if (env.npm_lifecycle_event === 'postinstall') return false;
|
|
25
26
|
return Boolean(stdin.isTTY && stdout.isTTY);
|
package/src/route-caps.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* rule clears it, so a cap never outlives the turn it was stated for.
|
|
14
14
|
*
|
|
15
15
|
* State: <stateDir>/route-caps.json
|
|
16
|
-
* { sessions: { [session_id]: { ts, T2?: { calls, out }, T1?: { calls, out } } } }
|
|
16
|
+
* { sessions: { [session_id]: { ts, T2?: { calls, out }, T1?: { calls, out }, T1H?: { calls, out } } } }
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { readFileSync } from 'node:fs';
|
|
@@ -49,7 +49,7 @@ export function recordRouteCaps(sessionId, caps, now = Date.now()) {
|
|
|
49
49
|
if (typeof sessionId !== 'string' || sessionId === '') return false;
|
|
50
50
|
const state = load();
|
|
51
51
|
const next = {};
|
|
52
|
-
for (const tier of ['T2', 'T1']) {
|
|
52
|
+
for (const tier of ['T2', 'T1', 'T1H']) {
|
|
53
53
|
if (validBudget(caps?.[tier])) next[tier] = { calls: caps[tier].calls ?? null, out: caps[tier].out };
|
|
54
54
|
}
|
|
55
55
|
const has = Object.keys(next).length > 0;
|
|
@@ -69,6 +69,6 @@ export function routeCapsFor(sessionId, now = Date.now()) {
|
|
|
69
69
|
const s = load().sessions[sessionId];
|
|
70
70
|
if (!s || !Number.isFinite(s.ts) || now - s.ts > TTL_MS) return null;
|
|
71
71
|
const out = {};
|
|
72
|
-
for (const tier of ['T2', 'T1']) if (validBudget(s[tier])) out[tier] = s[tier];
|
|
72
|
+
for (const tier of ['T2', 'T1', 'T1H']) if (validBudget(s[tier])) out[tier] = s[tier];
|
|
73
73
|
return Object.keys(out).length ? out : null;
|
|
74
74
|
}
|
package/src/route-inject.js
CHANGED
|
@@ -126,8 +126,8 @@ export function sessionModelRank({ env = process.env, transcriptPath = null, sna
|
|
|
126
126
|
* and saves nothing. Outside such a host an unset variable selects Claude
|
|
127
127
|
* Code's built-in default for the tier, so nothing is reported unreachable.
|
|
128
128
|
*/
|
|
129
|
-
const TIER_ALIAS_ENV = { T2: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', T1: 'ANTHROPIC_DEFAULT_SONNET_MODEL' };
|
|
130
|
-
const TIER_ALIAS = { T2: 'haiku', T1: 'sonnet' };
|
|
129
|
+
const TIER_ALIAS_ENV = { T2: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', T1: 'ANTHROPIC_DEFAULT_SONNET_MODEL', T1H: 'ANTHROPIC_DEFAULT_OPUS_MODEL' };
|
|
130
|
+
const TIER_ALIAS = { T2: 'haiku', T1: 'sonnet', T1H: 'opus' };
|
|
131
131
|
|
|
132
132
|
export function unreachableTiers(env = process.env) {
|
|
133
133
|
if (env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST !== '1') return [];
|
|
@@ -205,7 +205,8 @@ export function routeMatch(text, { rules, sessionRank = null, root, env = proces
|
|
|
205
205
|
const nearest = [...reachable].sort((a, b) => scopeRank(b) - scopeRank(a));
|
|
206
206
|
const t2 = nearest.find((r) => r.tier === 'T2');
|
|
207
207
|
const t1 = nearest.find((r) => r.tier === 'T1');
|
|
208
|
-
|
|
208
|
+
const t1h = nearest.find((r) => r.tier === 'T1H');
|
|
209
|
+
return t2 || t1 || t1h ? { cat, t2, t1, t1h } : null;
|
|
209
210
|
}
|
|
210
211
|
|
|
211
212
|
/** The budgets a match states, by tier: what the delegation guard repeats to the subagent. */
|
|
@@ -214,6 +215,7 @@ export function matchCaps(match) {
|
|
|
214
215
|
const caps = {};
|
|
215
216
|
if (match.t2) caps.T2 = ruleBudget(match.t2);
|
|
216
217
|
if (match.t1) caps.T1 = ruleBudget(match.t1);
|
|
218
|
+
if (match.t1h) caps.T1H = ruleBudget(match.t1h);
|
|
217
219
|
return caps;
|
|
218
220
|
}
|
|
219
221
|
|
|
@@ -248,10 +250,10 @@ function unreachableNotice(label, tiers, ko) {
|
|
|
248
250
|
export function routeHint(text, { rules, lang = userLanguage(), root, sessionRank = null, match, env } = {}) {
|
|
249
251
|
const found = match === undefined ? routeMatch(text, { rules, sessionRank, root, env }) : match;
|
|
250
252
|
if (!found) return null;
|
|
251
|
-
const { cat, t2, t1 } = found;
|
|
253
|
+
const { cat, t2, t1, t1h } = found;
|
|
252
254
|
const ko = lang === 'ko';
|
|
253
255
|
const label = ko ? (cat.label || cat.id) : (cat.labelEn || cat.label || cat.id);
|
|
254
|
-
if (!t2 && !t1 && found.unreachable?.length) return unreachableNotice(label, found.unreachable, ko);
|
|
256
|
+
if (!t2 && !t1 && !t1h && found.unreachable?.length) return unreachableNotice(label, found.unreachable, ko);
|
|
255
257
|
// `root` is threaded through to agentPhrase, which names the subagent only
|
|
256
258
|
// when its .md actually exists on this machine. A caller that pins it can
|
|
257
259
|
// therefore get a deterministic phrase; a test that does not pin it reads
|
|
@@ -262,21 +264,28 @@ export function routeHint(text, { rules, lang = userLanguage(), root, sessionRan
|
|
|
262
264
|
// T1 is named through its delegation agent only when that file exists here;
|
|
263
265
|
// otherwise the literal tier string stays, so output without agents is unchanged.
|
|
264
266
|
const t1Root = root === undefined ? undefined : root;
|
|
265
|
-
const
|
|
266
|
-
const
|
|
267
|
+
const tierPhrase = (name) => (resolveAgentName(name, t1Root) !== null ? phrase(name) : `model: ${name}`);
|
|
268
|
+
const picked = [t2, t1, t1h].filter(Boolean);
|
|
267
269
|
let target;
|
|
268
|
-
if (
|
|
270
|
+
if (picked.length >= 2) {
|
|
271
|
+
const named = (r) => (r.tier === 'T2' ? phrase(r.agent) : tierPhrase(r.tier === 'T1H' ? 'opus' : 'sonnet'));
|
|
272
|
+
const when = (r, i) => {
|
|
273
|
+
if (i === 0) return null;
|
|
274
|
+
if (r.tier === 'T1H') return ko ? '범위가 크면(Fable·Mythos 세션 한정)' : 'for large scope (Fable/Mythos sessions only)';
|
|
275
|
+
return ko ? '여러 단계·여러 파일이 얽히면' : 'when it spans multiple steps or file edits';
|
|
276
|
+
};
|
|
269
277
|
target = ko
|
|
270
|
-
?
|
|
271
|
-
: `${
|
|
278
|
+
? picked.map((r, i) => `${when(r, i) ?? '기본'} ${named(r)}`).join(', ')
|
|
279
|
+
: picked.map((r, i) => (i === 0 ? `${named(r)} by default` : `${named(r)} ${when(r, i)}`)).join(', ');
|
|
272
280
|
} else {
|
|
273
|
-
const only =
|
|
274
|
-
target =
|
|
281
|
+
const only = picked[0];
|
|
282
|
+
target = only.tier === 'T1H' ? tierPhrase('opus') : phrase(only.agent);
|
|
275
283
|
}
|
|
276
284
|
|
|
277
|
-
const
|
|
278
|
-
const
|
|
279
|
-
|
|
285
|
+
const MODEL_OF = { T2: 'haiku', T1: 'sonnet', T1H: 'opus' };
|
|
286
|
+
const caps = picked.map((r) => budgetCapPhrase(r, lang));
|
|
287
|
+
const capText = picked.length >= 2
|
|
288
|
+
? (ko ? '상한 ' : 'cap ') + picked.map((r, i) => `${MODEL_OF[r.tier]} ${caps[i]}`).join(' / ')
|
|
280
289
|
: (ko ? `상한 ${caps[0]}` : `cap ${caps[0]}`);
|
|
281
290
|
|
|
282
291
|
// The marker line is the one in ratchet-model.md, so the user sees the same
|
package/src/route-scan.js
CHANGED
|
@@ -30,6 +30,7 @@ import { sessionCost } from './claude-price.js';
|
|
|
30
30
|
import { learnProfileMapping, resetModelAliasCache } from './model-alias.js';
|
|
31
31
|
import { modelRuleBaseText } from './model-rules.js';
|
|
32
32
|
import { readResetMarks, isBeforeReset } from './saved-reset.cjs';
|
|
33
|
+
import { modelPresets as sourceModelPresets } from './presets-source.js';
|
|
33
34
|
|
|
34
35
|
// ── Tier bands (docs/TIER_CRITERIA.md §3) ────────────────────────────────
|
|
35
36
|
// T2 (haiku): finished in few calls, tiny output, near-zero mutation, no
|
|
@@ -44,6 +45,10 @@ export const T2_OUT_CLAMP = [1000, 3000]; // default 1500 pre-calibration
|
|
|
44
45
|
export const T1_MAX_MUTATING = 6;
|
|
45
46
|
export const T1_MAX_ERRORS = 1;
|
|
46
47
|
export const T1_OUT_CLAMP = [5000, 15000]; // default 8000 pre-calibration
|
|
48
|
+
// T1H (opus, Fable sessions only): too heavy for sonnet but still bounded.
|
|
49
|
+
export const T1H_MAX_ERRORS = 3;
|
|
50
|
+
export const T1H_MAX_MUTATING = 20;
|
|
51
|
+
export const T1H_OUT_CLAMP = [15000, 60000]; // default 30000 pre-calibration
|
|
47
52
|
export const T0_MIN_ERRORS = 2; // repeated tool errors = hard, by outcome
|
|
48
53
|
export const T0_MIN_MUTATING = 7;
|
|
49
54
|
// Episodes below this output size carry no delegable work (conversational
|
|
@@ -65,7 +70,14 @@ export const MIN_DELEGABLE_OUT = 100;
|
|
|
65
70
|
// questions that merely contained "statusline". None gained a hint. Bare
|
|
66
71
|
// '리뷰' and '보안' are left out on purpose: they are product nouns in ordinary
|
|
67
72
|
// status checks ("리뷰 허브 배치 확인"), so only the request-shaped forms count.
|
|
68
|
-
|
|
73
|
+
//
|
|
74
|
+
// 2026-10-18: production-database work and a user's go-ahead ("운영디비 승인할게
|
|
75
|
+
// 너가 진행해줘", "승인할게 너가 직접 실행해줘") read as light run episodes and
|
|
76
|
+
// became Opus (T1H) candidates. Measured on 6,473 local prompts over 90 days:
|
|
77
|
+
// the two term groups catch 7 + 12 prompts, of which 4 were not already
|
|
78
|
+
// escalated, all of them approvals or production-DB asks. Bare '승인' is left
|
|
79
|
+
// out: "승인된 룰" is a product noun in sprag's own ratchet flow.
|
|
80
|
+
export const ESCALATE_RE = /운영\s*(?:db|디비|데이터베이스|서버|환경)|프로덕션|\bprod(?:uction)?\s*(?:db|database|server|env)|승인\s*(?:할게|했어|했으니|했습니다|합니다|해줄게|완료)|\bapproved?\b|설계|아키텍처|리팩토링|원인\s*(?:분석|파악|확인|규명|조사)|원인을?\s*찾|버그|개선할|개선점|개선\s*(?:사항|방안)|검토해보|검수|코드\s*리뷰|리뷰\s*해|취약|보안\s*(?:점검|검토|감사|리뷰|이슈)|비교|왜(?=[\s?]|$)|이유|제출|배포|출시|퍼블리시|릴리[스즈](?!\s*노트)|(?<!나)머지|analyze|compare|evaluate|architect|refactor|submit|deploy|release|publish|merge|root cause|code review|security (?:review|audit)|vulnerabilit/i;
|
|
69
81
|
|
|
70
82
|
// Implementation asks phrased around a run/check verb ("설치와 동시에 설정되도록
|
|
71
83
|
// 하자", "피드백 확인해주고 아이콘도 만들자"). Editing is not delegable, and at
|
|
@@ -151,6 +163,18 @@ const CATEGORIES = [
|
|
|
151
163
|
kw: [[/\bgit |\bcommit(?:s|ted)?\b|\bpush(?:ed|es)?\b|\bnpm |\bpip3? |빌드해|빌드 돌/i, 2], [/실행|돌려|\brun\b|\bbuild\b|빌드|테스트|설치/i, 1]],
|
|
152
164
|
},
|
|
153
165
|
];
|
|
166
|
+
// The T2 agent a category's candidates name follows the published model presets
|
|
167
|
+
// when one covers that category (presets-source.js), so renaming an agent in the
|
|
168
|
+
// /ops console renames it in new candidates too. Categories without a preset
|
|
169
|
+
// (translate) keep the name above.
|
|
170
|
+
function t2AgentFor(cat) {
|
|
171
|
+
try {
|
|
172
|
+
const p = sourceModelPresets().find((x) => x.tier === 'T2' && x.category === cat.id);
|
|
173
|
+
if (p && typeof p.agent === 'string' && p.agent) return p.agent;
|
|
174
|
+
} catch { /* the built-in name below */ }
|
|
175
|
+
return cat.agent;
|
|
176
|
+
}
|
|
177
|
+
|
|
154
178
|
// English keywords are matched as whole words. As bare substrings, `pip`
|
|
155
179
|
// claimed "pipeline", `commit` "commitment", `find` "findings" and `status`
|
|
156
180
|
// "statusline", which filed questions about those under run, explore or check.
|
|
@@ -412,32 +436,30 @@ const percentile = (sorted, p) => sorted[Math.min(sorted.length - 1, Math.floor(
|
|
|
412
436
|
* Falls back to mid-clamp defaults when the sample is too small to trust.
|
|
413
437
|
*/
|
|
414
438
|
export function calibrateThresholds(episodeOuts) {
|
|
415
|
-
if (episodeOuts.length < 100) return { t2Out: 1500, t1Out: 8000, calibrated: false };
|
|
439
|
+
if (episodeOuts.length < 100) return { t2Out: 1500, t1Out: 8000, t1hOut: 30000, calibrated: false };
|
|
416
440
|
const sorted = [...episodeOuts].sort((a, b) => a - b);
|
|
417
441
|
return {
|
|
418
442
|
t2Out: clamp(Math.max(percentile(sorted, 0.25), 1500), T2_OUT_CLAMP),
|
|
419
443
|
t1Out: clamp(percentile(sorted, 0.75), T1_OUT_CLAMP),
|
|
444
|
+
t1hOut: clamp(percentile(sorted, 0.95), T1H_OUT_CLAMP),
|
|
420
445
|
calibrated: true,
|
|
421
446
|
};
|
|
422
447
|
}
|
|
423
448
|
|
|
424
449
|
/**
|
|
425
|
-
* Tier classification (docs/TIER_CRITERIA.md §3). Returns 'T0'|'T1'|'T2',
|
|
450
|
+
* Tier classification (docs/TIER_CRITERIA.md §3). Returns 'T0'|'T1H'|'T1'|'T2',
|
|
426
451
|
* or null when the episode carries nothing delegable. Order matters: hard
|
|
427
452
|
* evidence (errors, heavy mutation, big output, judgement keywords) wins
|
|
428
453
|
* before any cheap-band check.
|
|
429
454
|
*/
|
|
430
455
|
export function tierOf(ep, category, th) {
|
|
431
456
|
if (ep.out < MIN_DELEGABLE_OUT) return null;
|
|
432
|
-
if (
|
|
433
|
-
ep.errors >= T0_MIN_ERRORS ||
|
|
434
|
-
ep.mutating >= T0_MIN_MUTATING ||
|
|
435
|
-
ep.out > th.t1Out ||
|
|
436
|
-
ESCALATE_RE.test(ep.text)
|
|
437
|
-
) return 'T0';
|
|
457
|
+
if (ESCALATE_RE.test(ep.text)) return 'T0';
|
|
438
458
|
if (!category || ep.delegated > 0) return 'T0';
|
|
439
459
|
if (ep.calls <= T2_MAX_CALLS && ep.out <= th.t2Out && ep.mutating <= T2_MAX_MUTATING && ep.errors === 0) return 'T2';
|
|
440
460
|
if (ep.out <= th.t1Out && ep.mutating <= T1_MAX_MUTATING && ep.errors <= T1_MAX_ERRORS) return 'T1';
|
|
461
|
+
const t1hOut = th.t1hOut ?? 30000;
|
|
462
|
+
if (ep.errors <= T1H_MAX_ERRORS && ep.mutating <= T1H_MAX_MUTATING && ep.out <= t1hOut) return 'T1H';
|
|
441
463
|
return 'T0';
|
|
442
464
|
}
|
|
443
465
|
|
|
@@ -611,13 +633,13 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
611
633
|
const shapeTier = tierOf({ ...ep, errors: 0 }, cat, thresholds);
|
|
612
634
|
// Same rank gate as the candidate path below — a denominator counting
|
|
613
635
|
// episodes that can't produce a rule would skew that rule's error rate.
|
|
614
|
-
if ((shapeTier === 'T1' || shapeTier === 'T2') && worthDelegating(shapeTier, epRank)) {
|
|
636
|
+
if ((shapeTier === 'T1' || shapeTier === 'T2' || shapeTier === 'T1H') && worthDelegating(shapeTier, epRank)) {
|
|
615
637
|
bumpStats(`${shapeTier}|${cat.id}|${projectDir}`, ep);
|
|
616
638
|
bumpStats(`${shapeTier}|${cat.id}|*`, ep);
|
|
617
639
|
}
|
|
618
640
|
}
|
|
619
641
|
const tier = tierOf(ep, cat, thresholds);
|
|
620
|
-
if (tier !== 'T1' && tier !== 'T2') continue;
|
|
642
|
+
if (tier !== 'T1' && tier !== 'T2' && tier !== 'T1H') continue;
|
|
621
643
|
if (!worthDelegating(tier, epRank)) continue;
|
|
622
644
|
tieredEpisodes += 1;
|
|
623
645
|
const key = `${tier}|${cat.id}|${projectDir}`;
|
|
@@ -626,7 +648,7 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
626
648
|
category: cat.id,
|
|
627
649
|
label: cat.label,
|
|
628
650
|
labelEn: cat.labelEn,
|
|
629
|
-
agent: tier === 'T2' ? cat
|
|
651
|
+
agent: tier === 'T2' ? t2AgentFor(cat) : tier === 'T1H' ? 'opus' : 'sonnet',
|
|
630
652
|
project: projectDir,
|
|
631
653
|
projectPath: '',
|
|
632
654
|
count: 0,
|
|
@@ -653,7 +675,7 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
653
675
|
// where a promoted rule's real success rate lives, so they get their own
|
|
654
676
|
// pass: join each episode to the subagent transcripts it spawned, and file
|
|
655
677
|
// the outcome under the tier that run's model represents — a haiku run is a
|
|
656
|
-
// T2 rule firing, a sonnet run a T1 one. Runs that were not a downgrade
|
|
678
|
+
// T2 rule firing, a sonnet run a T1 one, an opus run (Fable session) a T1H one. Runs that were not a downgrade
|
|
657
679
|
// (same tier or higher) carry no delegation saving and are skipped.
|
|
658
680
|
const delegatedStats = new Map(); // "tier|category|project" → outcome aggregate
|
|
659
681
|
let unresolvedRuns = 0; // delegated runs dropped for an unpriceable model id
|
|
@@ -774,7 +796,7 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
774
796
|
// time rather than hardcoded downstream.
|
|
775
797
|
const budgetOf = (g) => ({
|
|
776
798
|
calls: g.tier === 'T2' ? T2_MAX_CALLS + 2 : null,
|
|
777
|
-
out: g.tier === 'T2' ? thresholds.t2Out : thresholds.t1Out,
|
|
799
|
+
out: g.tier === 'T2' ? thresholds.t2Out : g.tier === 'T1H' ? thresholds.t1hOut : thresholds.t1Out,
|
|
778
800
|
});
|
|
779
801
|
// Shared with the seed presets (src/seed-rules.js) through model-rules.js —
|
|
780
802
|
// one template, so a reworded rule cannot mean two different things
|
|
@@ -945,7 +967,7 @@ export async function shouldRescan(cache, { days = 14, afterDelegation = false }
|
|
|
945
967
|
}
|
|
946
968
|
|
|
947
969
|
/**
|
|
948
|
-
* Human-readable labels for the T0/T1/T2 codes and scope values — used by
|
|
970
|
+
* Human-readable labels for the T0/T1H/T1/T2 codes and scope values — used by
|
|
949
971
|
* every user-facing listing so a bare code never appears without its meaning
|
|
950
972
|
* (first-time users can't be expected to know the tier vocabulary).
|
|
951
973
|
*/
|
|
@@ -953,11 +975,13 @@ export function tierLabel(tier, lang = 'ko') {
|
|
|
953
975
|
const ko = {
|
|
954
976
|
T2: '단순 작업, haiku급이면 충분',
|
|
955
977
|
T1: '중간 난도, sonnet급이면 충분',
|
|
978
|
+
T1H: '고난도 구현, opus급이면 충분 (Fable 세션 전용)',
|
|
956
979
|
T0: '고난도, 지금 모델 유지',
|
|
957
980
|
};
|
|
958
981
|
const en = {
|
|
959
982
|
T2: 'simple — haiku-class is enough',
|
|
960
983
|
T1: 'moderate — sonnet-class is enough',
|
|
984
|
+
T1H: 'heavy, opus-class is enough (Fable sessions only)',
|
|
961
985
|
T0: 'hard — stays on the session model',
|
|
962
986
|
};
|
|
963
987
|
return (lang === 'ko' ? ko : en)[tier] || tier;
|
package/src/seed-rules.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* This module closes that gap with presets bundled in the package:
|
|
13
13
|
* presets/model-rules.json → tier-delegation rules (the model ratchet)
|
|
14
14
|
* presets/ratchet-rules.json → field-tested mistake rules (the global ratchet)
|
|
15
|
+
* or, when one has been fetched and verifies, the centrally published bundle
|
|
16
|
+
* with the same shape (presets-source.js).
|
|
15
17
|
*
|
|
16
18
|
* Nothing is written without the user agreeing to that specific rule. The
|
|
17
19
|
* SessionStart hook lists what is pending and tells the model to ask one rule at
|
|
@@ -25,9 +27,8 @@
|
|
|
25
27
|
|
|
26
28
|
import { readFileSync } from 'node:fs';
|
|
27
29
|
import { CLI_NAME } from './cli-name.js';
|
|
28
|
-
import { join,
|
|
29
|
-
import {
|
|
30
|
-
import { createHash } from 'node:crypto';
|
|
30
|
+
import { join, resolve } from 'node:path';
|
|
31
|
+
import { modelPresets as sourceModelPresets } from './presets-source.js';
|
|
31
32
|
import { sharedModelRulesForProject, loadSharedModelRules, sharedProjectRoots, realProjectRoot } from './shared-model-rules.js';
|
|
32
33
|
import { userDataDir } from './paths.js';
|
|
33
34
|
import { userLanguage } from './config.js';
|
|
@@ -45,8 +46,6 @@ import {
|
|
|
45
46
|
presetRuleEntries,
|
|
46
47
|
} from './harness.js';
|
|
47
48
|
|
|
48
|
-
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
49
|
-
|
|
50
49
|
// Codex answers are kept apart: saying no in one agent must not consume the
|
|
51
50
|
// other agent's offer, and the two agents write to different ratchet files.
|
|
52
51
|
export function seedStatePath({ agent = 'claude' } = {}) {
|
|
@@ -66,14 +65,12 @@ export function saveSeedState(state, opts = {}) {
|
|
|
66
65
|
writeStateFile(seedStatePath(opts), JSON.stringify(state, null, 2) + '\n');
|
|
67
66
|
}
|
|
68
67
|
|
|
69
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Tier-delegation presets: the published bundle when it verifies, else the
|
|
70
|
+
* bundled presets/model-rules.json (empty array when that is unreadable).
|
|
71
|
+
*/
|
|
70
72
|
export function modelPresets() {
|
|
71
|
-
|
|
72
|
-
const data = JSON.parse(readFileSync(join(packageRoot, 'presets', 'model-rules.json'), 'utf8'));
|
|
73
|
-
return Array.isArray(data.presets) ? data.presets : [];
|
|
74
|
-
} catch {
|
|
75
|
-
return [];
|
|
76
|
-
}
|
|
73
|
+
return sourceModelPresets();
|
|
77
74
|
}
|
|
78
75
|
|
|
79
76
|
/**
|
|
@@ -87,10 +84,11 @@ export function ratchetPresets(lang = userLanguage(), { agent = 'claude' } = {})
|
|
|
87
84
|
// tiers would only mislead a Codex session.
|
|
88
85
|
.filter((r) => !r.agents || r.agents.includes(agent))
|
|
89
86
|
.map((r) => ({
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
87
|
+
// The id is carried by the preset itself: first derived from the Korean
|
|
88
|
+
// text, then kept when a published bundle rewords the rule, so a user who
|
|
89
|
+
// accepted or declined it keeps that answer. presets-source.js fills it in
|
|
90
|
+
// for an entry that has none, from the Korean text as before.
|
|
91
|
+
id: r.id,
|
|
94
92
|
text: lang === 'ko' ? r.ko : r.en,
|
|
95
93
|
ko: r.ko,
|
|
96
94
|
}));
|
|
@@ -3,7 +3,7 @@ import { isAbsolute, resolve } from 'node:path';
|
|
|
3
3
|
import { loadModelRules, ruleBudget } from './model-rules.js';
|
|
4
4
|
import { findProjectRoot } from './harness.js';
|
|
5
5
|
|
|
6
|
-
export const SHARED_RULE_TIERS = ['T2', 'T1'];
|
|
6
|
+
export const SHARED_RULE_TIERS = ['T2', 'T1', 'T1H'];
|
|
7
7
|
const CATEGORIES = new Set(['paste', 'translate', 'explore', 'read', 'check', 'run']);
|
|
8
8
|
|
|
9
9
|
/** A root as a real path, so a symlinked checkout or macOS /var names one directory once. */
|
package/src/star-note.js
CHANGED
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Once, because a chip that keeps asking is an advertisement. For 24 hours,
|
|
10
10
|
* which is long enough to be seen across a day of sessions and short enough
|
|
11
|
-
* not to linger.
|
|
11
|
+
* not to linger. SPRAG_NO_NOTE=1 suppresses it, the same opt-out as every
|
|
12
12
|
* other one-time note.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { envVar } from './env.js';
|
|
15
16
|
import { createRequire } from 'node:module';
|
|
16
17
|
import { delegationSavedTotals } from './savings-ledger.js';
|
|
17
18
|
import { noteShownAt, markNoteShown } from './first-run-note.js';
|
|
@@ -43,7 +44,7 @@ export function toolSavedUsd(now = Date.now(), dir = userDataDir()) {
|
|
|
43
44
|
/** `{ usd, shownAt }` while the chip should show, else null. Never throws. */
|
|
44
45
|
export function readStarChip({ now = Date.now(), dir = userDataDir(), savedUsd } = {}) {
|
|
45
46
|
try {
|
|
46
|
-
if (
|
|
47
|
+
if (envVar('NO_NOTE') === '1') return null;
|
|
47
48
|
const shownAt = noteShownAt('star');
|
|
48
49
|
if (shownAt === null) {
|
|
49
50
|
const usd = savedUsd ?? toolSavedUsd(now, dir);
|