sprag-cli 3.79.0 → 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/README.ko.md CHANGED
@@ -258,13 +258,44 @@ sprag feedback "Bedrock에서 5m TTL 칩이 사라지지 않아요"
258
258
 
259
259
  ## 팀
260
260
 
261
- Sprag 은 켜지 않으면 로컬에서만 동작합니다. `sprag team join <토큰>` 을 실행하거나(조직 전체는 `SPRAG_TEAM_TOKEN`) 하면 컴퓨터마다 하루에 한 행씩 올라갑니다. 내용은 지출, 절감액, 위임 실행 횟수, 모델 계열 비중입니다. 프롬프트, 대화 기록, 파일 경로, 프로젝트 이름은 보내지 않으며, `sprag team status --show-payload` 로 올라갈 내용을 그대로 볼 수 있습니다. 그 대신 팀 소유자는 대시보드에서 구성원별 합계를 보고, 모든 구성원에게 자동으로 적용될 래칫 룰을 승인할 수 있습니다.
261
+ CLI는 무료이고 컴퓨터 한 대에서 혼자 동작합니다. 조직에는 **Sprag Pro** 와 **Sprag Enterprise** 가 같은 로컬 세션 로그로 만든 공용 대시보드를 더해 줍니다. 도입은 [sales@sprag.io](mailto:sales@sprag.io)로 문의해 주세요.
262
+
263
+ ### Pro: 팀 대시보드
264
+
265
+ <a href="https://sprag.io/assets/video/plan-pro-ko.mp4"><img src="https://raw.githubusercontent.com/rootstudioyaml/sprag/main/docs/plan-pro-ko.gif" width="640" alt="팀원마다 동물 캐릭터로 앉아 있는 가상 사무실, 지출과 절감액, 좌석, 30일 차트가 있는 조직 대시보드, 한 번 승인하면 모든 컴퓨터에 적용되는 팀 규칙을 차례로 보여 줍니다."></a>
266
+
267
+ - 조직 대시보드와 30일 추이 차트
268
+ - 사람마다 동물 캐릭터가 앉아 있는 가상 사무실
269
+ - 컴퓨터별 상태가 보이는 좌석 명단
270
+ - 세션 시작 때 적용되는 팀 승인 규칙
271
+ - `sprag team join` 으로 조직 배포
272
+ - 오너와 어드민 역할, 활동 기록
273
+
274
+ ### Enterprise: 프롬프트 모니터링
275
+
276
+ <a href="https://sprag.io/assets/video/plan-ent-ko.mp4"><img src="https://raw.githubusercontent.com/rootstudioyaml/sprag/main/docs/plan-ent-ko.gif" width="640" alt="사용자별 프롬프트 사용량과 30일 추이, 한 사람의 프롬프트 검색, 팀별로 프롬프트 수집을 켜는 설정을 보여 줍니다."></a>
277
+
278
+ Pro 의 기능에 더해 다음을 제공합니다.
279
+
280
+ - 팀별 프롬프트 수집, 비밀 값은 가림
281
+ - 정책을 켜기 전 기록은 수집하지 않음
282
+ - 사용자별 프롬프트 사용량과 30일 추이
283
+ - 사용자별 프롬프트 목록과 검색
284
+ - 모든 멤버에게 보이는 수집 정책
285
+
286
+ 프롬프트 수집은 조직 오너나 어드민이 켜기 전까지 꺼져 있습니다.
287
+
288
+ <sub>두 영상 모두 데모 대시보드에서 예시 데이터로 녹화했습니다. 전체 영상: <a href="https://sprag.io/assets/video/plan-pro-ko.mp4">Pro (30초)</a> · <a href="https://sprag.io/assets/video/plan-ent-ko.mp4">Enterprise (30초)</a></sub>
289
+
290
+ ### 컴퓨터가 보내는 것
291
+
292
+ Sprag 은 켜지 않으면 로컬에서만 동작합니다. `sprag team join <토큰>` 을 실행하거나(조직 전체는 `SPRAG_TEAM_TOKEN`) 하면 컴퓨터마다 하루에 한 행씩 올라갑니다. 내용은 지출, 절감액, 위임 실행 횟수, 모델 계열 비중입니다. 대화 기록, 파일 경로, 프로젝트 이름은 보내지 않습니다. 프롬프트도 Enterprise 조직이 수집 정책을 켜지 않는 한 보내지 않으며, `sprag team status --show-payload` 로 올라갈 내용을 그대로 볼 수 있습니다. 그 대신 팀 소유자는 대시보드에서 구성원별 합계를 보고, 모든 구성원에게 자동으로 적용될 래칫 룰을 승인할 수 있습니다.
262
293
 
263
294
  `SPRAG_NO_TEAM_SYNC=1` 은 팀 관련 통신을 모두 끄고, `sprag team leave` 는 참여를 해제합니다.
264
295
 
265
296
  - [팀 기능 안내](docs/TEAM.ko.md)
266
- - 대시보드: https://sprag.io/team/
267
- - 플랜과 가격: https://sprag.io/pricing/
297
+ - 오너와 어드민용 팀 콘솔: https://sprag.io/team/
298
+ - 플랜 안내: https://sprag.io/license/
268
299
 
269
300
  ## 라이선스
270
301
 
package/README.md CHANGED
@@ -312,13 +312,44 @@ For a bug, please include the tool version (`sprag --version`) and your OS. For
312
312
 
313
313
  ## Teams
314
314
 
315
- Sprag stays local unless you opt in. With `sprag team join <token>` (or `SPRAG_TEAM_TOKEN` for a whole organization), each machine uploads one row per day: spend, savings, delegated-run count and model-family share. Prompts, transcripts, file paths and project names are never sent, and `sprag team status --show-payload` prints exactly what would go up. In return the team owner sees per-member totals in a dashboard and can approve ratchet rules that every member picks up automatically.
315
+ The CLI is free and works on its own on one machine. For organizations, **Sprag Pro** and **Sprag Enterprise** add a shared dashboard built from the same local session logs. To start, write to [sales@sprag.io](mailto:sales@sprag.io).
316
+
317
+ ### Pro: team dashboard
318
+
319
+ <a href="https://sprag.io/assets/video/plan-pro-en.mp4"><img src="https://raw.githubusercontent.com/rootstudioyaml/sprag/main/docs/plan-pro-en.gif" width="640" alt="A virtual office where every teammate sits as an animal character, then the organization dashboard with spend, savings, seats and a 30-day chart, then team rules approved once and picked up by every machine."></a>
320
+
321
+ - Organization dashboard with a 30-day chart
322
+ - A virtual office with an animal for each person
323
+ - Seat roster with each machine's status
324
+ - Team-approved rules applied at session start
325
+ - Rollout with `sprag team join`
326
+ - Owner and admin roles, activity log
327
+
328
+ ### Enterprise: prompt monitoring
329
+
330
+ <a href="https://sprag.io/assets/video/plan-ent-en.mp4"><img src="https://raw.githubusercontent.com/rootstudioyaml/sprag/main/docs/plan-ent-en.gif" width="640" alt="Per-user prompt usage with a 30-day trend, a prompt search for one person, and the team setting that turns prompt capture on."></a>
331
+
332
+ Everything in Pro, plus:
333
+
334
+ - Prompt capture per team, with secrets redacted
335
+ - Nothing from before the policy was turned on
336
+ - Per-user prompt usage and a 30-day trend
337
+ - Each user's prompt list with search
338
+ - A capture policy every member can see
339
+
340
+ Prompt capture is off unless an organization owner or admin turns it on.
341
+
342
+ <sub>Both videos are recorded from the demo dashboard with sample data. Full videos: <a href="https://sprag.io/assets/video/plan-pro-en.mp4">Pro (30s)</a> · <a href="https://sprag.io/assets/video/plan-ent-en.mp4">Enterprise (30s)</a></sub>
343
+
344
+ ### What a machine sends
345
+
346
+ Sprag stays local unless you opt in. With `sprag team join <token>` (or `SPRAG_TEAM_TOKEN` for a whole organization), each machine uploads one row per day: spend, savings, delegated-run count and model-family share. Transcripts, file paths and project names are never sent. Prompts are not sent either, unless an Enterprise organization turns on prompt capture, and `sprag team status --show-payload` prints exactly what would go up. In return the team owner sees per-member totals in a dashboard and can approve ratchet rules that every member picks up automatically.
316
347
 
317
348
  `SPRAG_NO_TEAM_SYNC=1` turns all team traffic off, and `sprag team leave` removes the membership.
318
349
 
319
350
  - [Team layer guide](docs/TEAM.md)
320
- - Dashboard: https://sprag.io/team/
321
- - Plans and pricing: https://sprag.io/pricing/
351
+ - Team console for owners and admins: https://sprag.io/team/
352
+ - Plans: https://sprag.io/license/
322
353
 
323
354
  ## License
324
355
 
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
 
@@ -117,7 +119,7 @@ Sprag 은 룰을 `~/.claude/ratchet-team.md`(Codex 는 `<CODEX_HOME>/ratchet-tea
117
119
 
118
120
  ## 대시보드
119
121
 
120
- https://sprag.io/team/ 에서 등록된 오너나 어드민이 이메일 코드로 로그인하고, 팀을 만들고, 초대 토큰을 받습니다. 가입 절차는 없습니다. 오너, 어드민, 초대 대기 중 어느 쪽에도 없는 이메일은 코드를 보내기 전에 거절하며, 조직 오너에게 추가를 요청하라고 안내합니다. 초대받은 사람은 처음 로그인할 때 자동으로 오너나 어드민이 됩니다. 여기서 구성원별 지출과 절감액을 보고, 규칙 탭에서 조직 룰과 팀 룰을 관리하고, 구성원을 내보낼 수 있습니다. 조직 패널에는 요금제, 한도 대비 사용 좌석, 어드민, 전체 팀이 보입니다. 슈퍼유저는 sprag.io/ops/ 에서 모든 조직을 봅니다.
122
+ https://sprag.io/team/ 에서 등록된 오너나 어드민이 이메일 코드로 로그인하고, 팀을 만들고, 초대 토큰을 받습니다. 새 조직은 sales@sprag.io 로 문의합니다. 오너, 어드민, 초대 대기 중 어느 쪽에도 없는 이메일은 코드를 보내기 전에 거절하며, 조직 오너에게 추가를 요청하라고 안내합니다. 초대받은 사람은 처음 로그인할 때 자동으로 오너나 어드민이 됩니다. 여기서 구성원별 지출과 절감액을 보고, 규칙 탭에서 조직 룰과 팀 룰을 관리하고, 구성원을 내보낼 수 있습니다. 조직 패널에는 요금제, 한도 대비 사용 좌석, 어드민, 전체 팀이 보입니다. 슈퍼유저는 sprag.io/ops/ 에서 모든 조직을 봅니다.
121
123
 
122
124
  대시보드 맨 위의 기간 선택(7일, 30일, 90일, 이번 달, 지난 달, 직접 선택)이 멤버 표, 사용량 차트, 프롬프트 사용량 탭의 기간을 정합니다. 기본값은 최근 30일이고, 내 컴퓨터의 달력 날짜를 쓰며 종료일도 포함합니다. 최대 365일까지 고를 수 있습니다. 고른 기간은 주소(`?from=2026-10-01&to=2026-10-18`)에 남으므로 새로고침하거나 링크를 공유해도 같은 기간이 보입니다. sprag.io/ops/ 슈퍼유저 콘솔에도 같은 선택이 있어 조직 표, 조직 머리말, 조직의 프롬프트 사용량에 적용됩니다.
123
125
 
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
 
@@ -118,7 +120,7 @@ Create a team inside an organization you administer from the CLI with `sprag tea
118
120
 
119
121
  ## The dashboard
120
122
 
121
- https://sprag.io/team/ is where a registered owner or admin signs in with an email code, creates a team and gets the invite token. There is no sign-up: an email that is not an owner, an admin or a pending invite is refused before any code is sent, with a message to ask the organization's owner to add it. Invited people become owner or admin automatically on their first sign-in. From there they see per-member spend and savings, manage organization and team rules on the Rules tab, and remove members. The organization panel shows the plan, seats used against the limit, admins and every team. The superuser sees every organization at sprag.io/ops/.
123
+ https://sprag.io/team/ is where a registered owner or admin signs in with an email code, creates a team and gets the invite token. A new organization starts by writing to sales@sprag.io. An email that is not an owner, an admin or a pending invite is refused before any code is sent, with a message to ask the organization's owner to add it. Invited people become owner or admin automatically on their first sign-in. From there they see per-member spend and savings, manage organization and team rules on the Rules tab, and remove members. The organization panel shows the plan, seats used against the limit, admins and every team. The superuser sees every organization at sprag.io/ops/.
122
124
 
123
125
  A date range control at the top of the dashboard (7d, 30d, 90d, This month, Last month or Custom) sets the period for the members table, the Usage chart and the prompt usage tab. It defaults to the last 30 days, uses your local calendar days with the end date included, and allows 365 days at most. The choice is kept in the page address (`?from=2026-10-01&to=2026-10-18`), so a reload or a shared link shows the same period. The superuser console at /ops/ has the same control for the organization table, the organization header and its prompt usage.
124
126
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.79.0",
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) {