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.
Files changed (58) hide show
  1. package/NOTICE +5 -2
  2. package/README.ko.md +16 -0
  3. package/README.md +16 -0
  4. package/bin/cli.js +53 -4
  5. package/docs/CODEX.ko.md +3 -2
  6. package/docs/CODEX.md +5 -4
  7. package/docs/TEAM.ko.md +174 -0
  8. package/docs/TEAM.md +175 -0
  9. package/package.json +5 -1
  10. package/presets/agents.json +118 -0
  11. package/presets/doc2md/convert.py +1 -1
  12. package/presets/ratchet-rules.json +12 -2
  13. package/src/agents.js +3 -1
  14. package/src/cli-entry.js +40 -0
  15. package/src/codex/delegation.js +8 -5
  16. package/src/codex/hooks.js +25 -12
  17. package/src/codex/panel-setup.js +2 -1
  18. package/src/codex/panel.js +2 -1
  19. package/src/codex/route-scan.js +6 -4
  20. package/src/commands/brief.js +2 -0
  21. package/src/commands/codex-delegate.js +3 -3
  22. package/src/commands/codex.js +6 -5
  23. package/src/commands/harness.js +3 -4
  24. package/src/commands/install.js +19 -18
  25. package/src/commands/presets.js +158 -0
  26. package/src/commands/profile-map.js +2 -2
  27. package/src/commands/route-scan.js +25 -10
  28. package/src/commands/team.js +334 -0
  29. package/src/commands/update-check.js +2 -2
  30. package/src/cost.js +6 -2
  31. package/src/cowork-sync.js +5 -4
  32. package/src/debug.js +3 -2
  33. package/src/delegation-agents.js +49 -49
  34. package/src/delegation-guard.js +3 -2
  35. package/src/doc2md.cjs +4 -3
  36. package/src/env.cjs +7 -0
  37. package/src/env.js +19 -0
  38. package/src/first-run-note.js +3 -2
  39. package/src/harness-templates.js +10 -0
  40. package/src/harness.js +4 -14
  41. package/src/hook.cjs +4 -3
  42. package/src/model-rules.js +58 -29
  43. package/src/preset-sign.js +106 -0
  44. package/src/preset-sync.js +133 -0
  45. package/src/presets-source.js +209 -0
  46. package/src/prompt.js +2 -1
  47. package/src/route-caps.js +3 -3
  48. package/src/route-inject.js +24 -15
  49. package/src/route-scan.js +39 -15
  50. package/src/seed-rules.js +14 -16
  51. package/src/shared-model-rules.js +1 -1
  52. package/src/star-note.js +3 -2
  53. package/src/statusline-guard.js +144 -0
  54. package/src/stdin-payload.js +33 -4
  55. package/src/team-prompts.js +353 -0
  56. package/src/team-ratchet.js +276 -0
  57. package/src/team-sync.js +625 -0
  58. 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.CTS_NO_INPUT === '1') return false;
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
  }
@@ -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
- return t2 || t1 ? { cat, t2, t1 } : null;
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 t1Named = resolveAgentName('sonnet', t1Root) !== null;
266
- const t1Phrase = t1Named ? phrase('sonnet') : 'model: sonnet';
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 (t2 && t1) {
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
- ? `기본 ${phrase(t2.agent)}, 여러 단계·여러 파일이 얽히면 ${t1Phrase}`
271
- : `${phrase(t2.agent)} by default, ${t1Phrase} when it spans multiple steps or file edits`;
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 = t2 || t1;
274
- target = ko ? phrase(only.agent) : phrase(only.agent);
281
+ const only = picked[0];
282
+ target = only.tier === 'T1H' ? tierPhrase('opus') : phrase(only.agent);
275
283
  }
276
284
 
277
- const caps = [t2, t1].filter(Boolean).map((r) => budgetCapPhrase(r, lang));
278
- const capText = caps.length === 2
279
- ? (ko ? `상한 haiku ${caps[0]} / sonnet ${caps[1]}` : `cap haiku ${caps[0]} / sonnet ${caps[1]}`)
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
- export const ESCALATE_RE = /설계|아키텍처|리팩토링|원인\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;
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.agent : 'sonnet',
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, dirname, resolve } from 'node:path';
29
- import { fileURLToPath } from 'node:url';
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
- /** Bundled tier-delegation presets (empty array when the file is unreadable). */
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
- try {
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
- // Hashed from the Korean text in both languages: the id has to survive a
91
- // reworded translation, or a user who declined a rule would be asked again
92
- // the next time its English copy changes.
93
- id: `fix-${createHash('sha1').update(r.ko).digest('hex').slice(0, 6)}`,
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. CTS_NO_NOTE=1 suppresses it, the same opt-out as every
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 (process.env.CTS_NO_NOTE === '1') return null;
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);