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.
@@ -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
@@ -44,6 +44,10 @@ export const T2_OUT_CLAMP = [1000, 3000]; // default 1500 pre-calibration
44
44
  export const T1_MAX_MUTATING = 6;
45
45
  export const T1_MAX_ERRORS = 1;
46
46
  export const T1_OUT_CLAMP = [5000, 15000]; // default 8000 pre-calibration
47
+ // T1H (opus, Fable sessions only): too heavy for sonnet but still bounded.
48
+ export const T1H_MAX_ERRORS = 3;
49
+ export const T1H_MAX_MUTATING = 20;
50
+ export const T1H_OUT_CLAMP = [15000, 60000]; // default 30000 pre-calibration
47
51
  export const T0_MIN_ERRORS = 2; // repeated tool errors = hard, by outcome
48
52
  export const T0_MIN_MUTATING = 7;
49
53
  // Episodes below this output size carry no delegable work (conversational
@@ -65,7 +69,14 @@ export const MIN_DELEGABLE_OUT = 100;
65
69
  // questions that merely contained "statusline". None gained a hint. Bare
66
70
  // '리뷰' and '보안' are left out on purpose: they are product nouns in ordinary
67
71
  // 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;
72
+ //
73
+ // 2026-10-18: production-database work and a user's go-ahead ("운영디비 승인할게
74
+ // 너가 진행해줘", "승인할게 너가 직접 실행해줘") read as light run episodes and
75
+ // became Opus (T1H) candidates. Measured on 6,473 local prompts over 90 days:
76
+ // the two term groups catch 7 + 12 prompts, of which 4 were not already
77
+ // escalated, all of them approvals or production-DB asks. Bare '승인' is left
78
+ // out: "승인된 룰" is a product noun in sprag's own ratchet flow.
79
+ 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
80
 
70
81
  // Implementation asks phrased around a run/check verb ("설치와 동시에 설정되도록
71
82
  // 하자", "피드백 확인해주고 아이콘도 만들자"). Editing is not delegable, and at
@@ -412,32 +423,30 @@ const percentile = (sorted, p) => sorted[Math.min(sorted.length - 1, Math.floor(
412
423
  * Falls back to mid-clamp defaults when the sample is too small to trust.
413
424
  */
414
425
  export function calibrateThresholds(episodeOuts) {
415
- if (episodeOuts.length < 100) return { t2Out: 1500, t1Out: 8000, calibrated: false };
426
+ if (episodeOuts.length < 100) return { t2Out: 1500, t1Out: 8000, t1hOut: 30000, calibrated: false };
416
427
  const sorted = [...episodeOuts].sort((a, b) => a - b);
417
428
  return {
418
429
  t2Out: clamp(Math.max(percentile(sorted, 0.25), 1500), T2_OUT_CLAMP),
419
430
  t1Out: clamp(percentile(sorted, 0.75), T1_OUT_CLAMP),
431
+ t1hOut: clamp(percentile(sorted, 0.95), T1H_OUT_CLAMP),
420
432
  calibrated: true,
421
433
  };
422
434
  }
423
435
 
424
436
  /**
425
- * Tier classification (docs/TIER_CRITERIA.md §3). Returns 'T0'|'T1'|'T2',
437
+ * Tier classification (docs/TIER_CRITERIA.md §3). Returns 'T0'|'T1H'|'T1'|'T2',
426
438
  * or null when the episode carries nothing delegable. Order matters: hard
427
439
  * evidence (errors, heavy mutation, big output, judgement keywords) wins
428
440
  * before any cheap-band check.
429
441
  */
430
442
  export function tierOf(ep, category, th) {
431
443
  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';
444
+ if (ESCALATE_RE.test(ep.text)) return 'T0';
438
445
  if (!category || ep.delegated > 0) return 'T0';
439
446
  if (ep.calls <= T2_MAX_CALLS && ep.out <= th.t2Out && ep.mutating <= T2_MAX_MUTATING && ep.errors === 0) return 'T2';
440
447
  if (ep.out <= th.t1Out && ep.mutating <= T1_MAX_MUTATING && ep.errors <= T1_MAX_ERRORS) return 'T1';
448
+ const t1hOut = th.t1hOut ?? 30000;
449
+ if (ep.errors <= T1H_MAX_ERRORS && ep.mutating <= T1H_MAX_MUTATING && ep.out <= t1hOut) return 'T1H';
441
450
  return 'T0';
442
451
  }
443
452
 
@@ -611,13 +620,13 @@ export async function runRouteScan({ days = 14 } = {}) {
611
620
  const shapeTier = tierOf({ ...ep, errors: 0 }, cat, thresholds);
612
621
  // Same rank gate as the candidate path below — a denominator counting
613
622
  // episodes that can't produce a rule would skew that rule's error rate.
614
- if ((shapeTier === 'T1' || shapeTier === 'T2') && worthDelegating(shapeTier, epRank)) {
623
+ if ((shapeTier === 'T1' || shapeTier === 'T2' || shapeTier === 'T1H') && worthDelegating(shapeTier, epRank)) {
615
624
  bumpStats(`${shapeTier}|${cat.id}|${projectDir}`, ep);
616
625
  bumpStats(`${shapeTier}|${cat.id}|*`, ep);
617
626
  }
618
627
  }
619
628
  const tier = tierOf(ep, cat, thresholds);
620
- if (tier !== 'T1' && tier !== 'T2') continue;
629
+ if (tier !== 'T1' && tier !== 'T2' && tier !== 'T1H') continue;
621
630
  if (!worthDelegating(tier, epRank)) continue;
622
631
  tieredEpisodes += 1;
623
632
  const key = `${tier}|${cat.id}|${projectDir}`;
@@ -626,7 +635,7 @@ export async function runRouteScan({ days = 14 } = {}) {
626
635
  category: cat.id,
627
636
  label: cat.label,
628
637
  labelEn: cat.labelEn,
629
- agent: tier === 'T2' ? cat.agent : 'sonnet',
638
+ agent: tier === 'T2' ? cat.agent : tier === 'T1H' ? 'opus' : 'sonnet',
630
639
  project: projectDir,
631
640
  projectPath: '',
632
641
  count: 0,
@@ -653,7 +662,7 @@ export async function runRouteScan({ days = 14 } = {}) {
653
662
  // where a promoted rule's real success rate lives, so they get their own
654
663
  // pass: join each episode to the subagent transcripts it spawned, and file
655
664
  // 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
665
+ // T2 rule firing, a sonnet run a T1 one, an opus run (Fable session) a T1H one. Runs that were not a downgrade
657
666
  // (same tier or higher) carry no delegation saving and are skipped.
658
667
  const delegatedStats = new Map(); // "tier|category|project" → outcome aggregate
659
668
  let unresolvedRuns = 0; // delegated runs dropped for an unpriceable model id
@@ -774,7 +783,7 @@ export async function runRouteScan({ days = 14 } = {}) {
774
783
  // time rather than hardcoded downstream.
775
784
  const budgetOf = (g) => ({
776
785
  calls: g.tier === 'T2' ? T2_MAX_CALLS + 2 : null,
777
- out: g.tier === 'T2' ? thresholds.t2Out : thresholds.t1Out,
786
+ out: g.tier === 'T2' ? thresholds.t2Out : g.tier === 'T1H' ? thresholds.t1hOut : thresholds.t1Out,
778
787
  });
779
788
  // Shared with the seed presets (src/seed-rules.js) through model-rules.js —
780
789
  // one template, so a reworded rule cannot mean two different things
@@ -945,7 +954,7 @@ export async function shouldRescan(cache, { days = 14, afterDelegation = false }
945
954
  }
946
955
 
947
956
  /**
948
- * Human-readable labels for the T0/T1/T2 codes and scope values — used by
957
+ * Human-readable labels for the T0/T1H/T1/T2 codes and scope values — used by
949
958
  * every user-facing listing so a bare code never appears without its meaning
950
959
  * (first-time users can't be expected to know the tier vocabulary).
951
960
  */
@@ -953,11 +962,13 @@ export function tierLabel(tier, lang = 'ko') {
953
962
  const ko = {
954
963
  T2: '단순 작업, haiku급이면 충분',
955
964
  T1: '중간 난도, sonnet급이면 충분',
965
+ T1H: '고난도 구현, opus급이면 충분 (Fable 세션 전용)',
956
966
  T0: '고난도, 지금 모델 유지',
957
967
  };
958
968
  const en = {
959
969
  T2: 'simple — haiku-class is enough',
960
970
  T1: 'moderate — sonnet-class is enough',
971
+ T1H: 'heavy, opus-class is enough (Fable sessions only)',
961
972
  T0: 'hard — stays on the session model',
962
973
  };
963
974
  return (lang === 'ko' ? ko : en)[tier] || tier;
@@ -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. */
@@ -0,0 +1,176 @@
1
+ /**
2
+ * team-ratchet — rules the user's team approved, kept in a tool-owned file.
3
+ *
4
+ * `sprag team sync` caches the approved rules in team-rules.json. This module
5
+ * only turns that cache into a ratchet file (no network), keeps the CLAUDE.md
6
+ * import in step with the file, and starts the detached sync when it is due.
7
+ * Every function degrades to a no-op for a machine that never joined a team.
8
+ *
9
+ * Claude Code ~/.claude/ratchet-team.md
10
+ * Codex <CODEX_HOME>/ratchet-team.md
11
+ */
12
+
13
+ import * as childProcess from 'node:child_process';
14
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { dirname, join } from 'node:path';
17
+ import { codexUserDir } from './agent.js';
18
+ import { userLanguage } from './config.js';
19
+ import { debug } from './debug.js';
20
+ import {
21
+ HARNESS_BLOCK_BEGIN,
22
+ HARNESS_BLOCK_END,
23
+ TEAM_RATCHET_IMPORT_RE,
24
+ teamRatchetImportLine,
25
+ } from './harness-templates.js';
26
+ import { userDataDir } from './paths.js';
27
+ import { writeStateFile, writeViaTmp } from './state-file.js';
28
+
29
+ export { TEAM_RATCHET_IMPORT_RE, teamRatchetImportLine };
30
+
31
+ export const TEAM_RATCHET_MARKER = '<!-- sprag:team-ratchet -->';
32
+ const SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000;
33
+ const TOKEN_RE = /^spt_[0-9a-f]{32}$/;
34
+
35
+ export const teamStatePathFor = (dir = userDataDir()) => join(dir, 'team.json');
36
+ export const teamRulesCachePathFor = (dir = userDataDir()) => join(dir, 'team-rules.json');
37
+
38
+ function readJson(file) {
39
+ try {
40
+ const v = JSON.parse(readFileSync(file, 'utf8'));
41
+ return v && typeof v === 'object' ? v : null;
42
+ } catch { return null; }
43
+ }
44
+
45
+ export function readTeamState(dir = userDataDir()) {
46
+ return readJson(teamStatePathFor(dir)) || {};
47
+ }
48
+
49
+ export function readTeamRulesCache(dir = userDataDir()) {
50
+ return readJson(teamRulesCachePathFor(dir));
51
+ }
52
+
53
+ export function isJoined(state) {
54
+ return !!(state && state.memberKey && state.left !== true);
55
+ }
56
+
57
+ export function teamSyncDue(state, now = Date.now()) {
58
+ if (!isJoined(state)) return false;
59
+ const at = Number(state.lastSyncAt);
60
+ return !Number.isFinite(at) || at <= 0 || now - at > SYNC_INTERVAL_MS;
61
+ }
62
+
63
+ export function autoJoinPending(state, env = process.env) {
64
+ return TOKEN_RE.test(env.SPRAG_TEAM_TOKEN || '') && !isJoined(state) && state?.left !== true;
65
+ }
66
+
67
+ export function teamSyncDisabled(env = process.env) {
68
+ return env.CTS_NO_TEAM_SYNC === '1';
69
+ }
70
+
71
+ export function teamRatchetPath({ agent = 'claude' } = {}) {
72
+ return agent === 'codex'
73
+ ? join(codexUserDir(), 'ratchet-team.md')
74
+ : join(homedir(), '.claude', 'ratchet-team.md');
75
+ }
76
+
77
+ export function renderTeamRatchet(cache, { lang = 'en', agent = 'claude' } = {}) {
78
+ if (!cache || !Array.isArray(cache.rules)) return null;
79
+ const removed = new Set(Array.isArray(cache.removed) ? cache.removed : []);
80
+ const live = cache.rules.filter((r) => r && typeof r.text === 'string' && r.text.trim() && !removed.has(r.id));
81
+ const ratchet = live.filter((r) => (r.scope || 'ratchet') !== 'model');
82
+ const model = live.filter((r) => r.scope === 'model');
83
+ if (!ratchet.length && !model.length) return null;
84
+ const ko = lang === 'ko' && agent !== 'codex';
85
+ const name = cache.teamName || '';
86
+ const head = ko
87
+ ? `# 팀 래칫 룰 (sprag 관리)\n\n팀 "${name}" 이 승인한 룰입니다. sprag 가 세션 시작 때 통째로 다시 쓰므로 직접 고치지 마십시오. 목록: \`sprag team rules\``
88
+ : `# Team Ratchet Rules (managed by sprag)\n\nRules approved by team "${name}". sprag rewrites this file at session start, so do not edit it by hand. List: \`sprag team rules\``;
89
+ const line = (r) => `- ${r.text.trim().replace(/\s*\n\s*/g, ' ')}`;
90
+ let out = `${TEAM_RATCHET_MARKER}\n${head}\n`;
91
+ if (ratchet.length) out += `\n## Rules\n\n${ratchet.map(line).join('\n')}\n`;
92
+ if (model.length) out += `\n## Model rules\n\n${model.map(line).join('\n')}\n`;
93
+ return out;
94
+ }
95
+
96
+ function writeIfChanged(file, text) {
97
+ let before = null;
98
+ try { before = readFileSync(file, 'utf8'); } catch { /* first write */ }
99
+ if (before === text) return false;
100
+ mkdirSync(dirname(file), { recursive: true });
101
+ writeViaTmp(`${file}.tmp-${process.pid}`, file, text);
102
+ return true;
103
+ }
104
+
105
+ function ownedFile(file) {
106
+ try { return readFileSync(file, 'utf8').split('\n', 1)[0].trim() === TEAM_RATCHET_MARKER; } catch { return false; }
107
+ }
108
+
109
+ export function syncTeamRatchet({ agent = 'claude', lang = userLanguage(), dir = userDataDir() } = {}) {
110
+ const file = teamRatchetPath({ agent });
111
+ const state = readTeamState(dir);
112
+ const text = isJoined(state) ? renderTeamRatchet(readTeamRulesCache(dir), { lang, agent }) : null;
113
+ if (text === null) {
114
+ if (existsSync(file) && ownedFile(file)) {
115
+ rmSync(file, { force: true });
116
+ return { file, rules: 0, changed: true, removed: true };
117
+ }
118
+ return { file, rules: 0, changed: false, removed: false };
119
+ }
120
+ const changed = writeIfChanged(file, text);
121
+ return { file, rules: (text.match(/^- /gm) || []).length, changed, removed: false };
122
+ }
123
+
124
+ /**
125
+ * Keep the team import in the global harness block in step with the file.
126
+ * Only the tool's own marked block is edited; a CLAUDE.md without it is left alone.
127
+ */
128
+ export function ensureTeamImport() {
129
+ const file = join(homedir(), '.claude', 'CLAUDE.md');
130
+ let text;
131
+ try { text = readFileSync(file, 'utf8'); } catch { return { file, added: false, removed: false }; }
132
+ const begin = text.indexOf(HARNESS_BLOCK_BEGIN);
133
+ const end = text.indexOf(HARNESS_BLOCK_END);
134
+ if (begin < 0 || end < begin) return { file, added: false, removed: false };
135
+ const block = text.slice(begin, end);
136
+ const has = TEAM_RATCHET_IMPORT_RE.test(block);
137
+ const exists = existsSync(teamRatchetPath());
138
+ if (exists && !has) {
139
+ writeFileSync(file, `${text.slice(0, end)}${teamRatchetImportLine()}\n${text.slice(end)}`);
140
+ return { file, added: true, removed: false };
141
+ }
142
+ if (!exists && has) {
143
+ const cleaned = block.replace(/^@~\/\.claude\/ratchet-team\.md[ \t]*\r?\n?/m, '');
144
+ writeFileSync(file, `${text.slice(0, begin)}${cleaned}${text.slice(end)}`);
145
+ return { file, added: false, removed: true };
146
+ }
147
+ return { file, added: false, removed: false };
148
+ }
149
+
150
+ export function spawnTeamSyncIfDue({
151
+ state = readTeamState(), now = Date.now(), env = process.env,
152
+ spawn = childProcess.spawn, argv = process.argv, execPath = process.execPath,
153
+ } = {}) {
154
+ if (teamSyncDisabled(env)) return false;
155
+ if (!autoJoinPending(state, env) && !teamSyncDue(state, now)) return false;
156
+ try {
157
+ spawn(execPath, [argv[1], 'team', 'sync', '--background'],
158
+ { detached: true, stdio: 'ignore', windowsHide: true }).unref();
159
+ return true;
160
+ } catch (e) {
161
+ debug('team-ratchet:spawn', e);
162
+ return false;
163
+ }
164
+ }
165
+
166
+ export function autoJoinNoticeBlock(dir = userDataDir(), lang = userLanguage()) {
167
+ const file = teamStatePathFor(dir);
168
+ const state = readJson(file);
169
+ if (!state || state.autoJoinNoticePending !== true) return null;
170
+ const name = state.teamName || '';
171
+ try { writeStateFile(file, `${JSON.stringify({ ...state, autoJoinNoticePending: false }, null, 2)}\n`); }
172
+ catch (e) { debug('team-ratchet:notice-clear', e); }
173
+ return lang === 'ko'
174
+ ? `Sprag: 이 PC는 SPRAG_TEAM_TOKEN 으로 팀 "${name}" 에 자동 참여됐습니다. 보내는 것은 하루 단위 집계(지출·절감·위임 횟수)뿐입니다. \`sprag team status --show-payload\` 로 내용을 확인하고 \`sprag team leave\` 로 멈출 수 있습니다.`
175
+ : `Sprag: this machine was enrolled in team "${name}" through SPRAG_TEAM_TOKEN. It sends daily aggregates only (spend, savings, delegation counts). See what is sent with \`sprag team status --show-payload\`; stop with \`sprag team leave\`.`;
176
+ }