sprag-cli 3.68.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.
@@ -92,7 +92,9 @@ function pickTarget(group, { prices, known, home }) {
92
92
  .sort((a, b) => a.usd - b.usd);
93
93
  const eligible = cheaper.filter((x) => known.has(`${group.provider}|${x.model}`));
94
94
  // Simple work goes to the cheapest model; moderate work steps down one notch.
95
- const pick = group.tiers.T2 >= group.tiers.T1 ? eligible[0] : eligible.at(-1);
95
+ // The heaviest band goes to the most expensive cheaper model (the T1H target).
96
+ const { T2, T1, T1H } = group.tiers;
97
+ const pick = T2 >= T1 && T2 >= T1H ? eligible[0] : eligible.at(-1);
96
98
  return {
97
99
  suggestedModel: pick?.model ?? null,
98
100
  alternatives: pick ? [] : cheaper.slice(0, 3).map((x) => x.model),
@@ -135,10 +137,10 @@ export async function runCodexRouteScan({ days = 14, now = Date.now(), home, dir
135
137
  const runtime = codexPromptCategory(ep.text);
136
138
  if (!runtime || !categorize(ep.text, ep.tools)) continue;
137
139
  const tier = tierOf(ep, runtime, thresholds);
138
- if (tier !== 'T1' && tier !== 'T2') continue;
140
+ if (tier !== 'T1' && tier !== 'T2' && tier !== 'T1H') continue;
139
141
  const key = `${runtime.id}|${ep.provider}|${ep.model}|${ep.projectRoot}`;
140
142
  const g = groups.get(key) || { signature: key, category: runtime.id, label: runtime.label, labelEn: runtime.labelEn,
141
- from: ep.model, provider: ep.provider, projectRoot: ep.projectRoot, count: 0, tiers: { T1: 0, T2: 0 }, example: ep.text,
143
+ from: ep.model, provider: ep.provider, projectRoot: ep.projectRoot, count: 0, tiers: { T1: 0, T2: 0, T1H: 0 }, example: ep.text,
142
144
  tokens: { input: 0, cached: 0, cacheWrite: 0, output: 0 }, lastSeen: 0 };
143
145
  g.count++;
144
146
  g.tiers[tier]++;
@@ -154,7 +156,7 @@ export async function runCodexRouteScan({ days = 14, now = Date.now(), home, dir
154
156
  (!r.provider || r.provider === g.provider) &&
155
157
  (r.scope === 'global' || r.targetRoot === g.projectRoot))) return true;
156
158
  const shared = sharedCodexModelRules({ cfg, dir, root: g.projectRoot, model: g.from, provider: g.provider });
157
- return ['T1', 'T2'].every((tier) => !g.tiers[tier] || shared.some((r) => r.category === g.category && r.tier === tier));
159
+ return ['T1', 'T2', 'T1H'].every((tier) => !g.tiers[tier] || shared.some((r) => r.category === g.category && r.tier === tier));
158
160
  };
159
161
  const known = new Set([...observed, ...rules.filter((r) => r.provider).map((r) => `${r.provider}|${r.model}`)]);
160
162
  const target = cfg?.codex?.delegateTarget?.model;
@@ -43,7 +43,7 @@ function printShared(cfg, { details = false } = {}) {
43
43
  if (missing.length) {
44
44
  console.log(`Unmapped tiers for ${parent || 'any parent model'} on ${provider || 'unknown provider'}: ${missing.join(', ')}`
45
45
  + `${autoOn && auto.reason ? ` (auto: ${auto.reason})` : ''}. `
46
- + `Set: sprag delegate shared map <T1|T2> --from ${parent || '<parent-model>'} --model <target-model> --agent codex`);
46
+ + `Set: sprag delegate shared map <T1|T1H|T2> --from ${parent || '<parent-model>'} --model <target-model> --agent codex`);
47
47
  }
48
48
  console.log(`Automatic tier models: ${autoOn ? 'on' : 'off'} (turn ${autoOn ? 'off' : 'on'}: sprag delegate shared auto ${autoOn ? 'off' : 'on'} --agent codex)`);
49
49
  if (!parent && targets.length) console.log('Codex config names no default model; each mapping routes only sessions on its --from model.');
@@ -74,7 +74,7 @@ export function run({ args, getArg, hasFlag, root }) {
74
74
  if (sub === 'shared') {
75
75
  const action = args[2] || 'status';
76
76
  if (!['status', 'on', 'off', 'map', 'unmap', 'min-context', 'auto'].includes(action)) {
77
- throw new Error('Usage: delegate shared status|on|off|map <T1|T2>|unmap <T1|T2>|min-context <tokens|default>|auto on|off --agent codex');
77
+ throw new Error('Usage: delegate shared status|on|off|map <T1|T1H|T2>|unmap <T1|T1H|T2>|min-context <tokens|default>|auto on|off --agent codex');
78
78
  }
79
79
  if (hasFlag('--global') || hasFlag('--project')) throw new Error('Shared policies retain their approved scope; mappings do not change it.');
80
80
  const provided = (flag) => args.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
@@ -86,7 +86,7 @@ export function run({ args, getArg, hasFlag, root }) {
86
86
  const tier = args[3];
87
87
  const from = getArg('--from');
88
88
  const provider = getArg('--provider') ?? codexProviderName();
89
- if (!SHARED_RULE_TIERS.includes(tier)) throw new Error('Shared rule tier must be T1 or T2.');
89
+ if (!SHARED_RULE_TIERS.includes(tier)) throw new Error('Shared rule tier must be T1, T1H or T2.');
90
90
  const same = (t) => t.tier === tier && t.from === from && t.provider === provider;
91
91
  if (action === 'map') {
92
92
  const target = validateCodexSharedTarget({ tier, from, provider, model: getArg('--model'), effort: getArg('--effort') });
@@ -30,9 +30,9 @@ Usage: sprag <command> --agent codex [options]
30
30
  delegate implementation After design: resolve T1 with --from, --provider, --session
31
31
  delegate rules Codex rules and shared delegation policies
32
32
  delegate shared on|off|status Reuse approved policies; keep original scopes
33
- delegate shared map T1|T2 Map a tier: --from <parent> --model <target>
33
+ delegate shared map T1|T1H|T2 Map a tier: --from <parent> --model <target>
34
34
  Optional --provider <id> and --effort <level>
35
- delegate shared unmap T1|T2 Remove a mapping: --from <parent> [--provider <id>]
35
+ delegate shared unmap T1|T1H|T2 Remove a mapping: --from <parent> [--provider <id>]
36
36
  delegate shared min-context N Parent input tokens before shared guidance; default resets
37
37
  delegate shared auto on|off Resolve unmapped tiers from priced models already run in Codex
38
38
  route-scan [--refresh] Discover recurring simple turns in local logs
@@ -202,7 +202,7 @@ export async function run({ args, version }) {
202
202
  for (const c of open) {
203
203
  const target = c.suggestedModel ? `-> ${c.suggestedModel}${Number.isFinite(c.estSavedUsd) ? ` (~${signedUsd(c.estSavedUsd, 4)} over ${c.count} turns)` : ''}`
204
204
  : `-> choose --model${c.alternatives.length ? ` (priced cheaper: ${c.alternatives.join(', ')})` : ''}`;
205
- console.log(`R${c.id} ${c.category} x${c.count} [T2 ${c.tiers.T2} / T1 ${c.tiers.T1}] ${c.provider || 'unknown provider'} / ${c.from} ${target}`);
205
+ console.log(`R${c.id} ${c.category} x${c.count} [T2 ${c.tiers.T2} / T1 ${c.tiers.T1} / T1H ${c.tiers.T1H ?? 0}] ${c.provider || 'unknown provider'} / ${c.from} ${target}`);
206
206
  console.log(` ${c.projectRoot}`);
207
207
  console.log(` e.g. ${JSON.stringify(c.example)}`);
208
208
  }
@@ -255,7 +255,7 @@ export async function run({ args, hasFlag }) {
255
255
  example: routeCandidate.example,
256
256
  count: routeCandidate.count,
257
257
  // Calibrated budget snapshot — ratchet-model.md restates it when it
258
- // merges a category's T2 and T1 rules into one conditional rule.
258
+ // merges a category's T2, T1 and T1H rules into one conditional rule.
259
259
  budget: routeCandidate.budget || null,
260
260
  promotedAt: new Date().toISOString().slice(0, 10),
261
261
  lastSeen: new Date().toISOString().slice(0, 10),
@@ -303,6 +303,17 @@ export async function run({ args, hasFlag, numArg }) {
303
303
  // bundled rule reaches ratchet-preset.md here, without a reinstall. It
304
304
  // also moves presets older versions appended to ratchet.md.
305
305
  try { (await import('../preset-ratchet.js')).preparePresetRatchet(); } catch (e) { debug('route-scan:sync-preset', e); }
306
+ // Team layer (opt-in): re-render the team ratchet file from the cached rules,
307
+ // keep the CLAUDE.md import in step with the file, and kick the detached sync
308
+ // when it is due. Nothing here touches the network.
309
+ let teamBlock = null;
310
+ try {
311
+ const tr = await import('../team-ratchet.js');
312
+ tr.syncTeamRatchet({ lang });
313
+ tr.ensureTeamImport();
314
+ tr.spawnTeamSyncIfDue();
315
+ teamBlock = tr.autoJoinNoticeBlock(undefined, lang);
316
+ } catch (e) { debug('route-scan:team', e); }
306
317
  // Cowork cannot follow the ~/.claude imports, so it gets a pasted copy.
307
318
  try { (await import('../cowork-sync.js')).syncCoworkRatchets({ discover: true }); } catch (e) { debug('route-scan:cowork-sync', e); }
308
319
  // Registered rules whose delegated-category error rate crossed the
@@ -375,6 +386,7 @@ export async function run({ args, hasFlag, numArg }) {
375
386
  } catch (e) { debug('route-scan:update-check', e); /* the offer is optional */ }
376
387
 
377
388
  if (open.length === 0 && reviewRules.length === 0) {
389
+ if (teamBlock) console.log(teamBlock);
378
390
  if (updateBlock) console.log(updateBlock);
379
391
  if (seedBlock) console.log(seedBlock);
380
392
  if (doc2mdBlock) console.log(doc2mdBlock);
@@ -382,6 +394,7 @@ export async function run({ args, hasFlag, numArg }) {
382
394
  if (cohesionBlock) console.log(cohesionBlock);
383
395
  return; // nothing else to inject
384
396
  }
397
+ if (teamBlock) console.log(teamBlock);
385
398
  if (updateBlock) console.log(updateBlock);
386
399
  if (seedBlock) console.log(seedBlock);
387
400
  if (doc2mdBlock) console.log(doc2mdBlock);
@@ -392,10 +405,10 @@ export async function run({ args, hasFlag, numArg }) {
392
405
  if (open.length > 0) {
393
406
  if (lang === 'ko') {
394
407
  lines.push(`[sprag route-scan] 최근 ${cache.days}일 세션에서 비싼 모델(opus/fable)이 반복 처리해 온, 더 싼 모델로 넘겨도 되는 작업이 감지되었습니다.`);
395
- lines.push('(R<N>은 후보 번호, T2/T1은 난이도 등급입니다 — 사용자에게 전달할 때는 코드가 아니라 아래 풀어쓴 설명으로 브리핑하세요)');
408
+ lines.push('(R<N>은 후보 번호, T2/T1/T1H는 난이도 등급입니다 — 사용자에게 전달할 때는 코드가 아니라 아래 풀어쓴 설명으로 브리핑하세요)');
396
409
  } else {
397
410
  lines.push(`[sprag route-scan] Over the last ${cache.days} days, expensive models (opus/fable) repeatedly handled work that a cheaper model could take.`);
398
- lines.push('(R<N> is the candidate id, T2/T1 the difficulty tier — brief the user with the spelled-out wording below, not the codes.)');
411
+ lines.push('(R<N> is the candidate id, T2/T1/T1H the difficulty tier — brief the user with the spelled-out wording below, not the codes.)');
399
412
  }
400
413
  for (const c of open) {
401
414
  const tier = c.tier || 'T2';
@@ -490,7 +503,7 @@ export async function run({ args, hasFlag, numArg }) {
490
503
  (await import('../first-run-note.js')).printOnce('route-scan', lang);
491
504
  return;
492
505
  }
493
- console.log(lang === 'ko' ? '\n위임 후보 (R<N>=후보 번호, T2/T1=난이도 등급):' : '\nDelegation candidates (R<N> = candidate id, T2/T1 = difficulty tier):');
506
+ console.log(lang === 'ko' ? '\n위임 후보 (R<N>=후보 번호, T2/T1/T1H=난이도 등급):' : '\nDelegation candidates (R<N> = candidate id, T2/T1/T1H = difficulty tier):');
494
507
  for (const c of open) {
495
508
  const tier = c.tier || 'T2';
496
509
  if (lang === 'ko') {
@@ -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
 
@@ -22,10 +22,19 @@ const MARK_LINE = `# managed by sprag (${OWNER_MARK}); edits are overwritten`;
22
22
 
23
23
  // Models are aliases on purpose: behind a gateway they resolve through
24
24
  // ANTHROPIC_DEFAULT_*_MODEL, and a full model id would bypass that mapping.
25
+ //
26
+ // Effort is a separate axis from the tier and is set by how the work gets
27
+ // verified, not by how hard it is. Where the check is command output or a file
28
+ // comparison (lookups, test runs, mechanical conversion) extra thinking buys
29
+ // nothing, so those agents run at low. The worker implements a design the
30
+ // caller already fixed, against tests, so it takes the default for well-specified
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
+ // CLAUDE_CODE_EFFORT_LEVEL must stay unset: it overrides every agent's own value.
25
33
  export const DEFINITIONS = [
26
34
  {
27
35
  name: 'haiku-explore',
28
36
  model: 'haiku',
37
+ effort: 'low',
29
38
  tools: 'Read, Grep, Glob, Bash',
30
39
  role: 'Your role: read-only exploration, lookups, status checks, and questions about pasted logs or text.',
31
40
  description: 'Cheap read-only exploration, lookup, status checks and summaries for delegated tasks. It has no MCP tools.',
@@ -33,6 +42,7 @@ export const DEFINITIONS = [
33
42
  {
34
43
  name: 'haiku-runner',
35
44
  model: 'haiku',
45
+ effort: 'low',
36
46
  tools: 'Bash, Read, Grep, Glob',
37
47
  role: 'Your role: run the build, test, or git commands you are given and report the result.',
38
48
  description: 'Cheap runner for build, test and git commands in delegated tasks. It has no MCP tools.',
@@ -40,6 +50,7 @@ export const DEFINITIONS = [
40
50
  {
41
51
  name: 'haiku-translate',
42
52
  model: 'haiku',
53
+ effort: 'low',
43
54
  tools: 'Read, Grep, Glob, Edit, Write',
44
55
  role: 'Your role: batch translation and mechanical text conversion of the files or text you are given.',
45
56
  description: 'Cheap batch translation and structured text conversion for delegated tasks. It has no MCP tools.',
@@ -47,10 +58,23 @@ export const DEFINITIONS = [
47
58
  {
48
59
  name: 'sonnet-worker',
49
60
  model: 'sonnet',
61
+ effort: 'medium',
50
62
  tools: 'Read, Grep, Glob, Bash, Edit, Write, NotebookEdit',
51
63
  role: 'Your role: multi-step work and the implementation half of a design the caller already fixed.',
52
64
  description: 'Mid-tier worker for multi-step tasks and implementation handed over with a fixed design. It has no MCP tools.',
53
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
+ },
54
78
  ];
55
79
 
56
80
  function render(def) {
@@ -60,6 +84,7 @@ function render(def) {
60
84
  `description: ${def.description}`,
61
85
  `tools: ${def.tools}`,
62
86
  `model: ${def.model}`,
87
+ `effort: ${def.effort}`,
63
88
  MARK_LINE,
64
89
  '---',
65
90
  def.role,
@@ -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.`;
@@ -34,6 +34,22 @@ function markShown(key, now) {
34
34
  writeFileSync(firstRunStatePath(), JSON.stringify(state) + '\n');
35
35
  }
36
36
 
37
+ /** When `key` was marked shown (epoch ms), or null. For notes that stay
38
+ * visible for a window after the first time instead of printing once. */
39
+ export function noteShownAt(key) {
40
+ const at = Number(load()[key]);
41
+ return Number.isFinite(at) && at > 0 ? at : null;
42
+ }
43
+
44
+ /** Persist `key` as shown at `now`. Best-effort, like `shouldShowOnce`. */
45
+ export function markNoteShown(key, now = Date.now()) {
46
+ try {
47
+ markShown(key, now);
48
+ } catch {
49
+ /* state dir unwritable — the note may show again, never a failure */
50
+ }
51
+ }
52
+
37
53
  /** True only the first time this key is asked about. Best-effort — a
38
54
  * read-only state dir just means the note repeats, never that we crash. */
39
55
  export function shouldShowOnce(key, { now = Date.now() } = {}) {