sprag-cli 3.69.0 → 3.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +5 -2
- package/README.ko.md +16 -0
- package/README.md +16 -0
- package/bin/cli.js +53 -4
- package/docs/CODEX.ko.md +3 -2
- package/docs/CODEX.md +5 -4
- package/docs/TEAM.ko.md +174 -0
- package/docs/TEAM.md +175 -0
- package/package.json +5 -1
- package/presets/agents.json +118 -0
- package/presets/doc2md/convert.py +1 -1
- package/presets/ratchet-rules.json +12 -2
- package/src/agents.js +3 -1
- package/src/cli-entry.js +40 -0
- package/src/codex/delegation.js +8 -5
- package/src/codex/hooks.js +25 -12
- package/src/codex/panel-setup.js +2 -1
- package/src/codex/panel.js +2 -1
- package/src/codex/route-scan.js +6 -4
- package/src/commands/brief.js +2 -0
- package/src/commands/codex-delegate.js +3 -3
- package/src/commands/codex.js +6 -5
- package/src/commands/harness.js +3 -4
- package/src/commands/install.js +19 -18
- package/src/commands/presets.js +158 -0
- package/src/commands/profile-map.js +2 -2
- package/src/commands/route-scan.js +25 -10
- package/src/commands/team.js +334 -0
- package/src/commands/update-check.js +2 -2
- package/src/cost.js +6 -2
- package/src/cowork-sync.js +5 -4
- package/src/debug.js +3 -2
- package/src/delegation-agents.js +49 -49
- package/src/delegation-guard.js +3 -2
- package/src/doc2md.cjs +4 -3
- package/src/env.cjs +7 -0
- package/src/env.js +19 -0
- package/src/first-run-note.js +3 -2
- package/src/harness-templates.js +10 -0
- package/src/harness.js +4 -14
- package/src/hook.cjs +4 -3
- package/src/model-rules.js +58 -29
- package/src/preset-sign.js +106 -0
- package/src/preset-sync.js +133 -0
- package/src/presets-source.js +209 -0
- package/src/prompt.js +2 -1
- package/src/route-caps.js +3 -3
- package/src/route-inject.js +24 -15
- package/src/route-scan.js +39 -15
- package/src/seed-rules.js +14 -16
- package/src/shared-model-rules.js +1 -1
- package/src/star-note.js +3 -2
- package/src/statusline-guard.js +144 -0
- package/src/stdin-payload.js +33 -4
- package/src/team-prompts.js +353 -0
- package/src/team-ratchet.js +276 -0
- package/src/team-sync.js +625 -0
- package/src/update-check.js +3 -2
|
@@ -0,0 +1,276 @@
|
|
|
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 { envVar } from './env.js';
|
|
14
|
+
import { CLI_ENTRY, detachedChildEnv, isDetachedChild } from './cli-entry.js';
|
|
15
|
+
import * as childProcess from 'node:child_process';
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
import { codexUserDir } from './agent.js';
|
|
20
|
+
import { userLanguage } from './config.js';
|
|
21
|
+
import { debug } from './debug.js';
|
|
22
|
+
import {
|
|
23
|
+
HARNESS_BLOCK_BEGIN,
|
|
24
|
+
HARNESS_BLOCK_END,
|
|
25
|
+
TEAM_RATCHET_IMPORT_RE,
|
|
26
|
+
teamRatchetImportLine,
|
|
27
|
+
} from './harness-templates.js';
|
|
28
|
+
import { userDataDir } from './paths.js';
|
|
29
|
+
import { writeStateFile, writeViaTmp } from './state-file.js';
|
|
30
|
+
|
|
31
|
+
export { TEAM_RATCHET_IMPORT_RE, teamRatchetImportLine };
|
|
32
|
+
|
|
33
|
+
export const TEAM_RATCHET_MARKER = '<!-- sprag:team-ratchet -->';
|
|
34
|
+
const SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
35
|
+
const IN_FLIGHT_MS = 2 * 60 * 1000;
|
|
36
|
+
const TOKEN_RE = /^spt_[0-9a-f]{32}$/;
|
|
37
|
+
|
|
38
|
+
export const teamStatePathFor = (dir = userDataDir()) => join(dir, 'team.json');
|
|
39
|
+
export const teamRulesCachePathFor = (dir = userDataDir()) => join(dir, 'team-rules.json');
|
|
40
|
+
|
|
41
|
+
function readJson(file) {
|
|
42
|
+
try {
|
|
43
|
+
const v = JSON.parse(readFileSync(file, 'utf8'));
|
|
44
|
+
return v && typeof v === 'object' ? v : null;
|
|
45
|
+
} catch { return null; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function readTeamState(dir = userDataDir()) {
|
|
49
|
+
return readJson(teamStatePathFor(dir)) || {};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function readTeamRulesCache(dir = userDataDir()) {
|
|
53
|
+
return readJson(teamRulesCachePathFor(dir));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isJoined(state) {
|
|
57
|
+
return !!(state && state.memberKey && state.left !== true);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function teamSyncDue(state, now = Date.now()) {
|
|
61
|
+
if (!isJoined(state)) return false;
|
|
62
|
+
// Same rule as teamSyncDue in team-sync.js (a test pins the two together).
|
|
63
|
+
const attempt = Number(state.lastSyncAttemptAt);
|
|
64
|
+
if (Number.isFinite(attempt) && attempt > 0 && now - attempt >= 0 && now - attempt < IN_FLIGHT_MS) return false;
|
|
65
|
+
const failures = Number(state.syncFailures) || 0;
|
|
66
|
+
if (failures > 0) {
|
|
67
|
+
const delay = Math.min(15 * 60 * 1000 * 2 ** (failures - 1), 60 * 60 * 1000);
|
|
68
|
+
return !Number.isFinite(attempt) || attempt <= 0 || now - attempt >= delay;
|
|
69
|
+
}
|
|
70
|
+
const at = Number(state.lastSyncAt);
|
|
71
|
+
return !Number.isFinite(at) || at <= 0 || now - at >= SYNC_INTERVAL_MS;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const PROMPT_SYNC_INTERVAL_MS = 30 * 60 * 1000;
|
|
75
|
+
export const RULES_PULL_INTERVAL_MS = 60 * 60 * 1000;
|
|
76
|
+
const RULES_RETRY_MS = 15 * 60 * 1000;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The rules-only pull (one small RPC, no ledger parsing) is due an hour after
|
|
80
|
+
* the last time rules arrived, by either kind of sync. It stays out of the way
|
|
81
|
+
* of the full sync: not while that one is in flight or backing off after a
|
|
82
|
+
* failure (its retry pulls the rules too), and not within 15 minutes of its own
|
|
83
|
+
* last attempt, which doubles as its in-flight guard and failure backoff.
|
|
84
|
+
*/
|
|
85
|
+
export function rulesPullDue(state, now = Date.now(), env = process.env) {
|
|
86
|
+
if (!isJoined(state) || state.pausedReported || teamSyncDisabled(env)) return false;
|
|
87
|
+
if ((Number(state.syncFailures) || 0) > 0) return false;
|
|
88
|
+
const attempt = Number(state.lastSyncAttemptAt);
|
|
89
|
+
if (Number.isFinite(attempt) && attempt > 0 && now - attempt >= 0 && now - attempt < IN_FLIGHT_MS) return false;
|
|
90
|
+
const tried = Number(state.lastRulesAttemptAt);
|
|
91
|
+
if (Number.isFinite(tried) && tried > 0 && now - tried >= 0 && now - tried < RULES_RETRY_MS) return false;
|
|
92
|
+
const last = Math.max(Number(state.lastRulesAt) || 0, Number(state.lastSyncAt) || 0);
|
|
93
|
+
return last <= 0 || now - last >= RULES_PULL_INTERVAL_MS;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Stat-free gate for the prompt uploader: joined, capture on (as last seen by a
|
|
98
|
+
* sync), uploads not switched off or reported paused, and the last prompt run is
|
|
99
|
+
* 30 minutes old. Reads nothing but the already-loaded team.json.
|
|
100
|
+
*/
|
|
101
|
+
export function promptSyncDue(state, now = Date.now(), env = process.env) {
|
|
102
|
+
if (!isJoined(state) || state.promptCapture !== true || state.pausedReported) return false;
|
|
103
|
+
if (teamSyncDisabled(env)) return false;
|
|
104
|
+
const at = Number(state.lastPromptSyncAt);
|
|
105
|
+
return !Number.isFinite(at) || at <= 0 || now - at >= PROMPT_SYNC_INTERVAL_MS;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function autoJoinPending(state, env = process.env) {
|
|
109
|
+
return TOKEN_RE.test(env.SPRAG_TEAM_TOKEN || '') && !isJoined(state) && state?.left !== true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function teamSyncDisabled(env = process.env) {
|
|
113
|
+
return envVar('NO_TEAM_SYNC', env) === '1';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function teamRatchetPath({ agent = 'claude' } = {}) {
|
|
117
|
+
return agent === 'codex'
|
|
118
|
+
? join(codexUserDir(), 'ratchet-team.md')
|
|
119
|
+
: join(homedir(), '.claude', 'ratchet-team.md');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function renderTeamRatchet(cache, { lang = 'en', agent = 'claude' } = {}) {
|
|
123
|
+
if (!cache || !Array.isArray(cache.rules)) return null;
|
|
124
|
+
const removed = new Set(Array.isArray(cache.removed) ? cache.removed : []);
|
|
125
|
+
const live = cache.rules.filter((r) => r && typeof r.text === 'string' && r.text.trim() && !removed.has(r.id));
|
|
126
|
+
if (!live.length) return null;
|
|
127
|
+
// Rows from servers older than the org layer carry no level: they are team rules.
|
|
128
|
+
const org = live.filter((r) => r.level === 'org');
|
|
129
|
+
const team = live.filter((r) => r.level !== 'org');
|
|
130
|
+
const isModel = (r) => r.scope === 'model';
|
|
131
|
+
const ko = lang === 'ko' && agent !== 'codex';
|
|
132
|
+
const name = cache.teamName || '';
|
|
133
|
+
const orgName = cache.orgName || '';
|
|
134
|
+
const scopeLine = ko
|
|
135
|
+
? (orgName ? `조직 "${orgName}" 과 팀 "${name}" 에서 승인한 룰입니다.` : `팀 "${name}" 이 승인한 룰입니다.`)
|
|
136
|
+
: (orgName ? `Rules approved for organization "${orgName}" and team "${name}".` : `Rules approved by team "${name}".`);
|
|
137
|
+
const head = ko
|
|
138
|
+
? `# 팀 래칫 룰 (sprag 관리)\n\n${scopeLine} sprag 가 세션 시작 때 통째로 다시 쓰므로 직접 고치지 마십시오. 목록: \`sprag team rules\``
|
|
139
|
+
: `# Team Ratchet Rules (managed by sprag)\n\n${scopeLine} sprag rewrites this file at session start, so do not edit it by hand. List: \`sprag team rules\``;
|
|
140
|
+
const H = ko
|
|
141
|
+
? { org: '조직 룰', orgModel: '조직 모델 룰', team: '팀 룰', teamModel: '팀 모델 룰' }
|
|
142
|
+
: { org: 'Organization rules', orgModel: 'Organization model rules', team: 'Team rules', teamModel: 'Team model rules' };
|
|
143
|
+
const line = (r) => `- ${r.text.trim().replace(/\s*\n\s*/g, ' ')}`;
|
|
144
|
+
const section = (rows, title, modelTitle) => {
|
|
145
|
+
if (!rows.length) return '';
|
|
146
|
+
const plain = rows.filter((r) => !isModel(r));
|
|
147
|
+
const model = rows.filter(isModel);
|
|
148
|
+
let s = `\n## ${title}\n`;
|
|
149
|
+
if (plain.length) s += `\n${plain.map(line).join('\n')}\n`;
|
|
150
|
+
if (model.length) s += `\n### ${modelTitle}\n\n${model.map(line).join('\n')}\n`;
|
|
151
|
+
return s;
|
|
152
|
+
};
|
|
153
|
+
return `${TEAM_RATCHET_MARKER}\n${head}\n${section(org, H.org, H.orgModel)}${section(team, H.team, H.teamModel)}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function writeIfChanged(file, text) {
|
|
157
|
+
let before = null;
|
|
158
|
+
try { before = readFileSync(file, 'utf8'); } catch { /* first write */ }
|
|
159
|
+
if (before === text) return false;
|
|
160
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
161
|
+
writeViaTmp(`${file}.tmp-${process.pid}`, file, text);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function ownedFile(file) {
|
|
166
|
+
try { return readFileSync(file, 'utf8').split('\n', 1)[0].trim() === TEAM_RATCHET_MARKER; } catch { return false; }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function syncTeamRatchet({ agent = 'claude', lang = userLanguage(), dir = userDataDir() } = {}) {
|
|
170
|
+
const file = teamRatchetPath({ agent });
|
|
171
|
+
const state = readTeamState(dir);
|
|
172
|
+
const text = isJoined(state) ? renderTeamRatchet(readTeamRulesCache(dir), { lang, agent }) : null;
|
|
173
|
+
if (text === null) {
|
|
174
|
+
if (existsSync(file) && ownedFile(file)) {
|
|
175
|
+
rmSync(file, { force: true });
|
|
176
|
+
return { file, rules: 0, changed: true, removed: true };
|
|
177
|
+
}
|
|
178
|
+
return { file, rules: 0, changed: false, removed: false };
|
|
179
|
+
}
|
|
180
|
+
const changed = writeIfChanged(file, text);
|
|
181
|
+
return { file, rules: (text.match(/^- /gm) || []).length, changed, removed: false };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Keep the team import in the global harness block in step with the file.
|
|
186
|
+
* Only the tool's own marked block is edited; a CLAUDE.md without it is left alone.
|
|
187
|
+
*/
|
|
188
|
+
export function ensureTeamImport() {
|
|
189
|
+
const file = join(homedir(), '.claude', 'CLAUDE.md');
|
|
190
|
+
let text;
|
|
191
|
+
try { text = readFileSync(file, 'utf8'); } catch { return { file, added: false, removed: false }; }
|
|
192
|
+
const begin = text.indexOf(HARNESS_BLOCK_BEGIN);
|
|
193
|
+
const end = text.indexOf(HARNESS_BLOCK_END);
|
|
194
|
+
if (begin < 0 || end < begin) return { file, added: false, removed: false };
|
|
195
|
+
const block = text.slice(begin, end);
|
|
196
|
+
const has = TEAM_RATCHET_IMPORT_RE.test(block);
|
|
197
|
+
const exists = existsSync(teamRatchetPath());
|
|
198
|
+
if (exists && !has) {
|
|
199
|
+
writeFileSync(file, `${text.slice(0, end)}${teamRatchetImportLine()}\n${text.slice(end)}`);
|
|
200
|
+
return { file, added: true, removed: false };
|
|
201
|
+
}
|
|
202
|
+
if (!exists && has) {
|
|
203
|
+
const cleaned = block.replace(/^@~\/\.claude\/ratchet-team\.md[ \t]*\r?\n?/m, '');
|
|
204
|
+
writeFileSync(file, `${text.slice(0, begin)}${cleaned}${text.slice(end)}`);
|
|
205
|
+
return { file, added: false, removed: true };
|
|
206
|
+
}
|
|
207
|
+
return { file, added: false, removed: false };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function spawnTeamSyncIfDue({
|
|
211
|
+
state = readTeamState(), now = Date.now(), env = process.env,
|
|
212
|
+
spawn = childProcess.spawn, cliPath = CLI_ENTRY, execPath = process.execPath,
|
|
213
|
+
} = {}) {
|
|
214
|
+
if (isDetachedChild(env)) return false;
|
|
215
|
+
// Switched off: spawn once more only to report the pause (team-sync.js).
|
|
216
|
+
// Switched back on after a reported pause: spawn now to report the resume.
|
|
217
|
+
// Otherwise the full sync when it is due, else the hourly rules-only pull.
|
|
218
|
+
let flags;
|
|
219
|
+
if (teamSyncDisabled(env)) {
|
|
220
|
+
if (!isJoined(state) || state.pausedReported) return false;
|
|
221
|
+
flags = ['--background'];
|
|
222
|
+
} else if (autoJoinPending(state, env) || teamSyncDue(state, now) || (isJoined(state) && state.pausedReported)) {
|
|
223
|
+
flags = ['--background'];
|
|
224
|
+
} else if (rulesPullDue(state, now, env)) {
|
|
225
|
+
flags = ['--background', '--rules-only'];
|
|
226
|
+
} else {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
spawn(execPath, [cliPath, 'team', 'sync', ...flags],
|
|
231
|
+
{ detached: true, stdio: 'ignore', windowsHide: true, env: detachedChildEnv(env) }).unref();
|
|
232
|
+
return true;
|
|
233
|
+
} catch (e) {
|
|
234
|
+
debug('team-ratchet:spawn', e);
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Called from the per-prompt hooks: one small team.json read, no network and no
|
|
241
|
+
* parsing here. When the ledger/rules sync is due (6 hours after the last success,
|
|
242
|
+
* or its failure backoff elapsed), start the detached `team sync --background`
|
|
243
|
+
* child; else, when only the prompt upload is due, start it in prompts-only
|
|
244
|
+
* mode; else, when only the hourly rules pull is due, start it in rules-only
|
|
245
|
+
* mode. Prints nothing.
|
|
246
|
+
*/
|
|
247
|
+
export function spawnTeamSyncOnPrompt({
|
|
248
|
+
state = readTeamState(), now = Date.now(), env = process.env,
|
|
249
|
+
spawn = childProcess.spawn, cliPath = CLI_ENTRY, execPath = process.execPath,
|
|
250
|
+
} = {}) {
|
|
251
|
+
if (isDetachedChild(env) || teamSyncDisabled(env) || !isJoined(state)) return false;
|
|
252
|
+
let flags;
|
|
253
|
+
if (teamSyncDue(state, now)) flags = ['--background'];
|
|
254
|
+
else if (promptSyncDue(state, now, env)) flags = ['--background', '--prompts-only'];
|
|
255
|
+
else if (rulesPullDue(state, now, env)) flags = ['--background', '--rules-only'];
|
|
256
|
+
else return false;
|
|
257
|
+
try {
|
|
258
|
+
spawn(execPath, [cliPath, 'team', 'sync', ...flags], { detached: true, stdio: 'ignore', windowsHide: true, env: detachedChildEnv(env) }).unref();
|
|
259
|
+
return true;
|
|
260
|
+
} catch (e) {
|
|
261
|
+
debug('team-ratchet:spawn-prompt-hook', e);
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function autoJoinNoticeBlock(dir = userDataDir(), lang = userLanguage()) {
|
|
267
|
+
const file = teamStatePathFor(dir);
|
|
268
|
+
const state = readJson(file);
|
|
269
|
+
if (!state || state.autoJoinNoticePending !== true) return null;
|
|
270
|
+
const name = state.teamName || '';
|
|
271
|
+
try { writeStateFile(file, `${JSON.stringify({ ...state, autoJoinNoticePending: false }, null, 2)}\n`); }
|
|
272
|
+
catch (e) { debug('team-ratchet:notice-clear', e); }
|
|
273
|
+
return lang === 'ko'
|
|
274
|
+
? `Sprag: 이 PC는 SPRAG_TEAM_TOKEN 으로 팀 "${name}" 에 자동 참여됐습니다. 보내는 것은 하루 단위 집계(지출·절감·위임 횟수)뿐입니다. \`sprag team status --show-payload\` 로 내용을 확인하고 \`sprag team leave\` 로 멈출 수 있습니다.`
|
|
275
|
+
: `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\`.`;
|
|
276
|
+
}
|