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.
- package/NOTICE +5 -2
- package/README.ko.md +10 -0
- package/README.md +10 -0
- package/bin/cli.js +8 -1
- package/docs/CODEX.ko.md +1 -0
- package/docs/CODEX.md +1 -0
- package/docs/TEAM.ko.md +119 -0
- package/docs/TEAM.md +119 -0
- package/package.json +4 -1
- package/src/agents.js +3 -1
- package/src/codex/delegation.js +8 -5
- package/src/codex/hooks.js +14 -2
- package/src/codex/route-scan.js +6 -4
- package/src/commands/codex-delegate.js +3 -3
- package/src/commands/codex.js +3 -3
- package/src/commands/harness.js +1 -1
- package/src/commands/route-scan.js +16 -3
- package/src/commands/team.js +278 -0
- package/src/cost.js +6 -2
- package/src/cowork-sync.js +5 -4
- package/src/delegation-agents.js +13 -1
- package/src/delegation-guard.js +3 -2
- package/src/harness-templates.js +10 -0
- package/src/model-rules.js +58 -29
- package/src/route-caps.js +3 -3
- package/src/route-inject.js +24 -15
- package/src/route-scan.js +26 -15
- package/src/shared-model-rules.js +1 -1
- package/src/team-ratchet.js +176 -0
- package/src/team-sync.js +326 -0
package/src/route-inject.js
CHANGED
|
@@ -126,8 +126,8 @@ export function sessionModelRank({ env = process.env, transcriptPath = null, sna
|
|
|
126
126
|
* and saves nothing. Outside such a host an unset variable selects Claude
|
|
127
127
|
* Code's built-in default for the tier, so nothing is reported unreachable.
|
|
128
128
|
*/
|
|
129
|
-
const TIER_ALIAS_ENV = { T2: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', T1: 'ANTHROPIC_DEFAULT_SONNET_MODEL' };
|
|
130
|
-
const TIER_ALIAS = { T2: 'haiku', T1: 'sonnet' };
|
|
129
|
+
const TIER_ALIAS_ENV = { T2: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', T1: 'ANTHROPIC_DEFAULT_SONNET_MODEL', T1H: 'ANTHROPIC_DEFAULT_OPUS_MODEL' };
|
|
130
|
+
const TIER_ALIAS = { T2: 'haiku', T1: 'sonnet', T1H: 'opus' };
|
|
131
131
|
|
|
132
132
|
export function unreachableTiers(env = process.env) {
|
|
133
133
|
if (env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST !== '1') return [];
|
|
@@ -205,7 +205,8 @@ export function routeMatch(text, { rules, sessionRank = null, root, env = proces
|
|
|
205
205
|
const nearest = [...reachable].sort((a, b) => scopeRank(b) - scopeRank(a));
|
|
206
206
|
const t2 = nearest.find((r) => r.tier === 'T2');
|
|
207
207
|
const t1 = nearest.find((r) => r.tier === 'T1');
|
|
208
|
-
|
|
208
|
+
const t1h = nearest.find((r) => r.tier === 'T1H');
|
|
209
|
+
return t2 || t1 || t1h ? { cat, t2, t1, t1h } : null;
|
|
209
210
|
}
|
|
210
211
|
|
|
211
212
|
/** The budgets a match states, by tier: what the delegation guard repeats to the subagent. */
|
|
@@ -214,6 +215,7 @@ export function matchCaps(match) {
|
|
|
214
215
|
const caps = {};
|
|
215
216
|
if (match.t2) caps.T2 = ruleBudget(match.t2);
|
|
216
217
|
if (match.t1) caps.T1 = ruleBudget(match.t1);
|
|
218
|
+
if (match.t1h) caps.T1H = ruleBudget(match.t1h);
|
|
217
219
|
return caps;
|
|
218
220
|
}
|
|
219
221
|
|
|
@@ -248,10 +250,10 @@ function unreachableNotice(label, tiers, ko) {
|
|
|
248
250
|
export function routeHint(text, { rules, lang = userLanguage(), root, sessionRank = null, match, env } = {}) {
|
|
249
251
|
const found = match === undefined ? routeMatch(text, { rules, sessionRank, root, env }) : match;
|
|
250
252
|
if (!found) return null;
|
|
251
|
-
const { cat, t2, t1 } = found;
|
|
253
|
+
const { cat, t2, t1, t1h } = found;
|
|
252
254
|
const ko = lang === 'ko';
|
|
253
255
|
const label = ko ? (cat.label || cat.id) : (cat.labelEn || cat.label || cat.id);
|
|
254
|
-
if (!t2 && !t1 && found.unreachable?.length) return unreachableNotice(label, found.unreachable, ko);
|
|
256
|
+
if (!t2 && !t1 && !t1h && found.unreachable?.length) return unreachableNotice(label, found.unreachable, ko);
|
|
255
257
|
// `root` is threaded through to agentPhrase, which names the subagent only
|
|
256
258
|
// when its .md actually exists on this machine. A caller that pins it can
|
|
257
259
|
// therefore get a deterministic phrase; a test that does not pin it reads
|
|
@@ -262,21 +264,28 @@ export function routeHint(text, { rules, lang = userLanguage(), root, sessionRan
|
|
|
262
264
|
// T1 is named through its delegation agent only when that file exists here;
|
|
263
265
|
// otherwise the literal tier string stays, so output without agents is unchanged.
|
|
264
266
|
const t1Root = root === undefined ? undefined : root;
|
|
265
|
-
const
|
|
266
|
-
const
|
|
267
|
+
const tierPhrase = (name) => (resolveAgentName(name, t1Root) !== null ? phrase(name) : `model: ${name}`);
|
|
268
|
+
const picked = [t2, t1, t1h].filter(Boolean);
|
|
267
269
|
let target;
|
|
268
|
-
if (
|
|
270
|
+
if (picked.length >= 2) {
|
|
271
|
+
const named = (r) => (r.tier === 'T2' ? phrase(r.agent) : tierPhrase(r.tier === 'T1H' ? 'opus' : 'sonnet'));
|
|
272
|
+
const when = (r, i) => {
|
|
273
|
+
if (i === 0) return null;
|
|
274
|
+
if (r.tier === 'T1H') return ko ? '범위가 크면(Fable·Mythos 세션 한정)' : 'for large scope (Fable/Mythos sessions only)';
|
|
275
|
+
return ko ? '여러 단계·여러 파일이 얽히면' : 'when it spans multiple steps or file edits';
|
|
276
|
+
};
|
|
269
277
|
target = ko
|
|
270
|
-
?
|
|
271
|
-
: `${
|
|
278
|
+
? picked.map((r, i) => `${when(r, i) ?? '기본'} ${named(r)}`).join(', ')
|
|
279
|
+
: picked.map((r, i) => (i === 0 ? `${named(r)} by default` : `${named(r)} ${when(r, i)}`)).join(', ');
|
|
272
280
|
} else {
|
|
273
|
-
const only =
|
|
274
|
-
target =
|
|
281
|
+
const only = picked[0];
|
|
282
|
+
target = only.tier === 'T1H' ? tierPhrase('opus') : phrase(only.agent);
|
|
275
283
|
}
|
|
276
284
|
|
|
277
|
-
const
|
|
278
|
-
const
|
|
279
|
-
|
|
285
|
+
const MODEL_OF = { T2: 'haiku', T1: 'sonnet', T1H: 'opus' };
|
|
286
|
+
const caps = picked.map((r) => budgetCapPhrase(r, lang));
|
|
287
|
+
const capText = picked.length >= 2
|
|
288
|
+
? (ko ? '상한 ' : 'cap ') + picked.map((r, i) => `${MODEL_OF[r.tier]} ${caps[i]}`).join(' / ')
|
|
280
289
|
: (ko ? `상한 ${caps[0]}` : `cap ${caps[0]}`);
|
|
281
290
|
|
|
282
291
|
// The marker line is the one in ratchet-model.md, so the user sees the same
|
package/src/route-scan.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|