@kaydash9999/banker-plugins 0.13.0 → 0.15.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 (82) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +82 -0
  4. package/README.md +50 -17
  5. package/codex/manifest.json +11 -8
  6. package/codex/transform-matrix.md +16 -4
  7. package/commands/front-qa.md +8 -5
  8. package/commands/setup.md +13 -4
  9. package/hooks/graceful-pause.mjs +222 -0
  10. package/hooks/hooks.json +2 -1
  11. package/hooks/register.mjs +12 -0
  12. package/package.json +4 -1
  13. package/skills/3d-intro-build/SKILL.md +1 -1
  14. package/skills/3d-intro-setup/SKILL.md +1 -1
  15. package/skills/all-in-one/SKILL.md +160 -55
  16. package/skills/append_wiki/SKILL.md +1 -1
  17. package/skills/arch-diagram/SKILL.md +1 -1
  18. package/skills/audit-mock/SKILL.md +1 -1
  19. package/skills/audit-security/SKILL.md +1 -1
  20. package/skills/audit-web-page/SKILL.md +1 -1
  21. package/skills/cleansing-memory/SKILL.md +1 -1
  22. package/skills/compact-copy/SKILL.md +14 -13
  23. package/skills/compact-wiki/SKILL.md +1 -1
  24. package/skills/curation/SKILL.md +36 -3
  25. package/skills/deep-init/SKILL.md +1 -1
  26. package/skills/deep-research/SKILL.md +1 -1
  27. package/skills/docs-setup/SKILL.md +1 -1
  28. package/skills/humanizer/SKILL.md +1 -1
  29. package/skills/interval-report/SKILL.md +1 -1
  30. package/skills/lineage/SKILL.md +146 -16
  31. package/skills/lineage/lineage.py +1091 -107
  32. package/skills/make-notion-guide/SKILL.md +1 -1
  33. package/skills/motion-graphic-make/SKILL.md +1 -1
  34. package/skills/motion-graphic-setup/SKILL.md +1 -1
  35. package/skills/nothing-design/SKILL.md +1 -1
  36. package/skills/obsidizer/SKILL.md +1 -1
  37. package/skills/omc-reference/SKILL.md +68 -33
  38. package/skills/payload-mon/SKILL.md +66 -0
  39. package/skills/payload-mon/scripts/payload-mon.mjs +377 -0
  40. package/skills/payload-mon/scripts/payload-size.mjs +238 -0
  41. package/skills/pdf-vision-extract/SKILL.md +1 -1
  42. package/skills/play-qa/SKILL.md +1 -1
  43. package/skills/ralph-qa/SKILL.md +263 -112
  44. package/skills/ralph-qa/references/gemini-read-only.toml +9 -0
  45. package/skills/ralph-qa/references/gemini-seat.mjs +175 -0
  46. package/skills/ralph-qa/references/payload-scan.mjs +130 -0
  47. package/skills/ralph-qa/references/verifier-probe.mjs +872 -780
  48. package/skills/ready-compact/SKILL.md +114 -67
  49. package/skills/refresh-git-ignore/SKILL.md +1 -1
  50. package/skills/refresh-readme/SKILL.md +3 -3
  51. package/skills/rfp-author/SKILL.md +3 -3
  52. package/skills/setup-bypass-permissions/SKILL.md +123 -0
  53. package/skills/setup-bypass-permissions/agents/openai.yaml +4 -0
  54. package/skills/setup-bypass-permissions/scripts/bypass-permissions.mjs +381 -0
  55. package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.ps1 +247 -0
  56. package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.py +241 -0
  57. package/skills/{harness-factory → setup-harness-factory}/SKILL.md +3 -3
  58. package/skills/setup-insane-search/SKILL.md +1 -1
  59. package/skills/setup-java/SKILL.md +1 -1
  60. package/skills/setup-lsp/SKILL.md +1 -1
  61. package/skills/setup-mcp/SKILL.md +1 -1
  62. package/skills/setup-node/SKILL.md +1 -1
  63. package/skills/setup-omc/SKILL.md +11 -4
  64. package/skills/setup-omc-hud/SKILL.md +26 -1
  65. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +247 -0
  66. package/skills/setup-playwright/SKILL.md +1 -1
  67. package/skills/setup-pwsh/SKILL.md +1 -1
  68. package/skills/setup-python/SKILL.md +1 -1
  69. package/skills/setup-sandbox/SKILL.md +1 -1
  70. package/skills/setup-stitch/SKILL.md +1 -1
  71. package/skills/setup-tmux/SKILL.md +1 -1
  72. package/skills/smart-compact/SKILL.md +10 -9
  73. package/skills/summary-wiki/SKILL.md +1 -1
  74. package/skills/tone-compact/SKILL.md +203 -0
  75. package/skills/tone-compact/scripts/tone-compact.mjs +499 -0
  76. package/skills/ultra-init/SKILL.md +53 -42
  77. package/skills/ultra-interview/SKILL.md +1 -1
  78. package/skills/ultra-ui-qa/SKILL.md +1 -1
  79. package/skills/update-banker/SKILL.md +1 -1
  80. package/skills/vertical-pptx/SKILL.md +1 -1
  81. package/skills/vertical-pptx-setup/SKILL.md +1 -1
  82. package/skills/visual-ralph/SKILL.md +1 -1
@@ -1,860 +1,952 @@
1
1
  #!/usr/bin/env node
2
- /*
3
- * ralph-qa 검증자 프리플라이트 프로브 — 외부 검증 좌석이 "실제로 앉을 수 있는가"를 0토큰으로 판정한다.
2
+ /**
3
+ * verifier-probe: which external CLI seats ralph-qa can seat, and with which model,
4
+ * worked out from this machine alone. It sends no HTTP request and no prompt itself. The
5
+ * only child processes are `codex --version`, `codex debug models --bundled` and
6
+ * `opencode --version`, the last with opencode's model-list refresh turned off.
4
7
  *
5
- * 의존성 0. 전역 fetch 를 쓰지 않고 node:http / node:https 를 직접 쓴다. 근거 둘:
6
- * (1) 전역 fetch 는 Node 17.5 플래그·18.0 기본이라 package.json engines(node>=16.7)에서 미보장이다.
7
- * (2) 저장소 선례 — hooks/update-checkin.mjs:18-19 가 이미 node:http + node:https 를 직접 import 한다.
8
+ * node verifier-probe.mjs --runtime=claude|codex [--codex[=<model>]] [--gemini[=<model>]] [--opencode[=<provider/model>]]
8
9
  *
9
- * 이 프로브는 GET 만 낸다. 모델 호출(POST /chat/completions·/responses)은 어떤 경로에서도 하지 않는다.
10
+ * Only the CLIs named by a flag are looked at; with none, there are no external seats
11
+ * (reason "no-flag"). For each one it prints a seat with a decision:
12
+ * adopt seat it with `model` (`reason` says why that model is the best known one)
13
+ * ask the best model is unclear: offer `options` (best first; may be empty, then ask
14
+ * for a model by name). `fallback` is what to seat when nobody can be asked:
15
+ * a model, "@cli-default" (run the CLI without a model flag; only when its
16
+ * config names no model at all) or null, and then `noAskReason` is the reason
17
+ * to report
18
+ * unseated `reason` says why not
19
+ * The author's own family is never adopted, offered or used as a fallback (Claude Code runs
20
+ * Claude models, Codex runs GPT models and whatever family its config.toml names), and
21
+ * neither is codex when the author is Codex. When the runtime is unknown, or the flag and the
22
+ * environment disagree on it, both families are left out and codex is not seated. A model
23
+ * whose family cannot be read from its name is never adopted on its own or used as a
24
+ * fallback; it is listed under `unclassified`. A model given with the flag is taken as is,
25
+ * with `familyKnown: false` when its family cannot be read. A name that could carry shell
26
+ * syntax is dropped with a note, wherever it comes from: the seat commands put the chosen
27
+ * name on a command line.
10
28
  *
11
- * ── 유효성 사다리 (원문의 거처는 이 주석과 계획서다. SKILL.md 에는 출력 필드 표만 둔다) ────────────
12
- *
13
- * T0 존재 PATH 스캔으로 codex / gemini 를 찾는다 (Windows 는 PATHEXT 고려, 셸을 띄우지 않는다).
14
- * T0-b 전송 command -v curl → transport:"curl"
15
- * 없으면 python3 버전 프로브(>=3.6) → transport:"python3" (urllib 폴백)
16
- * 둘 다 없으면 → transport:"none"
17
- * → 외부 API 좌석 valid:false, reason:"no-transport"
18
- * ※ 프로브 자신은 transport 를 쓰지 않는다 — Node 의 node:http 로 직접 낸다.
19
- * transport 는 SKILL.md 산문이 지시하는 외부 좌석의 실제 검증 호출이 나갈 경로이며,
20
- * 프로브는 그것을 탐지해서 보고할 뿐 사용하지 않는다.
21
- * T1 해석 ~/.codex/config.toml 의 model_provider → [model_providers.X] 의 base_url / env_key.
22
- * 없으면 RALPH_QA_BASE_URL / OPENAI_BASE_URL, 그것도 없는데 API 키 env 만 유효하면
23
- * 표준 OpenAI 기본 base URL(상수). 키 env 마저 비어 있으면 여기서 무효.
24
- * "키는 유효한데 base_url 을 안 적어 뒀다"가 이 경로의 통상 케이스이고, 그걸
25
- * 미구성으로 처리하면 유효한 크리덴셜을 환경 탓으로 버리게 된다.
26
- * TOML 파서를 설치하지 않는다 — 필요한 두 필드만 줄 단위로 뽑는다(의존성 0).
27
- * codex login status / auth.json 은 유효성 근거로 쓰지 않는다 — 실측상 거짓 음성이고
28
- * (Not logged in 이라 답하면서 codex 는 정상 동작한다) exit code 로도 갈리지 않는다.
29
- * T2 생존 GET {base_url}/models (Authorization: Bearer → 401·403 이면 api-key 헤더로 재시도)
30
- * 2xx=유효 / 401·403=dead-credential / 404=route-missing /
31
- * 그 외 4xx·5xx=http-error / ECONNREFUSED=unreachable / 하드 타임아웃=timeout
32
- * 재시도는 base_url 이 **명시 설정**(codex provider config·base URL env)에서 온 경우에만
33
- * 허용한다. 기본 엔드포인트로 내려간 경우엔 한 번만 보낸다 — 아래 [기본 엔드포인트] 참조.
34
- * T3 계열 2xx 응답의 data[].id 를 계열 분류 → 저자 계열을 제외한 후보만 채택.
35
- * 계열 분류표는 상수다 — env override 를 두지 않는다. 분류표는 파라미터가 아니라
36
- * 자기승인을 막는 안전 장치 본체이고, env 하나로 claude-* 를 claude 가 아니게
37
- * 만들 수 있으면 코드로 승격시킨 방어를 환경변수로 되돌리는 셈이다.
38
- * 미분류 id 는 fail-closed 로 제외하고 개수를 unclassified 에 남긴다.
39
- * 분류 가능한 id 가 0 → unclassifiable-catalog / 후보가 0 → no-independent-model
40
- * T4 강등 착석 후(1회 이상 판정을 낸 뒤)의 인증실패·쿼터·레이트리밋은 좌석 상실이며
41
- * 실행을 INCONCLUSIVE 로 종결시킨다. 착석 전 무효(T2·T3)는 좌석이 애초에 없는 것이라
42
- * 상실이 아니다. T4 는 프로브가 아니라 저자 런타임이 루프 중에 판정한다.
43
- *
44
- * ── gemini 사다리 — 좌석이 둘이고 서로 독립이다 ─────────────────────────────────────────────
45
- *
46
- * 요구가 문장 두 개로 갈라져 있다: "gemini CLI 가 설치돼 있고 유효하면 쓴다"와 "gemini 모델을
47
- * 쓸 수 있는 정보(크리덴셜)가 유효하면 curl·python 으로 쓴다". 그래서 좌석도 둘이고, CLI 부재가
48
- * 크리덴셜 경로를 가리면 안 된다. 가리면 환경이 유효한 키를 줬는데도 "환경 탓에 모델 축이 안
49
- * 덮였다"(cli-absent)고 보고하게 되는데, 그게 이 스킬이 막겠다고 선언한 허위 커버리지다.
50
- *
51
- * [CLI 좌석] observed.cli.gemini · observed.external.gemini
52
- * G0 존재 PATH 스캔으로 gemini 를 찾는다 (Windows 는 PATHEXT 고려).
53
- * G1 인증 gemini 는 OpenAI 호환이 아니고 자체 인증을 쓴다 — T1(codex provider)·T2(OpenAI 호환
54
- * GET /models)로는 판정할 수 없다. 인증 후보 env 이름을 상수 목록으로 확인한다.
55
- * 전부 부재 → valid:false, reason:"gemini-no-auth"
56
- * G2 생존 T2 에 대응하는 0토큰 프리플라이트가 gemini CLI 에 있는지 이 머신에서 확인할 수 없었다.
57
- * → 프로브는 G2 를 수행하지 않고 cli.gemini.liveChecked=false 로 남긴다.
58
- * "확인했는데 통과"가 아니라 "확인하지 않았다"를 출력에 적는다.
59
- * 아래 GA2 는 CLI 가 아니라 API 를 재는 것이라 G2 를 대신하지 않는다.
60
- * G3 전송 gemini -p "<프롬프트>" (비대화형 1회 호출). G2 가 없으므로 이 좌석은
61
- * "첫 검증 호출이 곧 프로브"인 좌석이다 — 이 파일이 아니라 SKILL.md 산문이 낸다.
62
- * 첫 호출이 실패하면 착석 전 무효(= 좌석 없음)이고 좌석 상실이 아니다.
63
- *
64
- * [크리덴셜 좌석] observed.geminiApi · observed.external.geminiApi
65
- * GA1 인증 G1 과 같은 env 목록을 본다. CLI 존재 여부는 보지 않는다 — 별개 좌석이다.
66
- * GA2 생존 모델 목록 GET {base}/v1beta/models?key=<KEY> — T2 와 동등한 0토큰 판정이다.
67
- * 2xx=유효 / 401·403=dead-credential / 404=route-missing / 그 외·refused·timeout=무효.
68
- * 추론 호출(POST)은 어느 경로에서도 내지 않는다.
69
- * base 는 상수 기본값 + RALPH_QA_GEMINI_BASE_URL override 한 쌍이다.
70
- * 인증이 쿼리스트링에 실리므로 출력에는 redactUrl 로 search 를 떼어 낸 base 만 적는다.
71
- * GA3 전송 실제 검증 호출은 T0-b 의 transport 로 나간다(요청 형태는 OpenAI 호환이 아니다) —
72
- * 이 파일이 아니라 SKILL.md 산문이 낸다. transport 가 none 이면 좌석 무효.
73
- * GA4 강등 T4 와 같다.
74
- *
75
- * 비대칭은 그래도 남는다: 두 좌석 다 OpenAI 호환 카탈로그가 아니라 계열 필터(T3)를 못 돌린다.
76
- * 저자 계열 배제는 "엔드포인트·CLI 가 애초에 다른 벤더"라는 사실에 의존한다(저자 계열은 claude·gpt).
77
- * 이 머신에 gemini CLI 가 없어 G2·G3 은 한 번도 실행된 적이 없다(미검증). GA2 는 스텁 서버로 검증했다.
78
- *
79
- * ── 송신 게이트 두 개 ───────────────────────────────────────────────────────────────────────
80
- *
81
- * [--external=off] HTTP 를 한 건도 내지 않는다. 정적 탐지(PATH 스캔·config 파싱·env 이름 확인)에서
82
- * 멈추고 T2·GA2 를 건너뛴다. 이 플래그가 좌석 계산에서만 소비되면 SKILL.md 가 지시하는
83
- * "프로브(관측): 미실행" 이 거짓 보고가 된다 — 실제로는 키가 벤더 호스트로 나간 뒤이기 때문이다.
84
- * 미실행은 관측 필드에 명시한다: probed:false · status:null · live:null · reason:"external-off".
85
- * status:null 을 홀로 두지 않는 이유는 "안 했다"와 "했는데 실패했다"가 같은 모양이 되기 때문이다.
86
- *
87
- * [기본 엔드포인트] base_url 을 못 찾았을 때 키 env 이름이 지목하는 표준 벤더 호스트로 내려가는
88
- * 경로는 편의지만 무고지로 두면 위험하다: OPENAI_API_KEY 가 OpenAI 가 아닌 프로바이더(게이트웨이·
89
- * 프록시·사설 서버)의 키를 담는 구성이 흔하고, 그런 환경의 base URL 은 앱 설정에 있지 env 에 없다.
90
- * 그대로 두면 남의 프로바이더 키가 이름만 같은 벤더로 나간다. 그래서 셋을 건다.
91
- * (1) RALPH_QA_NO_DEFAULT_ENDPOINT 가 설정되면 내려가지 않는다 → 미구성(not-configured) 미착석.
92
- * (2) 기본 엔드포인트일 때는 401·403 대체 헤더 재시도를 하지 않는다 — 키를 두 번 보내지 않는다.
93
- * (3) 내려간 사실은 source(default:*)와 notes 양쪽에 항상 남는다.
94
- *
95
- * ── 출력 ────────────────────────────────────────────────────────────────────────────────────
96
- * 최상위 키는 정확히 observed · declared · notes 셋이다.
97
- * observed = 프로브가 직접 측정한 것 (probeMs·httpRequests·transport·cli·api·geminiApi·external·
98
- * candidates·unclassified)
99
- * probeMs 는 HTTP 를 한 건이라도 낸 실행에만 숫자가 있다(안 냈으면 null). 잰 것이 없는데
100
- * 작은 수를 적으면 "쟀는데 빨랐다"로 읽힌다. 실제로 낸 요청 수는 httpRequests 가 담는다.
101
- * declared = 프로브가 플래그 값을 되받아 적는 것 (authorFamily*·seats·externalAbsentReason)
102
- * 프로브는 서브에이전트를 띄우지 않으므로 seats.backbone 을 관측할 수 없다. 두 블록을 섞으면
103
- * "필터가 호출됐다는 가시적 흔적"이 검증 불가한 선언값에 희석된다.
104
- *
105
- * 키 값은 stdout/stderr 어디에도 내지 않는다. 키 이름(keyEnv)만 낸다.
106
- *
107
- * 사용: node verifier-probe.mjs --json [--author-family <v>] [--external auto|off] [--agents N]
108
- * env: RALPH_QA_PROBE_TIMEOUT_MS(하드 타임아웃) · RALPH_QA_NO_DEFAULT_ENDPOINT(기본 엔드포인트 차단)
109
- * RALPH_QA_BASE_URL·OPENAI_BASE_URL·RALPH_QA_GEMINI_BASE_URL(명시 base URL)
29
+ * Sources, all local: codex's bundled catalog (ordered by `priority`) and its config files
30
+ * (/etc/codex/managed_config.toml, config.toml under $CODEX_HOME or ~/.codex, /etc/codex/config.toml;
31
+ * their MCP servers, however the TOML defines them, are listed as `mcpServers` so the seat can
32
+ * turn them off, and a server it cannot turn off keeps codex unseated, as does a managed file
33
+ * that sets a key the seat turns off); gemini's $GEMINI_MODEL, its system and user settings.json
34
+ * (the user's under $GEMINI_CLI_HOME when set; settings that widen the seat's workspace keep it
35
+ * unseated, and usage statistics left on are noted) and whether its system policy folder holds
36
+ * policies;
37
+ * opencode's global, $OPENCODE_CONFIG, ~/.opencode, $OPENCODE_CONFIG_DIR and managed configs
38
+ * and its local copy of the models.dev list. Left out on purpose: the reviewed repository's own
39
+ * gemini and opencode settings and $OPENCODE_CONFIG_CONTENT, because the seats run in the
40
+ * payload folder with project config off and replace that variable. Whether a provider
41
+ * actually serves a model is not checked here: the seat's first call is the check.
110
42
  */
111
- import { accessSync, constants as fsConstants, readFileSync, statSync } from 'node:fs';
112
- import { spawnSync } from 'node:child_process';
113
- import http from 'node:http';
114
- import https from 'node:https';
115
- import { homedir } from 'node:os';
116
- import { delimiter, join } from 'node:path';
117
- import { pathToFileURL } from 'node:url';
118
-
119
- // ── 상수 안전 장치 ────────────────────────────────────────────────────────────────────────────
120
- // 계열 분류표. 이건 "하드코딩 0" 규칙의 적용 대상이 아니다 — 호출에 쓰이는 엔드포인트·키·기본
121
- // 모델 ID 가 아니라 자기승인을 막는 안전 장치 본체이므로 상수로 고정한다. env override 없음.
122
- // 첫 매치가 이긴다.
123
- const FAMILY_RULES = [
124
- ['claude', [/claude/, /anthropic/]],
125
- ['gemini', [/gemini/, /gemma/, /palm/, /bison/, /(^|[-._/])google/]],
126
- ['llama', [/llama/]],
127
- ['mistral', [/mistral/, /mixtral/, /codestral/, /ministral/, /magistral/, /pixtral/, /devstral/]],
128
- ['deepseek', [/deepseek/]],
129
- ['qwen', [/qwen/, /qwq/]],
130
- ['grok', [/grok/, /(^|[-._/])xai/]],
131
- ['cohere', [/cohere/, /command-r/, /(^|[-._/])aya[-._0-9]/]],
132
- ['phi', [/(^|[-._/])phi[-._0-9]/]],
133
- ['nova', [/(^|[-._/])(nova|titan)[-._0-9]/]],
134
- ['gpt', [/(^|[-._/])gpt/, /openai/, /(^|[-._/])o[1-9](\b|[-._])/, /davinci/, /babbage/, /codex/]],
135
- ];
43
+ import { spawnSync } from "node:child_process";
44
+ import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
45
+ import { homedir } from "node:os";
46
+ import { delimiter, dirname, isAbsolute, join } from "node:path";
47
+ import { fileURLToPath } from "node:url";
48
+
49
+ const SEAT_NAMES = ["codex", "gemini", "opencode"];
50
+
51
+ // The value of the `--runtime` at `i` and how many words it takes: `--runtime=x` one,
52
+ // `--runtime x` two, unless x is another flag, which is then read in its own turn.
53
+ function runtimeValue(argv, i, inline) {
54
+ if (inline !== undefined) return [inline, 1];
55
+ const next = argv[i + 1];
56
+ return next !== undefined && !next.startsWith("--") ? [next, 2] : ["", 1];
57
+ }
136
58
 
137
- // 저자 런타임 → 계열. 런타임을 특정하지 못하면 두 계열을 모두 배제한다(fail-closed).
138
- const AUTHOR_RUNTIME_FAMILY = { claude: 'claude', codex: 'gpt' };
139
- const AUTHOR_CAPABLE_FAMILIES = ['claude', 'gpt'];
140
-
141
- // gemini 인증 후보 env 이름. 키 값이 아니라 탐지 대상의 이름이며, 계열 분류표와 같은 범주의
142
- // 상수 안전 장치라 "하드코딩 0" 규칙의 적용 대상 밖이다.
143
- const GEMINI_AUTH_ENVS = ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GOOGLE_GENAI_API_KEY', 'GOOGLE_CLOUD_API_KEY'];
144
- const GEMINI_HOME_DIR = '.gemini';
145
-
146
- // base URL 기본값은 파라미터다 — 상수 기본값과 env override 를 한 쌍으로 둔다. 계열 분류표와 달리
147
- // 이건 안전 장치가 아니라 "어디에 물어보는가"라서, 다른 게이트웨이·프록시를 쓰는 환경이 자기 값을
148
- // 넣을 수 있어야 한다. 기본값이 없으면 "키는 유효한데 주소를 안 적었다"가 미구성으로 처리된다.
149
- const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
150
- const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com';
151
- const GEMINI_BASE_URL_ENV = 'RALPH_QA_GEMINI_BASE_URL';
152
- const GEMINI_MODELS_PATH = '/v1beta/models';
153
- const GEMINI_KEY_PARAM = 'key'; // 인증이 헤더가 아니라 쿼리스트링에 실린다(벤더 규격).
154
-
155
- // 저자 런타임 탐지용 env 이름(값은 읽지 않고 존재만 본다).
156
- const CLAUDE_RUNTIME_ENVS = ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_PLUGIN_ROOT'];
157
- const CODEX_RUNTIME_ENVS = ['CODEX_HOME', 'CODEX_SANDBOX', 'CODEX_SANDBOX_NETWORK_DISABLED', 'CODEX_MANAGED_BY_NPM'];
158
-
159
- // T1 폴백 env 이름(값은 base_url / 키 문자열이며 출력에는 이름만 나간다).
160
- const BASE_URL_ENVS = ['RALPH_QA_BASE_URL', 'OPENAI_BASE_URL'];
161
- const API_KEY_ENVS = ['RALPH_QA_API_KEY', 'OPENAI_API_KEY'];
162
-
163
- // 기본 벤더 엔드포인트 폴백 옵트아웃. 설정돼 있으면 base URL 을 명시 설정에서만 받는다.
164
- const NO_DEFAULT_ENDPOINT_ENV = 'RALPH_QA_NO_DEFAULT_ENDPOINT';
165
- const noDefaultEndpoint = (env) => Boolean(String(env[NO_DEFAULT_ENDPOINT_ENV] || '').trim());
166
-
167
- // 명시 설정에서 온 base URL 인가. codex provider config(codex:*)와 base URL env(env:*)만 명시다 —
168
- // 기본 엔드포인트(default:*)는 저자가 지목한 적 없는 목적지라 키를 두 번 보내지 않는다.
169
- const isExplicitSource = (source) => /^(codex|env):/.test(String(source || ''));
170
-
171
- // 외부 좌석 0 의 사유 토큰 — 닫힌 정의역이다. gemini-call-failed 는 G3(첫 검증 호출)에서만
172
- // 발생하므로 이 프로브가 내는 값이 아니다. 저자 런타임이 그 호출을 낸 뒤 보고에 적는다.
173
- const ABSENT_REASONS = [
174
- 'author-off',
175
- 'probe-unseated status=<code>',
176
- 'no-independent-model',
177
- 'unclassifiable-catalog',
178
- 'no-transport',
179
- 'cli-absent',
180
- 'gemini-call-failed',
181
- // 목적지는 있는데 키 env 가 비어 프로브를 아예 내지 못했다. 프로브가 안 돈 이상 probe-unseated 가
182
- // 아니고, 키가 없는 것은 실제로 환경 사실이므로 환경 계열이다.
183
- 'no-credential',
184
- // 저자가 RALPH_QA_NO_DEFAULT_ENDPOINT 로 기본 벤더 엔드포인트 하강을 껐고, 그래서 좌석이 못 섰다.
185
- // cli-absent 로 흘리면 안 된다 — 그 토큰은 "환경이 경로를 안 줬다"는 뜻이라 보고에서
186
- // APPROVE(모델축 미커버 — 환경)로 매핑되는데, 여기서 원인은 환경이 아니라 저자 설정이다.
187
- 'default-endpoint-off',
59
+ export function parseArgs(argv) {
60
+ const requested = {};
61
+ const unknown = [];
62
+ let runtime = "";
63
+ for (let i = 0; i < argv.length; ) {
64
+ const seat = /^--(codex|gemini|opencode)(?:=(.*))?$/.exec(argv[i]);
65
+ const rt = /^--runtime(?:=(.*))?$/.exec(argv[i]);
66
+ let used = 1;
67
+ if (seat) requested[seat[1]] = seat[2] || null;
68
+ else if (rt) [runtime, used] = runtimeValue(argv, i, rt[1]);
69
+ else unknown.push(argv[i]);
70
+ i += used;
71
+ }
72
+ return { requested, runtime: runtime.trim().toLowerCase() || null, unknown };
73
+ }
74
+
75
+ // A model's family from anywhere in its name: provider prefixes, region prefixes and gateway
76
+ // spellings (`amazon-bedrock/us.anthropic.claude-…`, `litellm/sonnet`, `gitlab/duo-chat-gpt-5`).
77
+ // The two author families match loosely on purpose: a miss would seat the author's own model
78
+ // as an independent one.
79
+ const FAMILIES = [
80
+ ["claude", /claude|anthropic|(^|[^a-z])(opus|sonnet|haiku)([^a-z]|$)/],
81
+ ["gpt", /openai|(^|[^a-z])(chat)?gpt(?![a-z])|(^|[^a-z])codex|(^|[^a-z])o\d/],
82
+ ["gemini", /gemini/],
83
+ ["gemma", /gemma/],
84
+ ["qwen", /qwen/],
85
+ ["llama", /llama/],
86
+ ["mistral", /mistral|mixtral|codestral|devstral|magistral/],
87
+ ["deepseek", /deepseek/],
88
+ ["grok", /grok|(^|[^a-z])xai[/.]/],
89
+ ["phi", /(^|[^a-z])phi-?\d/],
90
+ ["kimi", /kimi|moonshot/],
91
+ ["glm", /(^|[^a-z])glm|zhipu/],
92
+ ["minimax", /minimax/],
93
+ ["nova", /amazon\.nova|(^|[^a-z])nova-(pro|lite|micro|premier)/],
94
+ ["cohere", /cohere|command-r/],
188
95
  ];
96
+ const FAMILY_RE = Object.fromEntries(FAMILIES);
97
+ // gemini's own aliases; it resolves them to Gemini models (`auto` routes between Pro and Flash).
98
+ const GEMINI_ALIASES = new Set(["pro", "auto", "flash", "flash-lite"]);
99
+
100
+ export function familyOf(model) {
101
+ const name = String(model ?? "").toLowerCase();
102
+ if (GEMINI_ALIASES.has(name)) return "gemini";
103
+ for (const [family, re] of FAMILIES) if (re.test(name)) return family;
104
+ return null;
105
+ }
106
+
107
+ // True when the name matches any author family at all, not just as its first family.
108
+ export function isAuthorFamily(model, authorFamilies) {
109
+ const name = String(model ?? "").toLowerCase();
110
+ return authorFamilies.some((family) => FAMILY_RE[family].test(name));
111
+ }
112
+
113
+ // What a model name may hold to go on a seat's command line: letters, digits and `._:/@+-`,
114
+ // starting with a letter or digit. No spaces, quotes, `$`, backticks or other shell syntax.
115
+ const SAFE_MODEL = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,127}$/;
116
+ export const safeModel = (m) => typeof m === "string" && SAFE_MODEL.test(m);
189
117
 
190
- const DEFAULT_TIMEOUT_MS = 5000;
191
- const CANDIDATES_PER_FAMILY = 3;
192
- const MAX_BODY_BYTES = 8 * 1024 * 1024;
193
- const PY_VERSION_PROBE = 'import sys,urllib.request; sys.exit(0 if sys.version_info[:2]>=(3,6) else 1)';
194
-
195
- // ── 인자 ─────────────────────────────────────────────────────────────────────────────────────
196
-
197
- function parseArgs(argv) {
198
- const out = { json: false, authorFamily: null, external: 'auto', agents: 3, unknown: [] };
199
- for (let i = 0; i < argv.length; i += 1) {
200
- const arg = argv[i];
201
- const eq = arg.indexOf('=');
202
- const name = eq > 0 ? arg.slice(0, eq) : arg;
203
- const inlineValue = eq > 0 ? arg.slice(eq + 1) : null;
204
- const next = () => (inlineValue !== null ? inlineValue : argv[++i]);
205
- if (name === '--json') out.json = true;
206
- else if (name === '--author-family') out.authorFamily = (next() || '').trim() || null;
207
- else if (name === '--external') out.external = (next() || '').trim() === 'off' ? 'off' : 'auto';
208
- else if (name === '--agents') {
209
- const n = Number.parseInt(next(), 10);
210
- if (Number.isFinite(n) && n >= 1) out.agents = n;
211
- } else out.unknown.push(name);
118
+ // The safe names among `models`, with a note for each one dropped (its length, not its text).
119
+ function safeOnly(models, where, notes) {
120
+ const out = [];
121
+ for (const m of models) {
122
+ if (m == null || m === "") continue;
123
+ if (safeModel(m)) out.push(m);
124
+ else notes.push(`model name dropped (${where}, ${String(m).length} characters): it holds characters a command line cannot carry safely`);
212
125
  }
213
126
  return out;
214
127
  }
215
128
 
216
- // ── T0 / T0-b: PATH 스캔과 전송 수단 탐지 ────────────────────────────────────────────────────
129
+ const AUTHOR_FAMILIES = { claude: ["claude"], codex: ["gpt"] };
130
+ const UNKNOWN_AUTHOR = ["claude", "gpt"];
217
131
 
218
- // 셸을 띄우지 않는 command -v 대체. Windows 는 PATHEXT 의 각 확장자를 붙여 본다.
219
- function whichExecutable(name, env) {
220
- const dirs = String(env.PATH || env.Path || '').split(delimiter).filter(Boolean);
221
- const isWin = process.platform === 'win32';
222
- const exts = isWin
223
- ? String(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
224
- : [''];
225
- for (const dir of dirs) {
226
- for (const ext of exts) {
227
- const candidate = join(dir, name + ext);
228
- try {
229
- if (!statSync(candidate).isFile()) continue;
230
- if (!isWin) accessSync(candidate, fsConstants.X_OK); // POSIX 는 실행 비트까지 본다.
231
- return candidate;
232
- } catch { /* 다음 후보 */ }
233
- }
234
- }
235
- return null;
132
+ // Environment markers each runtime leaves on the shells it starts. Both or neither: unknown.
133
+ export const RUNTIME_MARKERS = {
134
+ claude: ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"],
135
+ codex: ["CODEX_THREAD_ID", "CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", "CODEX_MANAGED_BY_NPM",
136
+ "CODEX_MANAGED_BY_BUN", "CODEX_MANAGED_BY_PNPM", "CODEX_MANAGED_PACKAGE_ROOT"],
137
+ };
138
+
139
+ export function detectRuntime(env) {
140
+ const hits = Object.keys(RUNTIME_MARKERS).filter((rt) => RUNTIME_MARKERS[rt].some((k) => env[k]));
141
+ return hits.length === 1 ? hits[0] : null;
142
+ }
143
+
144
+ // The runtime to probe for: the flag, the markers when there is no flag, and neither when they
145
+ // disagree (the probe then treats the runtime as unknown and says why).
146
+ export function resolveRuntime(flag, env) {
147
+ const detected = detectRuntime(env);
148
+ if (flag && detected && flag !== detected) return `${flag} (the environment says ${detected})`;
149
+ return flag ?? detected;
236
150
  }
237
151
 
238
- // T0-b. curl → python3(>=3.6 버전 프로브) → none. 프로브 자신은 이 값을 쓰지 않고 보고만 한다.
239
- function detectTransport(env) {
240
- if (whichExecutable('curl', env)) return 'curl';
241
- const py = whichExecutable('python3', env);
242
- if (py) {
243
- try {
244
- const r = spawnSync(py, ['-c', PY_VERSION_PROBE], { stdio: 'ignore', timeout: DEFAULT_TIMEOUT_MS });
245
- if (r.status === 0) return 'python3';
246
- } catch { /* 버전 프로브 실패 = 전송 수단 없음으로 본다 */ }
152
+ // Why a seat ends up empty. The probe sets most; the skill sets model-declined (the person
153
+ // dropped the seat when asked), cli-call-failed when the first call failed with no candidate
154
+ // left to switch to, and the `noAskReason` of an ask it could not put to anyone. The probe sets
155
+ // cli-call-failed too when every call would fail (codex's legacy profile) or the seat could not
156
+ // hold its guards (MCP servers it cannot list or turn off, a managed or requirements file over
157
+ // the seat's flags, gemini's system policies, settings that widen gemini's workspace); `notes`
158
+ // then says what to change. SKILL.md lists the same set.
159
+ export const ABSENT_REASONS = [
160
+ 'no-flag',
161
+ 'self-family',
162
+ 'self-runtime',
163
+ 'model-declined',
164
+ 'runtime-unknown',
165
+ 'cli-absent',
166
+ 'no-independent-model',
167
+ 'no-default-model',
168
+ 'cli-call-failed',
169
+ ];
170
+
171
+ // Why a model was adopted (the first five) or asked about (the rest). SKILL.md lists the same set.
172
+ export const DECISION_REASONS = [
173
+ 'flag',
174
+ 'catalog-top-is-configured',
175
+ 'catalog-top',
176
+ 'cli-pro-alias',
177
+ 'single-candidate',
178
+ 'top-differs-from-configured',
179
+ 'custom-provider',
180
+ 'no-catalog',
181
+ 'open-model-set',
182
+ 'configured-vs-pro',
183
+ 'several-candidates',
184
+ 'no-candidate',
185
+ 'config-not-understood',
186
+ ];
187
+
188
+ export const CLI_DEFAULT = "@cli-default";
189
+
190
+ // opencode refreshes its models.dev list on every start, `--version` included, unless told not to.
191
+ const OPENCODE_QUIET = { OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_AUTOUPDATE: "1" };
192
+
193
+ const firstLine = (res) => (res?.status === 0 ? String(res.stdout).trim().split("\n")[0] || null : null);
194
+ const absent = (cli) => ({ cli, present: false, decision: "unseated", reason: "cli-absent" });
195
+ const adopt = (seat, model, reason) => ({ ...seat, decision: "adopt", model, reason });
196
+ const withNotes = (seat, notes) => (notes.length ? { ...seat, notes: [...new Set([...(seat.notes ?? []), ...notes])] } : seat);
197
+
198
+ // TOML keys: bare, "basic" or 'literal', dotted with optional spaces (`a."b.c" . d`).
199
+ const KEY_SEG = String.raw`(?:"(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+)`;
200
+ const KEY_PATH = String.raw`${KEY_SEG}(?:\s*\.\s*${KEY_SEG})*`;
201
+ const HEADER_RE = new RegExp(String.raw`^\s*\[(\[?)\s*(${KEY_PATH})\s*\]\]?\s*(?:#.*)?$`);
202
+ const ASSIGN_RE = new RegExp(String.raw`^\s*(${KEY_PATH})\s*=\s*(.*)$`);
203
+ const SEG_RE = /"((?:[^"\\]|\\.)*)"|'([^']*)'|([A-Za-z0-9_-]+)/g;
204
+ const STRING_VALUE = /^(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\s*(?:#.*)?$/;
205
+
206
+ // A dotted key's segments, quotes taken off (escapes kept as written).
207
+ const keyPath = (text) => [...text.matchAll(SEG_RE)].map((m) => m[1] ?? m[2] ?? m[3]);
208
+
209
+ // The walk's state: where it is (open string, nesting depth, table, array being gathered) and
210
+ // what it found so far.
211
+ const tomlState = () => ({ values: {}, arrays: {}, mcpServers: new Set(), mcpUnread: false, modelKeys: false,
212
+ open: null, depth: 0, table: [], roots: new Set(), array: null });
213
+
214
+ // The index just past the one-line string that opens at `i` (the line's end when it never closes).
215
+ function stringEnd(text, i) {
216
+ const q = text[i];
217
+ for (let j = i + 1; j < text.length; j += 1) {
218
+ if (q === '"' && text[j] === "\\") j += 1;
219
+ else if (text[j] === q) return j + 1;
247
220
  }
248
- return 'none';
249
- }
250
-
251
- // ── T1: provider 해석 ────────────────────────────────────────────────────────────────────────
252
-
253
- // TOML 파서를 쓰지 않는다. 최상위 model_provider 와 [model_providers.X] 의 base_url·env_key 만
254
- // 줄 단위로 뽑는다. 값이 한 줄 큰따옴표 문자열인 형태만 인정한다(이 두 필드는 항상 그 형태다).
255
- function readTomlFields(src, provider) {
256
- const lines = src.split(/\r?\n/);
257
- const out = { modelProvider: null, model: null, baseUrl: null, envKey: null };
258
- const wanted = ['model_providers.' + provider, 'model_providers."' + provider + '"'];
259
- let section = null;
260
- for (const raw of lines) {
261
- const header = /^\s*\[([^\]]+)\]\s*$/.exec(raw);
262
- if (header) { section = header[1].trim(); continue; }
263
- const kv = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"([^"]*)"/.exec(raw);
264
- if (!kv) continue;
265
- const [, key, value] = kv;
266
- if (section === null && key === 'model_provider') out.modelProvider = value;
267
- if (section === null && key === 'model') out.model = value;
268
- if (provider && wanted.indexOf(section) >= 0) {
269
- if (key === 'base_url') out.baseUrl = value;
270
- if (key === 'env_key') out.envKey = value;
221
+ return text.length;
222
+ }
223
+
224
+ // The index just past the delimiter that closes the multi-line string `open` (""" or ''') at or
225
+ // after `i`, -1 when this line does not close it. A basic one skips backslash escapes, and a run
226
+ // of up to five quotes ends with the last three of them (TOML lets one or two stand inside).
227
+ function multiEnd(text, i, open) {
228
+ for (let j = i; j < text.length; j += 1) {
229
+ if (open === '"""' && text[j] === "\\") j += 1;
230
+ else if (text.startsWith(open, j)) {
231
+ let k = j + 3;
232
+ while (k < j + 5 && text[k] === open[0]) k += 1;
233
+ return k;
271
234
  }
272
235
  }
273
- return out;
236
+ return -1;
274
237
  }
275
238
 
276
- function codexConfigPath(env) {
277
- const home = env.CODEX_HOME ? env.CODEX_HOME : join(env.HOME || env.USERPROFILE || homedir(), '.codex');
278
- return join(home, 'config.toml');
279
- }
280
-
281
- // api 구성 해석: codex config → env 폴백 → 없음. 키 값은 읽되 절대 출력하지 않는다.
282
- function resolveApi(env, notes) {
283
- let src = '';
284
- try { src = readFileSync(codexConfigPath(env), 'utf8'); } catch { src = ''; }
285
- if (src) {
286
- const first = readTomlFields(src, null);
287
- if (first.modelProvider) {
288
- const fields = readTomlFields(src, first.modelProvider);
289
- if (fields.baseUrl) {
290
- const keyEnv = fields.envKey || null;
291
- return {
292
- configured: true,
293
- source: 'codex:' + first.modelProvider,
294
- baseUrl: fields.baseUrl,
295
- keyEnv,
296
- key: keyEnv ? (env[keyEnv] || '') : '',
297
- // 좌석 모델 선호값. 저자가 이미 자기 CLI 에 설정해 둔 모델 이름이고 이 파일에는 어떤
298
- // 모델 ID 도 박혀 있지 않다 — 계열 필터를 통과할 때만 쓰이는 선호값일 뿐이다.
299
- preferredModel: first.model || null,
300
- };
301
- }
302
- notes.push('codex config 의 model_provider 는 있으나 [model_providers.' + first.modelProvider + '] 의 base_url 을 찾지 못했다.');
239
+ // One step of walkValue outside a string: the index after the token at `i`, -1 at a comment.
240
+ function valueStep(state, text, i) {
241
+ const three = text.slice(i, i + 3);
242
+ if (three === '"""' || three === "'''") {
243
+ state.open = three;
244
+ return i + 3;
245
+ }
246
+ const c = text[i];
247
+ if (c === "#") return -1;
248
+ if (c === '"' || c === "'") return stringEnd(text, i);
249
+ if (c === "[" || c === "{") state.depth += 1;
250
+ if (c === "]" || c === "}") state.depth = Math.max(0, state.depth - 1);
251
+ return i + 1;
252
+ }
253
+
254
+ // A value's text as it runs across lines: how deep `[` and `{` nesting is still open, and the
255
+ // multi-line string (""" or ''') still open, strings and comments aside. Inline tables can span
256
+ // lines too (TOML 1.1, which codex 0.144.5 reads).
257
+ function walkValue(state, text) {
258
+ let i = 0;
259
+ while (i >= 0 && i < text.length) {
260
+ if (!state.open) {
261
+ i = valueStep(state, text, i);
262
+ continue;
303
263
  }
264
+ const end = multiEnd(text, i, state.open);
265
+ if (end < 0) return;
266
+ state.open = null;
267
+ i = end;
304
268
  }
305
- for (const nameEnv of BASE_URL_ENVS) {
306
- const baseUrl = (env[nameEnv] || '').trim();
307
- if (!baseUrl) continue;
308
- const keyEnv = API_KEY_ENVS.find((k) => (env[k] || '').trim()) || API_KEY_ENVS[0];
309
- return { configured: true, source: 'env:' + nameEnv, baseUrl, keyEnv, key: env[keyEnv] || '', preferredModel: null };
269
+ }
270
+
271
+ // The MCP server a key path under mcp_servers names. One `mcp_servers = { ... }` inline table
272
+ // is not read for names: the probe could not say which servers to turn off.
273
+ function noteMcp(state, full) {
274
+ if (full[0] !== "mcp_servers") return;
275
+ if (full.length > 1) state.mcpServers.add(full[1]);
276
+ else state.mcpUnread = true;
277
+ }
278
+
279
+ // True when a key path ends in `model`, or its inline-table value sets one.
280
+ const setsModel = (full, value) => full[full.length - 1] === "model" || (value.startsWith("{") && /[{,]\s*model\s*=/.test(value));
281
+
282
+ // The strings of a top-level array, gathered line by line until it closes; comments aside.
283
+ const ARRAY_ITEM = /"((?:[^"\\]|\\.)*)"|'([^']*)'|#.*/g;
284
+ function gatherArray(state, text) {
285
+ if (!state.array) return;
286
+ state.array.text += text + "\n";
287
+ if (state.open || state.depth > 0) return;
288
+ state.arrays[state.array.key] = [...state.array.text.matchAll(ARRAY_ITEM)].filter((m) => !m[0].startsWith("#")).map((m) => m[1] ?? m[2]);
289
+ state.array = null;
290
+ }
291
+
292
+ // One `key = value` line: the MCP server it defines, whether it sets a model in any table,
293
+ // the multi-line string or array it opens, and a top-level string value or array of strings.
294
+ function assignment(state, path, value) {
295
+ const full = [...state.table, ...path];
296
+ state.roots.add(full[0]);
297
+ noteMcp(state, full);
298
+ if (setsModel(full, value)) state.modelKeys = true;
299
+ if (full.length === 1 && value.startsWith("[")) state.array = { key: full[0], text: "" };
300
+ walkValue(state, value);
301
+ gatherArray(state, value);
302
+ if (state.open || state.depth > 0) return;
303
+ const str = state.table.length === 0 && path.length === 1 ? STRING_VALUE.exec(value) : null;
304
+ if (str) state.values[path[0]] = str[1] ?? str[2];
305
+ }
306
+
307
+ // A table header: the table later keys go in, and the MCP server a [mcp_servers.<name>...] names.
308
+ function enterTable(state, header) {
309
+ state.roots.add(keyPath(header[2])[0]);
310
+ state.table = header[1] ? ["[[]]"] : keyPath(header[2]);
311
+ if (state.table[0] === "mcp_servers" && state.table.length > 1) state.mcpServers.add(state.table[1]);
312
+ }
313
+
314
+ // One line of readToml into `state`: walked inside a multi-line string, array or inline table
315
+ // (a `model =` there still counts), else a table header or a `key = value`.
316
+ function tomlLine(state, line) {
317
+ if (state.open || state.depth > 0) {
318
+ if (!state.open && /(?:^|[{,])\s*model\s*=/.test(line)) state.modelKeys = true;
319
+ walkValue(state, line);
320
+ gatherArray(state, line);
321
+ return;
310
322
  }
311
- // base_url 을 아무 데서도 못 찾았지만 키 env 는 유효한 통상 케이스. 여기서 미구성으로 끊으면
312
- // 환경이 준 유효한 크리덴셜을 버리는 셈이라, 표준 기본 base URL 로 내려간다. env 가 있으면
313
- // 위 루프에서 이미 이겼으므로 이 상수는 언제나 최후 순위다.
314
- const fallbackKeyEnv = API_KEY_ENVS.find((k) => (env[k] || '').trim());
315
- if (fallbackKeyEnv) {
316
- if (noDefaultEndpoint(env)) {
317
- // 옛 동작으로 되돌린다: 목적지를 저자가 지목하지 않았으므로 미구성이다. 키를 관측해 놓고
318
- // 침묵하면 보고가 "환경이 아무것도 주지 않았다"로 읽히므로 사유를 남긴다.
319
- notes.push('base_url 을 해석하지 못했고 ' + NO_DEFAULT_ENDPOINT_ENV + ' 가 설정돼 기본 벤더 엔드포인트로 내려가지 않았다 — 키 env(' + fallbackKeyEnv + ')는 있으나 목적지가 없어 미구성으로 둔다.');
320
- return { configured: false, source: 'none', baseUrl: null, keyEnv: null, key: '', preferredModel: null };
323
+ const header = HEADER_RE.exec(line);
324
+ if (header) return enterTable(state, header);
325
+ const assign = ASSIGN_RE.exec(line);
326
+ if (assign) assignment(state, keyPath(assign[1]), assign[2]);
327
+ }
328
+
329
+ // The servers each [mcp_servers] header in the text names when the lines after it are walked
330
+ // alone, up to the next header. A cross-check on the walk, which a string it misreads (or text
331
+ // that only looks like a header) could lead past the block; a misread here only adds names.
332
+ function rawMcpBlockNames(lines) {
333
+ const names = [];
334
+ lines.forEach((line, i) => {
335
+ const header = HEADER_RE.exec(line);
336
+ if (!header || header[1] || keyPath(header[2]).join(".") !== "mcp_servers") return;
337
+ const state = tomlState();
338
+ state.table = ["mcp_servers"];
339
+ for (const next of lines.slice(i + 1)) {
340
+ if (!state.open && state.depth === 0 && HEADER_RE.test(next)) break;
341
+ tomlLine(state, next);
321
342
  }
322
- notes.push('base_url 을 codex config·env 어디서도 해석하지 못해 표준 OpenAI 기본 base URL 로 내려갔다 (키 env: ' + fallbackKeyEnv + ') — 이 내려감이 싫으면 ' + NO_DEFAULT_ENDPOINT_ENV + ' 를 설정한다.');
323
- return {
324
- configured: true,
325
- source: 'default:openai',
326
- baseUrl: DEFAULT_OPENAI_BASE_URL,
327
- keyEnv: fallbackKeyEnv,
328
- key: env[fallbackKeyEnv],
329
- preferredModel: null,
330
- };
331
- }
332
- return { configured: false, source: 'none', baseUrl: null, keyEnv: null, key: '', preferredModel: null };
333
- }
334
-
335
- // GA1: gemini 크리덴셜 해석. CLI 존재 여부를 보지 않는다 — 두 경로는 별개 좌석이다.
336
- // 키 값은 읽되 절대 출력하지 않는다(호출자는 keyEnv 이름만 낸다).
337
- // reason 은 configured:false 일 때의 사유다 — 크리덴셜이 없는 것(no-credential)과 목적지가 없는
338
- // 것(not-configured)은 다르고, 좌석 사유가 그 둘을 뭉개면 보고가 원인을 못 짚는다.
339
- function resolveGeminiApi(env, notes = []) {
340
- const keyEnv = GEMINI_AUTH_ENVS.find((name) => (env[name] || '').trim()) || null;
341
- const override = (env[GEMINI_BASE_URL_ENV] || '').trim();
342
- const seat = (source, baseUrl, reason) => ({
343
- configured: Boolean(keyEnv) && !reason,
344
- source,
345
- baseUrl,
346
- keyEnv: reason === 'not-configured' ? null : keyEnv,
347
- key: keyEnv && reason !== 'not-configured' ? env[keyEnv] : '',
348
- reason: keyEnv ? reason : 'no-credential',
343
+ names.push(...state.mcpServers);
349
344
  });
350
- if (override) return seat('env:' + GEMINI_BASE_URL_ENV, override, null);
351
- if (keyEnv && noDefaultEndpoint(env)) {
352
- notes.push('gemini base URL 을 ' + GEMINI_BASE_URL_ENV + ' 에서 찾지 못했고 ' + NO_DEFAULT_ENDPOINT_ENV
353
- + ' 가 설정돼 기본 벤더 엔드포인트로 내려가지 않았다 — 키 env(' + keyEnv + ')는 있으나 목적지가 없어 미구성으로 둔다.');
354
- return seat('none', null, 'not-configured');
345
+ return names;
346
+ }
347
+
348
+ // Every `mcp_servers.<name>` the text spells, comment lines aside, and the keys under each
349
+ // [mcp_servers] header: a cross-check on the walk.
350
+ const RAW_MCP = new RegExp(String.raw`mcp_servers\s*\.\s*(${KEY_SEG})`, "g");
351
+ const rawMcpNames = (lines) => lines.filter((l) => !/^\s*#/.test(l))
352
+ .flatMap((l) => [...l.matchAll(RAW_MCP)].map((m) => keyPath(m[1])[0])).concat(rawMcpBlockNames(lines));
353
+
354
+
355
+ // What the probe reads from a TOML file: its top-level `key = "string"` pairs (quoted keys too),
356
+ // the first segment of every key and table (`roots`), the MCP server names wherever they are
357
+ // defined (table headers, under [mcp_servers], dotted keys), whether servers were written in a
358
+ // form it does not list or that the text names beyond what it listed (`mcpUnread`), whether any
359
+ // table sets a model (`modelKeys`) and whether a multi-line string, array or inline table was
360
+ // left open, and the strings of each top-level array (`arrays`). Enough for those keys; not a
361
+ // TOML parser.
362
+ export function readToml(text) {
363
+ const state = tomlState();
364
+ const lines = String(text ?? "").split(/\r?\n/);
365
+ for (const line of lines) tomlLine(state, line);
366
+ const unlisted = rawMcpNames(lines).filter((n) => !state.mcpServers.has(n));
367
+ return {
368
+ values: state.values,
369
+ roots: [...state.roots],
370
+ arrays: state.arrays,
371
+ mcpServers: [...state.mcpServers],
372
+ mcpUnread: state.mcpUnread || unlisted.length > 0,
373
+ modelKeys: state.modelKeys,
374
+ broken: state.open !== null || state.depth > 0,
375
+ };
376
+ }
377
+
378
+ export const topLevelToml = (text) => readToml(text).values;
379
+
380
+ // The comment or trailing comma that starts at `i`, as the index just past it; -1 when none.
381
+ function skipNoise(text, i) {
382
+ if (text.startsWith("//", i)) {
383
+ const end = text.indexOf("\n", i);
384
+ return end < 0 ? text.length : end;
385
+ }
386
+ if (text.startsWith("/*", i)) {
387
+ const end = text.indexOf("*/", i + 2);
388
+ return end < 0 ? text.length : end + 2;
355
389
  }
356
- if (keyEnv) {
357
- notes.push('gemini base URL 을 ' + GEMINI_BASE_URL_ENV + ' 에서 찾지 못해 표준 벤더 기본 엔드포인트로 내려갔다 (키 env: '
358
- + keyEnv + ') — 이 내려감이 싫으면 ' + NO_DEFAULT_ENDPOINT_ENV + ' 를 설정한다.');
390
+ if (text[i] === ",") {
391
+ let j = i + 1;
392
+ while (/\s/.test(text[j] ?? "")) j += 1;
393
+ if (text[j] === "}" || text[j] === "]") return i + 1;
359
394
  }
360
- return seat('default:gemini', DEFAULT_GEMINI_BASE_URL, null);
395
+ return -1;
361
396
  }
362
397
 
363
- // ── T2: 0토큰 생존 판정 ──────────────────────────────────────────────────────────────────────
364
-
365
- function modelsUrl(baseUrl) {
366
- return String(baseUrl).replace(/\/+$/, '') + '/models';
398
+ // JSON with comments and trailing commas (JSONC, as opencode and gemini accept) made plain.
399
+ export function stripJsonc(text) {
400
+ let out = "";
401
+ let inString = false;
402
+ for (let i = 0; i < text.length; ) {
403
+ const c = text[i];
404
+ if (inString) {
405
+ const step = c === "\\" ? 2 : 1;
406
+ out += text.slice(i, i + step);
407
+ if (c === '"') inString = false;
408
+ i += step;
409
+ continue;
410
+ }
411
+ const next = skipNoise(text, i);
412
+ if (next >= 0) {
413
+ i = next;
414
+ continue;
415
+ }
416
+ if (c === '"') inString = true;
417
+ out += c;
418
+ i += 1;
419
+ }
420
+ return out;
367
421
  }
368
422
 
369
- // GA2 요청 URL. 인증이 쿼리스트링에 실리므로 이 반환값은 요청에만 쓰고 출력·로그 어디에도 담지
370
- // 않는다. 설정 base 에 딸려 온 쿼리·프래그먼트는 버린다 — 남겨 두면 경로 조립이 깨지면서 키가
371
- // 엉뚱한 요청의 파라미터로 따라간다. 해석 불가한 base 는 null 을 돌려 호출자가 bad-url 로 끊는다.
372
- function geminiModelsUrl(baseUrl, key) {
423
+ // { value } for a readable JSONC text, { unreadable: true } for a broken one, {} when absent.
424
+ function readJsonc(text) {
425
+ if (text == null) return {};
373
426
  try {
374
- const url = new URL(String(baseUrl));
375
- url.hash = '';
376
- url.search = '';
377
- url.pathname = url.pathname.replace(/\/+$/, '') + GEMINI_MODELS_PATH;
378
- url.searchParams.set(GEMINI_KEY_PARAM, key);
379
- return url.toString();
427
+ const value = JSON.parse(stripJsonc(String(text)));
428
+ return value && typeof value === "object" && !Array.isArray(value) ? { value } : { unreadable: true };
380
429
  } catch {
381
- return null;
430
+ return { unreadable: true };
382
431
  }
383
432
  }
384
433
 
385
- // 출력용 URL 정화 — userinfo·쿼리·프래그먼트를 떼어 크리덴셜이 섞여 들어올 여지를 없앤다.
386
- function redactUrl(url) {
434
+ // codex's bundled catalog: listed, API-served slugs by priority; null when it cannot be read.
435
+ function readCatalog(res, notes) {
436
+ if (res?.status !== 0) return null;
387
437
  try {
388
- const parsed = new URL(url);
389
- parsed.username = '';
390
- parsed.password = '';
391
- parsed.search = '';
392
- parsed.hash = '';
393
- return parsed.toString();
438
+ const models = JSON.parse(res.stdout).models;
439
+ if (!Array.isArray(models)) return null;
440
+ const slugs = models
441
+ .filter((m) => m?.slug && m.visibility !== "hide" && m.supported_in_api !== false)
442
+ .sort((a, b) => (a.priority ?? Infinity) - (b.priority ?? Infinity))
443
+ .map((m) => m.slug);
444
+ return safeOnly(slugs, "codex catalog", notes);
394
445
  } catch {
395
446
  return null;
396
447
  }
397
448
  }
398
449
 
399
- // GET 한 번. 어떤 경로에서도 reject 하지 않고 {status, reason, body} 로 resolve 한다.
400
- // 인증(헤더 값이든 쿼리스트링이든)은 요청에만 실리고 반환값·로그 어디에도 담기지 않는다 —
401
- // 실패 사유는 고정 토큰이라 예외 메시지가 URL 을 물고 새어 나갈 경로가 없다.
402
- function httpGet(url, extraHeaders, timeoutMs) {
403
- return new Promise((resolve) => {
404
- let done = false;
405
- const finish = (result) => { if (!done) { done = true; resolve(result); } };
406
- let parsed;
407
- try { parsed = new URL(url); } catch { return finish({ status: 0, reason: 'bad-url', body: '' }); }
408
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
409
- return finish({ status: 0, reason: 'bad-url', body: '' });
410
- }
411
- const mod = parsed.protocol === 'https:' ? https : http;
412
- let req = null;
413
- const timer = setTimeout(() => {
414
- try { if (req) req.destroy(); } catch { /* 이미 닫힘 */ }
415
- finish({ status: 0, reason: 'timeout', body: '' });
416
- }, timeoutMs);
417
- if (typeof timer.unref === 'function') timer.unref();
418
- const headers = { accept: 'application/json', ...(extraHeaders || {}) };
419
- try {
420
- req = mod.request(parsed, { method: 'GET', headers }, (res) => {
421
- const status = res.statusCode || 0;
422
- let data = '';
423
- let bytes = 0;
424
- res.on('data', (chunk) => { bytes += chunk.length; if (bytes <= MAX_BODY_BYTES) data += chunk; });
425
- res.on('end', () => { clearTimeout(timer); finish({ status, reason: null, body: data }); });
426
- res.on('error', () => { clearTimeout(timer); finish({ status, reason: 'unreachable', body: '' }); });
427
- });
428
- req.on('error', () => { clearTimeout(timer); finish({ status: 0, reason: 'unreachable', body: '' }); });
429
- req.setTimeout(timeoutMs, () => { try { req.destroy(); } catch { /* 이미 닫힘 */ } });
430
- req.end(); // GET — 요청 본문 없음.
431
- } catch {
432
- clearTimeout(timer);
433
- finish({ status: 0, reason: 'unreachable', body: '' });
434
- }
435
- });
450
+ // Models split by what the probe may do with them: independent ones it may adopt or offer,
451
+ // unclassified ones only the person may pick; the author's own family is dropped.
452
+ function sortModels(models, authorFamilies) {
453
+ const independent = [];
454
+ const unclassified = [];
455
+ for (const m of new Set(models.filter(Boolean))) {
456
+ if (isAuthorFamily(m, authorFamilies)) continue;
457
+ (familyOf(m) ? independent : unclassified).push(m);
458
+ }
459
+ return { independent, unclassified };
436
460
  }
437
461
 
438
- function statusReason(status, transportReason) {
439
- if (transportReason) return transportReason; // timeout · unreachable · bad-url
440
- if (status >= 200 && status < 300) return null;
441
- if (status === 401 || status === 403) return 'dead-credential';
442
- if (status === 404) return 'route-missing';
443
- return 'http-error';
444
- }
445
-
446
- // Authorization: Bearer 를 먼저 쓰고, 401·403 이면 api-key 헤더로 한 번 더 시도한다
447
- // (이 머신의 프록시는 두 헤더 모두 200 을 낸다 — 다른 프록시는 한쪽만 받을 수 있다).
448
- // 재시도는 allowHeaderRetry 일 때만 — 저자가 지목하지 않은 기본 엔드포인트로 같은 키를 두 번
449
- // 보내면 오배송 1건이 2건이 된다. requests 는 실제로 낸 요청 수이며 호출자가 그대로 보고한다.
450
- async function probeModels(baseUrl, key, timeoutMs, allowHeaderRetry) {
451
- const url = modelsUrl(baseUrl);
452
- const first = await httpGet(url, { authorization: 'Bearer ' + key }, timeoutMs);
453
- if (allowHeaderRetry && (first.status === 401 || first.status === 403)) {
454
- const second = await httpGet(url, { 'api-key': key }, timeoutMs);
455
- if (second.status >= 200 && second.status < 300) return { ...second, header: 'api-key', requests: 2 };
456
- return { ...first, header: 'authorization', requests: 2 };
457
- }
458
- return { ...first, header: 'authorization', requests: 1 };
462
+ // The seat a flag-given model makes, or null when no model was given.
463
+ function flagged(base, model, authorFamilies) {
464
+ if (!model) return null;
465
+ if (isAuthorFamily(model, authorFamilies)) return { ...base, decision: "unseated", reason: "self-family" };
466
+ return { ...base, decision: "adopt", model, reason: "flag", familyKnown: familyOf(model) !== null };
459
467
  }
460
468
 
461
- // ── T3: 계열 분류와 후보 선정 ────────────────────────────────────────────────────────────────
469
+ // An ask: options and fallback hold only models whose family is known (the rest stay listed
470
+ // under `unclassified`); with no fallback, `noAskReason` is what to report when nobody can be asked.
471
+ function ask(seat, options, fallback, reason, noAskReason = "no-default-model") {
472
+ const known = [...new Set(options.filter((m) => m && familyOf(m)))];
473
+ const safe = fallback === CLI_DEFAULT || (fallback && familyOf(fallback)) ? fallback : null;
474
+ return { ...seat, decision: "ask", options: known, fallback: safe, reason, ...(safe ? {} : { noAskReason }) };
475
+ }
462
476
 
463
- function classify(id) {
464
- const lowered = String(id).toLowerCase();
465
- for (const [family, patterns] of FAMILY_RULES) {
466
- for (const pattern of patterns) if (pattern.test(lowered)) return family;
467
- }
468
- return null; // 미분류 — fail-closed 로 제외한다.
469
- }
470
-
471
- // 좌석 모델: 저자가 자기 CLI 에 이미 설정해 둔 모델이 계열 필터를 통과했으면 그것을 쓰고,
472
- // 아니면 통과 후보의 첫 항목을 쓴다. 어느 쪽도 이 파일에 모델 ID 를 박지 않는다.
473
- //
474
- // 단, codex 좌석이 함께 서면 그 선호를 그대로 따르면 안 된다 — preferred 의 출처가 바로 codex 의
475
- // config 이므로, 두 좌석이 같은 모델을 두 전송 경로로 부르면서 좌석 수만 2가 된다. 보고의 "외부 2"
476
- // 는 독립 검사 2건으로 읽히는데 모델 축에서는 하나다(승인 게이트가 넓어지지는 않지만 보고가
477
- // 실제보다 독립적으로 읽힌다 — 이 스킬이 defect 로 다루는 종류다).
478
- // 사다리: 다른 계열 → (없으면) 같은 계열의 다른 모델 → (없으면) 같은 모델. 내려간 단계는 pick 이
479
- // 담고 호출부가 note 로 드러낸다. codex 좌석이 없으면 가를 상대가 없으므로 종전 동작 그대로다.
480
- function pickSeatModel(candidates, preferred, avoid) {
481
- if (!avoid) {
482
- const hit = preferred ? candidates.find((row) => row.id === preferred) : null;
483
- return { id: hit ? hit.id : candidates[0].id, pick: 'unconstrained' };
484
- }
485
- const otherFamily = candidates.find((row) => row.family !== avoid.family);
486
- if (otherFamily) return { id: otherFamily.id, pick: 'independent-family' };
487
- const otherModel = candidates.find((row) => row.id !== avoid.model);
488
- if (otherModel) return { id: otherModel.id, pick: 'same-family-different-model' };
489
- return { id: candidates[0].id, pick: 'same-model' };
490
- }
491
-
492
- // 후보 목록은 계열별로 상한을 둔다. 카탈로그가 수백 개인 환경에서 전량을 내면 보고가 못 읽을
493
- // 크기가 되기 때문인데, 상한을 계열별로 걸어 두면 어떤 계열이든 존재하면 반드시 목록에 나타난다
494
- // — 저자 계열이 필터를 새어 나오는 회귀(AC-P1.4 가 보는 것)를 잘라내지 않는다. 전체 수는
495
- // candidateCount 가 따로 담는다.
496
- function sampleCandidates(candidates, keepId) {
497
- const perFamily = new Map();
498
- const out = [];
499
- for (const row of candidates) {
500
- const seen = perFamily.get(row.family) || 0;
501
- if (seen < CANDIDATES_PER_FAMILY || row.id === keepId) {
502
- perFamily.set(row.family, seen + 1);
503
- out.push(row);
504
- }
505
- }
506
- return out;
477
+ const codexConfigPath = (world) => join(world.env.CODEX_HOME || join(world.home, ".codex"), "config.toml");
478
+ const CODEX_MANAGED = "/etc/codex/managed_config.toml";
479
+ const CODEX_REQUIREMENTS = "/etc/codex/requirements.toml";
480
+
481
+ // codex's config files the probe can read, highest precedence first (codex 0.144.5): the legacy
482
+ // managed file, the user's config.toml, the system one. MDM and cloud layers are not files here.
483
+ function codexConfigFiles(world) {
484
+ const user = codexConfigPath(world);
485
+ return world.platform === "win32" ? [user] : [CODEX_MANAGED, user, "/etc/codex/config.toml"];
486
+ }
487
+
488
+ // Those layers as one: the first layer with a value wins, MCP servers add up.
489
+ function codexConfig(world) {
490
+ const layers = codexConfigFiles(world).map((f) => readToml(world.readFile(f)));
491
+ const pick = (key) => layers.map((l) => l.values[key]).find((v) => v !== undefined);
492
+ return {
493
+ cfg: { model: pick("model"), model_provider: pick("model_provider"), profile: pick("profile") },
494
+ mcpServers: [...new Set(layers.flatMap((l) => l.mcpServers))],
495
+ mcpUnread: layers.some((l) => l.mcpUnread),
496
+ modelKeys: layers.some((l) => l.modelKeys),
497
+ broken: layers.some((l) => l.broken),
498
+ };
499
+ }
500
+
501
+ // What `-c mcp_servers.<name>.enabled=false` can address: codex splits the key on dots.
502
+ const MCP_NAME = /^[A-Za-z0-9_-]+$/;
503
+
504
+ // The unseated seat when the seat command could not turn every MCP server off (a name `-c`
505
+ // cannot address, servers the probe could not list, a config it could not read to the end);
506
+ // null otherwise. An MCP tool runs outside codex's read-only sandbox.
507
+ function mcpBlockedSeat(seat, conf, notes) {
508
+ const bad = conf.mcpServers.filter((n) => !MCP_NAME.test(n)).length;
509
+ if (!bad && !conf.mcpUnread && !conf.broken) return null;
510
+ if (bad) notes.push(`${bad} MCP server name(s) hold characters \`-c mcp_servers.<name>.enabled=false\` cannot address: rename them to letters, digits, _ and -`);
511
+ if (conf.mcpUnread) notes.push("the probe could not list every MCP server the codex config defines (one inline mcp_servers table, or a name its walk did not place): write each server as its own [mcp_servers.<name>] table");
512
+ if (conf.broken) notes.push("the probe could not read the codex config to its end (a multi-line string, array or inline table left open), so its list of MCP servers may be short");
513
+ return { ...seat, decision: "unseated", reason: "cli-call-failed" };
514
+ }
515
+
516
+ // What the seat command turns off or pins with -c, --disable and -s. codex loads its legacy
517
+ // managed file last, over -c (0.144.5: "Overridden by legacy managed_config.toml"), so such a key
518
+ // there undoes the seat's guard.
519
+ const SEAT_KEYS = ["notify", "otel", "mcp_servers", "web_search", "tools", "analytics", "features", "hooks",
520
+ "sandbox_mode", "sandbox_workspace_write", "approval_policy"];
521
+
522
+ // The unseated seat when the managed file sets one of those keys; null otherwise.
523
+ function managedBlockedSeat(seat, world, notes) {
524
+ if (world.platform === "win32") return null;
525
+ const keys = readToml(world.readFile(CODEX_MANAGED)).roots.filter((k) => SEAT_KEYS.includes(k));
526
+ if (!keys.length) return null;
527
+ notes.push(`${CODEX_MANAGED} sets ${keys.join(", ")}, which codex applies over the seat's -c flags: the seat cannot hold its guards`);
528
+ return { ...seat, decision: "unseated", reason: "cli-call-failed" };
529
+ }
530
+
531
+ // What the seat sets that a requirements file may allow, and keys that hold no seat guard. codex
532
+ // holds /etc/codex/requirements.toml over -c, --disable and -s and runs the required value
533
+ // instead (0.144.5 under a fake /etc: a pinned features.shell_tool brings exec_command back under
534
+ // --disable shell_tool, a disallowed approval policy falls back with a warning, a sandbox list
535
+ // without read-only stops every run). Any other key could do the same to a guard.
536
+ const SEAT_ALLOWS = { allowed_sandbox_modes: "read-only", allowed_approval_policies: "never", allowed_web_search_modes: "disabled" };
537
+ const GUARD_FREE = ["enforce_residency"];
538
+
539
+ // The unseated seat when the requirements file could undo a seat guard; null otherwise.
540
+ function requirementsBlockedSeat(seat, world, notes) {
541
+ if (world.platform === "win32") return null;
542
+ const text = world.readFile(CODEX_REQUIREMENTS);
543
+ if (text === null || text === undefined) return null;
544
+ const read = readToml(text);
545
+ const allows = (k) => SEAT_ALLOWS[k] !== undefined && (read.arrays[k] ?? []).includes(SEAT_ALLOWS[k]);
546
+ const keys = read.roots.filter((k) => !GUARD_FREE.includes(k) && !allows(k));
547
+ if (!keys.length && !read.broken) return null;
548
+ notes.push(`${CODEX_REQUIREMENTS} ${keys.length ? `sets ${keys.join(", ")}` : "could not be read to its end"}, which codex holds over the seat's -c, --disable and -s flags: the seat cannot hold its guards`);
549
+ return { ...seat, decision: "unseated", reason: "cli-call-failed" };
550
+ }
551
+
552
+ function codexSeatBase(world, runtime) {
553
+ const present = Boolean(world.which("codex"));
554
+ if (runtime === "codex") return { cli: "codex", present, decision: "unseated", reason: "self-runtime" };
555
+ if (!runtime) return { cli: "codex", present, decision: "unseated", reason: "runtime-unknown" };
556
+ if (!present) return absent("codex");
557
+ return null;
507
558
  }
508
559
 
509
- function catalogIds(body) {
510
- let parsed;
511
- try { parsed = JSON.parse(body); } catch { return null; }
512
- const rows = Array.isArray(parsed) ? parsed : (parsed && Array.isArray(parsed.data) ? parsed.data : null);
513
- if (!rows) return null;
514
- const ids = [];
515
- for (const row of rows) {
516
- const id = row && typeof row === 'object' ? row.id : row;
517
- if (typeof id === 'string' && id) ids.push(id);
560
+ // True when `version` (its first x.y.z) is at least `floor`.
561
+ function atLeast(version, floor) {
562
+ const v = /(\d+)\.(\d+)\.(\d+)/.exec(String(version ?? ""));
563
+ if (!v) return false;
564
+ for (let i = 0; i < 3; i += 1) {
565
+ const d = Number(v[i + 1]) - floor[i];
566
+ if (d !== 0) return d > 0;
518
567
  }
519
- return ids;
568
+ return true;
520
569
  }
521
570
 
522
- // ── 저자 계열 도출 ───────────────────────────────────────────────────────────────────────────
571
+ // codex 0.144.5 refuses every run while config.toml keeps a top-level `profile` key.
572
+ const LEGACY_PROFILE_REFUSED_FROM = [0, 144, 5];
573
+
574
+ // The adopt-or-ask call for codex once its catalog's top model and the configuration are known.
575
+ // `ownModel`: config.toml sets a top-level model, whether or not it may be used. `cliDefault`:
576
+ // what running without a model flag may stand for, null when the config names any model.
577
+ function codexDecision(seat, { top, configured, ownModel, cliDefault, customProvider, unsure }) {
578
+ if (unsure) return ask(seat, [top, configured], configured, "config-not-understood");
579
+ if (!top) return ask(seat, [configured], configured ?? cliDefault, "no-catalog");
580
+ if (configured === top) return adopt(seat, top, "catalog-top-is-configured");
581
+ if (!ownModel && !customProvider) return adopt(seat, top, "catalog-top");
582
+ if (!ownModel) return ask(seat, [top], cliDefault, "custom-provider");
583
+ return ask(seat, [top, configured], configured, "top-differs-from-configured");
584
+ }
523
585
 
524
- function detectAuthorRuntime(env) {
525
- if (CLAUDE_RUNTIME_ENVS.some((name) => env[name])) return 'claude';
526
- if (CODEX_RUNTIME_ENVS.some((name) => env[name])) return 'codex';
586
+ // A configured model a seat may run: not the author's family (noted when it is).
587
+ function vetConfigured(raw, authorFamilies, notes) {
588
+ if (!raw) return null;
589
+ if (!isAuthorFamily(raw, authorFamilies)) return raw;
590
+ notes.push(`the configured model is of the author's family (${raw}): it is neither offered nor run as a fallback`);
527
591
  return null;
528
592
  }
529
593
 
530
- // ── 조립 ─────────────────────────────────────────────────────────────────────────────────────
594
+ // What codex's config says about a seat run, for codexDecision.
595
+ function codexRunFacts({ cfg, modelKeys }) {
596
+ return {
597
+ ownModel: Boolean(cfg.model),
598
+ // A model key in any table (a profile, say) may be what codex runs without -m.
599
+ cliDefault: modelKeys ? null : CLI_DEFAULT,
600
+ customProvider: Boolean(cfg.model_provider && cfg.model_provider !== "openai"),
601
+ unsure: Boolean(cfg.profile),
602
+ };
603
+ }
604
+
605
+ // The unseated seat when this codex refuses the config's legacy `profile` key; null otherwise.
606
+ function legacyProfileSeat(seat, cfg, notes) {
607
+ if (!cfg.profile || !atLeast(seat.version, LEGACY_PROFILE_REFUSED_FROM)) return null;
608
+ notes.push("config.toml has a top-level `profile` key, which this codex refuses for every run; move it to `--profile <name>` with <name>.config.toml");
609
+ return { ...seat, decision: "unseated", reason: "cli-call-failed" };
610
+ }
531
611
 
532
- async function run(argv, env) {
533
- const startedAt = Date.now();
534
- const args = parseArgs(argv);
612
+ function codexSeat(flag, { world, runtime, authorFamilies }) {
613
+ const early = codexSeatBase(world, runtime);
614
+ if (early) return early;
535
615
  const notes = [];
536
- if (args.unknown.length) notes.push('알 수 없는 인자를 무시했다: ' + args.unknown.join(' '));
616
+ const conf = codexConfig(world);
617
+ const configured = vetConfigured(safeOnly([conf.cfg.model], "config.toml", notes)[0], authorFamilies, notes);
618
+ const catalog = readCatalog(world.run("codex", ["debug", "models", "--bundled"]), notes) ?? [];
619
+ const sorted = sortModels([...catalog, configured], authorFamilies);
620
+ const version = firstLine(world.run("codex", ["--version"]));
621
+ const seat = { cli: "codex", present: true, version, configured, candidates: sorted.independent, unclassified: sorted.unclassified, mcpServers: conf.mcpServers };
622
+ const refused = legacyProfileSeat(seat, conf.cfg, notes) ?? managedBlockedSeat(seat, world, notes)
623
+ ?? requirementsBlockedSeat(seat, world, notes) ?? mcpBlockedSeat(seat, conf, notes);
624
+ if (refused) return withNotes(refused, notes);
625
+ const top = sortModels(catalog, authorFamilies).independent[0] ?? null;
626
+ const decided = flagged(seat, flag, authorFamilies) ?? codexDecision(seat, { top, configured, ...codexRunFacts(conf) });
627
+ return withNotes(decided, notes);
628
+ }
537
629
 
538
- // 송신 게이트. 좌석 계산보다 먼저 서 있어야 한다 — 아래 T2·GA2 가 이 값을 보고 건너뛴다.
539
- const externalOff = args.external === 'off';
540
- let httpRequests = 0; // 이 실행이 실제로 낸 HTTP 요청 수. 미실행 주장의 근거다.
541
- if (externalOff) {
542
- notes.push('--external=off — HTTP 를 한 건도 내지 않았다. 관측 블록의 api·geminiApi 는 측정값이 아니라 미실행 표시다(probed:false · status:null).');
543
- }
630
+ function geminiSystemSettings(world) {
631
+ if (world.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH) return world.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH;
632
+ if (world.platform === "darwin") return "/Library/Application Support/GeminiCli/settings.json";
633
+ if (world.platform === "win32") return "C:\\ProgramData\\gemini-cli\\settings.json";
634
+ return "/etc/gemini-cli/settings.json";
635
+ }
544
636
 
545
- const timeoutMs = (() => {
546
- const raw = Number.parseInt(env.RALPH_QA_PROBE_TIMEOUT_MS || '', 10);
547
- return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS;
548
- })();
549
-
550
- // 저자 계열: 런타임 도출이 기본, 명시 지정은 시끄러운 예외로 남긴다.
551
- const runtime = detectAuthorRuntime(env);
552
- let authorFamily;
553
- let authorFamilySource;
554
- if (args.authorFamily) {
555
- authorFamily = args.authorFamily;
556
- authorFamilySource = 'flag';
557
- notes.push('저자 계열이 플래그로 override 됐다 — 계열 필터의 신뢰 파라미터를 저자가 준 것이므로 보고에 드러낸다.');
558
- } else {
559
- authorFamily = runtime ? AUTHOR_RUNTIME_FAMILY[runtime] : 'unknown';
560
- authorFamilySource = 'runtime';
561
- if (!runtime) {
562
- notes.push('저자 런타임을 특정하지 못했다 — fail-closed 로 ' + AUTHOR_CAPABLE_FAMILIES.join('·') + ' 두 계열을 모두 후보에서 제외한다.');
563
- }
564
- }
565
- // 저자 계열을 특정하지 못하면 저자일 수 있는 계열을 모두 배제한다(fail-closed).
566
- const excludedFamilies = authorFamily === 'unknown' ? AUTHOR_CAPABLE_FAMILIES.slice() : [authorFamily];
567
-
568
- // T0 / T0-b.
569
- const transport = detectTransport(env);
570
- const codexPresent = Boolean(whichExecutable('codex', env));
571
- const geminiPresent = Boolean(whichExecutable('gemini', env));
572
- // GA1. CLI 좌석과 크리덴셜 좌석이 같은 env 목록을 보되, 관측은 한 곳에서만 한다.
573
- const geminiApi = resolveGeminiApi(env, notes);
574
- // CLI 좌석(G1)이 보는 것은 "인증 env 가 있는가"이지 "엔드포인트가 해석됐는가"가 아니다 —
575
- // 기본 엔드포인트를 껐다고 gemini CLI 의 자체 인증이 사라지는 것은 아니다.
576
- const geminiAuthEnvPresent = GEMINI_AUTH_ENVS.some((name) => (env[name] || '').trim());
577
- // 키는 있는데 목적지가 없는 상태를 사유 토큰에서 가르기 위해 필요하다(default-endpoint-off).
578
- const apiKeyEnvPresent = API_KEY_ENVS.some((name) => (env[name] || '').trim());
579
-
580
- if (codexPresent) {
581
- notes.push('codex login status·auth.json 은 유효성 근거로 쓰지 않았다 — 실측상 거짓 음성이다(Not logged in 을 출력하면서 codex 는 정상 동작하고 exit code 도 0이라 코드로도 갈리지 않는다). 인증은 provider env 키에서 온다.');
582
- }
583
- if (geminiPresent && !geminiAuthEnvPresent) {
584
- let geminiHome = false;
585
- try { geminiHome = statSync(join(env.HOME || env.USERPROFILE || homedir(), GEMINI_HOME_DIR)).isDirectory(); } catch { geminiHome = false; }
586
- if (geminiHome) {
587
- notes.push('gemini 홈 설정 디렉터리는 존재하나 인증 env 는 부재다 — 프로브는 env 만 인증 신호로 인정한다(fail-closed).');
588
- }
637
+ // gemini's system-defaults layer: $GEMINI_CLI_SYSTEM_DEFAULTS_PATH, or system-defaults.json beside
638
+ // the system settings (0.62.0).
639
+ const geminiSystemDefaults = (world) => world.env.GEMINI_CLI_SYSTEM_DEFAULTS_PATH
640
+ || join(dirname(geminiSystemSettings(world)), "system-defaults.json");
641
+
642
+ // gemini's system, user and system-defaults settings, in the order gemini lets them override
643
+ // (system over user over system-defaults); the user's sit under $GEMINI_CLI_HOME when it is set.
644
+ // Workspace settings are not read: the seat runs in the payload folder, where the
645
+ // repository's do not apply.
646
+ const geminiSettings = (world) => [geminiSystemSettings(world), join(world.env.GEMINI_CLI_HOME || world.home, ".gemini", "settings.json"),
647
+ geminiSystemDefaults(world)]
648
+ .map((file) => ({ file, ...readJsonc(world.readFile(file)) }));
649
+
650
+ // gemini's configured model and which settings files could not be read: $GEMINI_MODEL first,
651
+ // then the settings.
652
+ function geminiConfigured(world, settings, notes) {
653
+ const unreadable = [];
654
+ const pick = (name, where) => safeOnly([name], where, notes)[0] ?? null;
655
+ if (world.env.GEMINI_MODEL) return { configured: pick(world.env.GEMINI_MODEL, "$GEMINI_MODEL"), unreadable };
656
+ for (const read of settings) {
657
+ if (read.unreadable) unreadable.push(read.file);
658
+ const model = read.value?.model;
659
+ const name = typeof model === "string" ? model : model?.name;
660
+ if (name) return { configured: pick(name, read.file), unreadable };
589
661
  }
662
+ return { configured: null, unreadable };
663
+ }
590
664
 
591
- // T1.
592
- const api = resolveApi(env, notes);
593
- const hasKey = Boolean(String(api.key || '').trim());
594
-
595
- // T2 + T3. 요청을 내지 않은 경로는 status·live 를 null 로 둔다 — 0/false 로 적으면 "쟀는데
596
- // 죽었다"와 구분이 사라진다. 낸 경우에만 probed:true 이고 그때부터 숫자가 의미를 갖는다.
597
- let status = null;
598
- let live = null;
599
- let apiProbed = false;
600
- let apiReason = null;
601
- let candidates = [];
602
- let unclassified = 0;
603
- let catalogClassified = 0;
604
-
605
- if (externalOff) {
606
- apiReason = 'external-off'; // 미실행. 아래 좌석 게이트는 별도로 author-off 를 적는다.
607
- } else if (!api.configured) {
608
- apiReason = 'not-configured';
609
- } else if (!api.keyEnv || !hasKey) {
610
- apiReason = 'no-key'; // T1 에서 무효 — env 키가 비어 있으면 T2 를 낼 이유가 없다.
611
- } else {
612
- const result = await probeModels(api.baseUrl, api.key, timeoutMs, isExplicitSource(api.source));
613
- httpRequests += result.requests;
614
- apiProbed = true;
615
- live = false;
616
- status = result.status;
617
- apiReason = statusReason(result.status, result.reason);
618
- if (!apiReason) {
619
- const ids = catalogIds(result.body);
620
- if (!ids) {
621
- apiReason = 'catalog-unreadable';
622
- } else {
623
- for (const id of ids) {
624
- const family = classify(id);
625
- if (!family) { unclassified += 1; continue; } // 미분류는 fail-closed 로 제외.
626
- catalogClassified += 1;
627
- if (excludedFamilies.indexOf(family) >= 0) continue;
628
- candidates.push({ id, family });
629
- }
630
- if (catalogClassified === 0) apiReason = 'unclassifiable-catalog';
631
- else if (candidates.length === 0) apiReason = 'no-independent-model';
632
- else live = true;
633
- }
634
- }
635
- }
665
+ function geminiPoliciesDir(world) {
666
+ if (world.platform === "darwin") return "/Library/Application Support/GeminiCli/policies";
667
+ if (world.platform === "win32") return "C:\\ProgramData\\gemini-cli\\policies";
668
+ return "/etc/gemini-cli/policies";
669
+ }
636
670
 
637
- // GA2. 크리덴셜이 있으면 잰다 — CLI 존재 여부·저자 플래그와 무관하게 관측은 관측이다.
638
- // 응답 본문은 파싱하지 않는다: OpenAI 호환 카탈로그가 아니라 계열 필터(T3)를 돌릴 대상이 아니고,
639
- // 이 사다리에서 2xx 는 곧 "이 크리덴셜로 이 엔드포인트가 산다"는 뜻이다.
640
- let geminiApiStatus = null;
641
- let geminiApiLive = null;
642
- let geminiApiProbed = false;
643
- let geminiApiReason = null;
644
- if (externalOff) {
645
- geminiApiReason = 'external-off'; // 미실행.
646
- } else if (!geminiApi.configured) {
647
- geminiApiReason = geminiApi.reason; // no-credential(크리덴셜 없음) · not-configured(목적지 없음)
648
- } else {
649
- const url = geminiModelsUrl(geminiApi.baseUrl, geminiApi.key);
650
- if (!url) {
651
- geminiApiReason = 'bad-url'; // base 를 해석하지 못했다 — 요청을 낸 적이 없으므로 status 는 null 이다.
652
- } else {
653
- httpRequests += 1;
654
- const result = await httpGet(url, {}, timeoutMs);
655
- geminiApiProbed = true;
656
- geminiApiStatus = result.status;
657
- geminiApiReason = statusReason(result.status, result.reason);
658
- geminiApiLive = !geminiApiReason;
659
- }
671
+ // gemini ignores --admin-policy while its system policy folder holds a policy (0.62.0), and the
672
+ // seat's read-only tools rest on that flag: such a seat is not seated.
673
+ function geminiPolicySeat(seat, world, notes) {
674
+ const dir = geminiPoliciesDir(world);
675
+ if (!world.listDir(dir).some((f) => f.endsWith(".toml"))) return null;
676
+ notes.push(`gemini ignores --admin-policy while ${dir} holds policies: the seat cannot be held to its read-only tools`);
677
+ return { ...seat, decision: "unseated", reason: "cli-call-failed" };
678
+ }
679
+
680
+ // gemini adds the settings' context.includeDirectories to the seat's workspace and lists them in
681
+ // its first request, and in IDE mode (ide.enabled) it adds the IDE's open files and selection
682
+ // (0.62.0): the seat would no longer keep to its payload folder.
683
+ function geminiWorkspaceSeat(seat, settings, notes) {
684
+ const widened = settings.some((r) => (r.value?.context?.includeDirectories ?? []).length > 0);
685
+ const ide = settings.map((r) => r.value?.ide?.enabled).find((v) => v !== undefined) === true;
686
+ if (!widened && !ide) return null;
687
+ if (widened) notes.push("the gemini settings add folders to the workspace (context.includeDirectories), whose listing would go with the payload: take them out for this run");
688
+ if (ide) notes.push("the gemini settings turn on IDE mode (ide.enabled), which adds the IDE's open files to the request: turn it off for this run");
689
+ return { ...seat, decision: "unseated", reason: "cli-call-failed" };
690
+ }
691
+
692
+ // gemini sends usage statistics (model, session and tool metadata, not the payload) to
693
+ // play.googleapis.com unless the settings turn them off; no flag does.
694
+ function geminiStatsNotes(settings) {
695
+ const set = settings.map((r) => r.value?.privacy?.usageStatisticsEnabled).find((v) => v !== undefined);
696
+ return set === false ? [] : ["gemini sends usage statistics (metadata, not the payload) to play.googleapis.com; set privacy.usageStatisticsEnabled to false in its settings to stop it"];
697
+ }
698
+
699
+ // gemini runs the hooks its settings define in every session, this seat too; no flag turns them off.
700
+ // gemini's own sandbox (tools.sandbox, or GEMINI_SANDBOX, which wins) restarts gemini in a docker or
701
+ // podman container that the admin policy file does not reach, so every tool opens (0.62.0). The
702
+ // seat command turns it off with GEMINI_SANDBOX=false; this says so when it is on.
703
+ function geminiSandboxNotes(settings, env) {
704
+ const fromEnv = String(env.GEMINI_SANDBOX ?? "").trim().toLowerCase();
705
+ const set = settings.map((r) => r.value?.tools?.sandbox).find((v) => v !== undefined);
706
+ const on = fromEnv ? !["0", "false"].includes(fromEnv) : Boolean(set && (typeof set !== "object" || set.enabled));
707
+ return on ? ["gemini's own sandbox is on (tools.sandbox or GEMINI_SANDBOX): the seat command turns it off with GEMINI_SANDBOX=false, as the admin policy does not reach its container"] : [];
708
+ }
709
+
710
+ function geminiHookNotes(settings) {
711
+ const off = settings.some((r) => r.value?.hooksConfig?.enabled === false);
712
+ const hooks = settings.some((r) => Object.keys(r.value?.hooks ?? {}).length > 0);
713
+ return hooks && !off ? ["the gemini settings define hooks, which run in this seat too: no flag turns them off"] : [];
714
+ }
715
+
716
+ // gemini has no catalog to rank; its `pro` alias resolves to the strongest Pro model the
717
+ // account may use, while `auto` routes each request to Pro or Flash.
718
+ function geminiSeat(flag, { world, authorFamilies }) {
719
+ if (!world.which("gemini")) return absent("gemini");
720
+ const notes = [];
721
+ const settings = geminiSettings(world);
722
+ const { configured: raw, unreadable } = geminiConfigured(world, settings, notes);
723
+ const configured = vetConfigured(raw, authorFamilies, notes);
724
+ const sorted = sortModels(["pro", configured], authorFamilies);
725
+ const seat = { cli: "gemini", present: true, configured, candidates: sorted.independent, unclassified: sorted.unclassified };
726
+ notes.push(...unreadable.map((f) => `settings not readable: ${f}`), ...geminiHookNotes(settings), ...geminiStatsNotes(settings),
727
+ ...geminiSandboxNotes(settings, world.env));
728
+ const blocked = geminiPolicySeat(seat, world, notes) ?? geminiWorkspaceSeat(seat, settings, notes);
729
+ if (blocked) return withNotes(blocked, notes);
730
+ const out = flagged(seat, flag, authorFamilies) ?? geminiDecision(seat, configured, unreadable.length > 0);
731
+ return withNotes(out, notes);
732
+ }
733
+
734
+ function geminiDecision(seat, configured, unsure) {
735
+ if (unsure) return ask(seat, ["pro", configured], configured, "config-not-understood");
736
+ if (!configured || configured === "pro") return adopt(seat, "pro", "cli-pro-alias");
737
+ return ask(seat, ["pro", configured], configured, "configured-vs-pro");
738
+ }
739
+
740
+ function managedOpencodeDir(world) {
741
+ if (world.platform === "darwin") return "/Library/Application Support/opencode";
742
+ if (world.platform === "win32") return join(world.env.ProgramData || "C:\\ProgramData", "opencode");
743
+ return "/etc/opencode";
744
+ }
745
+
746
+ // opencode's config sources the seat will run with, lowest precedence first (opencode 1.3.10).
747
+ // $OPENCODE_CONFIG_CONTENT is not one of them: the seat command sets it to its reviewer agent.
748
+ function opencodeSources(world) {
749
+ const env = world.env;
750
+ const dirFiles = (dir) => (dir ? [join(dir, "opencode.jsonc"), join(dir, "opencode.json")] : []);
751
+ const global = join(env.XDG_CONFIG_HOME || join(world.home, ".config"), "opencode");
752
+ return [
753
+ ...["config.json", "opencode.json", "opencode.jsonc"].map((f) => join(global, f)),
754
+ ...(env.OPENCODE_CONFIG ? [env.OPENCODE_CONFIG] : []),
755
+ ...dirFiles(join(world.home, ".opencode")),
756
+ ...dirFiles(env.OPENCODE_CONFIG_DIR),
757
+ ...dirFiles(managedOpencodeDir(world)),
758
+ ];
759
+ }
760
+
761
+ // One opencode config source merged into `merged` as opencode merges them: the last `model`
762
+ // and provider lists win, provider models add up.
763
+ function mergeOpencode(merged, cfg) {
764
+ if (typeof cfg.model === "string" && cfg.model) merged.model = cfg.model;
765
+ if (Array.isArray(cfg.enabled_providers)) merged.enabled = cfg.enabled_providers;
766
+ if (Array.isArray(cfg.disabled_providers)) merged.disabled = cfg.disabled_providers;
767
+ for (const [name, def] of Object.entries(cfg.provider ?? {})) {
768
+ merged.provider[name] = [...new Set([...(merged.provider[name] ?? []), ...Object.keys(def?.models ?? {})])];
660
769
  }
770
+ }
661
771
 
662
- // 좌석 유효성(관측). transport 부재는 API 좌석에만 걸린다 — codex·gemini CLI 는 자기 전송을 쓴다.
663
- const apiSeat = (() => {
664
- if (externalOff) return { valid: false, reason: 'author-off' };
665
- if (!api.configured) return { valid: false, reason: 'not-configured' };
666
- if (transport === 'none') return { valid: false, reason: 'no-transport' };
667
- if (!live) return { valid: false, reason: apiReason };
668
- return { valid: true, reason: null };
669
- })();
670
- const codexSeat = (() => {
671
- if (externalOff) return { valid: false, reason: 'author-off' };
672
- if (!codexPresent) return { valid: false, reason: 'cli-absent' };
673
- if (runtime === 'codex') return { valid: false, reason: 'author-runtime' }; // 저자 자신은 외부 좌석 부적격.
674
- if (!api.configured) return { valid: false, reason: 'provider-unresolved' };
675
- if (!live) return { valid: false, reason: apiReason };
676
- return { valid: true, reason: null };
677
- })();
678
- const geminiSeat = (() => {
679
- if (externalOff) return { valid: false, reason: 'author-off' };
680
- if (!geminiPresent) return { valid: false, reason: 'cli-absent' };
681
- if (!geminiAuthEnvPresent) return { valid: false, reason: 'gemini-no-auth' };
682
- // G2 를 수행하지 않으므로 여기서는 "생존 미확인"까지가 최선이다. 첫 검증 호출이 곧 프로브다.
683
- return { valid: true, reason: null };
684
- })();
685
- // 크리덴셜 좌석은 CLI 좌석을 거치지 않는다 — geminiPresent 를 조건에 넣는 순간 B1(유효한 키를
686
- // 관측해 놓고 cli-absent 로 보고)이 되살아난다.
687
- const geminiApiSeat = (() => {
688
- if (externalOff) return { valid: false, reason: 'author-off' };
689
- if (!geminiApi.configured) return { valid: false, reason: geminiApi.reason };
690
- if (transport === 'none') return { valid: false, reason: 'no-transport' };
691
- if (!geminiApiLive) return { valid: false, reason: geminiApiReason };
692
- return { valid: true, reason: null };
693
- })();
694
-
695
- // 크리덴셜을 관측해 놓고 좌석이 안 서면 그 이유를 반드시 남긴다. 여기서 침묵하면 보고에는
696
- // 좌석 0 만 남고, 읽는 쪽은 그걸 "환경이 아무것도 주지 않았다"로 읽는다.
697
- if (geminiApi.configured && !geminiApiSeat.valid && !externalOff) {
698
- notes.push('gemini 모델 크리덴셜(' + geminiApi.keyEnv + ')은 있으나 좌석이 서지 못했다 — 사유 '
699
- + geminiApiSeat.reason + (geminiApiStatus ? ' (status ' + geminiApiStatus + ')' : '')
700
- + '. gemini CLI 부재는 이 경로와 무관하다.');
772
+ function opencodeConfig(world) {
773
+ const merged = { model: null, provider: {}, enabled: null, disabled: null, unreadable: [] };
774
+ for (const file of opencodeSources(world)) {
775
+ const read = readJsonc(world.readFile(file));
776
+ if (read.unreadable) merged.unreadable.push(file);
777
+ mergeOpencode(merged, read.value ?? {});
701
778
  }
779
+ return merged;
780
+ }
702
781
 
703
- // codex 좌석이 서면 그 좌석이 돌릴 모델의 계열을 api 좌석이 피한다. codex 의 모델은 config 의
704
- // model 키에서 오는데(preferredModel 과 같은 출처), 그것을 확인하지 못하거나 계열 분류에
705
- // 실패하면 fail-closed 로 codex 런타임의 계열을 피한다 — 모르는 채로 안 피하는 쪽이 더 나쁘다.
706
- const codexAvoid = (() => {
707
- if (!codexSeat.valid) return null;
708
- const model = api.preferredModel || null;
709
- const family = model ? classify(model) : null;
710
- if (!family) {
711
- notes.push('codex 좌석이 돌릴 모델의 계열을 특정하지 못해 fail-closed 로 '
712
- + AUTHOR_RUNTIME_FAMILY.codex + ' 계열을 api 좌석에서 피했다 — '
713
- + (model ? 'model=' + model + ' 이 계열 분류표에 걸리지 않는다.'
714
- : 'api 경로가 codex config 를 거치지 않았거나 config 에 model 키가 없다.'));
715
- }
716
- return { model, family: family || AUTHOR_RUNTIME_FAMILY.codex };
717
- })();
718
-
719
- // 좌석(선언). 프로브는 백본을 관측할 수 없고 플래그 값을 되받아 적는다.
720
- const external = [];
721
- if (apiSeat.valid) {
722
- const picked = pickSeatModel(candidates, api.preferredModel, codexAvoid);
723
- external.push({ kind: 'api', model: picked.id, modelPick: picked.pick });
724
- // 사다리를 내려간 사실은 반드시 드러낸다. 침묵하면 보고가 "외부 2 = 독립 2"로 읽힌다.
725
- if (picked.pick === 'same-family-different-model') {
726
- notes.push('api 좌석과 codex 좌석이 같은 계열(' + codexAvoid.family
727
- + ')이다 — 통과 후보에 다른 계열이 없어 모델만 갈랐다. 모델 축 독립은 부분적이다.');
728
- } else if (picked.pick === 'same-model') {
729
- notes.push('api 좌석과 codex 좌석이 같은 모델(' + picked.id
730
- + ')이다 — 통과한 후보가 그것뿐이다. 두 좌석은 전송 경로만 다르고 모델 축에서는 하나다.');
782
+ // opencode's `provider/model` names: the configured one first, then each usable provider's.
783
+ function opencodeModels(cfg) {
784
+ const usable = (p) => (!cfg.enabled || cfg.enabled.includes(p)) && !(cfg.disabled ?? []).includes(p);
785
+ const listed = Object.entries(cfg.provider).filter(([p]) => usable(p)).flatMap(([p, ms]) => ms.map((m) => `${p}/${m}`));
786
+ const own = cfg.model && usable(String(cfg.model).split("/")[0]) ? cfg.model : null;
787
+ return { configured: own, models: [own, ...listed] };
788
+ }
789
+
790
+ // The providers opencode's local copy of the models.dev list knows; null when it cannot be read.
791
+ function catalogProviders(world) {
792
+ const file = join(world.env.XDG_CACHE_HOME || join(world.home, ".cache"), "opencode", "models.json");
793
+ const read = readJsonc(world.readFile(file));
794
+ return read.value ? new Set(Object.keys(read.value)) : null;
795
+ }
796
+
797
+ // The models the config lists are all opencode can run only when enabled_providers names
798
+ // nothing but providers that list their models here and that models.dev (`catalog`) does not know.
799
+ function closedSet(cfg, catalog) {
800
+ if (!cfg.enabled?.length) return false;
801
+ return Boolean(catalog) && cfg.enabled.every((p) => cfg.provider[p]?.length > 0 && !catalog.has(p));
802
+ }
803
+
804
+ // opencode has no ranking: it adopts only when the configured model is the one model it can run.
805
+ // With one known model it asks: `no-catalog` when the models.dev copy could not be read,
806
+ // `open-model-set` when it was and opencode may still run models beyond the one known.
807
+ function opencodeDecision(seat, { configured, unsure, closed, catalog }) {
808
+ const known = seat.candidates;
809
+ if (unsure) return ask(seat, known, configured, "config-not-understood");
810
+ if (known.length === 0) return ask(seat, [], null, "no-candidate", seat.unclassified.length ? "no-default-model" : "no-independent-model");
811
+ if (known.length > 1) return ask(seat, known, configured, "several-candidates");
812
+ if (closed && known[0] === configured) return adopt(seat, configured, "single-candidate");
813
+ return ask(seat, known, configured, catalog ? "open-model-set" : "no-catalog");
814
+ }
815
+
816
+ function opencodeSeat(flag, { world, authorFamilies }) {
817
+ if (!world.which("opencode")) return absent("opencode");
818
+ const notes = [];
819
+ const version = firstLine(world.run("opencode", ["--version"], OPENCODE_QUIET));
820
+ const cfg = opencodeConfig(world);
821
+ const { configured: raw, models } = opencodeModels(cfg);
822
+ const usable = safeOnly(models, "opencode config", notes);
823
+ const configured = vetConfigured(usable.includes(raw) ? raw : null, authorFamilies, notes);
824
+ const sorted = sortModels(usable, authorFamilies);
825
+ const seat = { cli: "opencode", present: true, version, configured, candidates: sorted.independent, unclassified: sorted.unclassified };
826
+ notes.push(...cfg.unreadable.map((f) => `config not readable: ${f}`));
827
+ if (world.env.OPENCODE_CONFIG_CONTENT) notes.push("$OPENCODE_CONFIG_CONTENT is not read: the seat command replaces it with its own reviewer agent");
828
+ const catalog = catalogProviders(world);
829
+ const closed = closedSet(cfg, catalog);
830
+ const out = flagged(seat, flag, authorFamilies) ?? opencodeDecision(seat, { configured, unsure: cfg.unreadable.length > 0, closed, catalog });
831
+ return withNotes(out, notes);
832
+ }
833
+
834
+ const SEATS = { codex: codexSeat, gemini: geminiSeat, opencode: opencodeSeat };
835
+
836
+ // Two adopted seats on one model, provider prefix aside: one model axis, not two.
837
+ function sameModelNotes(seats) {
838
+ const bare = (m) => String(m).toLowerCase().split("/").pop();
839
+ const adopted = seats.filter((s) => s.decision === "adopt");
840
+ const notes = [];
841
+ for (let i = 0; i < adopted.length; i += 1) {
842
+ for (let j = i + 1; j < adopted.length; j += 1) {
843
+ if (bare(adopted[i].model) === bare(adopted[j].model)) notes.push(`same model on two seats: ${adopted[i].cli} and ${adopted[j].cli} (${adopted[i].model})`);
731
844
  }
732
845
  }
733
- if (codexSeat.valid) external.push({ kind: 'codex' });
734
- if (geminiSeat.valid) external.push({ kind: 'gemini', unverifiedPath: true });
735
- if (geminiApiSeat.valid) external.push({ kind: 'gemini-api' });
736
-
737
- // 사유 토큰은 ABSENT_REASONS 의 닫힌 정의역에서만 고른다.
738
- const externalAbsentReason = (() => {
739
- if (external.length) return null;
740
- if (externalOff) return 'author-off';
741
- if (api.configured) {
742
- if (transport === 'none') return 'no-transport';
743
- if (apiReason === 'unclassifiable-catalog') return 'unclassifiable-catalog';
744
- if (apiReason === 'no-independent-model') return 'no-independent-model';
745
- // 프로브를 돌리지 않았으면 "probe-unseated" 라고 말하지 않는다. 목적지는 설정됐는데 키 env 가
746
- // 비어 T2 를 아예 안 낸 경우(:596 no-key)가 그렇다 — httpRequests 0 · probed:false 인데
747
- // "프로브가 착석시키지 못했다"로 읽히고, <code> 자리에 상태코드가 아닌 문자열이 들어간다.
748
- // 위 gemini 분기 주석이 지목한 "두 번 거짓"과 같은 형태라 같은 규율을 적용한다.
749
- if (!apiProbed) return 'no-credential';
750
- const code = status > 0 ? String(status) : (apiReason || 'unreachable');
751
- return 'probe-unseated status=' + code;
752
- }
753
- // 기본 엔드포인트를 껐어도(configured:false) 크리덴셜은 관측된 그대로다 — 여기서 아래 cli-absent
754
- // 로 흘려보내면 "크리덴셜이 있으면 cli-absent 를 쓰지 않는다"가 옵트아웃 하나로 무너진다.
755
- // 키는 있는데 목적지가 없어서 못 선 경우 — 그 목적지를 없앤 것이 저자의 옵트아웃이라면 원인은
756
- // 저자다. 크리덴셜 유무와 무관하게 cli-absent 가 나오던 것이 N3 였다.
757
- // 이 검사는 아래 gemini 분기보다 **먼저** 와야 한다. 뒤에 두면 옵트아웃이 원인인데도 gemini
758
- // 분기가 먼저 잡아 `probe-unseated status=not-configured` 를 내는데, 그건 두 번 거짓이다 —
759
- // HTTP 를 한 건도 안 냈으므로 "probe-unseated" 가 아니고(probed:false), <code> 자리에 상태코드가
760
- // 아닌 문자열이 들어가며, 환경 계열로 분류돼 저자 설정이 원인인 것을 환경 탓으로 보고한다.
761
- if (noDefaultEndpoint(env) && (apiKeyEnvPresent || geminiAuthEnvPresent)) return 'default-endpoint-off';
762
- if (geminiApi.configured || (geminiAuthEnvPresent && geminiApi.reason === 'not-configured')) {
763
- // 환경이 유효한 크리덴셜을 준 이상 cli-absent 는 거짓말이다("환경이 경로를 안 줬다"는 뜻이라
764
- // 보고에서 APPROVE(모델축 미커버 — 환경)로 매핑된다). 실패한 지점을 그대로 적는다.
765
- if (transport === 'none') return 'no-transport';
766
- // api 가지와 같은 규율: 프로브를 실제로 내지 않았으면 probe-unseated 라고 말하지 않는다.
767
- // 여기 도달하는 경로는 형식이 깨진 RALPH_QA_GEMINI_BASE_URL override(bad-url) 처럼 목적지
768
- // 해석 자체가 실패한 경우다 — 그때도 httpRequests 는 0 이고 <code> 는 상태코드가 아니다.
769
- if (!geminiApiProbed) return 'no-credential';
770
- const code = geminiApiStatus > 0 ? String(geminiApiStatus) : (geminiApiReason || 'unreachable');
771
- return 'probe-unseated status=' + code;
772
- }
773
- if (geminiPresent && !geminiAuthEnvPresent) {
774
- // 닫힌 정의역에 gemini-no-auth 가 없다 — 사용 가능한 외부 경로가 없다는 뜻으로 cli-absent 를
775
- // 쓰고, 정확한 사유는 좌석 레코드(observed.external.gemini.reason)와 이 note 가 담는다.
776
- notes.push('gemini 는 PATH 에 있으나 인증 env 가 없어 착석 전 무효다(G1) — 좌석 사유는 gemini-no-auth 다.');
777
- }
778
- return 'cli-absent';
779
- })();
846
+ return notes;
847
+ }
848
+
849
+ // The author's families. A Codex author also runs the family its config.toml names (a local
850
+ // model through a custom provider, say), so a seat on that family would be the author again.
851
+ function authorFamiliesFor(rt, world, notes) {
852
+ const base = AUTHOR_FAMILIES[rt] ?? UNKNOWN_AUTHOR;
853
+ if (rt !== "codex") return base;
854
+ const model = codexConfig(world).cfg.model;
855
+ const family = model ? familyOf(model) : null;
856
+ if (model && !family) notes.push("the Codex session's configured model has no known family: a seat running that model is not recognised as the author's");
857
+ return family && !base.includes(family) ? [...base, family] : base;
858
+ }
859
+
860
+ const knownRuntime = (runtime) => (runtime === "claude" || runtime === "codex" ? runtime : null);
861
+ const unknownRuntimeNote = (runtime) => `runtime unknown${runtime ? ` (got "${runtime}")` : ""}: `
862
+ + "claude and gpt are both left out and codex is not seated; pass --runtime=claude|codex";
863
+
864
+ // The seat for one requested CLI; a flag value that is not a safe model name is dropped first.
865
+ function seatFor(name, value, ctx, notes) {
866
+ const flag = safeOnly([value], `--${name}`, notes)[0] ?? null;
867
+ return SEATS[name](flag, ctx);
868
+ }
869
+
870
+ export function probe({ requested, runtime, unknown = [], world = realWorld() }) {
871
+ const rt = knownRuntime(runtime);
872
+ const notes = [];
873
+ const authorFamilies = authorFamiliesFor(rt, world, notes);
874
+ const out = { runtime: rt ?? "unknown", authorFamilies, seats: [], notes };
875
+ if (!rt) notes.push(unknownRuntimeNote(runtime));
876
+ if (unknown.length) notes.push(`arguments not understood: ${unknown.join(" ")}`);
877
+ const names = SEAT_NAMES.filter((n) => n in requested);
878
+ if (names.length === 0) return { ...out, reason: "no-flag" };
879
+ for (const name of names) out.seats.push(seatFor(name, requested[name], { world, runtime: rt, authorFamilies }, notes));
880
+ notes.push(...sameModelNotes(out.seats));
881
+ return out;
882
+ }
780
883
 
781
- const sampled = sampleCandidates(candidates, external.length && external[0].kind === 'api' ? external[0].model : null);
782
- if (sampled.length < candidates.length) {
783
- notes.push('후보 ' + candidates.length + '개 중 ' + sampled.length + '개만 목록에 실었다(계열당 ' + CANDIDATES_PER_FAMILY + '개 상한) — 전체 수는 candidateCount 에 있다.');
884
+ // The executable `name` resolves to on PATH, only from absolute PATH entries (never the current
885
+ // folder, which cmd.exe would search first). Windows tries its executable extensions first.
886
+ function onPath(name) {
887
+ const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
888
+ for (const dir of String(process.env.PATH ?? "").split(delimiter)) {
889
+ if (!dir || !isAbsolute(dir)) continue;
890
+ for (const ext of exts) {
891
+ try {
892
+ const file = join(dir, name + ext);
893
+ if (statSync(file).isFile()) return file;
894
+ } catch {
895
+ /* not here */
896
+ }
897
+ }
784
898
  }
899
+ return null;
900
+ }
785
901
 
902
+ function realWorld() {
786
903
  return {
787
- observed: {
788
- // HTTP 를 한 건도 내지 않았으면 잰 것이 없다 — 숫자를 적으면 "쟀는데 빨랐다"로 읽힌다.
789
- probeMs: httpRequests > 0 ? Date.now() - startedAt : null,
790
- httpRequests, // 미실행 주장의 근거. --external=off 면 언제나 0 이다.
791
- transport,
792
- cli: {
793
- codex: { present: codexPresent },
794
- // liveChecked 는 CLI 좌석(G2)에 대한 것이라 항상 false 다 — GA2 는 CLI 가 아니라 API 를
795
- // 잰 것이므로 여기 값을 올리지 않는다. 그 결과는 observed.geminiApi 가 따로 담는다.
796
- gemini: { present: geminiPresent, authEnvPresent: geminiAuthEnvPresent, liveChecked: false },
797
- },
798
- api: {
799
- configured: api.configured,
800
- source: api.source,
801
- baseUrl: api.baseUrl ? redactUrl(api.baseUrl) : null,
802
- keyEnv: api.keyEnv, // 키 이름만. 값은 어디에도 내지 않는다.
803
- probed: apiProbed, // false 면 아래 status·live 는 측정값이 아니라 미실행 표시다.
804
- status,
805
- live,
806
- reason: apiReason,
807
- },
808
- geminiApi: {
809
- configured: geminiApi.configured,
810
- source: geminiApi.source,
811
- // 요청 URL 이 아니라 base 다 — redactUrl 이 search 를 떼므로 쿼리스트링의 키가 따라오지 않는다.
812
- baseUrl: redactUrl(geminiApi.baseUrl),
813
- keyEnv: geminiApi.keyEnv, // 키 이름만. 값은 어디에도 내지 않는다.
814
- probed: geminiApiProbed, // api.probed 와 같은 뜻이다.
815
- status: geminiApiStatus,
816
- live: geminiApiLive,
817
- reason: geminiApiReason,
818
- },
819
- external: { api: apiSeat, codex: codexSeat, gemini: geminiSeat, geminiApi: geminiApiSeat },
820
- candidates: sampled,
821
- candidateCount: candidates.length,
822
- unclassified, // 0 이어도 키를 생략하지 않는다 — 없으면 보고에서 침묵으로 읽힌다.
904
+ home: homedir(),
905
+ cwd: process.cwd(),
906
+ platform: process.platform,
907
+ env: process.env,
908
+ which: onPath,
909
+ readFile: (path) => {
910
+ try {
911
+ return readFileSync(path, "utf8");
912
+ } catch {
913
+ return null;
914
+ }
915
+ },
916
+ listDir: (path) => {
917
+ try {
918
+ return readdirSync(path);
919
+ } catch {
920
+ return [];
921
+ }
823
922
  },
824
- declared: {
825
- authorFamily,
826
- authorFamilySource,
827
- authorFamilyOverridden: authorFamilySource === 'flag',
828
- seats: {
829
- backbone: args.agents,
830
- sourceIndependent: args.agents >= 1 ? 1 : 0,
831
- external,
832
- },
833
- externalAbsentReason,
923
+ run: (cmd, args, env = {}) => {
924
+ const exe = onPath(cmd);
925
+ if (!exe) return { status: 127, stdout: "" };
926
+ // Windows runs .cmd and .bat shims through cmd.exe; the path is quoted and the arguments are fixed words.
927
+ const viaCmd = process.platform === "win32" && /\.(cmd|bat)$/i.test(exe);
928
+ const r = spawnSync(viaCmd ? `"${exe}"` : exe, args, {
929
+ encoding: "utf8",
930
+ timeout: 15000,
931
+ maxBuffer: 64 * 1024 * 1024,
932
+ shell: viaCmd,
933
+ env: { ...process.env, ...env },
934
+ });
935
+ return { status: r.status, stdout: r.stdout ?? "" };
834
936
  },
835
- notes,
836
937
  };
837
938
  }
838
939
 
839
- // 직접 실행일 때만 돈다 — 테스트가 import 해도 부작용이 없다.
840
- const isMain = (() => {
841
- try { return Boolean(process.argv[1]) && pathToFileURL(process.argv[1]).href === import.meta.url; }
842
- catch { return false; }
843
- })();
940
+ const invokedDirectly = () => {
941
+ try {
942
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
943
+ } catch {
944
+ return false;
945
+ }
946
+ };
844
947
 
845
- if (isMain) {
846
- run(process.argv.slice(2), process.env)
847
- .then((out) => { process.stdout.write(JSON.stringify(out, null, 2) + '\n'); })
848
- .catch(() => { process.stdout.write(JSON.stringify({ observed: null, declared: null, notes: ['probe-crashed'] }, null, 2) + '\n'); });
948
+ if (invokedDirectly()) {
949
+ const { requested, runtime, unknown } = parseArgs(process.argv.slice(2));
950
+ const rt = resolveRuntime(runtime, process.env);
951
+ process.stdout.write(JSON.stringify(probe({ requested, runtime: rt, unknown }), null, 2) + "\n");
849
952
  }
850
-
851
- // resolveApi·resolveGeminiApi 는 HTTP 없이 해석 단계만 in-process 로 검증하기 위해 내보낸다
852
- // (기본 base URL 로 내려가는 경로는 스텁으로 못 가리므로 네트워크 없이 여기서 잠근다).
853
- // probeModels·isExplicitSource 는 재시도 게이트를 스텁 서버로 직접 재기 위해 내보낸다 — 기본
854
- // 엔드포인트 경로는 자식 프로세스로 돌리면 실 벤더 호스트로 나가므로 in-process 로만 잰다.
855
- export {
856
- run, classify, whichExecutable, detectTransport, readTomlFields, resolveApi, resolveGeminiApi,
857
- probeModels, isExplicitSource,
858
- ABSENT_REASONS, FAMILY_RULES, DEFAULT_OPENAI_BASE_URL, DEFAULT_GEMINI_BASE_URL, GEMINI_BASE_URL_ENV,
859
- NO_DEFAULT_ENDPOINT_ENV,
860
- };