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,162 @@
1
+ import { execSync } from 'node:child_process'
2
+ import { readFileSync, writeFileSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { loadConfig } from '../config.js'
6
+ import { apiGet } from '../api.js'
7
+ import { matchDoneCandidates } from '../lib/task-path-match.js'
8
+
9
+ // spec docs/superpowers/specs/2026-07-01-completion-linkage-remove-vs-improve.md §5.4.
10
+ // hook(session-start.sh)이 매 세션 실행 → doing task 의 spec_ref 를 최근 커밋과 경로 강매칭 →
11
+ // 완료 후보 compact 1줄만 stdout 으로 낸다(hook 이 additionalContext 로 주입). transition 안 함(게이트 불변).
12
+
13
+ const DAY_MS = 24 * 60 * 60 * 1000
14
+ const MAX_COMMITS = 200
15
+ const MAX_FILES_PER_COMMIT = 50
16
+ // done-scan 은 SessionStart hook 안에서 돈다 — 네트워크 호출이 무한 대기하면 hook timeout(Codex 15s)을
17
+ // 터뜨린다. tasks/context 조회를 짧게 바운드하고, 초과 시 best-effort 로 조용히 skip(완료감지는 부가기능).
18
+ const NET_TIMEOUT_MS = 4000
19
+
20
+ function markerPath(projectId) {
21
+ return join(homedir(), '.rookie', `done-scan-${projectId}.last`)
22
+ }
23
+ function readMarkerReal(projectId) {
24
+ try {
25
+ return JSON.parse(readFileSync(markerPath(projectId), 'utf8'))
26
+ } catch {
27
+ return null
28
+ }
29
+ }
30
+ function writeMarkerReal(projectId, obj) {
31
+ try {
32
+ writeFileSync(markerPath(projectId), JSON.stringify(obj))
33
+ } catch {
34
+ /* best-effort */
35
+ }
36
+ }
37
+
38
+ function gitHeadReal() {
39
+ return execSync('git rev-parse HEAD', { stdio: ['ignore', 'pipe', 'ignore'] })
40
+ .toString()
41
+ .trim()
42
+ }
43
+
44
+ // 최근 MAX_COMMITS 커밋을 {sha,subject,files[]} 로 파싱. --name-only, NUL 구분 record.
45
+ function gitLogReal() {
46
+ // core.quotePath=false → non-ASCII(한글 등) 경로를 이스케이프 없이 그대로 출력(매칭 안정).
47
+ const raw = execSync(
48
+ `git -c core.quotePath=false log --max-count=${MAX_COMMITS} --name-only --pretty=format:%x1e%H%x1f%s`,
49
+ { stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 }
50
+ ).toString()
51
+ const commits = []
52
+ for (const rec of raw.split('\x1e')) {
53
+ if (!rec.trim()) continue
54
+ const nl = rec.indexOf('\n')
55
+ const header = nl === -1 ? rec : rec.slice(0, nl)
56
+ const body = nl === -1 ? '' : rec.slice(nl + 1)
57
+ const [sha, subject] = header.split('\x1f')
58
+ const files = body
59
+ .split('\n')
60
+ .map((s) => s.trim())
61
+ .filter(Boolean)
62
+ .slice(0, MAX_FILES_PER_COMMIT)
63
+ commits.push({ sha: (sha || '').trim(), subject: (subject || '').trim(), files })
64
+ }
65
+ return commits
66
+ }
67
+
68
+ function truncate(s, n) {
69
+ const v = String(s ?? '')
70
+ return v.length > n ? v.slice(0, n - 1) + '…' : v
71
+ }
72
+
73
+ export async function runDoneScan(flags = {}, deps = {}) {
74
+ const {
75
+ loadConfig: _loadConfig = loadConfig,
76
+ apiGet: _apiGet = apiGet,
77
+ gitHead = gitHeadReal,
78
+ gitLog = gitLogReal,
79
+ readMarker = readMarkerReal,
80
+ writeMarker = writeMarkerReal,
81
+ now = Date.now,
82
+ out = (line) => console.log(line),
83
+ } = deps
84
+
85
+ const cfg = _loadConfig()
86
+ if (!cfg) return
87
+
88
+ // project_id: hook 이 이미 fetch 한 context 에서 넘겨주면(--project-id) context 재fetch 생략
89
+ // (이중 조회 방지 + throttle 을 네트워크 0 으로 선검사). 단독 실행(수동)이면 스스로 fetch.
90
+ let projectId = flags['project-id'] || flags.projectId
91
+ if (!projectId) {
92
+ const repoPath = process.cwd()
93
+ let repoUrl = ''
94
+ try {
95
+ repoUrl = execSync('git remote get-url origin', { stdio: ['ignore', 'pipe', 'ignore'] })
96
+ .toString()
97
+ .trim()
98
+ } catch {
99
+ /* git repo 아님/원격 없음 — repo_url 빈 값 */
100
+ }
101
+ let ctx
102
+ try {
103
+ const q = `repo_url=${encodeURIComponent(repoUrl)}&repo_path=${encodeURIComponent(repoPath)}`
104
+ ctx = await _apiGet(cfg.endpoint, cfg.token, `/api/rookie/context?${q}`, { timeoutMs: NET_TIMEOUT_MS })
105
+ } catch {
106
+ return
107
+ }
108
+ projectId = ctx && ctx.current_project && ctx.current_project.id
109
+ }
110
+ if (!projectId) return // 미link
111
+ const project = { id: projectId }
112
+
113
+ // git HEAD (repo 아니면 throw → skip)
114
+ let head
115
+ try {
116
+ head = gitHead()
117
+ } catch {
118
+ return
119
+ }
120
+
121
+ // throttle: 같은 head + 24h 이내면 skip. head 변경 시 우회, --force 무시.
122
+ const marker = readMarker(project.id)
123
+ const nowMs = now()
124
+ const throttled =
125
+ !flags.force &&
126
+ marker &&
127
+ marker.head === head &&
128
+ nowMs - (marker.ts || 0) < DAY_MS
129
+ if (throttled) return
130
+
131
+ // doing tasks
132
+ let tasks = []
133
+ try {
134
+ const r = await _apiGet(
135
+ cfg.endpoint,
136
+ cfg.token,
137
+ `/api/rookie/tasks?project_id=${encodeURIComponent(project.id)}&status=doing`,
138
+ { timeoutMs: NET_TIMEOUT_MS }
139
+ )
140
+ tasks = (r && r.tasks) || []
141
+ } catch {
142
+ return // 조회 실패 시 마커 미기록(다음 기회 재시도)
143
+ }
144
+
145
+ if (tasks.length > 0) {
146
+ let commits
147
+ try {
148
+ commits = gitLog()
149
+ } catch {
150
+ return
151
+ }
152
+ const candidates = matchDoneCandidates(tasks, commits)
153
+ for (const c of candidates) {
154
+ // task id 는 전체 UUID 로 출력 — 차차가 승인 후 PATCH /tasks {id} 에 그대로 쓴다(prefix 로는 PATCH 불가).
155
+ // 두목께는 title 만 노출하고 raw uuid 는 마스킹(§10e). sha 12자 = __done_defer:<sha> prefix 충돌 방지.
156
+ out(`🎯 ${c.taskId} "${truncate(c.taskTitle, 40)}" ← ${c.sha.slice(0, 12)} ${truncate(c.subject, 60)}`)
157
+ }
158
+ }
159
+
160
+ // 산출 성공 후 마커 기록(선-touch 후 crash 방지).
161
+ writeMarker(project.id, { ts: nowMs, head })
162
+ }
@@ -0,0 +1,175 @@
1
+ import { homedir } from 'node:os'
2
+ import { loadConfig, loadGlobalConfig, saveConfig, configPath, getHostname } from '../config.js'
3
+ import { accountLine, accountWarning } from '../lib/account-ui.js'
4
+ import { confirm, ask, askPassword } from '../prompt.js'
5
+ import {
6
+ detectGlobalInstall,
7
+ detectLocalInstall,
8
+ deepScanForLocalInstalls,
9
+ detectInstalledTools,
10
+ } from '../detect.js'
11
+ import { apiGet, apiPostNoAuth } from '../api.js'
12
+ import { applyToolSetup, pendingToolSetup } from '../lib/apply-tool-setup.js'
13
+ import { renderDetected, renderConnectOutcome, toolName } from '../lib/connect-ui.js'
14
+ import { resolveEndpoint } from '../lib/resolve-endpoint.js'
15
+
16
+ async function pickChoice(prompt, options, autoValue) {
17
+ console.log(prompt)
18
+ const keys = Object.keys(options)
19
+ console.log(keys.map((k) => `[${k}]${options[k]}`).join(' '))
20
+ if (autoValue) return autoValue
21
+ const ans = (await ask('선택:')).toUpperCase()
22
+ if (keys.includes(ans)) return ans
23
+ console.log('잘못된 입력 — 취소.')
24
+ return 'C'
25
+ }
26
+
27
+ export async function runInit(flags) {
28
+ const scope = flags.local ? 'local' : 'global'
29
+ const globalExisting = detectGlobalInstall()
30
+ const localExisting = detectLocalInstall()
31
+
32
+ // 통일 연결(spec 2026-06-29 §4 item 4): 깔린 도구 감지 + 미설정 도구(early-return 우회 신호).
33
+ // "Claude 있고 Codex 만 추가" 가 기존 early-return 으로 막히던 P2 결함을 pending 으로 해소.
34
+ const tools = detectInstalledTools()
35
+ // applyToolSetup 와 동일한 대화형 판정 — pending/실제 적용을 일치시킨다.
36
+ const interactive = !!process.stdin.isTTY && !flags.yes
37
+ const pending = pendingToolSetup({ tools, scope, interactive })
38
+
39
+ if (scope === 'global') {
40
+ if (globalExisting && !flags.force) {
41
+ if (pending.length > 0) {
42
+ // 미설정 도구가 있으면 skip 하지 않고 마저 설치(도구-추가 모드).
43
+ console.log(`글로벌 Skill v${globalExisting.version} 유지 — 미설정 도구 추가: ${pending.map((t) => toolName(t.id)).join(', ')}`)
44
+ } else {
45
+ // §18 — first-time install + PAT 발급 본업. templates 동기화는 `rookie update` 로 분기.
46
+ const choice = await pickChoice(
47
+ `글로벌 Skill v${globalExisting.version} 이미 설치됨. templates 동기화는 \`rookie update\` 권장.`,
48
+ { U: '강제 재설치 (--force 동급)', S: '스킵', C: '취소' },
49
+ flags.yes ? 'S' : null
50
+ )
51
+ if (choice === 'S') return
52
+ if (choice === 'C') process.exit(1)
53
+ // U 선택 = force 효과 (이하 분기 force 처럼 동작)
54
+ }
55
+ }
56
+ if (localExisting && !flags.force && !flags.yes) {
57
+ const go = await confirm(
58
+ `현재 폴더에 로컬 Skill(v${localExisting.version})이 있습니다. 로컬이 override합니다. 글로벌 설치 계속할까요?`,
59
+ { defaultYes: false }
60
+ )
61
+ if (!go) return
62
+ }
63
+ } else {
64
+ if (localExisting && !flags.force) {
65
+ if (pending.length > 0) {
66
+ console.log(`로컬 Skill v${localExisting.version} 유지 — 미설정 도구 추가: ${pending.map((t) => toolName(t.id)).join(', ')}`)
67
+ } else {
68
+ const choice = await pickChoice(
69
+ `로컬 Skill이 이미 v${localExisting.version} 설치됨.`,
70
+ { R: '재설치', S: '스킵', C: '취소' },
71
+ flags.yes ? 'R' : null
72
+ )
73
+ if (choice === 'S') return
74
+ if (choice === 'C') process.exit(1)
75
+ }
76
+ }
77
+ if (globalExisting && !flags.force && !flags.yes) {
78
+ const go = await confirm(
79
+ `글로벌 Skill(v${globalExisting.version})이 이미 있습니다. 로컬이 override합니다. 계속?`,
80
+ { defaultYes: false }
81
+ )
82
+ if (!go) return
83
+ }
84
+ }
85
+
86
+ const cfg = loadConfig()
87
+ if (cfg && !flags.force) {
88
+ try {
89
+ const { paths } = await apiGet(
90
+ cfg.endpoint,
91
+ cfg.token,
92
+ `/api/rookie/projects/local-paths?hostname=${encodeURIComponent(getHostname())}`
93
+ )
94
+ const suspicious = paths.filter((p) => p.install_scope === 'local' || p.install_scope === 'both')
95
+ if (suspicious.length > 0) {
96
+ console.log('⚠ DB 기반 스캔 — 이 Mac에 등록된 로컬 설치 경로:')
97
+ for (const p of suspicious) console.log(` - ${p.local_path} (${p.install_scope})`)
98
+ if (!flags.yes) {
99
+ const ok = await confirm('계속 진행할까요?', { defaultYes: true })
100
+ if (!ok) return
101
+ }
102
+ }
103
+ } catch (e) {
104
+ console.log(`(DB 스캔 생략: ${e.message})`)
105
+ }
106
+ }
107
+
108
+ if (flags['deep-scan']) {
109
+ const found = deepScanForLocalInstalls([homedir()], 3)
110
+ if (found.length > 0) {
111
+ console.log('⚠ deep-scan — 홈 디렉토리에서 발견된 로컬 Skill:')
112
+ for (const f of found) console.log(` - ${f.projectDir} (v${f.version})`)
113
+ }
114
+ }
115
+
116
+ // B6-1: bootstrap 은 전역 config 를 쓰고 저장하므로 판정도 전역 기준(로컬 config 가 있어도
117
+ // 전역 미설정이면 bootstrap 진행 — 로컬 binding 이 전역 판정을 가리지 않게).
118
+ let cfg2 = loadGlobalConfig()
119
+ // §18 U-4 — force/PAT 디커플링: --force 는 templates 재설치만 강제하고 PAT 재발급은 안 함.
120
+ // PAT 갱신은 `rookie token rotate` 단일 진입점.
121
+ const needsBootstrap = !cfg2 || !cfg2.user_id
122
+ if (needsBootstrap) {
123
+ // flag(--endpoint=)/env/config 가 있으면 프롬프트 생략, 없으면 ask(기본값 therookie.kr).
124
+ const preset =
125
+ (typeof flags.endpoint === 'string' ? flags.endpoint : undefined)
126
+ ?? process.env.ROOKIE_ENDPOINT
127
+ ?? cfg2?.endpoint
128
+ const endpoint = preset
129
+ ? resolveEndpoint({ flag: preset })
130
+ : await ask('Rookie 앱 엔드포인트', { defaultValue: 'https://therookie.kr' })
131
+ const email = await ask('이메일')
132
+ const password = await askPassword('비밀번호:')
133
+ const defaultLabel = `${getHostname()}`
134
+ const label = await ask('토큰 라벨 (메모)', { defaultValue: defaultLabel })
135
+ console.log('→ Supabase Auth 로그인 + PAT 발급...')
136
+ const issued = await apiPostNoAuth(endpoint, '/api/rookie/tokens/bootstrap', {
137
+ email,
138
+ password,
139
+ label,
140
+ })
141
+ cfg2 = {
142
+ endpoint,
143
+ token: issued.token,
144
+ user_id: issued.user_id,
145
+ label: issued.label,
146
+ hostname: getHostname(),
147
+ }
148
+ saveConfig(cfg2)
149
+ console.log(`✓ PAT 발급·저장: ${configPath()} (user=${issued.user_name}, token_id=${issued.id})`)
150
+
151
+ // B1-4 (spec 2026-07-11 §B1): 발급 직후 계정 요약 — 오-binding 을 init 시점에 바로 보이게.
152
+ try {
153
+ const st = await apiGet(endpoint, issued.token, '/api/rookie/status')
154
+ const line = accountLine(st?.account)
155
+ if (line) {
156
+ console.log(line)
157
+ const warn = accountWarning(st?.account)
158
+ if (warn) console.log(warn)
159
+ if (process.stdin.isTTY) console.log('이 계정이 맞습니까? 아니라면 rookie login 으로 다시 로그인하세요.')
160
+ }
161
+ } catch { /* fail-soft — 요약 없이 진행 */ }
162
+ }
163
+
164
+ // 통일 연결(spec §4 item 4): login 과 같은 오케스트레이터 재사용 — 중복 설치 코드 없음.
165
+ const detected = renderDetected(tools)
166
+ if (detected) console.log(detected)
167
+ const report = await applyToolSetup({
168
+ tools,
169
+ scope,
170
+ cfg: cfg2,
171
+ interactive,
172
+ confirmWeak: (t) => confirm(`${toolName(t.id)} 디렉토리만 감지됨(CLI 미확인). 연결할까요?`, { defaultYes: true }),
173
+ })
174
+ for (const line of renderConnectOutcome(report, tools)) console.log(line)
175
+ }
@@ -0,0 +1,53 @@
1
+ import { execSync } from 'node:child_process'
2
+ import { basename } from 'node:path'
3
+ import { loadConfig, getHostname } from '../config.js'
4
+ import { apiPost } from '../api.js'
5
+ import { runInit } from './init.js'
6
+
7
+ export async function runLink(flags) {
8
+ const cfg = loadConfig()
9
+ if (!cfg) {
10
+ console.error('✗ 먼저 `rookie init` 을 실행하세요.')
11
+ process.exit(1)
12
+ }
13
+
14
+ let repoUrl = null
15
+ try {
16
+ repoUrl = execSync('git remote get-url origin', { stdio: ['ignore', 'pipe', 'ignore'] })
17
+ .toString().trim()
18
+ } catch {}
19
+
20
+ const repoPath = process.cwd()
21
+ const name = basename(repoPath)
22
+ const hostname = getHostname()
23
+
24
+ const installScope = flags.local ? 'local' : 'global'
25
+
26
+ const res = await apiPost(
27
+ cfg.endpoint,
28
+ cfg.token,
29
+ '/api/rookie/project',
30
+ {
31
+ repo_path: repoPath,
32
+ hostname,
33
+ name,
34
+ repo_url: repoUrl || undefined,
35
+ install_scope: installScope,
36
+ },
37
+ // B5: acted 이벤트가 surface null 버킷으로 빠지지 않게 표면 라벨 명시 (codex P2).
38
+ { headers: { 'x-rookie-surface': 'local_cli' } }
39
+ )
40
+ console.log(`✓ 프로젝트 등록: ${name} (project_id=${res.project_id})`)
41
+
42
+ // spec 2026-07-14 W-D3 — 등록 전 이 폴더에 격리돼 있던 기억을 프로젝트로 승격(경로 raw 출력 금지).
43
+ const p = res.promoted
44
+ if (p) {
45
+ const n = (p.work_records ?? 0) + (p.evidence ?? 0)
46
+ if (n > 0) console.log(`↑ 이 폴더의 기존 기억 ${n}건을 프로젝트로 승격했습니다.`)
47
+ if (p.failed?.length) console.error(`⚠ 승격 실패: ${p.failed.join(', ')} — 재실행하면 이어서 승격됩니다.`)
48
+ }
49
+
50
+ if (flags.local) {
51
+ await runInit({ local: true, yes: flags.yes, force: flags.force })
52
+ }
53
+ }
@@ -0,0 +1,160 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync, readFileSync, appendFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { loadConfig, loadGlobalConfig, saveConfig, configPath, localConfigPathFor, getHostname } from '../config.js'
5
+ import { apiGet, apiPostNoAuth, apiPostNoAuthRaw } from '../api.js'
6
+ import { interpretPoll } from '../lib/poll-state.js'
7
+ import { detectInstalledTools } from '../detect.js'
8
+ import { applyToolSetup } from '../lib/apply-tool-setup.js'
9
+ import { renderDetected, renderConnectOutcome, toolName } from '../lib/connect-ui.js'
10
+ import { confirm } from '../prompt.js'
11
+ import { resolveEndpoint } from '../lib/resolve-endpoint.js'
12
+ import { accountLine, accountWarning } from '../lib/account-ui.js'
13
+
14
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
15
+
16
+ // 브라우저 오픈 명령. Windows 의 start 는 cmd 내장이라 spawn('start') 가 실패하므로
17
+ // `cmd /c start "" <url>` 로 호출한다(첫 "" 는 start 의 title 인자 — URL 의 & 등 안전).
18
+ export function browserOpenCommand(url, platform = process.platform) {
19
+ if (platform === 'darwin') return { cmd: 'open', args: [url] }
20
+ if (platform === 'win32') return { cmd: 'cmd', args: ['/c', 'start', '', url] }
21
+ return { cmd: 'xdg-open', args: [url] }
22
+ }
23
+
24
+ function tryOpenBrowser(url) {
25
+ const { cmd, args } = browserOpenCommand(url)
26
+ try { spawn(cmd, args, { stdio: 'ignore', detached: true }).unref() } catch { /* 무해 — URL·코드는 출력됨 */ }
27
+ }
28
+
29
+ // B1-4/B6-2 재료 — 계정 요약 조회 (fail-soft: 실패·구서버 시 null).
30
+ async function fetchAccountSummary(endpoint, token) {
31
+ if (!endpoint || !token) return null
32
+ try {
33
+ const res = await apiGet(endpoint, token, '/api/rookie/status')
34
+ return res?.account ?? null
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ // B6-1: 로컬 .rookie/ (토큰 포함) 커밋 차단 — login --local 이 .gitignore 등록을 보장.
41
+ function ensureGitignoreHasRookie(dir) {
42
+ const gi = join(dir, '.gitignore')
43
+ try {
44
+ const cur = existsSync(gi) ? readFileSync(gi, 'utf8') : ''
45
+ const lines = cur.split('\n').map((l) => l.trim())
46
+ if (lines.includes('.rookie/') || lines.includes('.rookie')) return
47
+ appendFileSync(gi, `${cur && !cur.endsWith('\n') ? '\n' : ''}.rookie/\n`)
48
+ console.log(`✓ .gitignore 에 .rookie/ 추가 (토큰 커밋 차단): ${gi}`)
49
+ } catch (e) {
50
+ console.warn(`⚠ .gitignore 갱신 실패: ${e.message} — .rookie/ 를 직접 등록하세요.`)
51
+ }
52
+ }
53
+
54
+ // B6-2 게이트 판정 (순수 — 테스트용 export): 전역 scope 에서 기존과 다른 계정으로 승인될 때만 true.
55
+ // 같은 계정 재로그인(토큰 갱신)·최초 로그인(기존 user 없음)·--local 은 게이트 없음.
56
+ export function needsAccountSwitchGate({ scope, existingUserId, issuedUserId }) {
57
+ return scope === 'global' && !!existingUserId && !!issuedUserId && existingUserId !== issuedUserId
58
+ }
59
+
60
+ function accountDisplay(account, fallback) {
61
+ if (account?.email_masked) {
62
+ return account.profile_name ? `${account.email_masked} (프로필 ${account.profile_name})` : account.email_masked
63
+ }
64
+ return account?.profile_name ?? fallback
65
+ }
66
+
67
+ export async function runLogin(flags) {
68
+ const scope = flags.local ? 'local' : 'global'
69
+ // B6-2: 전역 login 의 기존-계정 비교는 전역 config 기준(로컬 config 에 오염되면 게이트가 무력화).
70
+ const existing = scope === 'global' ? loadGlobalConfig() : loadConfig()
71
+ // 우선순위 flag(--endpoint=) > ROOKIE_ENDPOINT > 기존 config > therookie.kr.
72
+ // 어느 하나라도 해결되면 프롬프트 생략(install.sh 비대화 경로).
73
+ const endpoint = resolveEndpoint({
74
+ flag: typeof flags.endpoint === 'string' ? flags.endpoint : undefined,
75
+ env: process.env.ROOKIE_ENDPOINT,
76
+ config: existing?.endpoint,
77
+ })
78
+ const label = `${getHostname()}`
79
+
80
+ let start
81
+ try {
82
+ start = await apiPostNoAuth(endpoint, '/api/rookie/device/start', { label })
83
+ } catch (e) {
84
+ const msg = /\b429\b/.test(String(e?.message)) ? '요청이 잦습니다. 잠시 후 다시 시도하세요.' : `연결 시작 실패: ${e?.message ?? e}`
85
+ console.error(`\n✗ ${msg}`)
86
+ process.exit(1)
87
+ }
88
+ // 서버의 verification_uri 는 req origin 추측이라 localhost 로 떨어질 수 있다
89
+ // (macmini 등 원격 endpoint 에서 바인딩이 localhost). CLI 가 설정한 endpoint 로
90
+ // 직접 구성해야 두목이 실제로 열 수 있는 주소가 된다.
91
+ const verifyUri = `${endpoint.replace(/\/$/, '')}/device`
92
+ const verifyUriComplete = `${verifyUri}?code=${encodeURIComponent(start.user_code)}`
93
+ console.log(`\n 브라우저에서 다음 주소를 열고 코드를 입력하세요:`)
94
+ console.log(` ${verifyUri}`)
95
+ console.log(` 코드: ${start.user_code}\n`)
96
+ tryOpenBrowser(verifyUriComplete)
97
+
98
+ let interval = start.interval ?? 5
99
+ const deadline = Date.now() + (start.expires_in ?? 600) * 1000
100
+ let issued = null
101
+ while (Date.now() < deadline) {
102
+ await sleep(interval * 1000)
103
+ const { body } = await apiPostNoAuthRaw(endpoint, '/api/rookie/device/token', { device_code: start.device_code })
104
+ const next = interpretPoll(body, interval)
105
+ interval = next.interval
106
+ if (next.action === 'done') { issued = next; break }
107
+ if (next.action === 'stop') { console.error(`\n✗ 코드 만료. 'rookie login' 을 다시 실행하세요.`); process.exit(1) }
108
+ }
109
+ if (!issued) { console.error(`\n✗ 시간 초과. 다시 시도하세요.`); process.exit(1) }
110
+
111
+ const cfg = { endpoint, token: issued.token, user_id: issued.user_id, label: issued.label ?? label, hostname: getHostname(), agent_name: issued.agent_name ?? null }
112
+
113
+ // B1-4: 새 토큰의 계정 요약 — 교체 게이트 표시와 종료 화면에 함께 쓴다.
114
+ const newAccount = await fetchAccountSummary(endpoint, issued.token)
115
+
116
+ // B6-2: 전역 계정 교체 확인 게이트. 같은 계정 재로그인(토큰 갱신)은 무확인 통과.
117
+ if (needsAccountSwitchGate({ scope, existingUserId: existing?.user_id, issuedUserId: issued.user_id })) {
118
+ const oldAccount = await fetchAccountSummary(existing.endpoint, existing.token)
119
+ const from = accountDisplay(oldAccount, existing.label ?? '기존 계정')
120
+ const to = accountDisplay(newAccount, issued.label ?? '새 계정')
121
+ console.log(`\n⚠️ 전역 계정을 교체합니다: ${from} → ${to}`)
122
+ console.log(' 테스트 목적이면 rookie login --local (프로젝트 전용 binding) 을 권장합니다.')
123
+ let go = false
124
+ if (flags.yes) go = true
125
+ else if (process.stdin.isTTY) go = await confirm('계속할까요?', { defaultYes: false })
126
+ if (!go) {
127
+ console.error('✗ 중단 — 전역 config 는 변경되지 않았습니다. 방금 발급된 PAT 는 /dashboard/tokens 에서 revoke 하세요.')
128
+ process.exit(1)
129
+ }
130
+ }
131
+
132
+ // B6-1: --local 은 토큰도 프로젝트-로컬 config 에 저장한다(전역 무변경) + .gitignore 보장.
133
+ const savePath = scope === 'local' ? localConfigPathFor(process.cwd()) : undefined
134
+ saveConfig(cfg, savePath ? { path: savePath } : undefined)
135
+ if (scope === 'local') ensureGitignoreHasRookie(process.cwd())
136
+ console.log(`✓ PAT 저장: ${savePath ?? configPath()}`)
137
+
138
+ // B1-4: init/login 종료 화면 — 계정 요약 + 확인 질문(비대화형은 요약만).
139
+ const line = accountLine(newAccount)
140
+ if (line) {
141
+ console.log(line)
142
+ const warn = accountWarning(newAccount)
143
+ if (warn) console.log(warn)
144
+ if (process.stdin.isTTY) console.log('이 계정이 맞습니까? 아니라면 rookie login 으로 다시 로그인하세요.')
145
+ }
146
+
147
+ // 통일 연결(spec 2026-06-29 §4): 이 컴퓨터에 깔린 도구를 감지해 한 번에 설정.
148
+ // TTY 면 대화형(weak 신호 확인 경유), install.sh 비대화 경로면 strong 만 자동.
149
+ const tools = detectInstalledTools()
150
+ const detected = renderDetected(tools)
151
+ if (detected) console.log(detected)
152
+ const report = await applyToolSetup({
153
+ tools,
154
+ scope,
155
+ cfg,
156
+ interactive: !!process.stdin.isTTY,
157
+ confirmWeak: (t) => confirm(`${toolName(t.id)} 디렉토리만 감지됨(CLI 미확인). 연결할까요?`, { defaultYes: true }),
158
+ })
159
+ for (const line of renderConnectOutcome(report, tools, cfg.agent_name)) console.log(line)
160
+ }
@@ -0,0 +1,44 @@
1
+ // B3 (spec 2026-07-11-memory-reliability-fix-design §B3) — orphan tail 추출 준비 명령.
2
+ // pending-extraction 큐를 오래된 순으로 읽어 rule-base 초안을 stdout 으로 뽑는다.
3
+ // 저장 판단·batch-mutate 호출·완료 marker 는 세션 LLM(차차)/사용자 몫 — 이 명령은 marker 를 건드리지 않는다.
4
+ import { existsSync } from 'node:fs'
5
+ import { join } from 'node:path'
6
+ import { homedir } from 'node:os'
7
+ import { listPendingExtractions, buildDraft, renderDraft, markerCommand } from '../lib/orphan-extract-core.js'
8
+ import { parseClaudeMessages, parseCodexMessages } from '../lib/sweep-core.mjs'
9
+ import { hashId8 } from '../lib/redact-lite.js'
10
+
11
+ const DEFAULT_BATCH = 5
12
+
13
+ export async function runOrphanExtract(flags = {}) {
14
+ // parseFlags 는 `--batch 5`(공백 구분)를 batch=true + positional 로 떨어뜨린다 — 둘 다 수용.
15
+ const raw = flags.batch === true ? flags.positional?.[0] : flags.batch
16
+ const n = Number(raw)
17
+ const batch = Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_BATCH
18
+ const orphanRoot = process.env.ROOKIE_ORPHAN_DIR || join(homedir(), '.rookie', 'orphan-recovery')
19
+
20
+ const all = listPendingExtractions({ orphanRoot })
21
+ if (all.length === 0) {
22
+ console.log('orphan pending: clean — 추출 대기 세션이 없습니다.')
23
+ return
24
+ }
25
+
26
+ const picked = all.slice(0, batch)
27
+ console.log(`== Orphan tail 추출 초안 — ${picked.length}건 (대기 ${all.length}건, 오래된 순) ==`)
28
+ for (const entry of picked) {
29
+ if (!entry.transcriptPath || !existsSync(entry.transcriptPath)) {
30
+ // transcript 소실 — marker 는 그대로 두면 영구 재노출되므로 안내만 (정리는 사용자/차차 판단).
31
+ console.log(`◦ [${entry.runtime}] 세션 ${hashId8(entry.sid)} — transcript 소실. 저장할 원문이 없습니다.`)
32
+ console.log(` → 큐에서 비우려면: ${markerCommand(entry)}`)
33
+ continue
34
+ }
35
+ const messages = entry.runtime === 'codex'
36
+ ? parseCodexMessages(entry.transcriptPath)
37
+ : parseClaudeMessages(entry.transcriptPath)
38
+ for (const line of renderDraft(entry, buildDraft(messages))) console.log(line)
39
+ }
40
+ const rest = all.length - picked.length
41
+ console.log('')
42
+ console.log(`저장 가치 있는 항목만 batch-mutate 로 저장한 뒤, 세션마다 완료 marker 를 남기세요 (저장할 게 없어도).`)
43
+ if (rest > 0) console.log(`남은 대기: ${rest}건 — rookie orphan-extract --batch ${batch} 재실행.`)
44
+ }