sprag-cli 3.79.1 → 3.80.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/bin/cli.js CHANGED
@@ -682,7 +682,7 @@ async function main() {
682
682
  }
683
683
  const { withBaseStatusline } = await import('../src/statusline-base.js');
684
684
  const noSessionOut = withBaseStatusline(formatNoSession(
685
- { caps, model, effort, windowLabel, version: PKG_VERSION, update: readUpdateChip(PKG_VERSION) },
685
+ { caps, model, effort, windowLabel, version: PKG_VERSION, update: readUpdateChip(PKG_VERSION), team: (await import('../src/team-sync.js')).readTeamChip() },
686
686
  { color: colorOk, mode: labelMode, segments: segmentsFromArgs() },
687
687
  ), { input: readStdinRaw() });
688
688
  if (guard) guard.finish(noSessionOut);
@@ -989,6 +989,8 @@ async function main() {
989
989
  options: { days, windowHours, windowLabel, version: PKG_VERSION },
990
990
  // Cached-only; the background refresh it may trigger lands on a later render.
991
991
  update: format === 'statusline' ? readUpdateChip(PKG_VERSION) : null,
992
+ // Member id and office animal from team.json; a file read, never the network.
993
+ team: format === 'statusline' ? (await import('../src/team-sync.js')).readTeamChip() : null,
992
994
  // The totals are already in hand above; passing them spares a second read of
993
995
  // both ledgers on every render.
994
996
  star: format === 'statusline'
package/docs/TEAM.ko.md CHANGED
@@ -24,7 +24,9 @@ Sprag 은 기본적으로 로컬에서만 동작합니다. 팀 기능은 켜야
24
24
 
25
25
  하루 단위 집계 업로드는 마지막 성공 이후 5분에 한 번 이하로 실행됩니다. SessionStart 훅(그리고 작은 로컬 파일만 읽는 프롬프트 훅)이 띄운 분리된 자식 프로세스가 보냅니다. 네트워크가 끊기는 등 동기화가 실패하면 5분 주기 대신 15분, 30분, 60분 뒤에 다시 시도합니다(최대 60분 간격). 마지막으로 올린 날짜를 기억해 두었다가 그 전날부터 최대 30일 치를 다시 보내므로, 며칠 꺼져 있던 컴퓨터도 처음 성공하는 동기화에서 빠진 날을 따라잡습니다. 하루 단위 행은 덮어쓰기라 다시 보내도 안전합니다. 프롬프트 수집을 켠 엔터프라이즈 팀의 프롬프트 업로드는 따로 30분 타이머로 움직입니다("프롬프트 사용량 (엔터프라이즈)" 절 참고). statusline 을 그리는 경로에서는 절대 실행되지 않습니다.
26
26
 
27
- 목적지는 `https://hiycfjwswtkixyoqkznm.supabase.co/rest/v1/rpc/*` 입니다(Supabase, ap-northeast-2 리전). 호출하는 RPC 는 `team_join`, `ledger_upsert`, `team_rules_get`, `team_rule_propose`, `team_leave`, `prompt_chunk_put`(엔터프라이즈 프롬프트 수집일 때만), `member_set_sync`(업로드 스위치 상태 호출, "업로드 스위치" 참고), `member_set_info`(OS 이름과 버전, `arch` 인자는 항상 빈 값) 입니다.
27
+ 팀에 참여한 동안 statusline 에 `team` 칩이 나타나 멤버 ID 와 가상 사무실(Sprag 타운)에서 받은 동물을 보여 줍니다. 예를 들면 `member-1e500474 🦊 여우` 입니다. 동물은 5분마다 도는 전체 동기화가 서버에서 받아 `team.json` 에 저장한 순위로 정합니다. 서버가 아직 이 기능을 지원하지 않거나 마지막으로 받은 지 하루가 넘었다면 멤버 ID 만 표시합니다. 칩을 그릴 때는 로컬 파일만 읽습니다. 버전 칩 바로 앞에 놓이며, `--segments team` 으로 이 칩만 따로 볼 수 있습니다.
28
+
29
+ 목적지는 `https://hiycfjwswtkixyoqkznm.supabase.co/rest/v1/rpc/*` 입니다(Supabase, ap-northeast-2 리전). 호출하는 RPC 는 `team_join`, `ledger_upsert`, `team_rules_get`, `team_rule_propose`, `team_leave`, `prompt_chunk_put`(엔터프라이즈 프롬프트 수집일 때만), `member_set_sync`(업로드 스위치 상태 호출, "업로드 스위치" 참고), `member_set_info`(OS 이름과 버전, `arch` 인자는 항상 빈 값), `member_standing`(statusline 칩에 쓸 본인의 사무실 동물과 순위만 받고 다른 사람의 수치는 받지 않음) 입니다.
28
30
 
29
31
  ## CLI에서 팀 만들기
30
32
 
package/docs/TEAM.md CHANGED
@@ -24,7 +24,9 @@ Not sent: prompts, transcripts, file paths, project names, rule text, API keys.
24
24
 
25
25
  The daily aggregate upload runs at most once every 5 minutes after the last successful one, from a detached child process that the SessionStart hook (and the prompt hook, which only reads a small local file) spawns. If a sync fails, for example because the network is down, it is retried after 15, then 30, then 60 minutes (60 at most) instead of every 5 minutes. The CLI remembers the last day it uploaded and re-sends from the day before it, up to 30 days, so a machine that was offline for days catches up on its first successful sync. Re-sending a day is safe because each day is an upsert. The prompt upload of an Enterprise team with capture on has its own 30-minute timer (see "Prompt usage (Enterprise)"). It never runs on the statusline path.
26
26
 
27
- Destination: `https://hiycfjwswtkixyoqkznm.supabase.co/rest/v1/rpc/*` (Supabase, region ap-northeast-2). The calls are the RPCs `team_join`, `ledger_upsert`, `team_rules_get`, `team_rule_propose`, `team_leave`, `prompt_chunk_put` (Enterprise prompt capture only), `member_set_sync` (the upload-switch status call, see "The upload switch") and `member_set_info` (OS name and version; the `arch` argument is always empty); `sprag team create` also calls `team_create` with your sign-in session.
27
+ While this machine is joined, the statusline shows a `team` chip with your member id and your animal from the virtual office (Sprag Town), for example `member-1e500474 🦊 Fox`. The animal comes from the standing the full sync fetches every 5 minutes and stores in `team.json`; if the server does not offer it yet, or the last fetch is more than a day old, only the member id is shown. Rendering the chip reads local files only. It sits just before the version chip, and `--segments team` shows it on its own.
28
+
29
+ Destination: `https://hiycfjwswtkixyoqkznm.supabase.co/rest/v1/rpc/*` (Supabase, region ap-northeast-2). The calls are the RPCs `team_join`, `ledger_upsert`, `team_rules_get`, `team_rule_propose`, `team_leave`, `prompt_chunk_put` (Enterprise prompt capture only), `member_set_sync` (the upload-switch status call, see "The upload switch") `member_set_info` (OS name and version; the `arch` argument is always empty) and `member_standing` (your own office animal and rank for the statusline chip; it returns nobody else's figures); `sprag team create` also calls `team_create` with your sign-in session.
28
30
 
29
31
  ## Create a team from the CLI
30
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.79.1",
3
+ "version": "3.80.0",
4
4
  "description": "Model-delegation router for Claude Code and Codex: lookups, checks and test runs go to cheaper models, documents go in as compact Markdown, and a live statusline shows spend and savings.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,6 +29,7 @@ import { bedrockDisplayFromId } from '../stdin-payload.js';
29
29
  import { modelFamily } from '../savings-ledger.js';
30
30
  import { shareLegend } from '../model-share.js';
31
31
  import { STAR_URL } from '../star-note.js';
32
+ import { speciesLabel } from '../office-species.js';
32
33
 
33
34
  // The 8-color ANSI defaults (RED=31, GREEN=32, YELLOW=33…) read as garish
34
35
  // next to each other — terminal palettes set them with unbalanced perceptual
@@ -365,6 +366,23 @@ function buildVersionSeg(version, update, c, isIcon, verbose, g) {
365
366
  return `${c(GRAY)}v${version}${age ? ` ✓${age} ago` : ''}${c(RESET)}`;
366
367
  }
367
368
 
369
+ /** A standing older than this is not shown; the 5-minute team sync refreshes it long before. */
370
+ const TEAM_STANDING_MAX_AGE_MS = 24 * 60 * 60 * 1000;
371
+
372
+ /**
373
+ * Team chip: the member id and, once the server has told us, the animal that
374
+ * member is in the team's virtual office (`member-1e500474 🦊 Fox`). Renders only
375
+ * for a joined machine. A missing, unknown or day-old standing leaves just the
376
+ * member label. Gray like the other identity chips; the emoji brings its own colour.
377
+ */
378
+ function buildTeamSeg(team, c, now = Date.now()) {
379
+ if (!team || typeof team !== 'object' || !team.label) return null;
380
+ const st = team.standing;
381
+ const fresh = st && Number.isFinite(st.fetchedAt) && now - st.fetchedAt <= TEAM_STANDING_MAX_AGE_MS;
382
+ const animal = fresh ? speciesLabel(st.grade, team.lang) : null;
383
+ return `${c(GRAY)}${team.label}${animal ? ` ${animal}` : ''}${c(RESET)}`;
384
+ }
385
+
368
386
  /**
369
387
  * Harness 🅷 segment builder — shared by the full report and the no-session
370
388
  * fallback line. Best-effort: never throws into the statusline (corrupted
@@ -532,7 +550,7 @@ function buildCapWarnSeg(capWarn, c, isIcon, g, color, mode) {
532
550
  * harness, model, and effort chips still render around the "no session data"
533
551
  * note.
534
552
  */
535
- export function formatNoSession({ caps = null, model = null, effort = null, windowLabel = '', version = '', update = null } = {}, { color = true, mode = 'icon', segments = null } = {}) {
553
+ export function formatNoSession({ caps = null, model = null, effort = null, windowLabel = '', version = '', update = null, team = null } = {}, { color = true, mode = 'icon', segments = null } = {}) {
536
554
  const c = (v) => (color ? v : '');
537
555
  // narrow shares the icon layout and swaps only the glyphs, so both modes take
538
556
  // the same branches from here on.
@@ -554,6 +572,8 @@ export function formatNoSession({ caps = null, model = null, effort = null, wind
554
572
  const effortSeg = buildEffortSeg(effort, c, isIcon, g);
555
573
  if (effortSeg) segs.push(effortSeg);
556
574
  segs.push(`${c(GRAY)}${g.hit} no session data${windowLabel ? ` · ${windowLabel}` : ''}${c(RESET)}`);
575
+ const teamSeg = buildTeamSeg(team, c);
576
+ if (teamSeg) segs.push(teamSeg);
557
577
  if (versionSeg && !(update && update.available)) segs.push(versionSeg);
558
578
  return segs.join(' · ') + (color ? '\x1b[K' : '');
559
579
  }
@@ -1157,7 +1177,9 @@ export const DEFAULT_SEGMENT_ORDER = [
1157
1177
  'hit', 'saved', 'period',
1158
1178
  // Calendar month.
1159
1179
  'month',
1160
- 'harness', 'korean', 'version',
1180
+ 'harness', 'korean',
1181
+ // Joined machines only: member id and office animal. Identity, so it sits with version.
1182
+ 'team', 'version',
1161
1183
  ];
1162
1184
 
1163
1185
  // Legacy `--segments` aliases: `5h` ↔ `five_hour`, `7d` ↔ `seven_day`.
@@ -1186,6 +1208,7 @@ function segmentsNamed(name, s, usageByKey) {
1186
1208
  case 'rules': return [s.rules];
1187
1209
  case 'subagents': return [s.subagents];
1188
1210
  case 'korean': return [s.korean];
1211
+ case 'team': return [s.team];
1189
1212
  case 'model': return [s.model];
1190
1213
  case 'effort': return [s.effort];
1191
1214
  // The routing chips (already empty when a headline line owns the story), then
@@ -1298,7 +1321,7 @@ function buildHeadlines(delegated, doc2md, want, eol) {
1298
1321
  * @param {boolean} [opts.verbose=false] - longer layout with labels
1299
1322
  * @param {boolean} [opts.timer=true] - show TTL countdown segment
1300
1323
  * @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses 🧠 ⏳ 💰 instead of word labels.
1301
- * @param {string[]|null} [opts.segments] - segments to render, in the order given. Names: cap-warn, spike, version, harness (a full score with no warning draws only when named here), korean (draws only when named here), model, effort, hit, ttl, month, saved, delegated, doc2md, star, ctx, period, `subagents` (this session's subagent runs; also rides with `delegated`), `rules` (only when named here), `usage` (every rate-limit window), plus per-window keys (`five_hour`, `seven_day`, …). `5h`/`7d` are kept as aliases for back-compat. cap-warn and spike keep the lead regardless of where they appear. Null/undefined = all, in the default order.
1324
+ * @param {string[]|null} [opts.segments] - segments to render, in the order given. Names: cap-warn, spike, version, harness (a full score with no warning draws only when named here), korean (draws only when named here), team (member id and office animal; joined machines only), model, effort, hit, ttl, month, saved, delegated, doc2md, star, ctx, period, `subagents` (this session's subagent runs; also rides with `delegated`), `rules` (only when named here), `usage` (every rate-limit window), plus per-window keys (`five_hour`, `seven_day`, …). `5h`/`7d` are kept as aliases for back-compat. cap-warn and spike keep the lead regardless of where they appear. Null/undefined = all, in the default order.
1302
1325
  * @param {boolean} [opts.singleLine=false] - force the legacy one-line layout. By default, when the delegation ledger has lifetime savings, the routing totals lead on their own first line and everything else moves to line 2 (Claude Code renders multi-line statuslines; `--single-line` is the escape hatch for terminals that only show the first line).
1303
1326
  */
1304
1327
  export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null, singleLine = false } = {}) {
@@ -1374,6 +1397,8 @@ export function formatReport(data, { color = true, verbose = false, timer = true
1374
1397
  // Version / upgrade chip. Read from a cache written by a detached background
1375
1398
  // check — this render path never touches the network.
1376
1399
  version: buildVersionSeg(options.version, data.update, c, isIcon, verbose, g),
1400
+ // Member id and office animal, from team.json; null unless this machine joined a team.
1401
+ team: buildTeamSeg(data.team, c),
1377
1402
  // Delegation-rule registry (`rules 10 · ⚠1`), opt-in through `--segments`.
1378
1403
  rules: buildRulesSeg(data.ruleStats, c, g),
1379
1404
  };
package/src/member-id.js CHANGED
@@ -32,6 +32,11 @@ function readValid(file) {
32
32
  } catch (e) { debug('member-id', e); return null; }
33
33
  }
34
34
 
35
+ /** The stored id, or null. Read-only: the statusline must not create files. */
36
+ export function readMemberId({ dir = userDataDir() } = {}) {
37
+ return readValid(memberIdPath(dir));
38
+ }
39
+
35
40
  // Never throws: a failed write still returns the id it generated, and the
36
41
  // cache hands the same one back on the next call.
37
42
  export function memberId({ dir = userDataDir(), random = () => randomBytes(4).toString('hex') } = {}) {
@@ -0,0 +1,49 @@
1
+ /**
2
+ * office-species — the animal each member gets in the team's virtual office
3
+ * ("Sprag Town"), as the statusline shows it: an emoji and a name per grade.
4
+ *
5
+ * The keys are the GRADES keys of site/assets/scene/office-scene.js plus
6
+ * 'ghost' (no uploaded data), and the names are the `of.g.<key>` strings of
7
+ * site/team/index.html. test/office-species.test.js pins both, so the console
8
+ * and the terminal cannot drift apart.
9
+ */
10
+
11
+ export const SPECIES = {
12
+ dragon: { emoji: '🐉', en: 'Dragon', ko: '용' },
13
+ unicorn: { emoji: '🦄', en: 'Unicorn', ko: '유니콘' },
14
+ lion: { emoji: '🦁', en: 'Lion', ko: '사자' },
15
+ tiger: { emoji: '🐯', en: 'Tiger', ko: '호랑이' },
16
+ eagle: { emoji: '🦅', en: 'Eagle', ko: '독수리' },
17
+ wolf: { emoji: '🐺', en: 'Wolf', ko: '늑대' },
18
+ cheetah: { emoji: '🐆', en: 'Cheetah', ko: '치타' },
19
+ fox: { emoji: '🦊', en: 'Fox', ko: '여우' },
20
+ owl: { emoji: '🦉', en: 'Owl', ko: '부엉이' },
21
+ husky: { emoji: '🐕', en: 'Husky', ko: '허스키' },
22
+ panda: { emoji: '🐼', en: 'Panda', ko: '판다' },
23
+ koala: { emoji: '🐨', en: 'Koala', ko: '코알라' },
24
+ penguin: { emoji: '🐧', en: 'Penguin', ko: '펭귄' },
25
+ raccoon: { emoji: '🦝', en: 'Raccoon', ko: '너구리' },
26
+ cat: { emoji: '🐱', en: 'Cat', ko: '고양이' },
27
+ dog: { emoji: '🐶', en: 'Dog', ko: '강아지' },
28
+ bear: { emoji: '🐻', en: 'Bear', ko: '곰' },
29
+ monkey: { emoji: '🐵', en: 'Monkey', ko: '원숭이' },
30
+ deer: { emoji: '🦌', en: 'Deer', ko: '사슴' },
31
+ elephant: { emoji: '🐘', en: 'Elephant', ko: '코끼리' },
32
+ pig: { emoji: '🐷', en: 'Pig', ko: '돼지' },
33
+ sheep: { emoji: '🐑', en: 'Sheep', ko: '양' },
34
+ rabbit: { emoji: '🐰', en: 'Rabbit', ko: '토끼' },
35
+ duck: { emoji: '🦆', en: 'Duck', ko: '오리' },
36
+ hedgehog: { emoji: '🦔', en: 'Hedgehog', ko: '고슴도치' },
37
+ squirrel: { emoji: '🐿️', en: 'Squirrel', ko: '다람쥐' },
38
+ hamster: { emoji: '🐹', en: 'Hamster', ko: '햄스터' },
39
+ frog: { emoji: '🐸', en: 'Frog', ko: '개구리' },
40
+ mouse: { emoji: '🐭', en: 'Mouse', ko: '생쥐' },
41
+ chick: { emoji: '🐤', en: 'Chick', ko: '병아리' },
42
+ ghost: { emoji: '👻', en: 'Ghost', ko: '유령' },
43
+ };
44
+
45
+ /** `"<emoji> <name>"` for a grade key in the given language, or null for an unknown key. */
46
+ export function speciesLabel(grade, lang = 'en') {
47
+ const s = Object.prototype.hasOwnProperty.call(SPECIES, grade) ? SPECIES[grade] : null;
48
+ return s ? `${s.emoji} ${lang === 'ko' ? s.ko : s.en}` : null;
49
+ }
package/src/team-sync.js CHANGED
@@ -28,7 +28,8 @@ import { release as osRelease } from 'node:os';
28
28
  import { createRequire } from 'node:module';
29
29
  import { userDataDir } from './paths.js';
30
30
  import { writeStateFile } from './state-file.js';
31
- import { memberId } from './member-id.js';
31
+ import { memberId, readMemberId } from './member-id.js';
32
+ import { userLanguage } from './config.js';
32
33
  import { debug } from './debug.js';
33
34
  import { loadLedger, modelFamily, roundUsd } from './savings-ledger.js';
34
35
  import { sessionCost, pricedParts } from './claude-price.js';
@@ -72,6 +73,45 @@ export function saveTeamState(state, dir = userDataDir()) {
72
73
  }
73
74
  }
74
75
 
76
+ /**
77
+ * The member's animal from the virtual office, stored as `standing` in team.json
78
+ * for the statusline chip. Best effort: a server older than member_standing
79
+ * (PGRST202) or any failure leaves the state untouched and never fails the caller.
80
+ */
81
+ async function refreshStanding({ memberKey, dir, fetch, now }) {
82
+ try {
83
+ const r = await rpc('member_standing', { member_key: memberKey }, { fetch });
84
+ if (!r || typeof r !== 'object' || typeof r.grade !== 'string') return;
85
+ const cur = loadTeamState(dir);
86
+ if (!isJoined(cur) || cur.memberKey !== memberKey) return;
87
+ saveTeamState({
88
+ ...cur,
89
+ standing: { grade: r.grade, tier: r.tier ?? null, rank: Number.isFinite(r.rank) ? r.rank : null, ranked: Number.isFinite(r.ranked) ? r.ranked : null, crown: !!r.crown, fetchedAt: now },
90
+ }, dir);
91
+ } catch (e) {
92
+ debug('team-sync', e); // PGRST202 "Could not find the function": an older server, ignore
93
+ }
94
+ }
95
+
96
+ /**
97
+ * What the statusline `team` chip needs, from team.json and the member-id file
98
+ * alone: no network and no file creation. Null when not joined (or left).
99
+ */
100
+ export function readTeamChip({ dir = userDataDir() } = {}) {
101
+ try {
102
+ const state = loadTeamState(dir);
103
+ if (!isJoined(state)) return null;
104
+ const label = String(state.displayName || '').trim() || readMemberId({ dir });
105
+ if (!label) return null;
106
+ const st = state.standing;
107
+ return {
108
+ label,
109
+ lang: userLanguage(),
110
+ standing: st && typeof st.grade === 'string' ? { grade: st.grade, fetchedAt: Number(st.fetchedAt) || 0 } : null,
111
+ };
112
+ } catch (e) { debug('team-sync', e); return null; }
113
+ }
114
+
75
115
  export function teamSyncDisabled(env = process.env) { return envVar('NO_TEAM_SYNC', env) === '1'; }
76
116
  export function isJoined(state) { return !!(state && state.memberKey && !state.left); }
77
117
 
@@ -433,7 +473,9 @@ export async function joinTeam({
433
473
  lastSyncRows: 0,
434
474
  };
435
475
  saveTeamState(state, dir);
436
- return reportDeviceInfo({ state, dir, fetch, env });
476
+ const out = await reportDeviceInfo({ state, dir, fetch, env });
477
+ await refreshStanding({ memberKey: r.member_key, dir, fetch, now });
478
+ return out;
437
479
  }
438
480
 
439
481
  export async function leaveTeam({ fetch, dir = userDataDir(), now = Date.now() } = {}) {
@@ -566,6 +608,7 @@ export async function syncTeam({
566
608
  delete next.lastError;
567
609
  saveTeamState(next, dir);
568
610
  refreshTeamRatchetFiles({ dir });
611
+ await refreshStanding({ memberKey: state.memberKey, dir, fetch, now });
569
612
  const prompts = await syncPrompts({ state: next, dir, fetch, now });
570
613
  return { uploaded: rows.length, rules: rules.length, ...(prompts !== null ? { prompts } : {}) };
571
614
  } catch (e) {