therookie 0.4.3

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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +63 -0
  3. package/bin/rookie.js +6 -0
  4. package/package.json +40 -0
  5. package/src/api.js +67 -0
  6. package/src/cli.js +139 -0
  7. package/src/commands/cluster-todos.js +74 -0
  8. package/src/commands/codex-setup.js +36 -0
  9. package/src/commands/context-diet.js +80 -0
  10. package/src/commands/doctor.js +710 -0
  11. package/src/commands/done-scan.js +162 -0
  12. package/src/commands/init.js +175 -0
  13. package/src/commands/link.js +53 -0
  14. package/src/commands/login.js +160 -0
  15. package/src/commands/orphan-extract.js +44 -0
  16. package/src/commands/save.js +91 -0
  17. package/src/commands/scan-specs.js +153 -0
  18. package/src/commands/status.js +44 -0
  19. package/src/commands/sweep-recovery.js +33 -0
  20. package/src/commands/token.js +105 -0
  21. package/src/commands/unlink.js +40 -0
  22. package/src/commands/update.js +220 -0
  23. package/src/config.js +65 -0
  24. package/src/detect.js +109 -0
  25. package/src/lib/account-ui.js +28 -0
  26. package/src/lib/apply-tool-setup.js +89 -0
  27. package/src/lib/codex-config.js +46 -0
  28. package/src/lib/connect-ui.js +59 -0
  29. package/src/lib/context-diet.js +628 -0
  30. package/src/lib/dead-letter.js +73 -0
  31. package/src/lib/deploy-artifacts.js +185 -0
  32. package/src/lib/hook-command.js +31 -0
  33. package/src/lib/manifest.js +87 -0
  34. package/src/lib/merge-hooks.js +31 -0
  35. package/src/lib/orphan-extract-core.js +113 -0
  36. package/src/lib/orphan-pending.js +49 -0
  37. package/src/lib/parse-hook-cwd.js +11 -0
  38. package/src/lib/poll-state.js +14 -0
  39. package/src/lib/redact-lite.js +55 -0
  40. package/src/lib/resolve-endpoint.js +8 -0
  41. package/src/lib/self-update.js +79 -0
  42. package/src/lib/skill-installer.js +55 -0
  43. package/src/lib/spec-scanner.js +85 -0
  44. package/src/lib/sweep-core.mjs +423 -0
  45. package/src/lib/task-path-match.js +122 -0
  46. package/src/prompt.js +60 -0
  47. package/templates/hook/bin/posttool-recall-nudge.sh +96 -0
  48. package/templates/hook/bin/pretool-md-read.sh +107 -0
  49. package/templates/hook/flush-session.sh +314 -0
  50. package/templates/hook/guard-destructive-db.sh +24 -0
  51. package/templates/hook/lib/config-resolve.sh +106 -0
  52. package/templates/hook/lib/cross-runtime-sweep.sh +202 -0
  53. package/templates/hook/lib/dispatcher.sh +423 -0
  54. package/templates/hook/lib/orphan-recovery.sh +491 -0
  55. package/templates/hook/lib/queue.sh +314 -0
  56. package/templates/hook/lib/skill-gate.sh +28 -0
  57. package/templates/hook/lib/transcript.sh +166 -0
  58. package/templates/hook/refresh-context.sh +163 -0
  59. package/templates/hook/session-start.sh +417 -0
  60. package/templates/hook/settings.partial.json +55 -0
  61. package/templates/hook/sweep-recovery.sh +43 -0
  62. package/templates/hook/user-prompt-skill-gate.sh +90 -0
  63. package/templates/launchd/com.rollingmango.rookie-sweep.plist +38 -0
  64. package/templates/skill/SKILL.md +214 -0
  65. package/version.json +4 -0
@@ -0,0 +1,220 @@
1
+ // rookie update — templates 동기화 전용 명령
2
+ // spec post-v5-decisions §18 — hook 자동 동기화
3
+ // PAT 재발급 0건. cfg.token / pending/ / last_success 무변경.
4
+
5
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, cpSync } from 'node:fs'
6
+ import { homedir } from 'node:os'
7
+ import { join, dirname } from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { loadConfig } from '../config.js'
10
+ import { apiGet } from '../api.js'
11
+ import { loadClientManifest, saveClientManifest, compareManifest, computeBundleManifest } from '../lib/manifest.js'
12
+ import { semverGt, fetchReleaseManifest, selfUpdateAndRerun, isNpmManagedInstall } from '../lib/self-update.js'
13
+ import { installSkill } from '../lib/skill-installer.js'
14
+ import { claudeHookCommand, escapeJsonStringValue } from '../lib/hook-command.js'
15
+ import { mergeHooks } from '../lib/merge-hooks.js'
16
+
17
+ const __filename = fileURLToPath(import.meta.url)
18
+ const __dirname = dirname(__filename)
19
+ const TEMPLATES = join(__dirname, '..', '..', 'templates')
20
+ const VERSION_PATH = join(__dirname, '..', '..', 'version.json')
21
+
22
+ export async function runUpdate(flags) {
23
+ const cfg = loadConfig()
24
+ if (!cfg) {
25
+ console.log('✗ ~/.rookie/config.json 없음 — `rookie init` 먼저 실행하세요.')
26
+ process.exit(1)
27
+ }
28
+
29
+ // task 77b93db0 — 자가 갱신 게이트. 서버 릴리스가 더 새 cli_version 이면 자기(구) 번들 템플릿을
30
+ // 설치하면 안 된다 — install.sh 로 재설치 후 새 CLI 의 update 로 위임. manifest 비교(아래
31
+ // "최신" 판정)보다 먼저 와야 hash 가 이미 선기록된 오염 설치본도 탈출한다.
32
+ const { cli_version: localCliVersion, skill_version: localSkillVersion } = JSON.parse(
33
+ readFileSync(VERSION_PATH, 'utf8')
34
+ )
35
+ if (!process.env.ROOKIE_UPDATE_REEXEC) {
36
+ const release = await fetchReleaseManifest(cfg.endpoint)
37
+ if (release && semverGt(release.cli_version, localCliVersion)) {
38
+ // npm(공식 채널) 설치본은 install.sh 자가 갱신을 돌리면 ~/.rookie/cli 에 이중 설치된다 —
39
+ // npm 으로 갱신하도록 안내하고 종료(구 번들 templates 동기화도 하지 않는다).
40
+ if (isNpmManagedInstall()) {
41
+ console.log(`CLI ${localCliVersion} → ${release.cli_version} 새 버전 — npm 으로 갱신하세요:`)
42
+ console.log(` npm i -g therookie@latest`)
43
+ return
44
+ }
45
+ console.log(`CLI ${localCliVersion} → ${release.cli_version} 자가 갱신 (서버 릴리스가 더 새 버전)`)
46
+ const code = await selfUpdateAndRerun({
47
+ endpoint: cfg.endpoint,
48
+ targetVersion: release.cli_version,
49
+ force: flags.force,
50
+ })
51
+ process.exit(code)
52
+ }
53
+ }
54
+
55
+ let serverManifest
56
+ try {
57
+ const status = await apiGet(cfg.endpoint, cfg.token, '/api/rookie/status')
58
+ serverManifest = status.templates
59
+ if (!serverManifest || typeof serverManifest.manifest_hash !== 'string') {
60
+ console.log('✗ 서버 응답에 templates manifest 부재 — 서버 버전이 §18 미반영입니다.')
61
+ process.exit(1)
62
+ }
63
+ } catch (e) {
64
+ console.log(`✗ 서버 연결 실패: ${e.message}`)
65
+ process.exit(1)
66
+ }
67
+
68
+ const local = loadClientManifest()
69
+ const cmp = compareManifest(serverManifest.manifest_hash, local)
70
+
71
+ if (cmp.match && !flags.force) {
72
+ console.log(`✓ 최신 — manifest ${cmp.server_hash.slice(0, 12)}…`)
73
+ return
74
+ }
75
+
76
+ if (!cmp.match) {
77
+ console.log(`hook templates 미스매치 감지`)
78
+ console.log(` 서버: ${cmp.server_hash.slice(0, 12)}…`)
79
+ console.log(` 로컬: ${cmp.local_hash ? cmp.local_hash.slice(0, 12) + '…' : '없음 (옛 install)'}`)
80
+ } else {
81
+ console.log(`--force — 강제 재설치 (manifest 일치 상태)`)
82
+ }
83
+
84
+ // task 77b93db0 — 번들 정합 검증. 자기 번들 템플릿이 서버 선언과 다르면(같은 cli_version 인데
85
+ // 내용이 다른 비정상 상태) 구 템플릿을 덮지 않도록 중단. --force 는 로컬 개발 번들 설치용으로
86
+ // 통과시키되, hash 기록은 아래에서 생략해 "최신" 오판을 만들지 않는다.
87
+ const bundleMatches =
88
+ computeBundleManifest(TEMPLATES, localSkillVersion).manifest_hash === serverManifest.manifest_hash
89
+ if (!bundleMatches && !flags.force) {
90
+ console.log('✗ 로컬 CLI 번들 템플릿이 서버 manifest 와 다릅니다 — 구 템플릿 설치를 중단합니다.')
91
+ console.log(` 재설치: curl -fsSL ${cfg.endpoint}/install.sh | bash`)
92
+ console.log(' (이 번들을 그대로 설치하려면 --force — 단 manifest hash 는 기록되지 않습니다)')
93
+ process.exit(1)
94
+ }
95
+
96
+ // hook 배포 (init.js 와 동일 경로 — 글로벌 ~/.rookie/bin)
97
+ const binDir = join(homedir(), '.rookie', 'bin')
98
+ mkdirSync(binDir, { recursive: true })
99
+ cpSync(join(TEMPLATES, 'hook', 'flush-session.sh'), join(binDir, 'flush-session.sh'))
100
+ cpSync(join(TEMPLATES, 'hook', 'session-start.sh'), join(binDir, 'session-start.sh'))
101
+ chmodSync(join(binDir, 'flush-session.sh'), 0o755)
102
+ chmodSync(join(binDir, 'session-start.sh'), 0o755)
103
+
104
+ // spec 2026-06-18 Phase 3 — cross-runtime sweep 진입점 (launchd/수동).
105
+ cpSync(join(TEMPLATES, 'hook', 'sweep-recovery.sh'), join(binDir, 'sweep-recovery.sh'))
106
+ chmodSync(join(binDir, 'sweep-recovery.sh'), 0o755)
107
+
108
+ // 2026-06-03 — PreToolUse(Bash) 파괴적 DB·git 명령 가드.
109
+ cpSync(join(TEMPLATES, 'hook', 'guard-destructive-db.sh'), join(binDir, 'guard-destructive-db.sh'))
110
+ chmodSync(join(binDir, 'guard-destructive-db.sh'), 0o755)
111
+
112
+ // 2026-06-24 — UserPromptSubmit rookie Skill 로드 게이트(미로드 세션 상기).
113
+ cpSync(join(TEMPLATES, 'hook', 'user-prompt-skill-gate.sh'), join(binDir, 'user-prompt-skill-gate.sh'))
114
+ chmodSync(join(binDir, 'user-prompt-skill-gate.sh'), 0o755)
115
+
116
+ // v6 P1 #5 — Stage 1 측정 hook (PreToolUse).
117
+ const pretoolBinDir = join(binDir, 'bin')
118
+ mkdirSync(pretoolBinDir, { recursive: true })
119
+ cpSync(
120
+ join(TEMPLATES, 'hook', 'bin', 'pretool-md-read.sh'),
121
+ join(pretoolBinDir, 'pretool-md-read.sh')
122
+ )
123
+ chmodSync(join(pretoolBinDir, 'pretool-md-read.sh'), 0o755)
124
+
125
+ // spec 2026-07-10 §3.B2 — PostToolUse recall nudge (교정 전용, 측정 hook 과 분리).
126
+ cpSync(
127
+ join(TEMPLATES, 'hook', 'bin', 'posttool-recall-nudge.sh'),
128
+ join(pretoolBinDir, 'posttool-recall-nudge.sh')
129
+ )
130
+ chmodSync(join(pretoolBinDir, 'posttool-recall-nudge.sh'), 0o755)
131
+
132
+ const libDir = join(binDir, 'lib')
133
+ mkdirSync(libDir, { recursive: true })
134
+ cpSync(join(TEMPLATES, 'hook', 'lib', 'queue.sh'), join(libDir, 'queue.sh'))
135
+ chmodSync(join(libDir, 'queue.sh'), 0o755)
136
+ // spec post-v5-decisions §17.2 H-2.1/H-2.2 — hot write retry + partial failure 재큐 dispatcher
137
+ cpSync(join(TEMPLATES, 'hook', 'lib', 'dispatcher.sh'), join(libDir, 'dispatcher.sh'))
138
+ chmodSync(join(libDir, 'dispatcher.sh'), 0o755)
139
+ // spec 2026-06-18 crash-recovery — transcript 변환 공유 helper + orphan flush recovery
140
+ cpSync(join(TEMPLATES, 'hook', 'lib', 'transcript.sh'), join(libDir, 'transcript.sh'))
141
+ chmodSync(join(libDir, 'transcript.sh'), 0o755)
142
+ cpSync(join(TEMPLATES, 'hook', 'lib', 'orphan-recovery.sh'), join(libDir, 'orphan-recovery.sh'))
143
+ chmodSync(join(libDir, 'orphan-recovery.sh'), 0o755)
144
+ // spec 2026-06-18 Phase 3 — Codex 어댑터 + cross-runtime sweep
145
+ cpSync(join(TEMPLATES, 'hook', 'lib', 'cross-runtime-sweep.sh'), join(libDir, 'cross-runtime-sweep.sh'))
146
+ chmodSync(join(libDir, 'cross-runtime-sweep.sh'), 0o755)
147
+ // 2026-06-24 — skill 로드 게이트 marker/epoch 공용 헬퍼 (session-start 가 source).
148
+ cpSync(join(TEMPLATES, 'hook', 'lib', 'skill-gate.sh'), join(libDir, 'skill-gate.sh'))
149
+ chmodSync(join(libDir, 'skill-gate.sh'), 0o755)
150
+ // B6 (spec 2026-07-11) — config 상향 탐색 + 세션 identity pinning 공용 헬퍼.
151
+ cpSync(join(TEMPLATES, 'hook', 'lib', 'config-resolve.sh'), join(libDir, 'config-resolve.sh'))
152
+ chmodSync(join(libDir, 'config-resolve.sh'), 0o755)
153
+
154
+ // settings.json 재병합 (글로벌)
155
+ const settingsPath = join(homedir(), '.claude', 'settings.json')
156
+ const settingsResult = await mergeSettings(settingsPath, join(TEMPLATES, 'hook', 'settings.partial.json'), {
157
+ flushCmd: claudeHookCommand(join(binDir, 'flush-session.sh')),
158
+ sessionStartCmd: claudeHookCommand(join(binDir, 'session-start.sh')),
159
+ pretoolMdReadCmd: claudeHookCommand(join(pretoolBinDir, 'pretool-md-read.sh')),
160
+ posttoolRecallNudgeCmd: claudeHookCommand(join(pretoolBinDir, 'posttool-recall-nudge.sh')),
161
+ guardCmd: claudeHookCommand(join(binDir, 'guard-destructive-db.sh')),
162
+ skillGateCmd: claudeHookCommand(join(binDir, 'user-prompt-skill-gate.sh')),
163
+ })
164
+
165
+ // v6 P0b — 글로벌 SKILL.md 갱신 (backup-and-overwrite). 같은 본문이면 no-op.
166
+ const skillResult = installSkill({
167
+ scope: 'global',
168
+ version: serverManifest.skill_version,
169
+ installedBy: 'rookie-cli update',
170
+ })
171
+ if (skillResult.installed) {
172
+ console.log(`✓ Skill 갱신 → ${skillResult.path}`)
173
+ if (skillResult.backup) console.log(` · 이전 본문 backup → ${skillResult.backup}`)
174
+ } else {
175
+ console.log(`✓ Skill 본문 변경 없음 (manifest 동기화만)`)
176
+ }
177
+
178
+ console.log(`✓ Hook 동기화: ${binDir}`)
179
+ if (settingsResult?.merged === false) {
180
+ console.log(`⚠ settings.json 이 깨진 JSON 이라 hook 병합을 건너뛰었습니다(원본 보존) — 수동 점검: ${settingsPath}`)
181
+ } else {
182
+ console.log(`✓ settings.json 병합: ${settingsPath}`)
183
+ }
184
+
185
+ // hash 기록은 번들 == 서버 선언일 때만 — 다른 본문을 설치하고 신규 hash 를 선기록하면
186
+ // 이후 update·--force 가 전부 "최신" 오판에 빠진다 (task 77b93db0 원인).
187
+ if (bundleMatches) {
188
+ saveClientManifest(serverManifest)
189
+ console.log(`✓ ~/.rookie/manifest.json 갱신 → ${serverManifest.manifest_hash.slice(0, 12)}…`)
190
+ } else {
191
+ console.log('⚠ 번들 ≠ 서버 manifest — hash 기록 생략 (다음 update 가 미스매치를 다시 감지합니다)')
192
+ }
193
+ }
194
+
195
+ // deploy-artifacts.js 의 mergeSettings 와 동일 동작 (mergeHooks 는 spec 2026-07-10 에서
196
+ // lib/merge-hooks.js 로 단일화 — mergeSettings 본문의 DRY 위배만 잔존, 범위 분리)
197
+ async function mergeSettings(settingsPath, partialPath, subs) {
198
+ // placeholder 는 JSON 문자열 안 — 값을 JSON escape 해야 win32 의 bash "..." 가 JSON.parse 를 안 깬다.
199
+ const partialRaw = readFileSync(partialPath, 'utf8')
200
+ .replaceAll('__FLUSH_SESSION_CMD__', escapeJsonStringValue(subs.flushCmd))
201
+ .replaceAll('__SESSION_START_CMD__', escapeJsonStringValue(subs.sessionStartCmd))
202
+ .replaceAll('__PRETOOL_MD_READ_CMD__', escapeJsonStringValue(subs.pretoolMdReadCmd))
203
+ .replaceAll('__POSTTOOL_RECALL_NUDGE_CMD__', escapeJsonStringValue(subs.posttoolRecallNudgeCmd))
204
+ .replaceAll('__GUARD_DESTRUCTIVE_DB_CMD__', escapeJsonStringValue(subs.guardCmd))
205
+ .replaceAll('__USER_PROMPT_SKILL_GATE_CMD__', escapeJsonStringValue(subs.skillGateCmd))
206
+ const partial = JSON.parse(partialRaw)
207
+
208
+ let current = {}
209
+ if (existsSync(settingsPath)) {
210
+ try { current = JSON.parse(readFileSync(settingsPath, 'utf8')) }
211
+ catch {
212
+ // 깨진 settings.json — 덮어쓰면 사용자 설정 소실. fail-closed: 병합 건너뛰고 보존(P1, codex).
213
+ return { merged: false, reason: 'settings_unparsable' }
214
+ }
215
+ }
216
+ const merged = mergeHooks(current, partial)
217
+ mkdirSync(dirname(settingsPath), { recursive: true })
218
+ writeFileSync(settingsPath, JSON.stringify(merged, null, 2))
219
+ return { merged: true }
220
+ }
package/src/config.js ADDED
@@ -0,0 +1,65 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'node:fs'
2
+ import { homedir, hostname } from 'node:os'
3
+ import { join, dirname, resolve } from 'node:path'
4
+
5
+ const CONFIG_DIR = join(homedir(), '.rookie')
6
+ const CONFIG_PATH = join(CONFIG_DIR, 'config.json')
7
+
8
+ function readJson(path) {
9
+ try { return JSON.parse(readFileSync(path, 'utf8')) }
10
+ catch { return null }
11
+ }
12
+
13
+ // B6-1 (spec 2026-07-11-memory-reliability-fix-design §B6): cwd 상향 탐색 —
14
+ // <dir>/.rookie/config.json. 전역 경로(~/.rookie/config.json) 자체는 로컬로 치지 않는다
15
+ // ($HOME 도달 시 전역과 혼동 방지 — 전역은 아래 resolveConfig 폴백 몫).
16
+ export function findLocalConfigPath(startDir = process.cwd()) {
17
+ let dir = resolve(startDir)
18
+ for (;;) {
19
+ const candidate = join(dir, '.rookie', 'config.json')
20
+ if (candidate !== CONFIG_PATH && existsSync(candidate)) return candidate
21
+ const parent = dirname(dir)
22
+ if (parent === dir) return null
23
+ dir = parent
24
+ }
25
+ }
26
+
27
+ // B6-1 해석 순서: env(ROOKIE_ENDPOINT+ROOKIE_TOKEN 둘 다) > 로컬(상향 탐색) > 전역.
28
+ // 반환: { config, path, scope } — path 는 env 스코프에서 null.
29
+ export function resolveConfig({ cwd = process.cwd(), env = process.env } = {}) {
30
+ if (env.ROOKIE_ENDPOINT && env.ROOKIE_TOKEN) {
31
+ return {
32
+ config: { endpoint: env.ROOKIE_ENDPOINT, token: env.ROOKIE_TOKEN },
33
+ path: null,
34
+ scope: 'env',
35
+ }
36
+ }
37
+ const localPath = findLocalConfigPath(cwd)
38
+ if (localPath) {
39
+ const cfg = readJson(localPath)
40
+ if (cfg) return { config: cfg, path: localPath, scope: 'local' }
41
+ }
42
+ return { config: readJson(CONFIG_PATH), path: CONFIG_PATH, scope: 'global' }
43
+ }
44
+
45
+ export function loadConfig(opts) {
46
+ return resolveConfig(opts).config
47
+ }
48
+
49
+ // 전역 config 만 명시적으로 읽는다 — B6-2 전역 교체 게이트가 로컬 config 에 오염되지 않게.
50
+ export function loadGlobalConfig() {
51
+ return readJson(CONFIG_PATH)
52
+ }
53
+
54
+ // path 미지정 = 전역(기존 호출 무변경). B6-1 의 로컬 저장은 login 이 명시 경로로 호출.
55
+ export function saveConfig(cfg, { path = CONFIG_PATH } = {}) {
56
+ mkdirSync(dirname(path), { recursive: true })
57
+ writeFileSync(path, JSON.stringify(cfg, null, 2))
58
+ try { chmodSync(path, 0o600) } catch {}
59
+ }
60
+
61
+ export function configPath() { return CONFIG_PATH }
62
+ // B6-4: 임의 디렉토리 기준 유효 config 경로(로컬 상향 탐색 > 전역) — sweep partition 판정용.
63
+ export function configPathForCwd(dir) { return findLocalConfigPath(dir) ?? CONFIG_PATH }
64
+ export function localConfigPathFor(dir) { return join(resolve(dir), '.rookie', 'config.json') }
65
+ export function getHostname() { return hostname() }
package/src/detect.js ADDED
@@ -0,0 +1,109 @@
1
+ import { readFileSync, existsSync, readdirSync, accessSync, constants } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ // 통일 연결 흐름(spec 2026-06-29-unified-connect-flow §3 D1): 이 컴퓨터에 깔린 AI 도구를 감지한다.
6
+ // registry 형 — Cursor/Gemini 등은 entry 추가만으로 확장. boolean 반환 금지.
7
+ // 신호: CLI 바이너리 존재 = 'strong'(붙일 의도 강함), 홈 디렉토리만 = 'weak', 없음 = 'none'.
8
+ // 비대화형 자동 설정은 strong 만(false positive 방지), 대화형은 weak 도 확인 경유.
9
+ const TOOL_REGISTRY = [
10
+ { id: 'claude', cliNames: ['claude'], homeDir: '.claude' },
11
+ { id: 'codex', cliNames: ['codex'], homeDir: '.codex' },
12
+ ]
13
+
14
+ // PATH 후보 경로 목록 — platform 별 구분자/확장자를 반영한 순수 함수(테스트 친화).
15
+ // Windows 는 PATH 구분자가 ';' 이고 실행 파일이 claude.cmd·codex.exe 처럼 PATHEXT 확장자를 가진다.
16
+ // path.delimiter 는 실행 OS 에 고정돼 크로스플랫폼 단위 테스트가 안 되므로 platform 인자로 분기한다.
17
+ export function cliCandidatePaths(name, env = process.env, platform = process.platform) {
18
+ const sep = platform === 'win32' ? ';' : ':'
19
+ const dirs = (env.PATH || '').split(sep).filter(Boolean)
20
+ const exts = platform === 'win32'
21
+ ? ['', ...(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)]
22
+ : ['']
23
+ const out = []
24
+ for (const dir of dirs) for (const ext of exts) out.push(join(dir, name + ext))
25
+ return out
26
+ }
27
+
28
+ // PATH 에서 실행 가능한 바이너리 존재 확인. 테스트는 hasCli 를 주입해 우회.
29
+ // 주의: 이 'strong' 신호는 표시·우선순위용일 뿐, 자동 연결의 필수 조건이 아니다 — 연결은 CLI 를
30
+ // 실행하지 않고 ~/.codex/config.toml·~/.claude/skills 만 건드린다. 그래서 비대화 PATH 가 빈약해
31
+ // 바이너리를 못 찾아도(아래 env.PATH 만 사용) 홈 설정 디렉토리(weak)로 자동 연결한다(applyToolSetup).
32
+ // → 환경별 바이너리 위치 추측(homebrew/nvm/login-shell)을 폐기, plain PATH 만 본다.
33
+ function defaultHasCli(name, env = process.env, platform = process.platform) {
34
+ return cliCandidatePaths(name, env, platform).some((p) => {
35
+ try {
36
+ accessSync(p, constants.X_OK)
37
+ return true
38
+ } catch {
39
+ return false
40
+ }
41
+ })
42
+ }
43
+
44
+ export function detectInstalledTools({ home = homedir(), env = process.env, hasCli = defaultHasCli } = {}) {
45
+ return TOOL_REGISTRY.map((t) => {
46
+ const cli = t.cliNames.some((n) => hasCli(n, env))
47
+ const dir = existsSync(join(home, t.homeDir))
48
+ const present = cli || dir
49
+ const signal = cli ? 'strong' : dir ? 'weak' : 'none'
50
+ const reason = cli ? `${t.id} CLI 발견` : dir ? `~/${t.homeDir} 존재` : '없음'
51
+ return { id: t.id, present, signal, reason }
52
+ })
53
+ }
54
+
55
+ export function parseSkillFrontmatter(path) {
56
+ if (!existsSync(path)) return null
57
+ const raw = readFileSync(path, 'utf8')
58
+ const m = raw.match(/^---\n([\s\S]*?)\n---/)
59
+ if (!m) return null
60
+ const out = {}
61
+ for (const line of m[1].split('\n')) {
62
+ const idx = line.indexOf(':')
63
+ if (idx < 0) continue
64
+ const k = line.slice(0, idx).trim()
65
+ const v = line.slice(idx + 1).trim()
66
+ out[k] = v
67
+ }
68
+ return out
69
+ }
70
+
71
+ export function detectGlobalInstall() {
72
+ const path = join(homedir(), '.claude', 'skills', 'rookie', 'SKILL.md')
73
+ const fm = parseSkillFrontmatter(path)
74
+ return fm ? { path, version: fm.version, scope: fm.scope } : null
75
+ }
76
+
77
+ export function detectLocalInstall(cwd = process.cwd()) {
78
+ const path = join(cwd, '.claude', 'skills', 'rookie', 'SKILL.md')
79
+ const fm = parseSkillFrontmatter(path)
80
+ return fm ? { path, version: fm.version, scope: fm.scope } : null
81
+ }
82
+
83
+ export function deepScanForLocalInstalls(rootDirs = [homedir()], maxDepth = 3) {
84
+ const found = []
85
+ for (const root of rootDirs) walk(root, 0, maxDepth, found)
86
+ return found
87
+ }
88
+
89
+ function walk(dir, depth, maxDepth, found) {
90
+ if (depth > maxDepth) return
91
+ let entries
92
+ try { entries = readdirSync(dir, { withFileTypes: true }) }
93
+ catch { return }
94
+ for (const e of entries) {
95
+ if (!e.isDirectory()) continue
96
+ if (e.name.startsWith('.')) {
97
+ if (e.name === '.claude') {
98
+ const candidate = join(dir, '.claude', 'skills', 'rookie', 'SKILL.md')
99
+ if (existsSync(candidate)) {
100
+ const fm = parseSkillFrontmatter(candidate)
101
+ if (fm) found.push({ path: candidate, projectDir: dir, version: fm.version, scope: fm.scope })
102
+ }
103
+ }
104
+ continue
105
+ }
106
+ if (e.name === 'node_modules') continue
107
+ walk(join(dir, e.name), depth + 1, maxDepth, found)
108
+ }
109
+ }
@@ -0,0 +1,28 @@
1
+ // B1 Identity Guard (spec 2026-07-11-memory-reliability-fix-design §B1) — 계정 요약 표시 공용.
2
+ // status/doctor/login/init 이 같은 1줄 포맷과 빈-계정 경고 판정을 쓴다.
3
+
4
+ // 규모 감각용 반올림 (§5 리스크: fact 건수는 반올림 표시 — 예: 4653 → 4.7k).
5
+ export function formatFactsCount(n) {
6
+ if (typeof n !== 'number') return '?'
7
+ if (n >= 1000) return `${Math.round(n / 100) / 10}k`
8
+ return String(n)
9
+ }
10
+
11
+ // '계정: de***@… (프로필 두목) · 프로젝트 12 · 기억 4.7k건' — account 없으면 null.
12
+ export function accountLine(account) {
13
+ if (!account) return null
14
+ const who = account.email_masked ?? account.profile_name ?? null
15
+ if (!who) return null
16
+ const profile = account.email_masked && account.profile_name ? ` (프로필 ${account.profile_name})` : ''
17
+ const projects = typeof account.projects_count === 'number' ? account.projects_count : '?'
18
+ return `계정: ${who}${profile} · 프로젝트 ${projects} · 기억 ${formatFactsCount(account.facts_count)}건`
19
+ }
20
+
21
+ // B1-2 빈-계정 경고: projects 0 && facts < 50. count 미상(null)은 오탐 방지 위해 경고하지 않는다.
22
+ export function accountWarning(account) {
23
+ if (!account) return null
24
+ if (account.projects_count === 0 && typeof account.facts_count === 'number' && account.facts_count < 50) {
25
+ return '⚠️ 이 계정은 비어 있습니다 — 본체 계정이 맞는지 확인하세요 (rookie login)'
26
+ }
27
+ return null
28
+ }
@@ -0,0 +1,89 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { installSharedHookRuntime, applyClaudeSetup, applyCodexSetup } from './deploy-artifacts.js'
5
+ import { ROOKIE_BEGIN } from './codex-config.js'
6
+
7
+ // 도구가 이미 rookie 로 설정됐는지. init 의 도구-추가 모드(early-return 우회) 판정용.
8
+ export function isToolConfigured(id, { scope, home = homedir(), cwd = process.cwd() } = {}) {
9
+ const base = scope === 'global' ? home : cwd
10
+ if (id === 'claude') return existsSync(join(base, '.claude', 'skills', 'rookie', 'SKILL.md'))
11
+ if (id === 'codex') {
12
+ const cfg = join(home, '.codex', 'config.toml')
13
+ return existsSync(cfg) && readFileSync(cfg, 'utf8').includes(ROOKIE_BEGIN)
14
+ }
15
+ return false
16
+ }
17
+
18
+ // 적용 대상이지만(게이트 통과 가능) 아직 rookie 미설정인 도구 목록.
19
+ // init 이 "Claude 있고 Codex 만 추가" 를 early-return 으로 막지 않게 하는 신호(spec §4 item 4, P2).
20
+ // interactive=false(비대화형)면 weak 신호는 applyToolSetup 이 어차피 skip 하므로 pending 에서도 제외 —
21
+ // "추가하겠다" 안내와 실제 동작을 일치시킨다(정직).
22
+ export function pendingToolSetup({ tools, scope, home, cwd } = {}) {
23
+ return (tools ?? []).filter((t) => {
24
+ if (!t.present || t.signal === 'none') return false
25
+ if (t.id === 'codex' && scope !== 'global') return false // D3
26
+ // weak(설정 디렉토리)도 비대화에서 자동 연결되므로 더는 pending 에서 제외하지 않는다(applyToolSetup 정합).
27
+ return !isToolConfigured(t.id, { scope, home, cwd })
28
+ })
29
+ }
30
+
31
+ // 통일 연결 흐름 오케스트레이터(spec 2026-06-29-unified-connect-flow §4 item 2).
32
+ // detectInstalledTools() 결과를 받아 적용 대상을 정하고, 1개+면 공유 런타임(1층)을
33
+ // 먼저 깐 뒤 도구별 adapter(Claude/Codex)를 적용한다. login·init 가 공유한다.
34
+ //
35
+ // 선택 규칙:
36
+ // - present(strong CLI 발견 또는 weak 설정 디렉토리만) → 비대화형에서도 자동 적용.
37
+ // 연결은 CLI 실행이 아니라 ~/.codex·~/.claude 설정 편집이라 weak 도 안전(두목 2026-06-30).
38
+ // - Codex adapter 는 scope=global 에서만(D3 — 전역 Codex↔로컬 hook 충돌 차단).
39
+ //
40
+ // 레이어 함수는 deps 로 주입 가능(테스트용). 기본은 실 구현.
41
+ export async function applyToolSetup({
42
+ tools,
43
+ scope,
44
+ cfg,
45
+ home,
46
+ cwd,
47
+ deps = {},
48
+ } = {}) {
49
+ const installShared = deps.installShared ?? installSharedHookRuntime
50
+ const applyClaude = deps.applyClaude ?? applyClaudeSetup
51
+ const applyCodex = deps.applyCodex ?? applyCodexSetup
52
+
53
+ const applied = []
54
+ const skipped = []
55
+ const toApply = []
56
+
57
+ for (const t of tools ?? []) {
58
+ if (!t.present || t.signal === 'none') { skipped.push({ id: t.id, reason: 'not_present' }); continue }
59
+ // D3: Codex 자동 등록은 scope=global 전제. 로컬에서는 전역 Codex 가 로컬 hook 을 무는 사고가 난다.
60
+ if (t.id === 'codex' && scope !== 'global') { skipped.push({ id: t.id, reason: 'codex_requires_global' }); continue }
61
+ // strong(CLI 발견)·weak(설정 디렉토리만) 모두 자동 연결한다. 연결은 CLI 를 실행하지 않고
62
+ // ~/.codex·~/.claude 설정만 건드리므로, 비대화 PATH 가 빈약해 바이너리를 못 찾아도(weak)
63
+ // 그 도구 사용자다 → 환경 무관 자동 연결(두목 2026-06-30, confirmWeak/interactive 게이트 폐기).
64
+ if (t.signal === 'strong' || t.signal === 'weak') { toApply.push(t); continue }
65
+ skipped.push({ id: t.id, reason: 'unknown_signal' })
66
+ }
67
+
68
+ let sharedInstalled = false
69
+ let sharedBinDir = null
70
+ if (toApply.length >= 1) {
71
+ sharedBinDir = installShared({ scope, home, cwd })?.binDir ?? null
72
+ sharedInstalled = true
73
+ }
74
+
75
+ for (const t of toApply) {
76
+ if (t.id === 'claude') {
77
+ applied.push({ id: 'claude', ...(await applyClaude({ scope, cfg, home, cwd })) })
78
+ } else if (t.id === 'codex') {
79
+ const r = applyCodex({ home })
80
+ if (r?.error) skipped.push({ id: 'codex', reason: r.error })
81
+ else applied.push({ id: 'codex', ...r })
82
+ } else {
83
+ // 미래 도구(Cursor/Gemini 등) — detector 엔 있지만 adapter 미구현. 안전 skip.
84
+ skipped.push({ id: t.id, reason: 'no_adapter' })
85
+ }
86
+ }
87
+
88
+ return { scope, sharedInstalled, sharedBinDir, applied, skipped }
89
+ }
@@ -0,0 +1,46 @@
1
+ // Codex config.toml 의 더루키 SessionStart hook 블록을 동의 기반 append/교체한다.
2
+ // TOML 파서 없이(의존성 0) 마커 블록 텍스트 조작 — 마커 밖 사용자 설정은 보존.
3
+ import { toPosixPath } from './hook-command.js'
4
+
5
+ export const ROOKIE_BEGIN = '# >>> rookie codex hook (managed) — 수동 편집 금지 >>>'
6
+ export const ROOKIE_END = '# <<< rookie codex hook (managed) <<<'
7
+
8
+ function block(hookScriptPath, platform = process.platform) {
9
+ // Codex SessionStart command handler. additionalContext 출력 형식은 Claude 와 동일.
10
+ // Windows 는 backslash 경로를 forward-slash 로 변환(Git Bash 가 실행) — macOS/Linux 무변경.
11
+ const path = toPosixPath(hookScriptPath, platform)
12
+ return [
13
+ ROOKIE_BEGIN,
14
+ '[[hooks.SessionStart]]',
15
+ 'matcher = "startup|resume"',
16
+ '',
17
+ '[[hooks.SessionStart.hooks]]',
18
+ 'type = "command"',
19
+ // B-3: codex_cli 표면을 인자로 전달 → session-start.sh 가 x-rookie-surface 로 비콘.
20
+ // (Codex Desktop 도 같은 config.toml 을 읽어 codex_cli 로 집계 — desktop 세분화는 후속.)
21
+ `command = 'bash "${path}" codex_cli'`,
22
+ 'timeout = 15',
23
+ ROOKIE_END,
24
+ ].join('\n')
25
+ }
26
+
27
+ // 기존 managed 블록이 있으면 교체, 없으면 끝에 append. 마커 밖은 보존.
28
+ export function upsertCodexHookBlock(existingToml, hookScriptPath, platform = process.platform) {
29
+ const fresh = block(hookScriptPath, platform)
30
+ const begin = existingToml.indexOf(ROOKIE_BEGIN)
31
+ if (begin === -1) {
32
+ const base = existingToml.replace(/\s*$/, '')
33
+ return (base ? base + '\n\n' : '') + fresh + '\n'
34
+ }
35
+ const endIdx = existingToml.indexOf(ROOKIE_END, begin)
36
+ if (endIdx === -1) {
37
+ // D4/§4 item 5: begin 만 있고 end 손상 → begin 이후를 무조건 버리면 사용자 config 가 유실된다.
38
+ // managed 영역 끝을 안전하게 못 정하므로 호출자가 경고·중단하도록 신호(파일 미변경).
39
+ const err = new Error('Codex config 의 rookie managed 블록이 손상됨(end marker 없음)')
40
+ err.code = 'MALFORMED_CODEX_MARKER'
41
+ throw err
42
+ }
43
+ const before = existingToml.slice(0, begin)
44
+ const after = existingToml.slice(endIdx + ROOKIE_END.length)
45
+ return before + fresh + after
46
+ }
@@ -0,0 +1,59 @@
1
+ // 통일 연결 흐름 — login·init 공유 출력 렌더링(순수 함수, 테스트 가능).
2
+ // applyToolSetup 리포트를 사람이 읽는 줄 배열로 변환. brand 톤(겸손한 신입, spec §4 item 7).
3
+ export const TOOL_NAMES = { claude: 'Claude Code', codex: 'Codex CLI' }
4
+
5
+ export function toolName(id) {
6
+ return TOOL_NAMES[id] ?? id
7
+ }
8
+
9
+ // 적용 전 감지 결과 한 줄. 감지 0개면 null.
10
+ export function renderDetected(tools) {
11
+ const present = (tools ?? []).filter((t) => t.present)
12
+ if (present.length === 0) return null
13
+ return ` 감지된 도구: ${present.map((t) => `${toolName(t.id)}(${t.reason})`).join(', ')}`
14
+ }
15
+
16
+ // applyToolSetup 리포트 + 감지 결과 → 출력 줄 배열(적용 결과 + 환영/안내).
17
+ // - 1개+ 적용: 환영 + "다른 도구는 rookie init" 안내(수용기준 6)
18
+ // - 감지됐지만 전부 skip: rookie init 안내(PAT 는 저장됨)
19
+ // - 감지 0개: 안내만(수용기준 1)
20
+ export function renderConnectOutcome(report, tools, agentName) {
21
+ // 사용자가 온보딩에서 정한 루키 이름(login 이 device 응답에서 받아 전달). 없으면 기본 'Rookie'.
22
+ const name = (agentName && String(agentName).trim()) || 'Rookie'
23
+ const lines = []
24
+ const applied = report?.applied ?? []
25
+ const skipped = report?.skipped ?? []
26
+ const anyPresent = (tools ?? []).some((t) => t.present)
27
+
28
+ if (report?.sharedBinDir) lines.push(`✓ 공유 hook 런타임: ${report.sharedBinDir}`)
29
+ for (const a of applied) {
30
+ if (a.id === 'claude') {
31
+ lines.push(`✓ ${toolName('claude')} 연결: ${a.skillDir}`)
32
+ if (a.settingsWarning === 'settings_unparsable') {
33
+ lines.push(` ⚠ settings.json 이 깨진 JSON 이라 hook 병합을 건너뛰었어요(원본 보존) — 수동 점검이 필요해요.`)
34
+ }
35
+ } else if (a.id === 'codex') lines.push(`✓ ${toolName('codex')} 연결: ${a.configPath}`)
36
+ else lines.push(`✓ ${toolName(a.id)} 연결`)
37
+ }
38
+ for (const s of skipped) {
39
+ if (s.reason === 'codex_requires_global') {
40
+ lines.push(`ⓘ ${toolName('codex')} 는 전역 연결에서만 자동 등록됩니다 — \`rookie login\`(--local 없이).`)
41
+ } else if (s.reason === 'malformed_marker') {
42
+ lines.push(`⚠ ${toolName('codex')} config 의 managed 블록이 손상돼 자동 등록을 중단했습니다 — config.toml 을 수동 점검하세요(기존 설정은 보존됨).`)
43
+ }
44
+ }
45
+
46
+ if (applied.length > 0) {
47
+ // 연결 성공이면 끝 — 'rookie init' 안내는 띄우지 않는다. init 은 login 과 같은 일이라
48
+ // 노이즈였다(두목 2026-06-30). 나중에 새 도구를 깔면 `rookie login` 을 다시 돌리면 자동 연결된다.
49
+ lines.push('')
50
+ lines.push(`✓ 이제 ${name}가 합류했습니다. 어느 세션에서든 같은 맥락으로 기억을 가져옵니다.`)
51
+ } else if (anyPresent) {
52
+ lines.push('')
53
+ lines.push('ⓘ 자동 연결된 도구가 없습니다 — `rookie login` 을 다시 실행해 보세요. PAT 는 저장됐습니다.')
54
+ } else {
55
+ lines.push('')
56
+ lines.push('감지된 AI 도구가 없습니다(Claude Code·Codex). 도구 설치 후 `rookie login` 을 다시 실행하세요. PAT 는 저장됐습니다.')
57
+ }
58
+ return lines
59
+ }