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.
- package/LICENSE +21 -0
- package/README.md +63 -0
- package/bin/rookie.js +6 -0
- package/package.json +40 -0
- package/src/api.js +67 -0
- package/src/cli.js +139 -0
- package/src/commands/cluster-todos.js +74 -0
- package/src/commands/codex-setup.js +36 -0
- package/src/commands/context-diet.js +80 -0
- package/src/commands/doctor.js +710 -0
- package/src/commands/done-scan.js +162 -0
- package/src/commands/init.js +175 -0
- package/src/commands/link.js +53 -0
- package/src/commands/login.js +160 -0
- package/src/commands/orphan-extract.js +44 -0
- package/src/commands/save.js +91 -0
- package/src/commands/scan-specs.js +153 -0
- package/src/commands/status.js +44 -0
- package/src/commands/sweep-recovery.js +33 -0
- package/src/commands/token.js +105 -0
- package/src/commands/unlink.js +40 -0
- package/src/commands/update.js +220 -0
- package/src/config.js +65 -0
- package/src/detect.js +109 -0
- package/src/lib/account-ui.js +28 -0
- package/src/lib/apply-tool-setup.js +89 -0
- package/src/lib/codex-config.js +46 -0
- package/src/lib/connect-ui.js +59 -0
- package/src/lib/context-diet.js +628 -0
- package/src/lib/dead-letter.js +73 -0
- package/src/lib/deploy-artifacts.js +185 -0
- package/src/lib/hook-command.js +31 -0
- package/src/lib/manifest.js +87 -0
- package/src/lib/merge-hooks.js +31 -0
- package/src/lib/orphan-extract-core.js +113 -0
- package/src/lib/orphan-pending.js +49 -0
- package/src/lib/parse-hook-cwd.js +11 -0
- package/src/lib/poll-state.js +14 -0
- package/src/lib/redact-lite.js +55 -0
- package/src/lib/resolve-endpoint.js +8 -0
- package/src/lib/self-update.js +79 -0
- package/src/lib/skill-installer.js +55 -0
- package/src/lib/spec-scanner.js +85 -0
- package/src/lib/sweep-core.mjs +423 -0
- package/src/lib/task-path-match.js +122 -0
- package/src/prompt.js +60 -0
- package/templates/hook/bin/posttool-recall-nudge.sh +96 -0
- package/templates/hook/bin/pretool-md-read.sh +107 -0
- package/templates/hook/flush-session.sh +314 -0
- package/templates/hook/guard-destructive-db.sh +24 -0
- package/templates/hook/lib/config-resolve.sh +106 -0
- package/templates/hook/lib/cross-runtime-sweep.sh +202 -0
- package/templates/hook/lib/dispatcher.sh +423 -0
- package/templates/hook/lib/orphan-recovery.sh +491 -0
- package/templates/hook/lib/queue.sh +314 -0
- package/templates/hook/lib/skill-gate.sh +28 -0
- package/templates/hook/lib/transcript.sh +166 -0
- package/templates/hook/refresh-context.sh +163 -0
- package/templates/hook/session-start.sh +417 -0
- package/templates/hook/settings.partial.json +55 -0
- package/templates/hook/sweep-recovery.sh +43 -0
- package/templates/hook/user-prompt-skill-gate.sh +90 -0
- package/templates/launchd/com.rollingmango.rookie-sweep.plist +38 -0
- package/templates/skill/SKILL.md +214 -0
- 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
|
+
}
|