sprag-cli 3.77.0 → 3.79.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/docs/TEAM.ko.md +10 -9
- package/docs/TEAM.md +10 -9
- package/package.json +1 -1
- package/src/commands/team.js +5 -3
- package/src/team-prompts.js +22 -5
- package/src/team-ratchet.js +2 -2
- package/src/team-sync.js +7 -4
package/docs/TEAM.ko.md
CHANGED
|
@@ -22,7 +22,7 @@ Sprag 은 기본적으로 로컬에서만 동작합니다. 팀 기능은 켜야
|
|
|
22
22
|
|
|
23
23
|
보내지 않는 것은 프롬프트, 대화 기록, 파일 경로, 프로젝트 이름, 룰 내용, API 키입니다. 프롬프트 수집을 켠 엔터프라이즈 팀의 옵트인 프롬프트 업로드만 예외이며 "프롬프트 사용량 (엔터프라이즈)" 절에 적었습니다. 룰은 직접 `sprag team propose` 를 실행할 때만 밖으로 나갑니다.
|
|
24
24
|
|
|
25
|
-
하루 단위 집계 업로드는 마지막 성공 이후
|
|
25
|
+
하루 단위 집계 업로드는 마지막 성공 이후 5분에 한 번 이하로 실행됩니다. SessionStart 훅(그리고 작은 로컬 파일만 읽는 프롬프트 훅)이 띄운 분리된 자식 프로세스가 보냅니다. 네트워크가 끊기는 등 동기화가 실패하면 5분 주기 대신 15분, 30분, 60분 뒤에 다시 시도합니다(최대 60분 간격). 마지막으로 올린 날짜를 기억해 두었다가 그 전날부터 최대 30일 치를 다시 보내므로, 며칠 꺼져 있던 컴퓨터도 처음 성공하는 동기화에서 빠진 날을 따라잡습니다. 하루 단위 행은 덮어쓰기라 다시 보내도 안전합니다. 프롬프트 수집을 켠 엔터프라이즈 팀의 프롬프트 업로드는 따로 30분 타이머로 움직입니다("프롬프트 사용량 (엔터프라이즈)" 절 참고). statusline 을 그리는 경로에서는 절대 실행되지 않습니다.
|
|
26
26
|
|
|
27
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` 인자는 항상 빈 값) 입니다.
|
|
28
28
|
|
|
@@ -83,7 +83,7 @@ sprag team propose "커밋 전에 lint 실행" --scope ratchet
|
|
|
83
83
|
|
|
84
84
|
`--scope` 는 `ratchet`(기본값) 또는 `model` 입니다. 제안은 오너나 어드민이 승인할 때까지 대기 상태로 남습니다.
|
|
85
85
|
|
|
86
|
-
**룰이 도착하는 시점.** 승인된 룰은 각 PC에서 다음 세션을 시작할 때 반영됩니다.
|
|
86
|
+
**룰이 도착하는 시점.** 승인된 룰은 각 PC에서 다음 세션을 시작할 때 반영됩니다. 약 5분마다 도는 사용량 동기화가 룰도 함께 받아 옵니다. 바로 적용하려면 `sprag team sync --force` 를 실행한 뒤 새 세션을 여십시오. 어느 쪽 동기화든 룰을 받는 즉시 룰 파일을 다시 쓰므로, 다음 세션은 추가 요청 없이 새 룰을 읽습니다. `sprag team sync --rules-only` 는 룰만 받고 아무것도 올리지 않습니다.
|
|
87
87
|
|
|
88
88
|
Sprag 은 룰을 `~/.claude/ratchet-team.md`(Codex 는 `<CODEX_HOME>/ratchet-team.md`)에 씁니다. 조직 룰이 먼저, 팀 룰이 그다음이고, 모델 룰은 각각의 하위 절로 들어갑니다. 그 파일이 있는 동안 `~/.claude/CLAUDE.md` 의 전역 harness 블록에서 import 합니다. Sprag 이 파일을 통째로 다시 쓰므로 직접 고치지 마십시오. `sprag team rules` 는 받아 둔 룰을 `- [org · model] 내용`, `- [team · ratchet] 내용` 형태로 보여 줍니다. `sprag team leave` 는 멤버 키, 캐시, import 를 지웁니다.
|
|
89
89
|
|
|
@@ -93,21 +93,21 @@ Sprag 은 룰을 `~/.claude/ratchet-team.md`(Codex 는 `<CODEX_HOME>/ratchet-tea
|
|
|
93
93
|
|---|---|
|
|
94
94
|
| `sprag team join [토큰] [--name <이름>] [--yes]` | 팀에 참여 |
|
|
95
95
|
| `sprag team status [--show-payload]` | 팀, 플랜, 프롬프트 수집 설정, 표시 이름, 마지막 동기화를 보여 줍니다. `--show-payload` 를 붙이면 보내는 기기 정보와 올라갈 하루 단위 행도 그대로 출력합니다 |
|
|
96
|
-
| `sprag team sync [--force] [--rules-only]` | 지금 업로드하고 룰을 받습니다. `--force` 는
|
|
96
|
+
| `sprag team sync [--force] [--rules-only]` | 지금 업로드하고 룰을 받습니다. `--force` 는 5분 간격을 무시하고, `--rules-only` 는 조직·팀 룰만 받고 아무것도 올리지 않습니다 |
|
|
97
97
|
| `sprag team rules` | 받아 둔 조직·팀 룰 보기 |
|
|
98
98
|
| `sprag team propose "<내용>" [--scope ratchet\|model]` | 팀에 룰 제안 |
|
|
99
99
|
| `sprag team leave` | 팀에서 나가고 업로드 중단 |
|
|
100
100
|
|
|
101
101
|
## 조직과 좌석
|
|
102
102
|
|
|
103
|
-
모든 팀은 조직에 속하고, 좌석은 팀이 아니라 조직 단위로 구매합니다. 조직은 Sprag만 개설합니다. sales@sprag.io 로 문의하면 슈퍼유저가 sprag.io/ops/ 에서 조직을 열고 첫 오너를 지정하며, 그다음부터 오너와 어드민이 그 안에 팀을 만듭니다.
|
|
103
|
+
모든 팀은 조직에 속하고, 좌석은 팀이 아니라 조직 단위로 구매합니다. 조직은 Sprag만 개설합니다. sales@sprag.io 로 문의하면 슈퍼유저가 sprag.io/ops/ 에서 조직을 열고 첫 오너를 이메일로 지정하며, 그다음부터 오너와 어드민이 그 안에 팀을 만듭니다. 오너 이메일은 필수이고 계정이 미리 없어도 됩니다. 한 번도 로그인한 적 없는 이메일이면 Sprag가 초대로 보관해 두었다가, 그 사람이 https://sprag.io/team/ 에 그 이메일로 처음 로그인하는 순간 오너가 됩니다.
|
|
104
104
|
|
|
105
105
|
컴퓨터 한 대는 조직마다 좌석 하나를 씁니다. `sprag team leave` 뒤에 다시 참여하거나, 재설치하거나, 같은 조직의 다른 팀 초대로 참여해도 서버가 기기 ID로 예전 행을 찾아 초대받은 팀으로 옮기고 멤버 키만 새로 발급합니다. 행도 좌석도 두 개가 되지 않습니다. 다른 조직에 참여하면 그 조직의 좌석을 따로 씁니다.
|
|
106
106
|
|
|
107
107
|
- **좌석이란.** 팀을 떠나지 않았고, 최근 30일 안에 무엇이든 보냈거나 업로드를 멈춘 상태인 컴퓨터 한 대(멤버 키 하나)입니다. 멈추지 않았는데 30일 넘게 연락이 없는 컴퓨터는 좌석을 차지하지 않습니다. 떠난 멤버도 세지 않습니다.
|
|
108
108
|
- **두 번째 컴퓨터.** 같은 사람이 컴퓨터 두 대를 쓰면 오너나 어드민이 두 번째 컴퓨터를 첫 번째에 합쳐 좌석 하나를 같이 쓰게 할 수 있습니다. 대시보드의 멤버 행에서 "좌석 합치기"를 누르고, "합치기 해제"를 누르면 다시 따로 셉니다. 합쳐진 행에는 "OO님과 자리를 같이 씁니다"가 보입니다.
|
|
109
109
|
- **한도.** 조직마다 좌석 한도가 있고, 한도를 넘는 참여는 거절됩니다. `sprag team join` 은 "이 조직에 남은 좌석이 없습니다. 조직 오너나 어드민에게 좌석 추가를 요청한 뒤 다시 참여하세요"를 출력하고 종료 코드 1로 끝납니다. 환경 변수로 하는 자동 참여는 조용히 넘어갔다가 다음 세션 시작 때 다시 시도합니다. 한도를 현재 사용량보다 낮춰도 기존 멤버는 그대로 쓰고, Sprag가 다시 올릴 때까지 새 참여만 거절됩니다.
|
|
110
|
-
- **역할.** 권한 단계는 세 가지입니다. 슈퍼유저는 Sprag 직원이며 요금제와 좌석 한도를 정하고, 조직을 정지하고, 오너를
|
|
110
|
+
- **역할.** 권한 단계는 세 가지입니다. 슈퍼유저는 Sprag 직원이며 요금제와 좌석 한도를 정하고, 조직을 정지하고, 이메일로 오너를 추가하고(계정이 없으면 초대로 처리), sprag.io/ops/ 에서 모든 조직을 봅니다. 오너는 회사의 최상위 단계로, 어드민을 이메일로 지정하거나 해제하고(계정이 없으면 초대되며, 오너가 취소하거나 본인이 처음 로그인할 때까지 "첫 로그인 대기"로 표시됩니다) 어드민이 하는 일을 모두 합니다. 어드민은 회사의 관리 단계로, 팀을 만들고 구성원과 룰과 좌석을 관리합니다. 팀을 만든 사람에게도 특별한 권한이 없으며, 팀은 조직의 오너와 어드민, 그리고 슈퍼유저만 관리합니다. 오너와 어드민은 대시보드에서 좌석 한도를 볼 수 있습니다. 좌석을 늘리려면 sales@sprag.io 로 문의합니다.
|
|
111
111
|
|
|
112
112
|
관리하는 조직 안에 팀을 만들려면 `sprag team create --org <조직 ID>` 를 씁니다. 조직은 Sprag만 개설하므로 `--org` 없이 실행하면 서버가 거절합니다. 명령은 조직 이름과 사용 중인 좌석 수를 함께 출력하고, `sprag team status` 는 조직 이름을 보여 줍니다.
|
|
113
113
|
|
|
@@ -117,7 +117,7 @@ Sprag 은 룰을 `~/.claude/ratchet-team.md`(Codex 는 `<CODEX_HOME>/ratchet-tea
|
|
|
117
117
|
|
|
118
118
|
## 대시보드
|
|
119
119
|
|
|
120
|
-
https://sprag.io/team/ 에서 오너나 어드민이 이메일 코드로 로그인하고, 팀을 만들고, 초대 토큰을 받습니다. 여기서 구성원별 지출과 절감액을 보고, 규칙 탭에서 조직 룰과 팀 룰을 관리하고, 구성원을 내보낼 수 있습니다. 조직 패널에는 요금제, 한도 대비 사용 좌석, 어드민, 전체 팀이 보입니다. 슈퍼유저는 sprag.io/ops/ 에서 모든 조직을 봅니다.
|
|
120
|
+
https://sprag.io/team/ 에서 등록된 오너나 어드민이 이메일 코드로 로그인하고, 팀을 만들고, 초대 토큰을 받습니다. 가입 절차는 없습니다. 오너, 어드민, 초대 대기 중 어느 쪽에도 없는 이메일은 코드를 보내기 전에 거절하며, 조직 오너에게 추가를 요청하라고 안내합니다. 초대받은 사람은 처음 로그인할 때 자동으로 오너나 어드민이 됩니다. 여기서 구성원별 지출과 절감액을 보고, 규칙 탭에서 조직 룰과 팀 룰을 관리하고, 구성원을 내보낼 수 있습니다. 조직 패널에는 요금제, 한도 대비 사용 좌석, 어드민, 전체 팀이 보입니다. 슈퍼유저는 sprag.io/ops/ 에서 모든 조직을 봅니다.
|
|
121
121
|
|
|
122
122
|
대시보드 맨 위의 기간 선택(7일, 30일, 90일, 이번 달, 지난 달, 직접 선택)이 멤버 표, 사용량 차트, 프롬프트 사용량 탭의 기간을 정합니다. 기본값은 최근 30일이고, 내 컴퓨터의 달력 날짜를 쓰며 종료일도 포함합니다. 최대 365일까지 고를 수 있습니다. 고른 기간은 주소(`?from=2026-10-01&to=2026-10-18`)에 남으므로 새로고침하거나 링크를 공유해도 같은 기간이 보입니다. sprag.io/ops/ 슈퍼유저 콘솔에도 같은 선택이 있어 조직 표, 조직 머리말, 조직의 프롬프트 사용량에 적용됩니다.
|
|
123
123
|
|
|
@@ -133,9 +133,10 @@ https://sprag.io/team/ 에서 오너나 어드민이 이메일 코드로 로그
|
|
|
133
133
|
|
|
134
134
|
- **제거**는 좌석을 비우고, 그 컴퓨터의 키는 더 이상 동작하지 않습니다.
|
|
135
135
|
- **복구**는 오너나 어드민이 내보낸 컴퓨터에만 쓸 수 있고, 좌석 한도 안에서만 됩니다.
|
|
136
|
+
- **삭제**는 이미 제거된 컴퓨터에만 나타납니다. 확인을 거치면 그 컴퓨터의 사용 기록과 프롬프트가 완전히 지워져 조직의 과거 합계에서도 빠지며, 되돌릴 수 없습니다. 활동 기록에는 컴퓨터 이름과 함께 "삭제" 한 줄만 남습니다. 사용 중인 컴퓨터는 먼저 제거해야 합니다.
|
|
136
137
|
- **좌석 합치기**와 합치기 해제는 위에서 설명한 대로입니다.
|
|
137
138
|
|
|
138
|
-
현황 아래의 활동 기록에는 참여, 멈춤, 재개, 탈퇴, 제거,
|
|
139
|
+
현황 아래의 활동 기록에는 참여, 멈춤, 재개, 탈퇴, 제거, 복구, 삭제가 누가 했는지(구성원, 오너, 어드민, 슈퍼유저)와 함께 남습니다.
|
|
139
140
|
|
|
140
141
|
## 플랜
|
|
141
142
|
|
|
@@ -145,13 +146,13 @@ Free, 프로(좌석당 월 $5), 엔터프라이즈(좌석당 월 $10, 프롬프
|
|
|
145
146
|
|
|
146
147
|
엔터프라이즈 조직의 오너와 어드민은 sprag.io/team 에서 프롬프트 탭을 씁니다. 사용자별 프롬프트 수, 글자 수, 평균 길이, 활동 일수, 마지막 프롬프트를 표로 보고, 사용자마다 프롬프트를 검색해 볼 수 있습니다. 프롬프트는 프롬프트 수집을 켠 엔터프라이즈 팀만 저장합니다. 프로 조직에는 잠금 화면이 나옵니다.
|
|
147
148
|
|
|
148
|
-
**무엇을 수집하나.** 직접 입력한 프롬프트만 올라갑니다. 메인 Claude Code 세션(`~/.claude/projects/**/*.jsonl`)과 Codex 세션(`<CODEX_HOME 또는 ~/.codex>/sessions/**/*.jsonl`)이 대상입니다. 프롬프트마다 시각, 프로젝트 폴더 이름(경로의 마지막 부분이며 전체 경로는 아닙니다), 본문(최대 20,000자)이 올라갑니다. 어시스턴트 답변, 도구 출력, 파일 내용, 서브에이전트 기록, 슬래시 명령 래퍼, 시스템 리마인더, 파일 경로는 수집하지 않습니다.
|
|
149
|
+
**무엇을 수집하나.** 직접 입력한 프롬프트만 올라갑니다. 메인 Claude Code 세션(`~/.claude/projects/**/*.jsonl`)과 Codex 세션(`<CODEX_HOME 또는 ~/.codex>/sessions/**/*.jsonl`)이 대상입니다. 프롬프트마다 시각, 프로젝트 폴더 이름(경로의 마지막 부분이며 전체 경로는 아닙니다), 본문(최대 20,000자)이 올라갑니다. Claude 가 답하는 도중에 미리 입력해 둔 프롬프트도 대상이고, 프롬프트에 붙여 넣은 텍스트는 그 프롬프트와 함께 보냅니다. 어시스턴트 답변, 도구 출력, 파일 내용, 서브에이전트 기록, 슬래시 명령 래퍼, `!` 셸 명령과 그 출력, 백그라운드 작업 알림, 다른 에이전트가 보낸 메시지, 시스템 리마인더, 파일 경로는 수집하지 않습니다.
|
|
149
150
|
|
|
150
151
|
**비밀값 가림.** 올리기 전에 눈에 띄는 비밀값은 `[redacted]` 로 바꿉니다. API 키와 토큰(`sk-...`, `sk-ant-...`, `ghp_...`, `gho_...`, `github_pat_...`, `AKIA...`, `xox[abpr]-...`), `Bearer` 토큰, 개인 키 블록, `KEY=`, `TOKEN=`, `PASSWORD=`, `SECRET=` 형태의 대입이 대상입니다. 흔한 형태만 잡을 뿐 모든 비밀값을 찾지는 못하므로, 수집이 켜진 컴퓨터에서는 프롬프트에 비밀값을 붙여 넣지 마십시오.
|
|
151
152
|
|
|
152
153
|
**과거분은 올리지 않습니다.** 컴퓨터가 프롬프트 수집이 켜진 것을 처음 보면 기존 기록의 끝에 표시를 하고 그 뒤에 입력한 것만 올립니다. 정책이 이 컴퓨터에 닿기 전에 쓴 내용은 전혀 나가지 않고, 수집이 꺼진 동안 입력한 내용도 나중에 올라가지 않습니다. 그 뒤에 새로 생긴 기록 파일은 처음부터 읽습니다. 정책이 꺼지면 컴퓨터가 읽던 위치(`prompts-offsets.json`)를 지우므로, 다시 켜면 모든 파일의 끝에서 새로 시작합니다.
|
|
153
154
|
|
|
154
|
-
**주기.** 프롬프트는 Claude Code 나 Codex 를 쓰는 동안 약 30분마다 올라가고, 하루 단위 집계는
|
|
155
|
+
**주기.** 프롬프트는 Claude Code 나 Codex 를 쓰는 동안 약 30분마다 올라가고, 하루 단위 집계는 5분마다 올라갑니다. 프롬프트를 제출할 때 작은 로컬 타이머를 확인하고, 30분이 지났으면 분리된 백그라운드 프로세스가 업로드를 시작합니다(훅 안에서는 네트워크 호출도 기록 파싱도 하지 않습니다). 세션을 시작할 때와 `sprag team sync --force` 를 실행할 때도 올라갑니다. 한 번에 최대 2,000건 또는 8MB까지 올리고 나머지는 다음 동기화로 넘어갑니다. 파일 경로는 보내지 않습니다. 파일은 경로 해시의 앞 16자로만 구분하고, 같은 조각을 다시 보내도 서버가 무시합니다.
|
|
155
156
|
|
|
156
157
|
**확인하기.** `sprag team status` 는 "프롬프트 수집: 켜짐"과 마지막 프롬프트 업로드의 시각과 건수를 보여 줍니다. `sprag team status --show-payload` 는 올라갈 차례를 기다리는 프롬프트 수와 예시 하나(시각, 프로젝트, 앞 80자)를 더 보여 주며, 대기 중인 프롬프트의 전체 본문은 출력하지 않습니다.
|
|
157
158
|
|
package/docs/TEAM.md
CHANGED
|
@@ -22,7 +22,7 @@ After you join, Sprag uploads one row per local calendar day:
|
|
|
22
22
|
|
|
23
23
|
Not sent: prompts, transcripts, file paths, project names, rule text, API keys. The one exception is the opt-in prompt upload of an Enterprise team with prompt capture on, described under "Prompt usage (Enterprise)". A rule leaves your machine only when you run `sprag team propose` yourself.
|
|
24
24
|
|
|
25
|
-
The daily aggregate upload runs at most once every
|
|
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
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.
|
|
28
28
|
|
|
@@ -83,7 +83,7 @@ sprag team propose "Run lint before every commit" --scope ratchet
|
|
|
83
83
|
|
|
84
84
|
`--scope` is `ratchet` (the default) or `model`. A proposal stays pending until an owner or admin approves it.
|
|
85
85
|
|
|
86
|
-
**When a rule arrives.** Approved rules reach a machine at its next session start.
|
|
86
|
+
**When a rule arrives.** Approved rules reach a machine at its next session start. Every usage sync, about every 5 minutes, also pulls the rules; `sprag team sync --force` then a new session applies them at once. Both kinds of sync rewrite the rule files as soon as the rules arrive, so the next session reads them without another round trip. `sprag team sync --rules-only` pulls the rules alone and uploads nothing.
|
|
87
87
|
|
|
88
88
|
Sprag writes the rules to `~/.claude/ratchet-team.md` (Codex: `<CODEX_HOME>/ratchet-team.md`), organization rules first and then team rules, each with its model rules in a sub-section, and imports that file from the global harness block of `~/.claude/CLAUDE.md` for as long as the file exists. Do not edit the file by hand, because Sprag rewrites it. `sprag team rules` lists the cached rules as `- [org · model] text` or `- [team · ratchet] text`. `sprag team leave` removes the member key, the cache and the import.
|
|
89
89
|
|
|
@@ -93,21 +93,21 @@ Sprag writes the rules to `~/.claude/ratchet-team.md` (Codex: `<CODEX_HOME>/ratc
|
|
|
93
93
|
|---|---|
|
|
94
94
|
| `sprag team join [token] [--name <n>] [--yes]` | Join a team |
|
|
95
95
|
| `sprag team status [--show-payload]` | Team, plan, prompt-capture flag, display name, last sync. `--show-payload` also prints the device info and the exact daily rows that would be uploaded |
|
|
96
|
-
| `sprag team sync [--force] [--rules-only]` | Upload now and pull rules. `--force` ignores the
|
|
96
|
+
| `sprag team sync [--force] [--rules-only]` | Upload now and pull rules. `--force` ignores the 5-minute interval; `--rules-only` pulls the organization and team rules and uploads nothing |
|
|
97
97
|
| `sprag team rules` | Show the cached organization and team rules |
|
|
98
98
|
| `sprag team propose "<text>" [--scope ratchet\|model]` | Propose a rule to the team |
|
|
99
99
|
| `sprag team leave` | Leave the team and stop uploading |
|
|
100
100
|
|
|
101
101
|
## Organizations and seats
|
|
102
102
|
|
|
103
|
-
Every team belongs to an organization, and seats are bought per organization, not per team. Only Sprag opens organizations: write to sales@sprag.io, and the superuser opens one at sprag.io/ops/ and names its first owner. Owners and admins then create teams inside it.
|
|
103
|
+
Every team belongs to an organization, and seats are bought per organization, not per team. Only Sprag opens organizations: write to sales@sprag.io, and the superuser opens one at sprag.io/ops/ and names its first owner by email. The owner email is required, and the owner does not need an account yet: if the email has never signed in, Sprag keeps it as an invite, and the person becomes the owner the first time they sign in at https://sprag.io/team/ with that email. Owners and admins then create teams inside it.
|
|
104
104
|
|
|
105
105
|
A machine holds one seat per organization. When it joins again, with `sprag team join` after `sprag team leave`, after a reinstall, or with the invite of another team in the same organization, the server finds its earlier row by the machine ID, moves it to the invited team and issues a new member key; no second row and no second seat appear. Joins to a different organization take a seat there.
|
|
106
106
|
|
|
107
107
|
- **What a seat is.** One machine (one member key) that has not left and either sent anything in the last 30 days or is paused. A machine that has been silent for 30 days or more without being paused holds no seat. Members who left are not counted.
|
|
108
108
|
- **Second machine.** When the same person uses two machines, an owner or admin can merge the second one into the first so the two share one seat. In the dashboard, use "Merge seat" on the member row; "Unmerge" gives it its own seat back. A merged row shows "shares a seat with <name>".
|
|
109
109
|
- **The limit.** Each organization has a seat limit. A join past the limit is refused: `sprag team join` exits 1 with "This organization has no free seat. Ask your organization owner or admin to add seats", and an environment auto-join stays silent and tries again at a later session start. Lowering the limit below current use never removes anyone; it only refuses new joins until Sprag raises it again.
|
|
110
|
-
- **Roles.** There are three permission levels. The superuser is Sprag staff: it sets the plan and the seat limit, suspends organizations, adds owners, and sees every organization at sprag.io/ops/. An owner is a company's top level: it appoints and removes admins, and does everything an admin does. An admin is a company's management level: it creates teams and manages members, rules and seats. The person who created a team has no special rights over it; only the organization's owners and admins and the superuser manage teams. Owners and admins see the seat limit in the dashboard. To add seats, write to sales@sprag.io.
|
|
110
|
+
- **Roles.** There are three permission levels. The superuser is Sprag staff: it sets the plan and the seat limit, suspends organizations, adds owners by email (an email without an account is invited), and sees every organization at sprag.io/ops/. An owner is a company's top level: it appoints and removes admins by email (an email without an account is invited and shown as waiting for its first sign-in until the owner cancels it or the person signs in), and does everything an admin does. An admin is a company's management level: it creates teams and manages members, rules and seats. The person who created a team has no special rights over it; only the organization's owners and admins and the superuser manage teams. Owners and admins see the seat limit in the dashboard. To add seats, write to sales@sprag.io.
|
|
111
111
|
- **The plan.** Sprag sets the plan (`pro` or `enterprise`) on the organization; owners and admins cannot change it. Prompt capture can be switched on only while the organization is on Enterprise. To change the plan, contact us.
|
|
112
112
|
|
|
113
113
|
Create a team inside an organization you administer from the CLI with `sprag team create --org <org-id>`; without `--org` the server refuses, because only Sprag opens organizations. The command prints the organization name and how many seats are used. `sprag team status` shows the organization name.
|
|
@@ -118,7 +118,7 @@ Create a team inside an organization you administer from the CLI with `sprag tea
|
|
|
118
118
|
|
|
119
119
|
## The dashboard
|
|
120
120
|
|
|
121
|
-
https://sprag.io/team/ is where
|
|
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/.
|
|
122
122
|
|
|
123
123
|
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
124
|
|
|
@@ -134,9 +134,10 @@ Row actions:
|
|
|
134
134
|
|
|
135
135
|
- **Remove** frees the seat, and the machine's key stops working.
|
|
136
136
|
- **Restore** brings back only machines that an owner or admin removed, and only within the seat limit.
|
|
137
|
+
- **Delete** appears on a machine that is already removed. After a confirmation it erases the machine's usage history and prompts for good, so they also drop out of the organization's past totals, and it cannot be undone. The activity log keeps one "deleted" line with the machine's name. An active machine must be removed first.
|
|
137
138
|
- **Seat merge** and unmerge, as described above.
|
|
138
139
|
|
|
139
|
-
Below the roster, an activity log lists joined, paused, resumed, left, removed and
|
|
140
|
+
Below the roster, an activity log lists joined, paused, resumed, left, removed, restored and deleted events, with who did each one: member, owner, admin or superuser.
|
|
140
141
|
|
|
141
142
|
## Plans
|
|
142
143
|
|
|
@@ -146,13 +147,13 @@ Free, Pro ($5 per seat per month) and Enterprise ($10 per seat per month, with p
|
|
|
146
147
|
|
|
147
148
|
Owners and admins of an Enterprise organization get a Prompts tab on sprag.io/team: a per-user table of prompts, characters, average length, active days and last prompt, and a searchable prompt list per user. Prompts are stored only for Enterprise teams that have prompt capture switched on; Pro organizations see a locked state.
|
|
148
149
|
|
|
149
|
-
**What is captured.** Only the prompts you typed yourself, from your main Claude Code sessions (`~/.claude/projects/**/*.jsonl`) and Codex sessions (`<CODEX_HOME or ~/.codex>/sessions/**/*.jsonl`). Each prompt goes up as its time, the project folder's name (the last part of the path, never the full path) and its text, cut at 20,000 characters. Not captured: assistant replies, tool output, file contents, subagent transcripts, slash-command wrappers, system reminders, or any file path.
|
|
150
|
+
**What is captured.** Only the prompts you typed yourself, from your main Claude Code sessions (`~/.claude/projects/**/*.jsonl`) and Codex sessions (`<CODEX_HOME or ~/.codex>/sessions/**/*.jsonl`). Each prompt goes up as its time, the project folder's name (the last part of the path, never the full path) and its text, cut at 20,000 characters. A prompt you type while Claude is still working (a queued prompt) counts too. Text you paste into a prompt goes up as part of it. Not captured: assistant replies, tool output, file contents, subagent transcripts, slash-command wrappers, `!` shell commands and their output, background-task notifications, messages from other agents, system reminders, or any file path.
|
|
150
151
|
|
|
151
152
|
**Redaction.** Before upload, obvious secrets are replaced with `[redacted]`: API keys and tokens (`sk-...`, `sk-ant-...`, `ghp_...`, `gho_...`, `github_pat_...`, `AKIA...`, `xox[abpr]-...`), `Bearer` tokens, private key blocks, and `KEY=`, `TOKEN=`, `PASSWORD=` and `SECRET=` assignments. This catches common shapes, not every secret, so do not paste secrets into prompts on a machine with capture on.
|
|
152
153
|
|
|
153
154
|
**No backfill.** The first time a machine sees prompt capture on, it marks the end of every existing transcript and uploads only what you type after that. Nothing written before the policy reached your machine is sent, and what you type while capture is off is never sent later. Transcripts created afterwards are read from their start. When the policy turns off, the machine forgets its place (`prompts-offsets.json`), so switching it on again starts fresh at the end of every file.
|
|
154
155
|
|
|
155
|
-
**Cadence.** Prompts are uploaded about every 30 minutes while Claude Code or Codex is in use; the daily aggregate
|
|
156
|
+
**Cadence.** Prompts are uploaded about every 30 minutes while Claude Code or Codex is in use; the daily aggregate goes up every 5 minutes. A prompt you submit checks a small local timer and, when 30 minutes have passed, starts a detached background upload (no network and no parsing happen in the hook itself). A session start and `sprag team sync --force` also upload. One run sends at most 2,000 prompts or 8 MB; the rest waits for the next sync. The file path is never sent; each file is identified by the first 16 characters of a hash of its path, and a repeated chunk is ignored by the server.
|
|
156
157
|
|
|
157
158
|
**Check it.** `sprag team status` shows "Prompt capture: on" and the time and count of the last prompt upload. `sprag team status --show-payload` also shows how many prompts are waiting and one example (time, project, first 80 characters), never the full text of the pending prompts.
|
|
158
159
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sprag-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.79.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": {
|
package/src/commands/team.js
CHANGED
|
@@ -15,7 +15,7 @@ import { pendingPrompts } from '../team-prompts.js';
|
|
|
15
15
|
import { memberId } from '../member-id.js';
|
|
16
16
|
import {
|
|
17
17
|
loadTeamState, deviceInfo, isJoined, joinTeam, leaveTeam, syncTeam, autoJoin, aggregateDaily, rpc,
|
|
18
|
-
teamRulesCachePath, teamSyncDisabled, requestSignInCode, verifySignInCode, createTeam, isSeatLimitError,
|
|
18
|
+
teamRulesCachePath, teamSyncDisabled, requestSignInCode, verifySignInCode, createTeam, isSeatLimitError, isOrgExpiredError,
|
|
19
19
|
} from '../team-sync.js';
|
|
20
20
|
|
|
21
21
|
const T = {
|
|
@@ -40,6 +40,7 @@ const T = {
|
|
|
40
40
|
aborted: 'Not joined.',
|
|
41
41
|
joined: (n) => `Joined team "${n}". Check it with: sprag team status`,
|
|
42
42
|
seatLimit: 'This organization has no free seat. Ask your organization owner or admin to add seats, then join again.',
|
|
43
|
+
orgExpired: "This organization's paid term has ended, so the server no longer accepts uploads or joins. Ask your organization owner to renew it (sales@sprag.io).",
|
|
43
44
|
org: 'Organization',
|
|
44
45
|
seats: (u, l) => `${u}/${l} seats used`,
|
|
45
46
|
orgCreated: (n, u, l) => `Organization: ${n}${u != null && l != null ? ` (${u}/${l} seats used)` : ''}`,
|
|
@@ -105,6 +106,7 @@ const T = {
|
|
|
105
106
|
aborted: '참여하지 않았습니다.',
|
|
106
107
|
joined: (n) => `팀 "${n}" 에 참여했습니다. 확인: sprag team status`,
|
|
107
108
|
seatLimit: '이 조직에 남은 좌석이 없습니다. 조직 오너나 어드민에게 좌석 추가를 요청한 뒤 다시 참여하세요',
|
|
109
|
+
orgExpired: '조직의 이용 기간이 끝나 서버가 업로드와 참여를 받지 않습니다. 조직 오너에게 연장을 요청하세요(sales@sprag.io)',
|
|
108
110
|
org: '조직',
|
|
109
111
|
seats: (u, l) => `좌석 ${u}/${l} 사용 중`,
|
|
110
112
|
orgCreated: (n, u, l) => `조직: ${n}${u != null && l != null ? ` (좌석 ${u}/${l} 사용 중)` : ''}`,
|
|
@@ -284,7 +286,7 @@ export async function run({ args, hasFlag, getArg, version, fetch, interactive,
|
|
|
284
286
|
}
|
|
285
287
|
out(`${m.name}: ${st.displayName}`);
|
|
286
288
|
out(st.lastSyncAt ? m.lastSync(new Date(st.lastSyncAt).toLocaleString(), st.lastSyncRows || 0) : m.neverSynced);
|
|
287
|
-
if (st.lastError) out(`${m.lastError}: ${st.lastError}`);
|
|
289
|
+
if (st.lastError) out(isOrgExpiredError(st.lastError) ? m.orgExpired : `${m.lastError}: ${st.lastError}`);
|
|
288
290
|
if (hasFlag('--show-payload')) {
|
|
289
291
|
out(`device: ${JSON.stringify(st.deviceInfo || deviceInfo())}`);
|
|
290
292
|
out(JSON.stringify(await aggregateDaily(), null, 2));
|
|
@@ -336,7 +338,7 @@ export async function run({ args, hasFlag, getArg, version, fetch, interactive,
|
|
|
336
338
|
return verb && verb !== 'help' ? process.exit(2) : undefined;
|
|
337
339
|
}
|
|
338
340
|
} catch (e) {
|
|
339
|
-
console.error(isSeatLimitError(e) ? m.seatLimit : e.message);
|
|
341
|
+
console.error(isSeatLimitError(e) ? m.seatLimit : isOrgExpiredError(e) ? m.orgExpired : e.message);
|
|
340
342
|
return process.exit(1);
|
|
341
343
|
}
|
|
342
344
|
}
|
package/src/team-prompts.js
CHANGED
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
* Runs inside syncTeam only when the team's policy has prompt capture on (the
|
|
5
5
|
* server sends prompt_capture = true, and only for Enterprise organizations).
|
|
6
6
|
* What leaves the machine: the prompts the user typed in their own main
|
|
7
|
-
* sessions (Claude Code transcripts and Codex sessions),
|
|
7
|
+
* sessions (Claude Code transcripts and Codex sessions), including the ones
|
|
8
|
+
* typed while the assistant was still working (queued), each as
|
|
8
9
|
* { at, project (a directory basename), text }, with obvious secrets replaced
|
|
9
10
|
* by "[redacted]". Never sent: assistant replies, tool output, file contents,
|
|
10
|
-
* subagent transcripts, slash-command wrappers, system reminders, local paths
|
|
11
|
+
* subagent transcripts, slash-command wrappers, system reminders, local paths,
|
|
12
|
+
* background-task notifications, messages from other agents, and `!` shell
|
|
13
|
+
* commands with their output.
|
|
11
14
|
*
|
|
12
15
|
* Consent boundary: the first time a machine sees capture on, every existing
|
|
13
16
|
* transcript's offset is set to its current size, so history written before the
|
|
@@ -77,14 +80,17 @@ const basenameOf = (p) => {
|
|
|
77
80
|
return s.slice(Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\')) + 1).slice(0, 80);
|
|
78
81
|
};
|
|
79
82
|
|
|
80
|
-
const WRAPPER_RE = /<(?:command-name|command-message|command-args|local-command-stdout|local-command-stderr|local-command-caveat)>/;
|
|
83
|
+
const WRAPPER_RE = /<(?:command-name|command-message|command-args|local-command-stdout|local-command-stderr|local-command-caveat|bash-input|bash-stdout|bash-stderr|task-notification)>/;
|
|
84
|
+
// machine-written turns that arrive as user messages: a background task's completion, another agent's message, a harness notice
|
|
85
|
+
const MACHINE_RE = /^\s*(?:<task-notification>|<agent-message\b|\[SYSTEM NOTIFICATION)/;
|
|
81
86
|
const REMINDER_RE = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
|
|
82
87
|
|
|
83
88
|
/** Strip reminders, drop wrappers and injected context; null when nothing user-typed is left. */
|
|
84
89
|
function cleanPrompt(raw) {
|
|
85
90
|
if (typeof raw !== 'string') return null;
|
|
86
|
-
if (WRAPPER_RE.test(raw)) return null;
|
|
91
|
+
if (WRAPPER_RE.test(raw) || MACHINE_RE.test(raw)) return null;
|
|
87
92
|
const text = raw.replace(REMINDER_RE, '').trim();
|
|
93
|
+
if (MACHINE_RE.test(text)) return null;
|
|
88
94
|
if (!text) return null;
|
|
89
95
|
if (text.startsWith('[Request interrupted by user')) return null;
|
|
90
96
|
return redactSecrets(text).slice(0, MAX_TEXT);
|
|
@@ -97,7 +103,18 @@ const textParts = (content) => (Array.isArray(content) ? content : [])
|
|
|
97
103
|
export function claudePromptFromLine(line) {
|
|
98
104
|
let e;
|
|
99
105
|
try { e = JSON.parse(line); } catch { return null; }
|
|
100
|
-
if (!e || e.
|
|
106
|
+
if (!e || e.isSidechain) return null;
|
|
107
|
+
// A prompt typed while the assistant is still working is stored as a queued_command attachment, never as a user line;
|
|
108
|
+
// only the ones a person typed (origin.kind 'human') count, not queued task notifications or messages from other agents.
|
|
109
|
+
const q = e.type === 'attachment' ? e.attachment : null;
|
|
110
|
+
if (q) {
|
|
111
|
+
if (q.type !== 'queued_command' || q.isMeta || q.commandMode !== 'prompt' || q.origin?.kind !== 'human') return null;
|
|
112
|
+
const text = cleanPrompt(q.prompt);
|
|
113
|
+
const at = Date.parse(q.timestamp || e.timestamp);
|
|
114
|
+
if (!text || !Number.isFinite(at)) return null;
|
|
115
|
+
return { at: new Date(at).toISOString(), project: basenameOf(e.cwd), text };
|
|
116
|
+
}
|
|
117
|
+
if (e.type !== 'user' || e.isMeta || e.isCompactSummary || e.isVisibleInTranscriptOnly) return null;
|
|
101
118
|
const c = e.message?.content;
|
|
102
119
|
const text = cleanPrompt(typeof c === 'string' ? c : textParts(c));
|
|
103
120
|
const at = Date.parse(e.timestamp);
|
package/src/team-ratchet.js
CHANGED
|
@@ -31,7 +31,7 @@ import { writeStateFile, writeViaTmp } from './state-file.js';
|
|
|
31
31
|
export { TEAM_RATCHET_IMPORT_RE, teamRatchetImportLine };
|
|
32
32
|
|
|
33
33
|
export const TEAM_RATCHET_MARKER = '<!-- sprag:team-ratchet -->';
|
|
34
|
-
const SYNC_INTERVAL_MS =
|
|
34
|
+
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // TEAM_SYNC_INTERVAL_MS in team-sync.js
|
|
35
35
|
const IN_FLIGHT_MS = 2 * 60 * 1000;
|
|
36
36
|
const TOKEN_RE = /^spt_[0-9a-f]{32}$/;
|
|
37
37
|
|
|
@@ -238,7 +238,7 @@ export function spawnTeamSyncIfDue({
|
|
|
238
238
|
|
|
239
239
|
/**
|
|
240
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 (
|
|
241
|
+
* parsing here. When the ledger/rules sync is due (5 minutes after the last success,
|
|
242
242
|
* or its failure backoff elapsed), start the detached `team sync --background`
|
|
243
243
|
* child; else, when only the prompt upload is due, start it in prompts-only
|
|
244
244
|
* mode; else, when only the hourly rules pull is due, start it in rules-only
|
package/src/team-sync.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* team-rules.json last pulled rules (organization rules first, then team
|
|
13
13
|
* rules; each row carries level 'org' or 'team')
|
|
14
14
|
*
|
|
15
|
-
* Rules arrive with every full sync (
|
|
15
|
+
* Rules arrive with every full sync (5 minutes) and with the rules-only pull
|
|
16
16
|
* (`team sync --rules-only`, at most hourly). Either kind rewrites the ratchet
|
|
17
17
|
* files right away, so a new session picks the rules up without waiting for
|
|
18
18
|
* the next session-start render.
|
|
@@ -46,7 +46,7 @@ export const TEAM_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzd
|
|
|
46
46
|
// Local testing only: SPRAG_TEAM_API_URL + SPRAG_TEAM_ANON_KEY point the client at a local stack.
|
|
47
47
|
export const teamApiUrl = (env = process.env) => envVar('TEAM_API_URL', env) || TEAM_API_URL;
|
|
48
48
|
export const teamAnonKey = (env = process.env) => envVar('TEAM_ANON_KEY', env) || TEAM_ANON_KEY;
|
|
49
|
-
export const TEAM_SYNC_INTERVAL_MS =
|
|
49
|
+
export const TEAM_SYNC_INTERVAL_MS = 5 * 60 * 1000; // the copy in team-ratchet.js must match (test/team-prompts.test.js pins them)
|
|
50
50
|
export const TEAM_STATE_VERSION = 1;
|
|
51
51
|
|
|
52
52
|
const TOKEN_RE = /^spt_[0-9a-f]{32}$/;
|
|
@@ -93,7 +93,7 @@ export function ledgerRetryDelayMs(failures) {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
|
-
* The ledger/rules sync is due every
|
|
96
|
+
* The ledger/rules sync is due every 5 minutes after the last SUCCESS, or, after a
|
|
97
97
|
* failure, when the backoff since the last attempt has elapsed. An attempt that
|
|
98
98
|
* began less than 2 minutes ago counts as in flight, so two hooks do not both spawn.
|
|
99
99
|
*/
|
|
@@ -247,6 +247,9 @@ export class SeatLimitError extends Error {
|
|
|
247
247
|
|
|
248
248
|
export const isSeatLimitError = (e) => !!e && (e.code === 'SEAT_LIMIT' || /seat limit reached/i.test(String(e.message || '')));
|
|
249
249
|
|
|
250
|
+
/** The server refuses uploads and joins 14 days after an organization's paid term ends ('org_expired: ...'). */
|
|
251
|
+
export const isOrgExpiredError = (e) => /\borg_expired\b/.test(String((e && e.message) || e || ''));
|
|
252
|
+
|
|
250
253
|
/** Local calendar day, same key as stats.js. */
|
|
251
254
|
function dayKey(ms) {
|
|
252
255
|
const d = new Date(ms);
|
|
@@ -538,7 +541,7 @@ export async function syncTeam({
|
|
|
538
541
|
}
|
|
539
542
|
}
|
|
540
543
|
// Prompts have their own 30-minute timer and are cheap (bytes past the stored
|
|
541
|
-
// offsets); the ledger re-parses
|
|
544
|
+
// offsets); the ledger re-parses only the days since its watermark (about a second) and keeps its 5 minutes.
|
|
542
545
|
if (promptsOnly || (!force && !teamSyncDue(state, now))) {
|
|
543
546
|
if (!force && !promptSyncDue(state, now)) return { skipped: 'not-due' };
|
|
544
547
|
if (!state.promptCapture) return { skipped: 'not-due' };
|