sprag-cli 3.70.0 → 3.72.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 +17 -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
@@ -0,0 +1,118 @@
1
+ {
2
+ "note": [
3
+ "Delegation-only subagents written to ~/.claude/agents by syncDelegationAgents().",
4
+ "Same shape as the \"agents\" array of a published preset bundle, so the bundled copy",
5
+ "and a remote bundle render through one path. A remote bundle replaces this list",
6
+ "when its signature verifies; otherwise this file is used.",
7
+ "Models are aliases on purpose (a gateway resolves them through ANTHROPIC_DEFAULT_*_MODEL),",
8
+ "except opus-worker, which names the full id: a bare alias resolves to an older model.",
9
+ "Effort follows how the work is verified: low for lookups and command runs, medium for",
10
+ "implementation against tests, high for the larger opus share.",
11
+ "The files are rendered in English (description.en, body.en): they are model-facing",
12
+ "prompts, and the Korean copy is what the /ops console shows next to it."
13
+ ],
14
+ "agents": [
15
+ {
16
+ "name": "haiku-explore",
17
+ "model": "haiku",
18
+ "effort": "low",
19
+ "tools": [
20
+ "Read",
21
+ "Grep",
22
+ "Glob",
23
+ "Bash"
24
+ ],
25
+ "description": {
26
+ "ko": "위임 작업용 저비용 읽기 전용 탐색, 조회, 상태 확인, 요약 에이전트입니다. MCP 도구는 없습니다.",
27
+ "en": "Cheap read-only exploration, lookup, status checks and summaries for delegated tasks. It has no MCP tools."
28
+ },
29
+ "body": {
30
+ "ko": "맡은 역할: 읽기 전용 탐색, 조회, 상태 확인, 붙여넣은 로그나 텍스트를 두고 묻는 질문에 답합니다.\n위임받은 작업만 하고, 호출자가 프롬프트에 적은 상한(도구 호출 횟수, 출력 토큰)을 넘기지 마십시오.\n호출자 프롬프트의 언어로 간결하게 보고하십시오.\nMCP 도구나 목록에 없는 도구가 필요하면 즉시 멈추고 \"needs general-purpose: <필요한 도구>\" 한 줄로 보고하십시오. 부모가 다시 위임합니다.\n확인하지 못한 내용은 추측하지 말고 확인하지 못했다고 말하십시오.",
31
+ "en": "Your role: read-only exploration, lookups, status checks, and questions about pasted logs or text.\nDo only the delegated task, and stay within the caps (tool calls, output tokens) the caller wrote in the prompt.\nReport concisely, in the language of the caller's prompt.\nIf you need an MCP tool or any tool not on your list, stop at once and report one line: \"needs general-purpose: <tools needed>\". The parent will delegate again.\nDo not guess at anything you could not verify; say that you could not verify it."
32
+ }
33
+ },
34
+ {
35
+ "name": "haiku-runner",
36
+ "model": "haiku",
37
+ "effort": "low",
38
+ "tools": [
39
+ "Bash",
40
+ "Read",
41
+ "Grep",
42
+ "Glob"
43
+ ],
44
+ "description": {
45
+ "ko": "위임 작업의 빌드·테스트·git 명령을 실행하는 저비용 에이전트입니다. MCP 도구는 없습니다.",
46
+ "en": "Cheap runner for build, test and git commands in delegated tasks. It has no MCP tools."
47
+ },
48
+ "body": {
49
+ "ko": "맡은 역할: 전달받은 빌드·테스트·git 명령을 실행하고 결과를 보고합니다.\n위임받은 작업만 하고, 호출자가 프롬프트에 적은 상한(도구 호출 횟수, 출력 토큰)을 넘기지 마십시오.\n호출자 프롬프트의 언어로 간결하게 보고하십시오.\nMCP 도구나 목록에 없는 도구가 필요하면 즉시 멈추고 \"needs general-purpose: <필요한 도구>\" 한 줄로 보고하십시오. 부모가 다시 위임합니다.\n확인하지 못한 내용은 추측하지 말고 확인하지 못했다고 말하십시오.\n전체 테스트, 빌드, 렌더처럼 프로세스를 여럿 띄우는 명령은 프로세스 상한을 걸고 실행하십시오. macOS 와 Linux 에서는 `(ulimit -u $(( $(ps -u $(id -u) -o pid= | wc -l) + 150 )); <명령>)` 으로 감싸고, Windows 에서는 그대로 실행합니다. 출력에 `spawn ... EAGAIN` 이나 `Resource temporarily unavailable` 이 보이면 테스트 실패가 아니라 프로세스 폭주이므로, 명령을 다시 돌리지 말고 그 줄을 인용해 보고하십시오.",
50
+ "en": "Your role: run the build, test, or git commands you are given and report the result.\nDo only the delegated task, and stay within the caps (tool calls, output tokens) the caller wrote in the prompt.\nReport concisely, in the language of the caller's prompt.\nIf you need an MCP tool or any tool not on your list, stop at once and report one line: \"needs general-purpose: <tools needed>\". The parent will delegate again.\nDo not guess at anything you could not verify; say that you could not verify it.\nRun a command that starts many processes (a full test suite, a build, a render) under a process cap: on macOS and Linux wrap it as `(ulimit -u $(( $(ps -u $(id -u) -o pid= | wc -l) + 150 )); <command>)`, on Windows run it as is. If the output shows `spawn ... EAGAIN` or `Resource temporarily unavailable`, that is a process runaway, not a test failure: do not rerun the command; quote that line and report."
51
+ }
52
+ },
53
+ {
54
+ "name": "haiku-translate",
55
+ "model": "haiku",
56
+ "effort": "low",
57
+ "tools": [
58
+ "Read",
59
+ "Grep",
60
+ "Glob",
61
+ "Edit",
62
+ "Write"
63
+ ],
64
+ "description": {
65
+ "ko": "위임 작업용 저비용 일괄 번역·정형 텍스트 변환 에이전트입니다. MCP 도구는 없습니다.",
66
+ "en": "Cheap batch translation and structured text conversion for delegated tasks. It has no MCP tools."
67
+ },
68
+ "body": {
69
+ "ko": "맡은 역할: 전달받은 파일이나 텍스트의 일괄 번역과 정형 텍스트 변환입니다.\n위임받은 작업만 하고, 호출자가 프롬프트에 적은 상한(도구 호출 횟수, 출력 토큰)을 넘기지 마십시오.\n호출자 프롬프트의 언어로 간결하게 보고하십시오.\nMCP 도구나 목록에 없는 도구가 필요하면 즉시 멈추고 \"needs general-purpose: <필요한 도구>\" 한 줄로 보고하십시오. 부모가 다시 위임합니다.\n확인하지 못한 내용은 추측하지 말고 확인하지 못했다고 말하십시오.",
70
+ "en": "Your role: batch translation and mechanical text conversion of the files or text you are given.\nDo only the delegated task, and stay within the caps (tool calls, output tokens) the caller wrote in the prompt.\nReport concisely, in the language of the caller's prompt.\nIf you need an MCP tool or any tool not on your list, stop at once and report one line: \"needs general-purpose: <tools needed>\". The parent will delegate again.\nDo not guess at anything you could not verify; say that you could not verify it."
71
+ }
72
+ },
73
+ {
74
+ "name": "sonnet-worker",
75
+ "model": "sonnet",
76
+ "effort": "medium",
77
+ "tools": [
78
+ "Read",
79
+ "Grep",
80
+ "Glob",
81
+ "Bash",
82
+ "Edit",
83
+ "Write",
84
+ "NotebookEdit"
85
+ ],
86
+ "description": {
87
+ "ko": "여러 단계 작업과 설계가 확정된 구현을 맡는 중간 티어 워커입니다. MCP 도구는 없습니다.",
88
+ "en": "Mid-tier worker for multi-step tasks and implementation handed over with a fixed design. It has no MCP tools."
89
+ },
90
+ "body": {
91
+ "ko": "맡은 역할: 여러 단계로 이어지는 작업과, 호출자가 설계를 확정한 구현을 맡습니다.\n위임받은 작업만 하고, 호출자가 프롬프트에 적은 상한(도구 호출 횟수, 출력 토큰)을 넘기지 마십시오.\n호출자 프롬프트의 언어로 간결하게 보고하십시오.\nMCP 도구나 목록에 없는 도구가 필요하면 즉시 멈추고 \"needs general-purpose: <필요한 도구>\" 한 줄로 보고하십시오. 부모가 다시 위임합니다.\n확인하지 못한 내용은 추측하지 말고 확인하지 못했다고 말하십시오.\n전체 테스트, 빌드, 렌더처럼 프로세스를 여럿 띄우는 명령은 프로세스 상한을 걸고 실행하십시오. macOS 와 Linux 에서는 `(ulimit -u $(( $(ps -u $(id -u) -o pid= | wc -l) + 150 )); <명령>)` 으로 감싸고, Windows 에서는 그대로 실행합니다. 출력에 `spawn ... EAGAIN` 이나 `Resource temporarily unavailable` 이 보이면 테스트 실패가 아니라 프로세스 폭주이므로, 명령을 다시 돌리지 말고 그 줄을 인용해 보고하십시오.",
92
+ "en": "Your role: multi-step work and the implementation half of a design the caller already fixed.\nDo only the delegated task, and stay within the caps (tool calls, output tokens) the caller wrote in the prompt.\nReport concisely, in the language of the caller's prompt.\nIf you need an MCP tool or any tool not on your list, stop at once and report one line: \"needs general-purpose: <tools needed>\". The parent will delegate again.\nDo not guess at anything you could not verify; say that you could not verify it.\nRun a command that starts many processes (a full test suite, a build, a render) under a process cap: on macOS and Linux wrap it as `(ulimit -u $(( $(ps -u $(id -u) -o pid= | wc -l) + 150 )); <command>)`, on Windows run it as is. If the output shows `spawn ... EAGAIN` or `Resource temporarily unavailable`, that is a process runaway, not a test failure: do not rerun the command; quote that line and report."
93
+ }
94
+ },
95
+ {
96
+ "name": "opus-worker",
97
+ "model": "claude-opus-5-5",
98
+ "effort": "high",
99
+ "tools": [
100
+ "Read",
101
+ "Grep",
102
+ "Glob",
103
+ "Bash",
104
+ "Edit",
105
+ "Write",
106
+ "NotebookEdit"
107
+ ],
108
+ "description": {
109
+ "ko": "Fable 세션용 고성능 워커로, 설계가 확정된 대규모 구현·디버깅·리팩터링을 맡습니다. MCP 도구는 없습니다.",
110
+ "en": "Heavy worker for Fable sessions: large implementation, debugging and refactoring handed over with a fixed design. It has no MCP tools."
111
+ },
112
+ "body": {
113
+ "ko": "맡은 역할: 호출자가 설계를 확정한 대규모 구현과 디버깅입니다. 설계 판단과 되돌릴 수 없는 작업은 호출자에게 넘기십시오.\n위임받은 작업만 하고, 호출자가 프롬프트에 적은 상한(도구 호출 횟수, 출력 토큰)을 넘기지 마십시오.\n호출자 프롬프트의 언어로 간결하게 보고하십시오.\nMCP 도구나 목록에 없는 도구가 필요하면 즉시 멈추고 \"needs general-purpose: <필요한 도구>\" 한 줄로 보고하십시오. 부모가 다시 위임합니다.\n확인하지 못한 내용은 추측하지 말고 확인하지 못했다고 말하십시오.\n전체 테스트, 빌드, 렌더처럼 프로세스를 여럿 띄우는 명령은 프로세스 상한을 걸고 실행하십시오. macOS 와 Linux 에서는 `(ulimit -u $(( $(ps -u $(id -u) -o pid= | wc -l) + 150 )); <명령>)` 으로 감싸고, Windows 에서는 그대로 실행합니다. 출력에 `spawn ... EAGAIN` 이나 `Resource temporarily unavailable` 이 보이면 테스트 실패가 아니라 프로세스 폭주이므로, 명령을 다시 돌리지 말고 그 줄을 인용해 보고하십시오.",
114
+ "en": "Your role: large implementation and debugging work whose design the caller already fixed. Hand design judgement and irreversible actions back to the caller.\nDo only the delegated task, and stay within the caps (tool calls, output tokens) the caller wrote in the prompt.\nReport concisely, in the language of the caller's prompt.\nIf you need an MCP tool or any tool not on your list, stop at once and report one line: \"needs general-purpose: <tools needed>\". The parent will delegate again.\nDo not guess at anything you could not verify; say that you could not verify it.\nRun a command that starts many processes (a full test suite, a build, a render) under a process cap: on macOS and Linux wrap it as `(ulimit -u $(( $(ps -u $(id -u) -o pid= | wc -l) + 150 )); <command>)`, on Windows run it as is. If the output shows `spawn ... EAGAIN` or `Resource temporarily unavailable`, that is a process runaway, not a test failure: do not rerun the command; quote that line and report."
115
+ }
116
+ }
117
+ ]
118
+ }
@@ -34,7 +34,7 @@ import zipfile
34
34
 
35
35
  MAX_UNCOMPRESSED = 500 * 1024 * 1024
36
36
  MAX_RATIO = 200
37
- ROW_CAP = int(os.environ.get("CTS_DOC2MD_ROW_CAP", "50000"))
37
+ ROW_CAP = int(os.environ.get("SPRAG_DOC2MD_ROW_CAP", os.environ.get("CTS_DOC2MD_ROW_CAP", "50000")))
38
38
  ZIP_EXTS = {".pptx", ".xlsx", ".docx"}
39
39
 
40
40
 
@@ -6,31 +6,39 @@
6
6
  "each answer. Both read this file, so there is one source for the wording.",
7
7
  "Each rule carries both languages because the text is injected into the model's",
8
8
  "context, where a Korean-only rule would steer an English session into Korean.",
9
- "Ids are derived from the `ko` text (the canonical one), so a rule keeps its",
10
- "identity — and a user's 'no' — when the English wording is revised."
9
+ "Each rule carries a stable `id`. It was derived once from the `ko` text",
10
+ "(fix-<sha1(ko)[0:6]>) and is kept when the wording changes, so a rule keeps its",
11
+ "identity, and a user's 'no', across rewording. This file is also the fallback for",
12
+ "the centrally published preset bundle; `npm run presets:pull` refreshes it."
11
13
  ],
12
14
  "rules": [
13
15
  {
16
+ "id": "fix-bd7836",
14
17
  "ko": "웹 fetch/search에서 robots·UA 차단('unable to fetch' 등)이 발생하면 → curl에 브라우저 UA(Mozilla/5.0 ... Chrome/...) 헤더를 붙여 재시도. reddit 등은 .json 엔드포인트 + UA 조합으로 접근 가능",
15
18
  "en": "When a web fetch/search is blocked by robots or user-agent checks ('unable to fetch' and similar) → retry with curl carrying a browser UA header (Mozilla/5.0 ... Chrome/...). Sites like reddit also answer on their .json endpoints with that UA"
16
19
  },
17
20
  {
21
+ "id": "fix-4168c2",
18
22
  "ko": "ratchet 룰 promote 호출 직전 사용자에게 scope(global/project)를 먼저 묻는다 — Bash 환경은 non-TTY라 CLI readline 프롬프트가 안 뜨므로, 호출자(LLM)가 대신 묻고 --global/--project 플래그를 명시해 실행",
19
23
  "en": "Ask the user for the scope (global or project) before calling ratchet promote — the Bash environment is non-TTY, so the CLI's readline prompt never appears; the caller (the model) has to ask and pass --global/--project explicitly"
20
24
  },
21
25
  {
26
+ "id": "fix-ce98b4",
22
27
  "ko": "자동화 도구가 grep·정규식으로 찾는 마커 텍스트(헤더·앵커·키워드 라인)는 리팩터링·압축·수정 금지 — 정확 매칭 검출은 한 글자만 바뀌어도 깨짐",
23
28
  "en": "Never refactor, compress or reword marker text that an automated tool finds by grep or regex (headings, anchors, keyword lines) — exact-match detection breaks on a single changed character"
24
29
  },
25
30
  {
31
+ "id": "fix-b1da7a",
26
32
  "ko": "자동화 설계에서 vision/screenshot 루프가 보이면 → 호출 가능한 함수·API·MCP tool부터 찾는다. 통제 가능한 내부 도구는 무조건 API, 통제 불가 외부 SaaS만 vision 차선책. 인터페이스를 바꾼 뒤에 모델을 내린다 — 거꾸로 가면 토큰 폭발",
27
33
  "en": "When an automation design calls for a vision/screenshot loop → look for a callable function, API or MCP tool first. Anything you control gets an API; vision is the fallback only for external SaaS you cannot change. Fix the interface before lowering the model — doing it the other way round explodes the token cost"
28
34
  },
29
35
  {
36
+ "id": "fix-1dee5f",
30
37
  "ko": "입력 데이터(ground-truth) 변경 시 그에 의존하는 모든 파생 자산을 끝까지 재생성한다 — 중간 단계를 생략하면 stale 산출물이 새 입력 위에 얹혀 결과가 깨짐",
31
38
  "en": "When the ground-truth input changes, regenerate every derived asset all the way through — skipping an intermediate step leaves a stale artifact sitting on top of the new input and the result breaks"
32
39
  },
33
40
  {
41
+ "id": "fix-f558f9",
34
42
  "ko": "탐색·조회·상태 확인·명령 실행 같은 단순 요청은 `model: haiku` 서브에이전트로 위임해 상위 모델 토큰을 아낀다 (읽기 전용 preset 에이전트가 이미 있으면 그걸 쓰고, 없으면 티어만 내려서 위임)",
35
43
  "en": "Delegate simple requests — lookup, status checks, running commands — to a `model: haiku` subagent to keep the expensive model's tokens for real work (use an existing read-only preset agent when there is one; otherwise delegate with the tier lowered)",
36
44
  "agents": [
@@ -38,15 +46,22 @@
38
46
  ]
39
47
  },
40
48
  {
49
+ "id": "fix-aa18bc",
41
50
  "ko": "지침이나 룰이 지켜지지 않아 같은 품질 문제가 재발하면 문구를 다시 쓰지 말 것 → 그 조항이 기계로 판정 가능한지부터 따지고(문자열 비교나 정규식으로 결정되면 판정 가능), 가능하면 쓰기 시점 훅이나 테스트 같은 차단 지점을 추가한다. 판단이 필요한 잔여 부분만 지침에 남기고 그 경계를 지침에 적는다. 실제 코퍼스에 돌려 오탐 건수를 센 뒤 좁히고, 회귀 테스트를 같은 커밋에 넣는다",
42
51
  "en": "When a guideline keeps being violated and the same quality defect recurs, do NOT reword the instruction → first ask whether the clause is machine-decidable (a string comparison or regex settles it), and if so add an enforcement point such as a write-time hook or a test. Leave only the judgement remainder in the prose and state that boundary there. Measure the false-positive count on a real corpus, narrow until it is acceptable, and land a regression test in the same commit"
43
52
  },
44
53
  {
54
+ "id": "fix-777a15",
45
55
  "ko": "여러 파일에 걸친 구현을 맡으면 → 설계와 스펙 확정은 메인 모델이 하고 코드 작성은 서브에이전트에 위임한다(Claude는 `model: sonnet`, Codex는 명시적 매핑을 우선해 해석한 T1을 쓰며 haiku나 T2에는 구현을 맡기지 않는다). 위임 프롬프트에는 설계, 수정할 파일, 제약, 완료 기준, 테스트 명령, 예산, 권장 모델과 그 이유를 적어 다른 문맥 없이 실행할 수 있게 한다. 이미 읽은 파일의 약 50줄 이하 변경이나 의존 단계가 하나뿐인 짧은 구현, 그리고 위임 도구나 하위 모델을 쓸 수 없는 경우는 메인 모델이 직접 처리한다. 위임한 구현은 끝날 때까지 기다리고 같은 구현을 중복 실행하지 않으며, 돌아온 diff와 테스트 결과를 메인 모델이 직접 검증한 뒤에 완료로 보고한다",
46
56
  "en": "When an implementation spans several files → the main model settles the design and specification, then delegates the code writing to a subagent (Claude: `model: sonnet`; Codex: its resolved T1, explicit mapping first; never haiku or T2 for implementation). Write the delegation prompt so it runs without other context: design, files to change, constraints, completion criteria, test commands, budget, and the recommended model with a one-line reason. Keep changes of roughly 50 lines or less in files already read, a single short dependency chain, or cases where the subagent tools or the lower model are unavailable on the main model. Wait for the delegated work instead of duplicating it, and verify the returned diff and the actual test results before reporting completion",
47
57
  "agents": [
48
58
  "claude"
49
59
  ]
60
+ },
61
+ {
62
+ "id": "fix-bec8e9",
63
+ "ko": "전체 테스트·빌드·렌더처럼 프로세스를 여럿 띄우는 명령을 직접 돌리거나 서브에이전트에 맡길 때 → 프로세스 상한을 걸고 실행한다. macOS 와 Linux 는 `(ulimit -u $(( $(ps -u $(id -u) -o pid= | wc -l) + 150 )); <명령>)` 으로 감싼다. 상한에 걸리면 커널이 `spawn ... EAGAIN` 으로 거절하므로 머신이 멈추는 대신 범인 명령줄이 남는다(2026-10-20 테스트 파일이 자신을 복제해 node 4,055개, 메모리 고갈로 세 번 멈춤). `EAGAIN` 은 테스트 실패가 아니라 폭주 신호이므로 재실행하지 말고 `ps` 스냅샷으로 어떤 명령이 불어났는지부터 본다. 백그라운드 자식을 띄우는 코드는 `process.argv[1]` 대신 모듈 위치에서 구한 진입점 경로와 재진입 마커를 쓴다",
64
+ "en": "When you run, or hand a subagent, a command that starts many processes (a full test suite, a build, a render) → run it under a process cap. On macOS and Linux wrap it as `(ulimit -u $(( $(ps -u $(id -u) -o pid= | wc -l) + 150 )); <command>)`. At the cap the kernel refuses with `spawn ... EAGAIN`, so the culprit's command line survives instead of the machine stalling (2026-10-20: a test file relaunched itself, 4,055 node processes, three memory-exhaustion stalls). `EAGAIN` is a runaway signal, not a test failure: do not rerun; take a `ps` snapshot and see which command multiplied. Code that starts a background child uses an entry path resolved from the module's location plus a re-entry marker, never `process.argv[1]`"
50
65
  }
51
66
  ]
52
67
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The script a detached child of this CLI must run.
3
+ *
4
+ * The preset sync and the two team syncs start a detached child to do their
5
+ * network work, and they used to pass `process.argv[1]` as its script. Inside
6
+ * the CLI that is bin/cli.js. Inside anything else that imports these modules
7
+ * it is that caller's script: under `node --test` a test file relaunched
8
+ * itself, detached, on every session-start case, and each copy did the same.
9
+ * 4,055 processes in 59 seconds on 2026-10-20, and three memory-exhaustion
10
+ * stalls in two days. So the entry is fixed here from this module's own
11
+ * location, and a spawned child carries a marker that refuses to spawn again.
12
+ */
13
+ import * as childProcess from 'node:child_process';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { envVar } from './env.js';
16
+
17
+ export const CLI_ENTRY = fileURLToPath(new URL('../bin/cli.js', import.meta.url));
18
+
19
+ /** The marker a detached child carries; read through envVar('DETACHED_CHILD'). */
20
+ export const DETACHED_CHILD_VAR = 'SPRAG_DETACHED_CHILD';
21
+
22
+ export function isDetachedChild(env = process.env) {
23
+ return envVar('DETACHED_CHILD', env) === '1';
24
+ }
25
+
26
+ /** The environment for a detached child: the caller's, plus the marker. */
27
+ export function detachedChildEnv(env = process.env) {
28
+ return { ...env, [DETACHED_CHILD_VAR]: '1' };
29
+ }
30
+
31
+ /**
32
+ * Start `sprag <args>` detached, never awaited, never from a detached child.
33
+ * Returns true when a child was started. Every background refresh goes
34
+ * through here so that no caller can name the script itself.
35
+ */
36
+ export function spawnDetachedCli(args, { env = process.env, spawn = childProcess.spawn, execPath = process.execPath } = {}) {
37
+ if (isDetachedChild(env)) return false;
38
+ spawn(execPath, [CLI_ENTRY, ...args], { detached: true, stdio: 'ignore', windowsHide: true, env: detachedChildEnv(env) }).unref();
39
+ return true;
40
+ }
@@ -1,5 +1,6 @@
1
+ import { envVar } from '../env.js';
2
+ import { spawnDetachedCli } from '../cli-entry.js';
1
3
  import { createRequire } from 'node:module';
2
- import { spawn } from 'node:child_process';
3
4
  import { createHash } from 'node:crypto';
4
5
  import { mkdirSync, openSync, readFileSync, readSync, closeSync, statSync } from 'node:fs';
5
6
  import { join, resolve } from 'node:path';
@@ -116,9 +117,8 @@ async function routeNotice(root, { spawnRescan = defaultRescan } = {}) {
116
117
  }
117
118
 
118
119
  function defaultRescan() {
119
- if (process.env.CTS_NO_ROUTE_SCAN === '1') return;
120
- spawn(process.execPath, [process.argv[1], 'route-scan', '--refresh', '--quiet', '--agent', 'codex'],
121
- { detached: true, stdio: 'ignore', windowsHide: true }).unref();
120
+ if (envVar('NO_ROUTE_SCAN') === '1') return;
121
+ spawnDetachedCli(['route-scan', '--refresh', '--quiet', '--agent', 'codex']);
122
122
  }
123
123
 
124
124
  /**
@@ -130,9 +130,8 @@ function defaultRescan() {
130
130
  * of the parent is the earliest point that knows one was spawned.
131
131
  */
132
132
  function defaultLedgerRefresh() {
133
- if (process.env.CTS_NO_ROUTE_SCAN === '1') return;
134
- spawn(process.execPath, [process.argv[1], 'route-scan', 'savings', '--refresh', '--quiet', '--agent', 'codex'],
135
- { detached: true, stdio: 'ignore', windowsHide: true }).unref();
133
+ if (envVar('NO_ROUTE_SCAN') === '1') return;
134
+ spawnDetachedCli(['route-scan', 'savings', '--refresh', '--quiet', '--agent', 'codex']);
136
135
  }
137
136
 
138
137
  export function lintCodexTool(payload, { scope = 'all' } = {}) {
@@ -180,6 +179,7 @@ export async function codexHookOutput(event, payload, { cfg = loadConfig(), refr
180
179
  const parts = [];
181
180
  // Keep the preset file current with this version's wording before it is read.
182
181
  if (event === 'session-start') {
182
+ try { (await import('../preset-sync.js')).spawnPresetSyncIfDue(); } catch (e) { debug('codex:preset-sync', e); }
183
183
  try { (await import('../preset-ratchet.js')).preparePresetRatchet({ agent: 'codex' }); } catch (e) { debug('codex:preset', e); }
184
184
  }
185
185
  const { presetRatchetPath } = await import('../preset-ratchet.js');
@@ -219,7 +219,7 @@ export async function codexHookOutput(event, payload, { cfg = loadConfig(), refr
219
219
  try { korean = syncCodexKoreanBlock({ cfg }); } catch (e) { debug('codex:korean-sync', e); }
220
220
  if (korean?.action !== 'unchanged') parts.push({ priority: 2, spill: true, text: koreanStyleInjection({ cfg }) });
221
221
  parts.push({ priority: 3, spill: true, text: await cohesionInjection({ cfg }) });
222
- if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) parts.push({ priority: 4, spill: true, text: codexDocumentNote() });
222
+ if (envVar('NO_DOC2MD') !== '1' && cfg?.codex?.doc2md !== false) parts.push({ priority: 4, spill: true, text: codexDocumentNote() });
223
223
  // Offers ask the user a question; a `codex exec` run has no one to answer it.
224
224
  if (event === 'session-start' && payload.source !== 'compact' && !isCodexExecSession(payload.transcript_path)) {
225
225
  if (version) parts.push({ priority: 5, text: updateOffer(version, { agent: 'codex', lang: userLanguage() }) });
@@ -231,6 +231,7 @@ export async function codexHookOutput(event, payload, { cfg = loadConfig(), refr
231
231
  }
232
232
  if (event === 'prompt') {
233
233
  const parts = [];
234
+ try { (await import('../team-ratchet.js')).spawnTeamSyncOnPrompt(); } catch (e) { debug('codex:team-prompts', e); }
234
235
  if (codexDelegateEnabled(cfg)) {
235
236
  // Before the hint for this prompt is recorded: a hint from an earlier turn
236
237
  // that no child claimed is closed, and children that did run get priced.
@@ -247,14 +248,14 @@ export async function codexHookOutput(event, payload, { cfg = loadConfig(), refr
247
248
  }
248
249
  catch (e) { debug('codex:brief', e); }
249
250
  }
250
- if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) {
251
+ if (envVar('NO_DOC2MD') !== '1' && cfg?.codex?.doc2md !== false) {
251
252
  const identity = codexDocumentContext(payload);
252
253
  parts.push(doc2md.contextForPrompt(payload, { lang: userLanguage(), agent: 'codex', convert: (source) => convertCodexDocument(source, identity) }));
253
254
  }
254
255
  return context('UserPromptSubmit', parts.filter(Boolean).join('\n\n'));
255
256
  }
256
257
  if (event === 'pre-tool') {
257
- if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) {
258
+ if (envVar('NO_DOC2MD') !== '1' && cfg?.codex?.doc2md !== false) {
258
259
  const result = codexDocumentTool(payload);
259
260
  if (result) return result;
260
261
  }
@@ -8,6 +8,7 @@
8
8
  * not zsh, or the user already has their own `codex` alias/function.
9
9
  */
10
10
 
11
+ import { envVar } from '../env.js';
11
12
  import { execFileSync } from 'node:child_process';
12
13
  import { existsSync } from 'node:fs';
13
14
  import { basename, dirname, join } from 'node:path';
@@ -21,7 +22,7 @@ export function tmuxAvailable({ exec = execFileSync } = {}) {
21
22
 
22
23
  export function ensureTmux({ platform = process.platform, env = process.env, exec = execFileSync, exists = existsSync } = {}) {
23
24
  // Checked first, before probing anything, so tests and CI never spawn brew.
24
- if (env.CTS_NO_TMUX === '1' || (env.CI && env.CI !== 'false')) return { status: 'skipped' };
25
+ if (envVar('NO_TMUX', env) === '1' || (env.CI && env.CI !== 'false')) return { status: 'skipped' };
25
26
  if (tmuxAvailable({ exec })) return { status: 'present' };
26
27
  if (platform !== 'darwin') {
27
28
  return { status: 'unavailable', detail: 'install tmux with your package manager (e.g. sudo apt install tmux)' };
@@ -1,3 +1,4 @@
1
+ import { envVar } from '../env.js';
1
2
  import { resolve, relative, isAbsolute, basename } from 'node:path';
2
3
  import { realpathSync, statSync } from 'node:fs';
3
4
  import { stat } from 'node:fs/promises';
@@ -151,7 +152,7 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
151
152
  cohesion: cfg?.cohesion?.enabled === true,
152
153
  brief: cfg?.codex?.brief !== false,
153
154
  delegate: codexDelegateEnabled(cfg),
154
- doc2md: cfg?.codex?.doc2md !== false && process.env.CTS_NO_DOC2MD !== '1',
155
+ doc2md: cfg?.codex?.doc2md !== false && envVar('NO_DOC2MD') !== '1',
155
156
  update: version ? readUpdateChip(version, { refresh: refreshUpdate }) : null,
156
157
  updatedAt: new Date() };
157
158
  };
@@ -23,6 +23,8 @@ export async function run({ hasFlag }) {
23
23
  }
24
24
  const ctx = readStdinJson() || {};
25
25
  const parts = [];
26
+ // Team prompt upload (Enterprise, opt-in): a stat-light gate, the work runs in a detached child.
27
+ try { (await import('../team-ratchet.js')).spawnTeamSyncOnPrompt(); } catch (e) { debug('brief:team-prompts', e); }
26
28
  try {
27
29
  const { runBrief } = await import('../brief.js');
28
30
  // `root` is the session's cwd for the same reason routeHint takes it below:
@@ -1,3 +1,4 @@
1
+ import { envVar } from '../env.js';
1
2
  import { createArgs } from '../cli-args.js';
2
3
  import { configureCodexHooks } from '../codex/installer.js';
3
4
  import { initCodexHarness, uninitCodexHarness, codexHarnessStatus, syncCodexKoreanBlock } from '../codex/harness.js';
@@ -412,7 +413,7 @@ export async function run({ args, version }) {
412
413
  const hook = configureCodexHooks({ panelAuto });
413
414
  console.log(`Codex hooks: ${hook.file} (${hook.action})`);
414
415
  for (const line of describePanelSetup(panel)) console.log(`Codex ${line}`);
415
- if (process.env.CTS_NO_HARNESS !== '1') {
416
+ if (envVar('NO_HARNESS') !== '1') {
416
417
  const h = initCodexHarness({ root, scope: 'global' });
417
418
  console.log(`Codex harness: ${h.file}`);
418
419
  try {
@@ -544,7 +545,7 @@ export async function run({ args, version }) {
544
545
  const { createRequire } = await import('node:module');
545
546
  const doc2md = createRequire(import.meta.url)('../doc2md.cjs');
546
547
  const { codexHookStatus } = await import('../codex/installer.js');
547
- console.log(`Codex document conversion: ${loadConfig()?.codex?.doc2md === false || process.env.CTS_NO_DOC2MD === '1' ? 'off' : 'on'}`);
548
+ console.log(`Codex document conversion: ${loadConfig()?.codex?.doc2md === false || envVar('NO_DOC2MD') === '1' ? 'off' : 'on'}`);
548
549
  console.log(`Formats: ${doc2md.TARGET_EXTENSIONS.join(' ')}`);
549
550
  console.log(`Converter: ${doc2md.findInterpreter() || 'missing; run sprag doc2md install-converter --agent codex'}`);
550
551
  console.log(`Cache: ${doc2md.cacheDir()}`);
@@ -274,9 +274,8 @@ export async function run({ args, hasFlag }) {
274
274
  // Event-triggered refresh: establish the new rule's stat baseline
275
275
  // right away instead of waiting for the next data-gated rescan.
276
276
  try {
277
- const { spawn } = await import('node:child_process');
278
- spawn(process.execPath, [process.argv[1], 'route-scan', '--refresh', '--quiet'],
279
- { detached: true, stdio: 'ignore', windowsHide: true }).unref();
277
+ const { spawnDetachedCli } = await import('../cli-entry.js');
278
+ spawnDetachedCli(['route-scan', '--refresh', '--quiet']);
280
279
  } catch (e) { debug('promote:spawn-refresh', e); /* baseline arrives on the next gated rescan */ }
281
280
  return;
282
281
  }
@@ -18,6 +18,7 @@
18
18
  * terminal (postinstall, CI, pipes) it keeps theirs, as before.
19
19
  */
20
20
 
21
+ import { envVar } from '../env.js';
21
22
  import { debug } from '../debug.js';
22
23
 
23
24
  /** Thrown when the user declines an optional step, so it is not reported as a failure. */
@@ -50,7 +51,7 @@ export async function run({ hasFlag }) {
50
51
  // English — the one outcome a Korean-locale machine should not get by
51
52
  // default. Already decided means never asked again.
52
53
  try {
53
- if (!languageDecided() && !process.env.CTS_LANG) {
54
+ if (!languageDecided() && !envVar('LANG')) {
54
55
  const { koreanLocaleDetected } = await import('../korean-style.js');
55
56
  const detected = koreanLocaleDetected() ? 'ko' : 'en';
56
57
  console.log('');
@@ -63,14 +64,14 @@ export async function run({ hasFlag }) {
63
64
  }
64
65
  setUserLanguage(chosen);
65
66
  console.log(` language: set to ${chosen === 'ko' ? '한국어' : 'English'} — change it any time with \`sprag mode lang=${chosen === 'ko' ? 'en' : 'ko'}\``);
66
- } else if (process.env.CTS_LANG) {
67
+ } else if (envVar('LANG')) {
67
68
  // Escape hatch for scripted installs, which cannot answer a prompt but
68
69
  // do know which language the machine's user reads.
69
- const forced = setUserLanguage(process.env.CTS_LANG);
70
+ const forced = setUserLanguage(envVar('LANG'));
70
71
  console.log('');
71
72
  console.log(forced
72
- ? ` language: set to ${forced} (CTS_LANG)`
73
- : ` language: ignored CTS_LANG=${process.env.CTS_LANG} — use 'ko' or 'en'`);
73
+ ? ` language: set to ${forced} (SPRAG_LANG)`
74
+ : ` language: ignored SPRAG_LANG=${envVar('LANG')} — use 'ko' or 'en'`);
74
75
  }
75
76
  } catch (e) {
76
77
  debug('install:language', e); // the English fallback still works
@@ -166,16 +167,16 @@ export async function run({ hasFlag }) {
166
167
  // any one project, and the harness principles are project-independent.
167
168
  // Only ever ADDS — harnessInit appends its own marked block, backs up the
168
169
  // previous file, and leaves surrounding content untouched. Skipped when a
169
- // block is already there (nothing to do) and when CTS_NO_HARNESS=1 is set,
170
+ // block is already there (nothing to do) and when SPRAG_NO_HARNESS=1 is set,
170
171
  // for anyone who wants the statusline without the CLAUDE.md rules.
171
172
  try {
172
173
  const { harnessInit, harnessStatus } = await import('../harness.js');
173
174
  const before = harnessStatus(undefined, { scope: 'global' });
174
- if (process.env.CTS_NO_HARNESS === '1') {
175
+ if (envVar('NO_HARNESS') === '1') {
175
176
  console.log('');
176
177
  console.log(lang === 'ko'
177
- ? ' harness: CTS_NO_HARNESS=1 이므로 건너뜁니다 (나중에 `harness init --global`).'
178
- : ' harness: skipped (CTS_NO_HARNESS=1) — run `harness init --global` later.');
178
+ ? ' harness: SPRAG_NO_HARNESS=1 이므로 건너뜁니다 (나중에 `harness init --global`).'
179
+ : ' harness: skipped (SPRAG_NO_HARNESS=1) — run `harness init --global` later.');
179
180
  } else if (before.hasBlock) {
180
181
  console.log('');
181
182
  console.log(lang === 'ko'
@@ -242,11 +243,11 @@ export async function run({ hasFlag }) {
242
243
  // upgrade never re-enables something they turned off.
243
244
  try {
244
245
  const ks = await import('../korean-style.js');
245
- if (process.env.CTS_NO_KOREAN === '1') {
246
+ if (envVar('NO_KOREAN') === '1') {
246
247
  console.log('');
247
248
  console.log(lang === 'ko'
248
- ? ' korean: CTS_NO_KOREAN=1 이므로 건너뜁니다 (나중에 `korean on`).'
249
- : ' korean: skipped (CTS_NO_KOREAN=1) — run `korean on` later.');
249
+ ? ' korean: SPRAG_NO_KOREAN=1 이므로 건너뜁니다 (나중에 `korean on`).'
250
+ : ' korean: skipped (SPRAG_NO_KOREAN=1) — run `korean on` later.');
250
251
  } else if (ks.koreanStyleDecided()) {
251
252
  console.log('');
252
253
  console.log(lang === 'ko'
@@ -328,11 +329,11 @@ export async function run({ hasFlag }) {
328
329
  const { createRequire } = await import('node:module');
329
330
  const require = createRequire(import.meta.url);
330
331
  const doc2md = require('../doc2md.cjs');
331
- if (process.env.CTS_NO_DOC2MD === '1') {
332
+ if (envVar('NO_DOC2MD') === '1') {
332
333
  console.log('');
333
334
  console.log(lang === 'ko'
334
- ? ' doc2md: CTS_NO_DOC2MD=1 이므로 건너뜁니다 (나중에 `doc2md on`).'
335
- : ' doc2md: skipped (CTS_NO_DOC2MD=1) — run `doc2md on` later.');
335
+ ? ' doc2md: SPRAG_NO_DOC2MD=1 이므로 건너뜁니다 (나중에 `doc2md on`).'
336
+ : ' doc2md: skipped (SPRAG_NO_DOC2MD=1) — run `doc2md on` later.');
336
337
  } else {
337
338
  const { installDoc2mdHook } = await import('../installer.js');
338
339
  const res = installDoc2mdHook();
@@ -430,7 +431,7 @@ export async function run({ hasFlag }) {
430
431
  try {
431
432
  const { codexUserDir } = await import('../agent.js');
432
433
  const { existsSync } = await import('node:fs');
433
- if (process.env.CTS_NO_CODEX !== '1' && existsSync(codexUserDir())) {
434
+ if (envVar('NO_CODEX') !== '1' && existsSync(codexUserDir())) {
434
435
  let proceed = true;
435
436
  if (interactive) {
436
437
  console.log('');
@@ -451,7 +452,7 @@ export async function run({ hasFlag }) {
451
452
  if (!tmuxAvailable() && !await confirm(lang === 'ko'
452
453
  ? ' codex: tmux 가 없습니다. Homebrew 로 설치해 같은 창에 패널을 붙일까요?'
453
454
  : ' codex: tmux is missing. Install it with Homebrew so the panel shares the Codex window?', { defaultValue: true })) {
454
- env = { ...process.env, CTS_NO_TMUX: '1' };
455
+ env = { ...process.env, SPRAG_NO_TMUX: '1' };
455
456
  }
456
457
  }
457
458
  let panel;
@@ -465,7 +466,7 @@ export async function run({ hasFlag }) {
465
466
  console.log('');
466
467
  console.log(` codex: hooks ${hook.action} (${hook.file})`);
467
468
  for (const line of describePanelSetup(panel, lang)) console.log(` codex: ${line}`);
468
- if (process.env.CTS_NO_HARNESS !== '1') {
469
+ if (envVar('NO_HARNESS') !== '1') {
469
470
  const h = initCodexHarness({ scope: 'global' });
470
471
  console.log(` codex: harness ${h.file}`);
471
472
  try {