sprag-cli 3.70.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.
Files changed (45) hide show
  1. package/README.ko.md +7 -1
  2. package/README.md +7 -1
  3. package/bin/cli.js +47 -5
  4. package/docs/CODEX.ko.md +2 -2
  5. package/docs/CODEX.md +4 -4
  6. package/docs/TEAM.ko.md +74 -19
  7. package/docs/TEAM.md +75 -19
  8. package/package.json +2 -1
  9. package/presets/agents.json +118 -0
  10. package/presets/doc2md/convert.py +1 -1
  11. package/presets/ratchet-rules.json +12 -2
  12. package/src/cli-entry.js +40 -0
  13. package/src/codex/hooks.js +11 -10
  14. package/src/codex/panel-setup.js +2 -1
  15. package/src/codex/panel.js +2 -1
  16. package/src/commands/brief.js +2 -0
  17. package/src/commands/codex.js +3 -2
  18. package/src/commands/harness.js +2 -3
  19. package/src/commands/install.js +19 -18
  20. package/src/commands/presets.js +158 -0
  21. package/src/commands/profile-map.js +2 -2
  22. package/src/commands/route-scan.js +9 -7
  23. package/src/commands/team.js +82 -26
  24. package/src/commands/update-check.js +2 -2
  25. package/src/debug.js +3 -2
  26. package/src/delegation-agents.js +48 -60
  27. package/src/doc2md.cjs +4 -3
  28. package/src/env.cjs +7 -0
  29. package/src/env.js +19 -0
  30. package/src/first-run-note.js +3 -2
  31. package/src/harness.js +4 -14
  32. package/src/hook.cjs +4 -3
  33. package/src/preset-sign.js +106 -0
  34. package/src/preset-sync.js +133 -0
  35. package/src/presets-source.js +209 -0
  36. package/src/prompt.js +2 -1
  37. package/src/route-scan.js +14 -1
  38. package/src/seed-rules.js +14 -16
  39. package/src/star-note.js +3 -2
  40. package/src/statusline-guard.js +144 -0
  41. package/src/stdin-payload.js +33 -4
  42. package/src/team-prompts.js +353 -0
  43. package/src/team-ratchet.js +116 -16
  44. package/src/team-sync.js +336 -37
  45. package/src/update-check.js +3 -2
@@ -10,6 +10,8 @@
10
10
  * Codex <CODEX_HOME>/ratchet-team.md
11
11
  */
12
12
 
13
+ import { envVar } from './env.js';
14
+ import { CLI_ENTRY, detachedChildEnv, isDetachedChild } from './cli-entry.js';
13
15
  import * as childProcess from 'node:child_process';
14
16
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
15
17
  import { homedir } from 'node:os';
@@ -30,6 +32,7 @@ export { TEAM_RATCHET_IMPORT_RE, teamRatchetImportLine };
30
32
 
31
33
  export const TEAM_RATCHET_MARKER = '<!-- sprag:team-ratchet -->';
32
34
  const SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000;
35
+ const IN_FLIGHT_MS = 2 * 60 * 1000;
33
36
  const TOKEN_RE = /^spt_[0-9a-f]{32}$/;
34
37
 
35
38
  export const teamStatePathFor = (dir = userDataDir()) => join(dir, 'team.json');
@@ -56,8 +59,50 @@ export function isJoined(state) {
56
59
 
57
60
  export function teamSyncDue(state, now = Date.now()) {
58
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
+ }
59
70
  const at = Number(state.lastSyncAt);
60
- return !Number.isFinite(at) || at <= 0 || now - at > SYNC_INTERVAL_MS;
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;
61
106
  }
62
107
 
63
108
  export function autoJoinPending(state, env = process.env) {
@@ -65,7 +110,7 @@ export function autoJoinPending(state, env = process.env) {
65
110
  }
66
111
 
67
112
  export function teamSyncDisabled(env = process.env) {
68
- return env.CTS_NO_TEAM_SYNC === '1';
113
+ return envVar('NO_TEAM_SYNC', env) === '1';
69
114
  }
70
115
 
71
116
  export function teamRatchetPath({ agent = 'claude' } = {}) {
@@ -78,19 +123,34 @@ export function renderTeamRatchet(cache, { lang = 'en', agent = 'claude' } = {})
78
123
  if (!cache || !Array.isArray(cache.rules)) return null;
79
124
  const removed = new Set(Array.isArray(cache.removed) ? cache.removed : []);
80
125
  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;
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';
84
131
  const ko = lang === 'ko' && agent !== 'codex';
85
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}".`);
86
137
  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\``;
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' };
89
143
  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;
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)}`;
94
154
  }
95
155
 
96
156
  function writeIfChanged(file, text) {
@@ -149,13 +209,26 @@ export function ensureTeamImport() {
149
209
 
150
210
  export function spawnTeamSyncIfDue({
151
211
  state = readTeamState(), now = Date.now(), env = process.env,
152
- spawn = childProcess.spawn, argv = process.argv, execPath = process.execPath,
212
+ spawn = childProcess.spawn, cliPath = CLI_ENTRY, execPath = process.execPath,
153
213
  } = {}) {
154
- if (teamSyncDisabled(env)) return false;
155
- if (!autoJoinPending(state, env) && !teamSyncDue(state, now)) return false;
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
+ }
156
229
  try {
157
- spawn(execPath, [argv[1], 'team', 'sync', '--background'],
158
- { detached: true, stdio: 'ignore', windowsHide: true }).unref();
230
+ spawn(execPath, [cliPath, 'team', 'sync', ...flags],
231
+ { detached: true, stdio: 'ignore', windowsHide: true, env: detachedChildEnv(env) }).unref();
159
232
  return true;
160
233
  } catch (e) {
161
234
  debug('team-ratchet:spawn', e);
@@ -163,6 +236,33 @@ export function spawnTeamSyncIfDue({
163
236
  }
164
237
  }
165
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
+
166
266
  export function autoJoinNoticeBlock(dir = userDataDir(), lang = userLanguage()) {
167
267
  const file = teamStatePathFor(dir);
168
268
  const state = readJson(file);
package/src/team-sync.js CHANGED
@@ -9,26 +9,42 @@
9
9
  *
10
10
  * Files in the user data dir:
11
11
  * team.json membership state (mode 0600; holds the member key)
12
- * team-rules.json last pulled team rules
12
+ * team-rules.json last pulled rules (organization rules first, then team
13
+ * rules; each row carries level 'org' or 'team')
13
14
  *
14
- * Opt out of all network traffic with CTS_NO_TEAM_SYNC=1.
15
+ * Rules arrive with every full sync (6 hours) and with the rules-only pull
16
+ * (`team sync --rules-only`, at most hourly). Either kind rewrites the ratchet
17
+ * files right away, so a new session picks the rules up without waiting for
18
+ * the next session-start render.
19
+ *
20
+ * Opt out of all network traffic with SPRAG_NO_TEAM_SYNC=1.
15
21
  */
16
22
 
23
+ import { envVar } from './env.js';
17
24
  import { readFileSync, existsSync, rmSync, chmodSync } from 'node:fs';
25
+ import { execFileSync } from 'node:child_process';
18
26
  import { join } from 'node:path';
19
- import { userInfo } from 'node:os';
27
+ import { userInfo, release as osRelease } from 'node:os';
20
28
  import { createRequire } from 'node:module';
21
29
  import { userDataDir } from './paths.js';
22
30
  import { writeStateFile } from './state-file.js';
23
31
  import { debug } from './debug.js';
24
32
  import { loadLedger, modelFamily, roundUsd } from './savings-ledger.js';
25
33
  import { sessionCost, pricedParts } from './claude-price.js';
34
+ import { uploadPrompts, dropOffsets, promptOffsetsExist } from './team-prompts.js';
35
+ import { rulesPullDue, syncTeamRatchet, ensureTeamImport } from './team-ratchet.js';
36
+ import { codexUserDir } from './agent.js';
37
+
38
+ export const PROMPT_SYNC_INTERVAL_MS = 30 * 60 * 1000;
26
39
 
27
40
  const require = createRequire(import.meta.url);
28
41
 
29
42
  export const TEAM_API_URL = 'https://hiycfjwswtkixyoqkznm.supabase.co';
30
43
  // Public anon key (role=anon). Access is gated by the RPCs, not by this value.
31
44
  export const TEAM_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImhpeWNmandzd3RraXh5b3Frem5tIiwicm9sZSI6ImFub24iLCJpYXQiOjE3OTIyNTc4MzYsImV4cCI6MjEwNzgzMzgzNn0.gyYcvGD70CEX64aGV-dsmZgmmOBdvhJlzIpx9Qo2l3w';
45
+ // Local testing only: SPRAG_TEAM_API_URL + SPRAG_TEAM_ANON_KEY point the client at a local stack.
46
+ export const teamApiUrl = (env = process.env) => envVar('TEAM_API_URL', env) || TEAM_API_URL;
47
+ export const teamAnonKey = (env = process.env) => envVar('TEAM_ANON_KEY', env) || TEAM_ANON_KEY;
32
48
  export const TEAM_SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000;
33
49
  export const TEAM_STATE_VERSION = 1;
34
50
 
@@ -55,15 +71,85 @@ export function saveTeamState(state, dir = userDataDir()) {
55
71
  }
56
72
  }
57
73
 
58
- export function teamSyncDisabled(env = process.env) { return env.CTS_NO_TEAM_SYNC === '1'; }
74
+ export function teamSyncDisabled(env = process.env) { return envVar('NO_TEAM_SYNC', env) === '1'; }
59
75
  export function isJoined(state) { return !!(state && state.memberKey && !state.left); }
60
76
 
77
+ /** Prompt uploads run on their own 30-minute timer, only while capture is on. */
78
+ export function promptSyncDue(state, now = Date.now()) {
79
+ return isJoined(state) && state.promptCapture === true && !state.pausedReported
80
+ && (!state.lastPromptSyncAt || now - state.lastPromptSyncAt >= PROMPT_SYNC_INTERVAL_MS);
81
+ }
82
+
83
+ export const LEDGER_RETRY_BASE_MS = 15 * 60 * 1000;
84
+ export const LEDGER_RETRY_MAX_MS = 60 * 60 * 1000;
85
+ const IN_FLIGHT_MS = 2 * 60 * 1000;
86
+ export const LEDGER_WINDOW_DAYS = 30;
87
+
88
+ /** Retry delay after n consecutive failed ledger syncs: 15, 30, 60 minutes, then 60. */
89
+ export function ledgerRetryDelayMs(failures) {
90
+ const n = Math.max(1, Math.floor(Number(failures) || 1));
91
+ return Math.min(LEDGER_RETRY_BASE_MS * 2 ** (n - 1), LEDGER_RETRY_MAX_MS);
92
+ }
93
+
94
+ /**
95
+ * The ledger/rules sync is due every 6 hours after the last SUCCESS, or, after a
96
+ * failure, when the backoff since the last attempt has elapsed. An attempt that
97
+ * began less than 2 minutes ago counts as in flight, so two hooks do not both spawn.
98
+ */
61
99
  export function teamSyncDue(state, now = Date.now()) {
62
- return isJoined(state) && (!state.lastSyncAt || now - state.lastSyncAt >= TEAM_SYNC_INTERVAL_MS);
100
+ if (!isJoined(state)) return false;
101
+ const attempt = Number(state.lastSyncAttemptAt);
102
+ if (Number.isFinite(attempt) && attempt > 0 && now - attempt >= 0 && now - attempt < IN_FLIGHT_MS) return false;
103
+ const failures = Number(state.syncFailures) || 0;
104
+ if (failures > 0) return !Number.isFinite(attempt) || attempt <= 0 || now - attempt >= ledgerRetryDelayMs(failures);
105
+ return !state.lastSyncAt || now - state.lastSyncAt >= TEAM_SYNC_INTERVAL_MS;
106
+ }
107
+
108
+ /**
109
+ * Which local days a ledger sync uploads: from the day before the watermark (that
110
+ * day was still open when it was uploaded) through today, at most 30 days. With
111
+ * no watermark, the last 30 days.
112
+ */
113
+ export function ledgerWindow(state, now = Date.now()) {
114
+ const today = new Date(now);
115
+ const earliest = new Date(today.getFullYear(), today.getMonth(), today.getDate() - (LEDGER_WINDOW_DAYS - 1));
116
+ let start = earliest;
117
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(state?.ledgerSyncedThrough || ''));
118
+ if (m) {
119
+ const from = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]) - 1);
120
+ if (from > earliest) start = from;
121
+ }
122
+ const startKey = dayKey(start.getTime());
123
+ // aggregateDaily cuts by time, so ask for a little more and filter by day below
124
+ const days = Math.ceil((now - start.getTime()) / 86400000) + 1;
125
+ return { startKey, days };
126
+ }
127
+
128
+ /**
129
+ * The preset bundle version this machine runs (0 for the bundled presets), so
130
+ * the /ops console can show how far a published version has rolled out. A
131
+ * server without the preset_version argument (before 20261018001400_presets)
132
+ * answers "Could not find the function"; the upload is then repeated without it.
133
+ */
134
+ async function ledgerUpsert({ memberKey, rows, version, dir, fetch }) {
135
+ const params = { member_key: memberKey, rows, sprag_version: version || '' };
136
+ let presetVersion = null;
137
+ try {
138
+ const { presetSource } = await import('./presets-source.js');
139
+ const s = presetSource({ dir });
140
+ presetVersion = s.kind === 'remote' ? s.version : 0;
141
+ } catch (e) { debug('team-sync:preset-version', e); }
142
+ if (presetVersion == null) return rpc('ledger_upsert', params, { fetch });
143
+ try {
144
+ return await rpc('ledger_upsert', { ...params, preset_version: presetVersion }, { fetch });
145
+ } catch (e) {
146
+ if (!/preset_version|Could not find the function|PGRST202/i.test(String(e && e.message))) throw e;
147
+ return rpc('ledger_upsert', params, { fetch });
148
+ }
63
149
  }
64
150
 
65
151
  export async function rpc(fn, params, {
66
- fetch = globalThis.fetch, timeoutMs = 8000, url = TEAM_API_URL, key = TEAM_ANON_KEY,
152
+ fetch = globalThis.fetch, timeoutMs = 8000, url = teamApiUrl(), key = teamAnonKey(),
67
153
  } = {}) {
68
154
  const ctl = new AbortController();
69
155
  const timer = setTimeout(() => ctl.abort(), timeoutMs);
@@ -95,7 +181,7 @@ export async function rpc(fn, params, {
95
181
  * as rpc(). Errors carry the server's own message. The access token only ever
96
182
  * lives in memory: it is never written to disk or printed.
97
183
  */
98
- async function apiJson(path, { body, accessToken, label, fetch = globalThis.fetch, timeoutMs = 8000, url = TEAM_API_URL, key = TEAM_ANON_KEY, prefer } = {}) {
184
+ async function apiJson(path, { body, accessToken, label, fetch = globalThis.fetch, timeoutMs = 8000, url = teamApiUrl(), key = teamAnonKey(), prefer } = {}) {
99
185
  const ctl = new AbortController();
100
186
  const timer = setTimeout(() => ctl.abort(), timeoutMs);
101
187
  try {
@@ -130,16 +216,36 @@ export async function verifySignInCode({ email, code, fetch } = {}) {
130
216
  return { accessToken: r.access_token, userId: r.user.id };
131
217
  }
132
218
 
133
- /** Insert a team owned by the signed-in user (RLS allows the owner insert). */
134
- export async function createTeam({ name, accessToken, userId, fetch } = {}) {
135
- const r = await apiJson('/rest/v1/teams', {
136
- body: { name, owner_uid: userId }, accessToken, prefer: 'return=representation', label: 'create team', fetch,
219
+ /**
220
+ * Create a team through the team_create RPC as the signed-in user. A null orgId
221
+ * makes the server open a new organization named after the team; otherwise the
222
+ * caller must be an owner or admin of that organization. userId is accepted for
223
+ * call-site compatibility; the server derives the owner from the session.
224
+ */
225
+ export async function createTeam({ name, accessToken, userId, orgId = null, fetch } = {}) {
226
+ void userId;
227
+ const r = await apiJson('/rest/v1/rpc/team_create', {
228
+ body: { p_name: name, p_org_id: orgId || null }, accessToken, label: 'create team', fetch,
137
229
  });
138
230
  const row = Array.isArray(r) ? r[0] : r;
139
231
  if (!row || !row.invite_token) throw new Error('create team: no invite token returned');
140
- return { id: row.id, name: row.name, inviteToken: row.invite_token, plan: row.plan };
232
+ return {
233
+ id: row.id, name: row.name, inviteToken: row.invite_token, plan: row.plan,
234
+ orgId: row.org_id || null, orgName: row.org_name || null,
235
+ };
141
236
  }
142
237
 
238
+ /** Typed error for the server's 'seat limit reached' refusal (code SEAT_LIMIT). */
239
+ export class SeatLimitError extends Error {
240
+ constructor(message) {
241
+ super(message);
242
+ this.name = 'SeatLimitError';
243
+ this.code = 'SEAT_LIMIT';
244
+ }
245
+ }
246
+
247
+ export const isSeatLimitError = (e) => !!e && (e.code === 'SEAT_LIMIT' || /seat limit reached/i.test(String(e.message || '')));
248
+
143
249
  /** Local calendar day, same key as stats.js. */
144
250
  function dayKey(ms) {
145
251
  const d = new Date(ms);
@@ -224,14 +330,73 @@ export async function aggregateDaily({ now = Date.now(), dir = userDataDir(), da
224
330
  }));
225
331
  }
226
332
 
333
+ // Device info: OS name, OS version and CPU architecture. Nothing that names the
334
+ // machine (no hostname, username or path) is read or sent.
335
+ const DARWIN_TO_MACOS = { 20: '11', 21: '12', 22: '13', 23: '14', 24: '15', 25: '26' };
336
+
337
+ export function deviceInfo({
338
+ platform = process.platform, arch = process.arch, release = osRelease(),
339
+ exec = (cmd, args) => execFileSync(cmd, args, { timeout: 2000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }),
340
+ readText = (f) => readFileSync(f, 'utf8'),
341
+ } = {}) {
342
+ let os = String(platform);
343
+ let osVersion = String(release || '');
344
+ try {
345
+ if (platform === 'darwin') {
346
+ os = 'macOS';
347
+ let v = '';
348
+ try { v = String(exec('sw_vers', ['-productVersion'])).trim(); } catch (e) { debug('team-sync', e); }
349
+ osVersion = v || DARWIN_TO_MACOS[parseInt(release, 10)] || osVersion;
350
+ } else if (platform === 'win32') {
351
+ os = 'Windows';
352
+ const build = parseInt(String(release).split('.')[2], 10);
353
+ osVersion = build >= 22000 ? '11' : '10';
354
+ } else if (platform === 'linux') {
355
+ os = 'Linux';
356
+ try {
357
+ const text = readText('/etc/os-release');
358
+ const get = (k) => (text.match(new RegExp(`^${k}=(.*)$`, 'm')) || [])[1]?.trim().replace(/^"|"$/g, '');
359
+ // NAME + VERSION_ID ("Ubuntu", "24.04"): the dashboard prints them
360
+ // side by side, and PRETTY_NAME already carries the version.
361
+ const name = get('NAME') || get('PRETTY_NAME');
362
+ if (name) { os = name; osVersion = get('VERSION_ID') || osVersion; }
363
+ } catch (e) { debug('team-sync', e); }
364
+ }
365
+ } catch (e) { debug('team-sync', e); }
366
+ return { os: os.slice(0, 40), os_version: osVersion.slice(0, 40), arch: String(arch).slice(0, 40) };
367
+ }
368
+
369
+ // Reports device info once after a join and again only when it changes. Fails
370
+ // soft: the next sync retries. Never runs while uploads are switched off.
371
+ export async function reportDeviceInfo({ state, dir = userDataDir(), fetch, env = process.env } = {}) {
372
+ if (teamSyncDisabled(env) || !isJoined(state)) return state;
373
+ const info = deviceInfo();
374
+ if (state.deviceInfo && JSON.stringify(state.deviceInfo) === JSON.stringify(info)) return state;
375
+ try {
376
+ await rpc('member_set_info', { member_key: state.memberKey, ...info }, { fetch });
377
+ const next = { ...state, deviceInfo: info };
378
+ saveTeamState(next, dir);
379
+ return next;
380
+ } catch (e) {
381
+ debug('team-sync', e);
382
+ return state;
383
+ }
384
+ }
385
+
227
386
  export async function joinTeam({
228
- token, displayName, agent = 'claude', autoJoined = false, fetch, dir = userDataDir(), version, now = Date.now(),
387
+ token, displayName, agent = 'claude', autoJoined = false, fetch, dir = userDataDir(), version, now = Date.now(), env = process.env,
229
388
  } = {}) {
230
389
  if (!TOKEN_RE.test(String(token || ''))) throw new Error('invalid invite token (expected spt_ + 32 hex characters)');
231
390
  const name = String(displayName || '').trim() || 'member';
232
- const r = await rpc('team_join', {
233
- invite_token: token, display_name: name, agent, sprag_version: version || '', auto_joined: !!autoJoined,
234
- }, { fetch });
391
+ let r;
392
+ try {
393
+ r = await rpc('team_join', {
394
+ invite_token: token, display_name: name, agent, sprag_version: version || '', auto_joined: !!autoJoined,
395
+ }, { fetch });
396
+ } catch (e) {
397
+ if (isSeatLimitError(e)) throw new SeatLimitError(e.message);
398
+ throw e;
399
+ }
235
400
  if (!r || !r.member_key) throw new Error('team_join: no member key returned');
236
401
  const state = {
237
402
  version: TEAM_STATE_VERSION,
@@ -240,6 +405,10 @@ export async function joinTeam({
240
405
  memberId: r.member_id,
241
406
  memberKey: r.member_key,
242
407
  plan: r.plan,
408
+ orgId: r.org_id || null,
409
+ orgName: r.org_name || null,
410
+ seatsUsed: Number.isFinite(r.seats_used) ? r.seats_used : null,
411
+ seatLimit: Number.isFinite(r.seat_limit) ? r.seat_limit : null,
243
412
  promptCapture: !!r.prompt_capture,
244
413
  displayName: name,
245
414
  agent,
@@ -250,7 +419,7 @@ export async function joinTeam({
250
419
  lastSyncRows: 0,
251
420
  };
252
421
  saveTeamState(state, dir);
253
- return state;
422
+ return reportDeviceInfo({ state, dir, fetch, env });
254
423
  }
255
424
 
256
425
  export async function leaveTeam({ fetch, dir = userDataDir(), now = Date.now() } = {}) {
@@ -267,38 +436,167 @@ export async function leaveTeam({ fetch, dir = userDataDir(), now = Date.now() }
267
436
  return next;
268
437
  }
269
438
 
439
+ /**
440
+ * Pull the approved rules and write team-rules.json. Rows are kept as the
441
+ * server sends them, `level` included. Returns the RPC body and the rule rows.
442
+ */
443
+ async function pullRules({ memberKey, dir, fetch, now }) {
444
+ const r = (await rpc('team_rules_get', { member_key: memberKey }, { fetch })) || {};
445
+ const rules = Array.isArray(r.rules) ? r.rules : [];
446
+ writeStateFile(teamRulesCachePath(dir), JSON.stringify({
447
+ version: 1, fetchedAt: now, teamId: r.team_id, teamName: r.team_name, orgId: r.org_id || null, orgName: r.org_name || null,
448
+ plan: r.plan, promptCapture: !!r.prompt_capture, rules, removed: Array.isArray(r.removed) ? r.removed : [],
449
+ }, null, 2) + '\n');
450
+ return { r, rules };
451
+ }
452
+
453
+ /** State fields a rules pull refreshes. */
454
+ function withRulesMeta(state, r, now) {
455
+ const next = { ...state, lastRulesAt: now };
456
+ if (r.team_name) next.teamName = r.team_name;
457
+ if (r.plan) next.plan = r.plan;
458
+ if (r.org_name) next.orgName = r.org_name;
459
+ if (r.prompt_capture !== undefined) next.promptCapture = !!r.prompt_capture;
460
+ delete next.lastRulesError;
461
+ return next;
462
+ }
463
+
464
+ /**
465
+ * Re-render the ratchet files from the fresh cache, for Claude Code and, when
466
+ * its home exists, Codex, and keep the CLAUDE.md import in step. Fails soft:
467
+ * the session-start hook renders them again anyway.
468
+ */
469
+ export function refreshTeamRatchetFiles({ dir = userDataDir() } = {}) {
470
+ const out = {};
471
+ for (const agent of ['claude', 'codex']) {
472
+ if (agent === 'codex' && !existsSync(codexUserDir())) continue;
473
+ try { out[agent] = syncTeamRatchet({ agent, dir }); } catch (e) { debug('team-sync:ratchet', e); }
474
+ }
475
+ try { ensureTeamImport(); } catch (e) { debug('team-sync:import', e); }
476
+ return out;
477
+ }
478
+
270
479
  export async function syncTeam({
271
- now = Date.now(), dir = userDataDir(), fetch, force = false, env = process.env, version, sessions,
480
+ now = Date.now(), dir = userDataDir(), fetch, force = false, env = process.env, version, sessions, promptsOnly = false, rulesOnly = false,
272
481
  } = {}) {
273
- if (teamSyncDisabled(env)) return { skipped: 'disabled' };
274
- const state = loadTeamState(dir);
482
+ let state = loadTeamState(dir);
483
+ // The switch stops every usage upload. A joined machine still tells the
484
+ // server once that it paused (no usage data), so the organization sees a
485
+ // paused seat instead of a machine that went quiet.
486
+ if (teamSyncDisabled(env)) {
487
+ if (!isJoined(state) || state.pausedReported) return { skipped: 'disabled' };
488
+ try {
489
+ await rpc('member_set_sync', { member_key: state.memberKey, paused: true }, { fetch });
490
+ saveTeamState({ ...state, pausedReported: true }, dir);
491
+ return { skipped: 'disabled', reported: 'paused' };
492
+ } catch (e) {
493
+ debug('team-sync', e);
494
+ return { skipped: 'disabled', error: e.message };
495
+ }
496
+ }
275
497
  if (state.left) return { skipped: 'left' };
276
498
  if (!isJoined(state)) return { skipped: 'not-joined' };
277
- if (!force && !teamSyncDue(state, now)) return { skipped: 'not-due' };
499
+ let resumed = false;
500
+ if (state.pausedReported) {
501
+ try {
502
+ await rpc('member_set_sync', { member_key: state.memberKey, paused: false }, { fetch });
503
+ state = { ...state };
504
+ delete state.pausedReported;
505
+ saveTeamState(state, dir);
506
+ force = true;
507
+ resumed = true;
508
+ } catch (e) {
509
+ debug('team-sync', e);
510
+ }
511
+ }
512
+ // Rules only: one small RPC, no ledger and no prompts, and lastSyncAt stays
513
+ // put. A resume falls through to the full sync instead.
514
+ if (rulesOnly && !resumed) {
515
+ if (!force && !rulesPullDue(state, now, env)) return { skipped: 'not-due' };
516
+ state = { ...state, lastRulesAttemptAt: now };
517
+ try { saveTeamState(state, dir); } catch (e) { debug('team-sync', e); }
518
+ try {
519
+ const { r, rules } = await pullRules({ memberKey: state.memberKey, dir, fetch, now });
520
+ saveTeamState(withRulesMeta(loadTeamState(dir), r, now), dir);
521
+ refreshTeamRatchetFiles({ dir });
522
+ return { uploaded: 0, rules: rules.length, rulesOnly: true };
523
+ } catch (e) {
524
+ debug('team-sync', e);
525
+ try { saveTeamState({ ...loadTeamState(dir), lastRulesError: e.message }, dir); } catch (e2) { debug('team-sync', e2); }
526
+ return { error: e.message };
527
+ }
528
+ }
529
+ // Prompts have their own 30-minute timer and are cheap (bytes past the stored
530
+ // offsets); the ledger re-parses 30 days of sessions and keeps its 6 hours.
531
+ if (promptsOnly || (!force && !teamSyncDue(state, now))) {
532
+ if (!force && !promptSyncDue(state, now)) return { skipped: 'not-due' };
533
+ if (!state.promptCapture) return { skipped: 'not-due' };
534
+ // claim the slot first so a second hook in the same minute finds nothing due
535
+ state = { ...state, lastPromptSyncAt: now };
536
+ saveTeamState(state, dir);
537
+ const prompts = await syncPrompts({ state, dir, fetch, now });
538
+ return { uploaded: 0, rules: 0, prompts: prompts ?? 0, promptsOnly: true };
539
+ }
540
+ // Claim the attempt first: a second hook within two minutes sees it in flight.
541
+ state = { ...state, lastSyncAttemptAt: now };
542
+ try { saveTeamState(state, dir); } catch (e) { debug('team-sync', e); }
543
+ state = await reportDeviceInfo({ state, dir, fetch, env });
278
544
  try {
279
- const rows = (await aggregateDaily({ now, dir, sessions })).slice(-MAX_ROWS);
280
- await rpc('ledger_upsert', { member_key: state.memberKey, rows, sprag_version: version || '' }, { fetch });
281
- const r = (await rpc('team_rules_get', { member_key: state.memberKey }, { fetch })) || {};
282
- const rules = Array.isArray(r.rules) ? r.rules : [];
283
- writeStateFile(teamRulesCachePath(dir), JSON.stringify({
284
- version: 1, fetchedAt: now, teamId: r.team_id, teamName: r.team_name, plan: r.plan,
285
- promptCapture: !!r.prompt_capture, rules, removed: Array.isArray(r.removed) ? r.removed : [],
286
- }, null, 2) + '\n');
287
- const next = { ...state, lastSyncAt: now, lastSyncRows: rows.length };
288
- if (r.team_name) next.teamName = r.team_name;
289
- if (r.plan) next.plan = r.plan;
290
- if (r.prompt_capture !== undefined) next.promptCapture = !!r.prompt_capture;
545
+ const win = ledgerWindow(state, now);
546
+ const rows = (await aggregateDaily({ now, dir, sessions, days: win.days }))
547
+ .filter((r) => r.day >= win.startKey).slice(-MAX_ROWS);
548
+ await ledgerUpsert({ memberKey: state.memberKey, rows, version, dir, fetch });
549
+ const { r, rules } = await pullRules({ memberKey: state.memberKey, dir, fetch, now });
550
+ const next = withRulesMeta({ ...state, lastSyncAt: now, lastSyncAttemptAt: now, lastSyncRows: rows.length, ledgerSyncedThrough: dayKey(now) }, r, now);
551
+ delete next.syncFailures;
291
552
  delete next.lastError;
292
553
  saveTeamState(next, dir);
293
- return { uploaded: rows.length, rules: rules.length };
554
+ refreshTeamRatchetFiles({ dir });
555
+ const prompts = await syncPrompts({ state: next, dir, fetch, now });
556
+ return { uploaded: rows.length, rules: rules.length, ...(prompts !== null ? { prompts } : {}) };
294
557
  } catch (e) {
295
558
  debug('team-sync', e);
296
- // Stamp lastSyncAt too, so an offline machine backs off for the interval.
297
- try { saveTeamState({ ...state, lastSyncAt: now, lastError: e.message }, dir); } catch (e2) { debug('team-sync', e2); }
559
+ // lastSyncAt stays the last SUCCESS and the watermark does not move, so the
560
+ // next success re-sends every missed day. Retry after 15, 30, 60 minutes.
561
+ try {
562
+ saveTeamState({ ...state, lastSyncAttemptAt: now, syncFailures: (Number(state.syncFailures) || 0) + 1, lastError: e.message }, dir);
563
+ } catch (e2) { debug('team-sync', e2); }
298
564
  return { error: e.message };
299
565
  }
300
566
  }
301
567
 
568
+ /**
569
+ * Prompt upload step of a sync. Capture off: forget the offsets so a later
570
+ * switch-on starts at the end of every file. Capture on: upload what is new.
571
+ * Fails soft; a problem here never marks the whole sync as failed.
572
+ */
573
+ async function syncPrompts({ state, dir, fetch, now = Date.now() }) {
574
+ try {
575
+ if (!state.promptCapture) {
576
+ if (promptOffsetsExist(dir)) dropOffsets(dir);
577
+ return null;
578
+ }
579
+ const put = (params) => rpc('prompt_chunk_put', { member_key: state.memberKey, ...params }, { fetch, timeoutMs: 30000 });
580
+ const r = await uploadPrompts({ put, dir });
581
+ const fresh = loadTeamState(dir);
582
+ if (r.off) {
583
+ dropOffsets(dir);
584
+ saveTeamState({ ...fresh, promptCapture: false }, dir);
585
+ return null;
586
+ }
587
+ const patch = { lastPromptSyncAt: now };
588
+ if (r.uploaded > 0) {
589
+ Object.assign(patch, { lastPromptUploadAt: Date.now(), lastPromptCount: r.uploaded,
590
+ promptsUploadedTotal: (fresh.promptsUploadedTotal || 0) + r.uploaded });
591
+ }
592
+ saveTeamState({ ...fresh, ...patch }, dir);
593
+ return r.uploaded;
594
+ } catch (e) {
595
+ debug('team-sync', e);
596
+ return null;
597
+ }
598
+ }
599
+
302
600
  export async function autoJoin({ env = process.env, fetch, dir = userDataDir(), version, now = Date.now() } = {}) {
303
601
  const token = env.SPRAG_TEAM_TOKEN;
304
602
  if (!token || !TOKEN_RE.test(token)) return null;
@@ -309,8 +607,9 @@ export async function autoJoin({ env = process.env, fetch, dir = userDataDir(),
309
607
  let name = 'member';
310
608
  try { name = userInfo().username || 'member'; } catch { /* no passwd entry */ }
311
609
  try {
312
- return await joinTeam({ token, displayName: name, autoJoined: true, fetch, dir, version, now });
610
+ return await joinTeam({ token, displayName: name, autoJoined: true, fetch, dir, version, now, env });
313
611
  } catch (e) {
612
+ // SeatLimitError (code SEAT_LIMIT) lands here too: auto-join stays silent and retries next session.
314
613
  debug('team-sync', e);
315
614
  return null;
316
615
  }