sprag-cli 3.69.0 → 3.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,278 @@
1
+ /**
2
+ * team — `sprag team <create|join|leave|status|sync|rules|propose>`.
3
+ *
4
+ * The opt-in team layer. `join` shows exactly what will be uploaded (daily
5
+ * aggregates only) and asks before enrolling. `sync --background` is what the
6
+ * SessionStart hook's detached child runs: auto-join from SPRAG_TEAM_TOKEN,
7
+ * then upload and pull, printing nothing. All network access goes through
8
+ * rpc() in src/team-sync.js. Exit codes: 0 ok, 1 error, 2 usage.
9
+ */
10
+
11
+ import { createInterface } from 'node:readline';
12
+ import { readFileSync } from 'node:fs';
13
+ import { userInfo } from 'node:os';
14
+ import { userLanguage } from '../config.js';
15
+ import {
16
+ loadTeamState, isJoined, joinTeam, leaveTeam, syncTeam, autoJoin, aggregateDaily, rpc,
17
+ teamRulesCachePath, teamSyncDisabled, requestSignInCode, verifySignInCode, createTeam,
18
+ } from '../team-sync.js';
19
+
20
+ const T = {
21
+ en: {
22
+ usage: [
23
+ 'Usage: sprag team <command>',
24
+ ' create [--email <e>] [--name <n>] [--code <6 digits>] [--no-join] create a team (you become the owner)',
25
+ ' join [token] [--name <n>] [--yes] join a team (token or SPRAG_TEAM_TOKEN)',
26
+ ' leave leave the team and stop uploading',
27
+ ' status [--show-payload] membership, last sync, what is sent',
28
+ ' sync [--force] upload aggregates and pull team rules now',
29
+ ' rules show cached team rules',
30
+ ' propose <text> [--scope ratchet|model] propose a rule to the team',
31
+ ].join('\n'),
32
+ willSend: 'Joining uploads daily aggregates only, per local day:\n - spend (USD)\n - savings (USD)\n - delegation run counts\n - model-family share\nNo prompts, file paths or transcripts are sent.',
33
+ asName: (n) => `Display name: ${n}`,
34
+ ask: 'Join? [y/N] ',
35
+ needYes: 'Not a terminal. Re-run with --yes to confirm: sprag team join <token> --yes',
36
+ noToken: 'Missing invite token. Pass it as an argument or set SPRAG_TEAM_TOKEN.',
37
+ aborted: 'Not joined.',
38
+ joined: (n) => `Joined team "${n}". Check it with: sprag team status`,
39
+ left: 'Left the team. Nothing is uploaded any more.',
40
+ notJoined: 'Not in a team. Join with: sprag team join <token>',
41
+ team: 'Team', plan: 'Plan', capture: 'Prompt capture', name: 'Display name',
42
+ lastSync: (t, n) => `Last sync: ${t} (${n} rows)`,
43
+ neverSynced: 'Last sync: never',
44
+ lastError: 'Last error',
45
+ on: 'on', off: 'off',
46
+ disabled: 'Team sync is disabled (CTS_NO_TEAM_SYNC=1).',
47
+ skipped: (s) => `Skipped: ${s}`,
48
+ synced: (u, r) => `Synced: ${u} day rows uploaded, ${r} team rules.`,
49
+ noRules: 'No team rules yet.',
50
+ proposed: (id) => `Proposed. Id: ${id}`,
51
+ needText: 'Missing rule text. Usage: sprag team propose <text> [--scope ratchet|model]',
52
+ badScope: 'Scope must be ratchet or model.',
53
+ needJoin: 'Join a team first: sprag team join <token>',
54
+ createUsage: 'Usage: sprag team create --email <e> --name "<team name>" [--code <6 digits>] [--no-join]',
55
+ badEmail: 'That does not look like an email address.',
56
+ badCode: 'The code is 6 digits, like 123456.',
57
+ needEmail: 'Missing --email. Usage: sprag team create --email <e> --name "<team name>"',
58
+ needName: 'Missing --name. Usage: sprag team create --email <e> --name "<team name>" --code <6 digits>',
59
+ alreadyJoined: (n) => `This machine is already in team "${n}". Run \`sprag team leave\` first, or pass --no-join.`,
60
+ askEmail: 'Your email: ',
61
+ askCode: 'Code from the email (6 digits): ',
62
+ askTeamName: (d) => `Team name [${d}]: `,
63
+ codeSent: (e) => `A 6-digit code was sent to ${e} (valid 15 minutes).`,
64
+ rerun: (e, n) => `Then run: sprag team create --email ${e} --name "${n}" --code <code>`,
65
+ created: (n) => `Created team "${n}".`,
66
+ inviteToken: 'Invite token',
67
+ memberCmd: 'Member command',
68
+ orgLine: 'Organization env',
69
+ dashboard: (e) => `Dashboard: https://sprag.io/team/ (sign in with ${e})`,
70
+ noJoinNote: 'This machine did not join (--no-join).',
71
+ syncFailed: (e) => `Team created, but the first sync failed: ${e}`,
72
+ },
73
+ ko: {
74
+ usage: [
75
+ '사용법: sprag team <명령>',
76
+ ' create [--email <e>] [--name <이름>] [--code <6자리>] [--no-join] 팀 만들기 (만든 사람이 소유자)',
77
+ ' join [토큰] [--name <이름>] [--yes] 팀에 참여 (토큰 또는 SPRAG_TEAM_TOKEN)',
78
+ ' leave 팀에서 나가고 업로드 중단',
79
+ ' status [--show-payload] 참여 상태, 마지막 동기화, 보내는 내용',
80
+ ' sync [--force] 지금 집계를 올리고 팀 규칙을 받기',
81
+ ' rules 받아 둔 팀 규칙 보기',
82
+ ' propose <내용> [--scope ratchet|model] 팀에 규칙 제안',
83
+ ].join('\n'),
84
+ willSend: '참여하면 로컬 날짜별 하루 단위 집계만 올라갑니다.\n - 지출(USD)\n - 절감(USD)\n - 위임 실행 횟수\n - 모델 계열 비중\n프롬프트, 파일 경로, 대화 기록은 보내지 않습니다.',
85
+ asName: (n) => `표시 이름: ${n}`,
86
+ ask: '참여할까요? [y/N] ',
87
+ needYes: '터미널이 아닙니다. 확인하려면 --yes 를 붙여 다시 실행하세요: sprag team join <토큰> --yes',
88
+ noToken: '초대 토큰이 없습니다. 인자로 넘기거나 SPRAG_TEAM_TOKEN 을 설정하세요.',
89
+ aborted: '참여하지 않았습니다.',
90
+ joined: (n) => `팀 "${n}" 에 참여했습니다. 확인: sprag team status`,
91
+ left: '팀에서 나왔습니다. 이제 아무것도 업로드하지 않습니다.',
92
+ notJoined: '참여한 팀이 없습니다. 참여하려면: sprag team join <토큰>',
93
+ team: '팀', plan: '플랜', capture: '프롬프트 수집', name: '표시 이름',
94
+ lastSync: (t, n) => `마지막 동기화: ${t} (${n}행)`,
95
+ neverSynced: '마지막 동기화: 아직 없음',
96
+ lastError: '마지막 오류',
97
+ on: '켜짐', off: '꺼짐',
98
+ disabled: '팀 동기화가 꺼져 있습니다 (CTS_NO_TEAM_SYNC=1).',
99
+ skipped: (s) => `건너뜀: ${s}`,
100
+ synced: (u, r) => `동기화 완료: ${u}일치 집계 업로드, 팀 규칙 ${r}개.`,
101
+ noRules: '아직 팀 규칙이 없습니다.',
102
+ proposed: (id) => `제안했습니다. ID: ${id}`,
103
+ needText: '규칙 내용이 없습니다. 사용법: sprag team propose <내용> [--scope ratchet|model]',
104
+ badScope: '범위는 ratchet 또는 model 이어야 합니다.',
105
+ needJoin: '먼저 팀에 참여하세요: sprag team join <토큰>',
106
+ createUsage: '사용법: sprag team create --email <e> --name "<팀 이름>" [--code <6자리>] [--no-join]',
107
+ badEmail: '이메일 주소 형식이 아닙니다',
108
+ badCode: '코드는 123456 같은 6자리 숫자입니다',
109
+ needEmail: '--email 이 없습니다. 사용법: sprag team create --email <e> --name "<팀 이름>"',
110
+ needName: '--name 이 없습니다. 사용법: sprag team create --email <e> --name "<팀 이름>" --code <6자리>',
111
+ alreadyJoined: (n) => `이 PC는 이미 팀 "${n}" 에 참여 중입니다. 먼저 \`sprag team leave\` 를 실행하거나 --no-join 을 붙이세요`,
112
+ askEmail: '이메일: ',
113
+ askCode: '메일로 받은 코드 (6자리): ',
114
+ askTeamName: (d) => `팀 이름 [${d}]: `,
115
+ codeSent: (e) => `${e} 로 6자리 코드를 보냈습니다 (15분간 유효)`,
116
+ rerun: (e, n) => `받은 코드로 다시 실행하세요: sprag team create --email ${e} --name "${n}" --code <코드>`,
117
+ created: (n) => `팀 "${n}" 을 만들었습니다`,
118
+ inviteToken: '초대 토큰',
119
+ memberCmd: '멤버용 명령',
120
+ orgLine: '조직 환경 변수',
121
+ dashboard: (e) => `대시보드: https://sprag.io/team/ (${e} 로 로그인)`,
122
+ noJoinNote: '이 PC는 참여하지 않았습니다 (--no-join)',
123
+ syncFailed: (e) => `팀은 만들어졌지만 첫 동기화에 실패했습니다: ${e}`,
124
+ },
125
+ };
126
+
127
+ function prompt(question) {
128
+ return new Promise((resolve) => {
129
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
130
+ rl.question(question, (a) => { rl.close(); resolve(a.trim()); });
131
+ });
132
+ }
133
+
134
+ async function confirm(question) {
135
+ return /^y(es)?$/i.test(await prompt(question));
136
+ }
137
+
138
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
139
+ const CODE_RE = /^\d{6}$/;
140
+
141
+ /** `sprag team create`: sign in by emailed code, insert the team, join as first member. */
142
+ async function createFlow({ m, out, hasFlag, getArg, version, fetch, interactive, ask }) {
143
+ const noJoin = hasFlag('--no-join');
144
+ const st0 = loadTeamState();
145
+ if (isJoined(st0) && !noJoin) { console.error(m.alreadyJoined(st0.teamName || '')); return process.exit(1); }
146
+
147
+ let email = getArg('--email');
148
+ let name = getArg('--name');
149
+ const code = getArg('--code');
150
+ if (code !== undefined && code !== null && !CODE_RE.test(String(code))) { console.error(m.badCode); return process.exit(2); }
151
+ if (!email && interactive) email = await ask(m.askEmail);
152
+ if (!email) { console.error(m.needEmail); return process.exit(2); }
153
+ if (!EMAIL_RE.test(email)) { console.error(m.badEmail); return process.exit(2); }
154
+
155
+ let osName = 'my';
156
+ try { osName = userInfo().username || 'my'; } catch { /* no passwd entry */ }
157
+ const defaultName = `${osName}'s team`;
158
+
159
+ let useCode = code;
160
+ if (!useCode) {
161
+ if (!interactive) {
162
+ await requestSignInCode({ email, fetch });
163
+ out(m.codeSent(email));
164
+ out(m.rerun(email, name || defaultName));
165
+ return undefined;
166
+ }
167
+ await requestSignInCode({ email, fetch });
168
+ out(m.codeSent(email));
169
+ useCode = await ask(m.askCode);
170
+ if (!CODE_RE.test(useCode)) { console.error(m.badCode); return process.exit(2); }
171
+ }
172
+ if (!name && interactive) name = (await ask(m.askTeamName(defaultName))) || defaultName;
173
+ if (!name) { console.error(m.needName); return process.exit(2); }
174
+
175
+ const { accessToken, userId } = await verifySignInCode({ email, code: useCode, fetch });
176
+ const team = await createTeam({ name, accessToken, userId, fetch });
177
+ out(m.created(team.name || name));
178
+ out(`${m.inviteToken}: ${team.inviteToken}`);
179
+ out(`${m.memberCmd}: npm i -g sprag-cli@latest && sprag team join ${team.inviteToken}`);
180
+ out(`${m.orgLine}: export SPRAG_TEAM_TOKEN=${team.inviteToken}`);
181
+ out(m.dashboard(email));
182
+
183
+ if (noJoin) { out(m.noJoinNote); return undefined; }
184
+ let display = 'member';
185
+ try { display = userInfo().username || 'member'; } catch { /* no passwd entry */ }
186
+ await joinTeam({ token: team.inviteToken, displayName: display, fetch, version });
187
+ if (!teamSyncDisabled()) {
188
+ const r = await syncTeam({ force: true, fetch, version });
189
+ if (r.error) console.error(m.syncFailed(r.error));
190
+ else if (!r.skipped) out(m.synced(r.uploaded, r.rules));
191
+ }
192
+ return undefined;
193
+ }
194
+
195
+ export async function run({ args, hasFlag, getArg, version, fetch, interactive, ask }) {
196
+ const m = T[userLanguage()] || T.en;
197
+ const verb = args[1];
198
+ const out = (s) => console.log(s);
199
+
200
+ try {
201
+ switch (verb) {
202
+ case 'create': {
203
+ const tty = interactive !== undefined ? interactive : !!(process.stdin.isTTY && process.stdout.isTTY);
204
+ return await createFlow({ m, out, hasFlag, getArg, version, fetch, interactive: tty, ask: ask || prompt });
205
+ }
206
+ case 'join': {
207
+ const token = (args[2] && !args[2].startsWith('--') ? args[2] : null) || process.env.SPRAG_TEAM_TOKEN;
208
+ if (!token) { console.error(m.noToken); return process.exit(2); }
209
+ let name = getArg('--name');
210
+ if (!name) { try { name = userInfo().username; } catch { name = 'member'; } }
211
+ out(m.willSend);
212
+ out(m.asName(name));
213
+ if (!hasFlag('--yes')) {
214
+ if (!process.stdin.isTTY || !process.stdout.isTTY) { console.error(m.needYes); return process.exit(2); }
215
+ if (!(await confirm(m.ask))) { out(m.aborted); return undefined; }
216
+ }
217
+ const st = await joinTeam({ token, displayName: name, version });
218
+ out(m.joined(st.teamName));
219
+ return undefined;
220
+ }
221
+ case 'leave':
222
+ await leaveTeam();
223
+ out(m.left);
224
+ return undefined;
225
+ case 'status': {
226
+ const st = loadTeamState();
227
+ if (!isJoined(st)) { out(m.notJoined); return undefined; }
228
+ out(`${m.team}: ${st.teamName}`);
229
+ out(`${m.plan}: ${st.plan}`);
230
+ out(`${m.capture}: ${st.promptCapture ? m.on : m.off}`);
231
+ out(`${m.name}: ${st.displayName}`);
232
+ out(st.lastSyncAt ? m.lastSync(new Date(st.lastSyncAt).toLocaleString(), st.lastSyncRows || 0) : m.neverSynced);
233
+ if (st.lastError) out(`${m.lastError}: ${st.lastError}`);
234
+ if (hasFlag('--show-payload')) out(JSON.stringify(await aggregateDaily(), null, 2));
235
+ return undefined;
236
+ }
237
+ case 'sync': {
238
+ if (hasFlag('--background')) {
239
+ await autoJoin({ version });
240
+ await syncTeam({ version });
241
+ return undefined;
242
+ }
243
+ const r = await syncTeam({ force: hasFlag('--force'), version });
244
+ if (hasFlag('--quiet')) return undefined;
245
+ if (r.error) { console.error(r.error); return process.exit(1); }
246
+ if (r.skipped) out(r.skipped === 'disabled' ? m.disabled : m.skipped(r.skipped));
247
+ else out(m.synced(r.uploaded, r.rules));
248
+ return undefined;
249
+ }
250
+ case 'rules': {
251
+ let cache = null;
252
+ try { cache = JSON.parse(readFileSync(teamRulesCachePath(), 'utf8')); } catch { /* none cached */ }
253
+ const rules = cache && Array.isArray(cache.rules) ? cache.rules : [];
254
+ if (rules.length === 0) { out(m.noRules); return undefined; }
255
+ for (const r of rules) out(`- [${r.scope}] ${r.text}`);
256
+ return undefined;
257
+ }
258
+ case 'propose': {
259
+ const text = args.slice(2).filter((a, i, arr) => !a.startsWith('--') && arr[i - 1] !== '--scope').join(' ').trim();
260
+ if (!text) { console.error(m.needText); return process.exit(2); }
261
+ const scope = getArg('--scope') || 'ratchet';
262
+ if (scope !== 'ratchet' && scope !== 'model') { console.error(m.badScope); return process.exit(2); }
263
+ const st = loadTeamState();
264
+ if (!isJoined(st)) { console.error(m.needJoin); return process.exit(2); }
265
+ if (teamSyncDisabled()) { out(m.disabled); return undefined; }
266
+ const id = await rpc('team_rule_propose', { member_key: st.memberKey, rule_text: text, scope });
267
+ out(m.proposed(id));
268
+ return undefined;
269
+ }
270
+ default:
271
+ out(m.usage);
272
+ return verb && verb !== 'help' ? process.exit(2) : undefined;
273
+ }
274
+ } catch (e) {
275
+ console.error(e.message);
276
+ return process.exit(1);
277
+ }
278
+ }
package/src/cost.js CHANGED
@@ -249,8 +249,11 @@ export function modelRank(model) {
249
249
  return rank ?? 1;
250
250
  }
251
251
 
252
- /** Price rank each delegation tier targets: T2 → haiku, T1 → sonnet. */
253
- export const TIER_TARGET_RANK = { T2: 0, T1: 1 };
252
+ /**
253
+ * Price rank each delegation tier targets: T2 → haiku, T1 → sonnet, T1H → opus.
254
+ * T1H is worth it only from a rank-3 (Fable/Mythos) session.
255
+ */
256
+ export const TIER_TARGET_RANK = { T2: 0, T1: 1, T1H: 2 };
254
257
 
255
258
  /**
256
259
  * Which delegation tier a subagent run at this price rank represents.
@@ -260,6 +263,7 @@ export const TIER_TARGET_RANK = { T2: 0, T1: 1 };
260
263
  export function tierForRank(rank) {
261
264
  if (rank === 0) return 'T2';
262
265
  if (rank === 1) return 'T1';
266
+ if (rank === 2) return 'T1H';
263
267
  return null;
264
268
  }
265
269
 
@@ -33,6 +33,7 @@ import { writeStateFile } from './state-file.js';
33
33
  import { readSettings, writeSettings } from './installer.js';
34
34
  import { modelRatchetPathFor } from './model-rules.js';
35
35
  import { presetRatchetPath } from './preset-ratchet.js';
36
+ import { teamRatchetPath } from './team-ratchet.js';
36
37
  import { globalRatchetMdPath } from './harness.js';
37
38
  import { debug } from './debug.js';
38
39
 
@@ -183,11 +184,11 @@ export const coworkCopyPath = (dir) => join(dir, COPY_REL);
183
184
 
184
185
  // ---------- content ----------
185
186
 
186
- /** @returns {string|null} null when none of the three source files exists */
187
+ /** @returns {string|null} null when none of the source files exists */
187
188
  export function renderCoworkRatchet(lang = userLanguage()) {
188
189
  const ko = lang === 'ko';
189
190
  const parts = [];
190
- for (const file of [modelRatchetPathFor('global'), presetRatchetPath(), globalRatchetMdPath()]) {
191
+ for (const file of [modelRatchetPathFor('global'), presetRatchetPath(), teamRatchetPath(), globalRatchetMdPath()]) {
191
192
  let body;
192
193
  try { body = readFileSync(file, 'utf8'); } catch { continue; }
193
194
  const name = file.split(/[\\/]/).pop();
@@ -195,8 +196,8 @@ export function renderCoworkRatchet(lang = userLanguage()) {
195
196
  }
196
197
  if (parts.length === 0) return null;
197
198
  const intro = ko
198
- ? '이 파일은 sprag가 Claude 앱 Cowork 탭용으로 생성한 래칫 사본입니다. Cowork는 ~/.claude 의 설정과 import를 읽지 않으므로, 원본 래칫 세 파일의 본문을 여기에 옮겨 둡니다. sprag가 원본이 바뀔 때마다 이 파일을 통째로 다시 쓰므로 직접 고치지 마십시오. Claude Code(Code 탭)는 settings.json 의 claudeMdExcludes 로 이 파일을 읽지 않습니다.'
199
- : 'This file is a copy of the ratchet rules that sprag generates for the Cowork tab of the Claude app. Cowork does not read the settings or imports under ~/.claude, so the bodies of the three source ratchet files are copied here. sprag rewrites this whole file whenever a source changes, so do not edit it by hand. Claude Code (the Code tab) skips this file through claudeMdExcludes in settings.json.';
199
+ ? '이 파일은 sprag가 Claude 앱 Cowork 탭용으로 생성한 래칫 사본입니다. Cowork는 ~/.claude 의 설정과 import를 읽지 않으므로, 원본 래칫 파일들의 본문을 여기에 옮겨 둡니다. sprag가 원본이 바뀔 때마다 이 파일을 통째로 다시 쓰므로 직접 고치지 마십시오. Claude Code(Code 탭)는 settings.json 의 claudeMdExcludes 로 이 파일을 읽지 않습니다.'
200
+ : 'This file is a copy of the ratchet rules that sprag generates for the Cowork tab of the Claude app. Cowork does not read the settings or imports under ~/.claude, so the bodies of the source ratchet files are copied here. sprag rewrites this whole file whenever a source changes, so do not edit it by hand. Claude Code (the Code tab) skips this file through claudeMdExcludes in settings.json.';
200
201
  return `${COWORK_MARKER}\n${intro}\n\n${parts.join('\n')}`;
201
202
  }
202
203
 
@@ -28,7 +28,7 @@ const MARK_LINE = `# managed by sprag (${OWNER_MARK}); edits are overwritten`;
28
28
  // comparison (lookups, test runs, mechanical conversion) extra thinking buys
29
29
  // nothing, so those agents run at low. The worker implements a design the
30
30
  // caller already fixed, against tests, so it takes the default for well-specified
31
- // work, medium. The main session's effort is never touched here, and
31
+ // work, medium; the opus worker takes the larger, harder share of that work at high. The main session's effort is never touched here, and
32
32
  // CLAUDE_CODE_EFFORT_LEVEL must stay unset: it overrides every agent's own value.
33
33
  export const DEFINITIONS = [
34
34
  {
@@ -63,6 +63,18 @@ export const DEFINITIONS = [
63
63
  role: 'Your role: multi-step work and the implementation half of a design the caller already fixed.',
64
64
  description: 'Mid-tier worker for multi-step tasks and implementation handed over with a fixed design. It has no MCP tools.',
65
65
  },
66
+ {
67
+ // The full model id, not the `opus` alias: Claude Code resolves a bare
68
+ // alias to an older model than the one the tier means (haiku was observed
69
+ // resolving to Haiku 4.5). A host that remaps ANTHROPIC_DEFAULT_OPUS_MODEL
70
+ // is better served by the rule's `model: opus` fallback phrase.
71
+ name: 'opus-worker',
72
+ model: 'claude-opus-5-5',
73
+ effort: 'high',
74
+ tools: 'Read, Grep, Glob, Bash, Edit, Write, NotebookEdit',
75
+ role: 'Your role: large implementation and debugging work whose design the caller already fixed. Hand design judgement and irreversible actions back to the caller.',
76
+ description: 'Heavy worker for Fable sessions: large implementation, debugging and refactoring handed over with a fixed design. It has no MCP tools.',
77
+ },
66
78
  ];
67
79
 
68
80
  function render(def) {
@@ -123,6 +123,7 @@ export async function buildAppendix(payload, { cfg = loadConfig(), home = homedi
123
123
  // because the Korean section below weighs the same answer.
124
124
  const modelHint = String(payload?.tool_input?.model || payload?.tool_input?.subagent_type || '');
125
125
  const isHaiku = /haiku/i.test(modelHint);
126
+ const isOpus = /opus/i.test(modelHint);
126
127
 
127
128
  const bounds = boundsText();
128
129
  if (bounds) {
@@ -132,8 +133,8 @@ export async function buildAppendix(payload, { cfg = loadConfig(), home = homedi
132
133
  // own calibrated budget for the tier replaces them, so the subagent is told
133
134
  // the same limit the main model was (see route-caps.js). A T1 rule sets no
134
135
  // call count, and the default one stays in that case.
135
- const fallback = isHaiku ? { calls: 8, out: 1500 } : { calls: 20, out: 8000 };
136
- const matched = routeCaps?.[isHaiku ? 'T2' : 'T1'];
136
+ const fallback = isHaiku ? { calls: 8, out: 1500 } : isOpus ? { calls: 20, out: 30000 } : { calls: 20, out: 8000 };
137
+ const matched = routeCaps?.[isHaiku ? 'T2' : isOpus ? 'T1H' : 'T1'];
137
138
  const calls = Number.isFinite(matched?.calls) && matched.calls > 0 ? matched.calls : fallback.calls;
138
139
  const out = Number.isFinite(matched?.out) && matched.out > 0 ? matched.out : fallback.out;
139
140
  const capLine = `Cap for this delegation: ${calls} tool calls, ${out.toLocaleString('en-US')} output tokens.`;
@@ -52,6 +52,16 @@ export function presetRatchetImportLine() {
52
52
  }
53
53
  export const PRESET_RATCHET_IMPORT_RE = /^@~\/\.claude\/ratchet-preset\.md\s*$/m;
54
54
 
55
+ /**
56
+ * The team ratchet: rules the user's team approved, cached by `sprag team sync`
57
+ * and rendered into a tool-owned file. Unlike the others its import is not in
58
+ * the rendered block; it is added only for members, when the file exists.
59
+ */
60
+ export function teamRatchetImportLine() {
61
+ return '@~/.claude/ratchet-team.md';
62
+ }
63
+ export const TEAM_RATCHET_IMPORT_RE = /^@~\/\.claude\/ratchet-team\.md\s*$/m;
64
+
55
65
  /**
56
66
  * Render ratchet-preset.md. `rules` is [{ id, text }] in display order. The
57
67
  * file is loaded into every session, so the header stays short.
@@ -72,7 +72,8 @@ export function wilsonLowerBound(errs, n) {
72
72
  // route-scan's pre-calibration defaults. Kept as local constants rather than
73
73
  // imported: route-scan imports this module, and a static back-import would
74
74
  // close the cycle.
75
- const DEFAULT_BUDGET = { T2: { calls: 8, out: 1500 }, T1: { calls: null, out: 8000 } };
75
+ const DEFAULT_BUDGET = { T2: { calls: 8, out: 1500 }, T1: { calls: null, out: 8000 },
76
+ T1H: { calls: null, out: 30000 } };
76
77
 
77
78
  /** The budget a rule is held to: its calibrated one, or the default of its tier. */
78
79
  export function ruleBudget(rule) {
@@ -132,20 +133,27 @@ function shortExample(text) {
132
133
  return one.length <= EXAMPLE_MAX ? one : `${one.slice(0, EXAMPLE_MAX - 1)}…`;
133
134
  }
134
135
 
135
- // T1 is named through its delegation agent only when that file exists; without
136
- // it the literal `model: sonnet` stays so output on such machines is unchanged.
137
- function t1Target(root, ko) {
138
- if (resolveAgentName('sonnet', root) === null) return null;
139
- return (ko ? agentPhrase : agentPhraseEn)('sonnet', { root });
136
+ // T1 (and T1H) is named through its delegation agent only when that file
137
+ // exists; without it the literal `model: sonnet` / `model: opus` stays so
138
+ // output on such machines is unchanged.
139
+ function t1Target(root, ko, tier = 'sonnet') {
140
+ if (resolveAgentName(tier, root) === null) return null;
141
+ return (ko ? agentPhrase : agentPhraseEn)(tier, { root });
140
142
  }
141
143
 
142
144
  export function modelRuleBaseText(g, lang = userLanguage(), { root } = {}) {
143
145
  if (lang === 'ko') {
146
+ if (g.tier === 'T1H') {
147
+ return `"${g.label}" 고난도 구현 요청(예: "${shortExample(g.example)}")은 Fable·Mythos 세션에 한해 ${t1Target(root, true, 'opus') ?? 'model: opus'}에 위임`;
148
+ }
144
149
  return g.tier === 'T2'
145
150
  ? `"${g.label}" 단순 요청(예: "${shortExample(g.example)}")은 ${agentPhrase(g.agent, { root })}에 위임`
146
151
  : `"${g.label}" 중간 난도 요청(예: "${shortExample(g.example)}")은 ${t1Target(root, true) ?? 'model: sonnet'}에 위임`;
147
152
  }
148
153
  const example = g.exampleEn || g.example;
154
+ if (g.tier === 'T1H') {
155
+ return `Heavy "${g.labelEn}" implementation requests (e.g. "${shortExample(example)}") go to ${t1Target(root, false, 'opus') ?? 'a model: opus subagent'}, in Fable/Mythos sessions only`;
156
+ }
149
157
  return g.tier === 'T2'
150
158
  ? `Simple "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${agentPhraseEn(g.agent, { root })}`
151
159
  : `Moderate "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${t1Target(root, false) ?? 'a model: sonnet subagent'}`;
@@ -275,8 +283,12 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
275
283
  '- **effort**: 모델 티어와 다른 축이다. 설치된 서브에이전트는 검증 형태에 따라',
276
284
  ' effort가 고정되어 있다. 명령 출력이나 파일 대조로 검증되는 위임(조회·명령',
277
285
  ' 실행·정형 변환을 맡는 haiku 에이전트)은 low, 확정된 설계를 테스트에 맞춰',
278
- ' 구현하는 위임(sonnet 워커)은 medium이다. 메인 세션의 effort는 바꾸지 않고,',
286
+ ' 구현하는 위임(sonnet 워커)은 medium, 범위가 큰 구현·디버깅 위임(opus 워커)은',
287
+ ' high다. 메인 세션의 effort는 바꾸지 않고,',
279
288
  ' CLAUDE_CODE_EFFORT_LEVEL은 서브에이전트의 effort를 덮어쓰므로 설정하지 않는다.',
289
+ '- **고난도 구현(T1H)**: 설계는 메인이 확정하고, 범위가 크거나 sonnet 이 틀린',
290
+ ' 적 있는 구현·디버깅은 opus 에 위임한다. Fable·Mythos 세션에서만 적용되며,',
291
+ ' 설계 판단과 되돌릴 수 없는 작업은 메인 모델에 남긴다.',
280
292
  '- **위임 배치**: 서브에이전트 비용의 지배 항은 생성이 아니라 컨텍스트',
281
293
  ' 적재다. 같은 파일들을 읽어야 하는 작업 여러 개는 에이전트 하나에 묶어',
282
294
  ' 보내고, 읽는 영역이 다른 작업만 나눈다.',
@@ -337,8 +349,13 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
337
349
  ' by how their work is verified: low where the check is command output or a',
338
350
  ' file comparison (the haiku agents for lookups, command runs and mechanical',
339
351
  ' conversion), medium where a fixed design is implemented against tests (the',
340
- ' sonnet worker). Leave the main session\'s effort alone, and never set',
352
+ ' sonnet worker), high for large implementation or debugging (the opus',
353
+ ' worker). Leave the main session\'s effort alone, and never set',
341
354
  ' CLAUDE_CODE_EFFORT_LEVEL: it overrides every subagent\'s own value.',
355
+ '- **Heavy implementation (T1H)**: the main model fixes the design; large-scope',
356
+ ' implementation or debugging, or work sonnet has already got wrong, goes to',
357
+ ' opus. Applies in Fable/Mythos sessions only; design judgement and',
358
+ ' irreversible work stay on the main model.',
342
359
  '- **Batching**: a subagent\'s dominant cost is context ingestion, not',
343
360
  ' generation. Bundle tasks that read the same files into one agent; split',
344
361
  ' only when the areas they read differ.',
@@ -384,13 +401,13 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
384
401
  const saved = r.savedUsd ? `, saved ~${signedUsd(r.savedUsd)}` : '';
385
402
  return `${base}, delegated ×${r.delegatedRuns} err ${pct(r.delegatedErrRate)}%${saved}`;
386
403
  };
387
- // Merged T2+T1 rules carry two caps, so they state both once rather than
404
+ // Merged rules carry one cap per tier, so they state them once rather than
388
405
  // repeating the whole stop-condition per tier.
389
- const mergedBudget = (t2, t1) => ko
390
- ? `상한 haiku ${budgetCapPhrase(t2, 'ko')} / sonnet ${budgetCapPhrase(t1, 'ko')}`
391
- : `cap haiku ${budgetCapPhrase(t2, 'en')} / sonnet ${budgetCapPhrase(t1, 'en')}`;
406
+ const TIER_MODEL = { T2: 'haiku', T1: 'sonnet', T1H: 'opus' };
407
+ const mergedBudget = (tiers) => (ko ? '상한 ' : 'cap ') + tiers
408
+ .map((r) => `${TIER_MODEL[r.tier]} ${budgetCapPhrase(r, ko ? 'ko' : 'en')}`).join(' / ');
392
409
 
393
- // A category can carry both a T2 (haiku) and a T1 (sonnet) rule. Tier is
410
+ // A category can carry a T2 (haiku), a T1 (sonnet) and a T1H (opus) rule. Tier is
394
411
  // only known after an episode finishes, so two separate bullets give the
395
412
  // reading LLM no way to pick one at request time — merge such pairs into a
396
413
  // single conditional rule with explicit request-time criteria (default to
@@ -405,38 +422,50 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
405
422
  // text stored on the rule is whatever wording and language were current when
406
423
  // it was promoted, which is how an English line sat in a Korean file. A rule
407
424
  // missing what the template needs still falls back to its stored text.
408
- const baseTextOf = (r) => ((r.tier === 'T2' || r.tier === 'T1') && r.label && (ko || r.labelEn)
425
+ const baseTextOf = (r) => ((r.tier === 'T2' || r.tier === 'T1' || r.tier === 'T1H') && r.label && (ko || r.labelEn)
409
426
  ? modelRuleBaseText(r, lang, { root }) : r.rule);
427
+ const DELEGATION_TIERS = ['T2', 'T1', 'T1H'];
410
428
  for (const group of byCategory.values()) {
411
- const t2 = group.find((r) => r.tier === 'T2');
412
- const t1 = group.find((r) => r.tier === 'T1');
429
+ const picked = DELEGATION_TIERS.map((t) => group.find((r) => r.tier === t)).filter(Boolean);
413
430
  // A second rule of a tier the line already covers (two global rules of one
414
431
  // category, promoted from different projects) says nothing the line does
415
432
  // not, so it adds no line of its own. Its review flag and its numbers stay
416
433
  // visible on the line that speaks for it.
417
- const extras = t2 || t1 ? group.filter((r) => r !== t2 && r !== t1 && (r.tier === 'T2' || r.tier === 'T1')) : [];
418
- const unknown = group.filter((r) => r !== t2 && r !== t1 && !extras.includes(r));
434
+ const extras = picked.length ? group.filter((r) => !picked.includes(r) && DELEGATION_TIERS.includes(r.tier)) : [];
435
+ const unknown = group.filter((r) => !picked.includes(r) && !extras.includes(r));
419
436
  const extraHealth = extras.map(healthOf).join('');
420
437
  const extraStats = extras.map((r) => ` / +${r.tier} ${statsOf(r)}`).join('');
421
- if (t2 && t1) {
438
+ if (picked.length >= 2) {
422
439
  // Seeded rules carry a localized example; scan-produced ones only have the
423
440
  // user's own prompt, so exampleEn is a preference, not a requirement.
424
441
  const ex = (r) => shortExample(ko ? r.example : (r.exampleEn || r.example));
425
- // One line per category, stating only what differs between the two tiers.
442
+ const target = (r) => {
443
+ if (r.tier === 'T2') return (ko ? agentPhrase : agentPhraseEn)(r.agent, { root });
444
+ const name = r.tier === 'T1H' ? 'opus' : 'sonnet';
445
+ return t1Target(root, ko, name) ?? `model: ${name}`;
446
+ };
447
+ // One line per category, stating only what differs between the tiers.
426
448
  // The exceptions this used to spell out (design judgement, irreversible
427
449
  // work) are identical in every rule and now sit in the header's shared
428
450
  // clauses, so a rule the model re-reads on every request is short enough to
429
451
  // match at a glance.
452
+ const when = (r, first) => {
453
+ if (first) return ko ? '기본' : 'by default';
454
+ if (r.tier === 'T1H') return ko ? '범위가 크면(Fable·Mythos 세션 한정)' : 'for large scope (Fable/Mythos sessions only)';
455
+ return ko ? '여러 단계·여러 파일이 얽히면' : 'when it spans multiple steps or file edits';
456
+ };
457
+ const parts = picked.map((r, i) => (ko
458
+ ? `${when(r, i === 0)} ${target(r)}(예: "${ex(r)}")`
459
+ : `${i === 0 ? `${target(r)} ${when(r, true)}` : `${target(r)} ${when(r, false)}`} (e.g. "${ex(r)}")`));
460
+ const head = picked[0];
430
461
  const rule = ko
431
- ? `"${t2.label}": 기본 ${agentPhrase(t2.agent, { root })}(예: "${ex(t2)}"), ` +
432
- `여러 단계·여러 파일이 얽히면 ${t1Target(root, true) ?? 'model: sonnet'}(예: "${ex(t1)}"). ` +
433
- mergedBudget(t2, t1)
434
- : `"${t2.labelEn || t2.label}": ${agentPhraseEn(t2.agent, { root })} by default (e.g. "${ex(t2)}"), ` +
435
- `${t1Target(root, false) ?? 'model: sonnet'} when it spans multiple steps or file edits (e.g. "${ex(t1)}"). ` +
436
- mergedBudget(t2, t1);
437
- lines.push(`- ${rule}${healthOf(t2)}${healthOf(t1)}${extraHealth} <!-- T2 ${statsOf(t2)} / T1 ${statsOf(t1)}${extraStats} -->`);
438
- } else if (t2 || t1) {
439
- const r = t2 || t1;
462
+ ? `"${head.label}": ${parts.join(', ')}. ${mergedBudget(picked)}`
463
+ : `"${head.labelEn || head.label}": ${parts.join(', ')}. ${mergedBudget(picked)}`;
464
+ const health = picked.map(healthOf).join('');
465
+ const stats = picked.map((r) => `${r.tier} ${statsOf(r)}`).join(' / ');
466
+ lines.push(`- ${rule}${health}${extraHealth} <!-- ${stats}${extraStats} -->`);
467
+ } else if (picked.length === 1) {
468
+ const r = picked[0];
440
469
  lines.push(`- ${composeRuleText(baseTextOf(r), r, lang)}${healthOf(r)}${extraHealth} <!-- ${statsOf(r)}${extraStats} -->`);
441
470
  }
442
471
  for (const r of unknown) lines.push(`- ${composeRuleText(r.rule, r, lang)}${healthOf(r)} <!-- ${statsOf(r)} -->`);
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
  }