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,79 @@
1
+ // task 77b93db0 — rookie update 자가 갱신.
2
+ // 서버가 더 새 cli_version 을 배포했으면 자기(구) 번들 템플릿을 설치하면 안 된다 —
3
+ // install.sh(단일 설치 구현, sha256 검증 포함)를 받아 실행해 새 버전을 깔고,
4
+ // 새 CLI 의 update 로 위임한다. 재귀는 ROOKIE_UPDATE_REEXEC env 로 차단.
5
+
6
+ import { existsSync } from 'node:fs'
7
+ import { spawnSync } from 'node:child_process'
8
+ import { homedir } from 'node:os'
9
+ import { join, sep } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+
12
+ // npm(공식 채널)으로 설치된 CLI 는 node_modules 아래에 위치한다. 자체 tarball 설치본은
13
+ // ~/.rookie/cli/<ver>/ 아래다. npm 설치본에서 install.sh 자가 갱신을 돌리면 ~/.rookie/cli 에
14
+ // 이중 설치되므로, update 는 여기서 분기해 `npm i -g therookie@latest` 안내로 뺀다.
15
+ export function isNpmManagedInstall(moduleUrl = import.meta.url) {
16
+ try {
17
+ return fileURLToPath(moduleUrl).includes(`${sep}node_modules${sep}`)
18
+ } catch {
19
+ return false
20
+ }
21
+ }
22
+
23
+ export function semverGt(a, b) {
24
+ const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0)
25
+ const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0)
26
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
27
+ const d = (pa[i] || 0) - (pb[i] || 0)
28
+ if (d !== 0) return d > 0
29
+ }
30
+ return false
31
+ }
32
+
33
+ // GET /rookie-cli/manifest.json (public 정적 파일, 인증 불요). fail-soft — 없거나 못 읽으면 null.
34
+ export async function fetchReleaseManifest(endpoint, { fetchImpl = fetch } = {}) {
35
+ try {
36
+ const res = await fetchImpl(`${endpoint}/rookie-cli/manifest.json`)
37
+ if (!res.ok) return null
38
+ const m = await res.json()
39
+ return m && typeof m.cli_version === 'string' ? m : null
40
+ } catch {
41
+ return null
42
+ }
43
+ }
44
+
45
+ // install.sh 로 자가 재설치 후 새 CLI 의 `update` 를 같은 flag 로 재실행. exit code 반환.
46
+ export async function selfUpdateAndRerun({ endpoint, targetVersion, force = false, fetchImpl = fetch, spawn = spawnSync }) {
47
+ let script
48
+ try {
49
+ const res = await fetchImpl(`${endpoint}/install.sh`)
50
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
51
+ script = await res.text()
52
+ } catch (e) {
53
+ console.log(`✗ install.sh 다운로드 실패: ${e.message}`)
54
+ return 1
55
+ }
56
+
57
+ const install = spawn('sh', ['-s'], {
58
+ input: script,
59
+ stdio: ['pipe', 'inherit', 'inherit'],
60
+ env: { ...process.env, ROOKIE_ENDPOINT: endpoint },
61
+ })
62
+ if (install.error || install.status !== 0) {
63
+ console.log(`✗ 자가 갱신 실패 — 수동 재설치: curl -fsSL ${endpoint}/install.sh | bash`)
64
+ return 1
65
+ }
66
+
67
+ const rookieHome = process.env.ROOKIE_HOME || join(homedir(), '.rookie')
68
+ const nextCli = join(rookieHome, 'cli', targetVersion, 'bin', 'rookie.js')
69
+ if (!existsSync(nextCli)) {
70
+ console.log(`✗ 갱신된 CLI 를 찾지 못했습니다: ${nextCli}`)
71
+ return 1
72
+ }
73
+
74
+ const rerun = spawn(process.execPath, [nextCli, 'update', ...(force ? ['--force'] : [])], {
75
+ stdio: 'inherit',
76
+ env: { ...process.env, ROOKIE_UPDATE_REEXEC: '1' },
77
+ })
78
+ return rerun.status ?? 1
79
+ }
@@ -0,0 +1,55 @@
1
+ // v6 P0b — Skill 본문 install/upgrade. 기존 init/update 의 인라인 skill 쓰기 로직을
2
+ // backup 옵션과 함께 모듈화. 같은 내용이면 no-op, 다른 내용이면 .bak.<ts> 백업 후 덮어쓰기.
3
+
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
5
+ import { join, dirname } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { homedir } from 'node:os'
8
+
9
+ const __filename = fileURLToPath(import.meta.url)
10
+ const DEFAULT_TEMPLATE = join(
11
+ dirname(__filename),
12
+ '..',
13
+ '..',
14
+ 'templates',
15
+ 'skill',
16
+ 'SKILL.md'
17
+ )
18
+
19
+ export function installSkill({
20
+ home = homedir(),
21
+ scope = 'global',
22
+ version,
23
+ installedBy,
24
+ templatePath = DEFAULT_TEMPLATE,
25
+ now = () => new Date().toISOString(),
26
+ } = {}) {
27
+ if (!version) throw new Error('installSkill: version required')
28
+ if (!installedBy) throw new Error('installSkill: installedBy required')
29
+
30
+ const base = scope === 'global' ? join(home, '.claude') : home
31
+ const targetDir = join(base, 'skills', 'rookie')
32
+ const targetPath = join(targetDir, 'SKILL.md')
33
+
34
+ const raw = readFileSync(templatePath, 'utf8')
35
+ const rendered = raw
36
+ .replaceAll('__VERSION__', version)
37
+ .replaceAll('__SCOPE__', scope)
38
+ .replaceAll('__INSTALLED_AT__', now())
39
+ .replaceAll('__INSTALLED_BY__', installedBy)
40
+
41
+ mkdirSync(targetDir, { recursive: true })
42
+
43
+ let backupPath = null
44
+ if (existsSync(targetPath)) {
45
+ const existing = readFileSync(targetPath, 'utf8')
46
+ if (existing === rendered) {
47
+ return { installed: false, reason: 'no_change', path: targetPath }
48
+ }
49
+ backupPath = `${targetPath}.bak.${Date.now()}`
50
+ writeFileSync(backupPath, existing)
51
+ }
52
+
53
+ writeFileSync(targetPath, rendered)
54
+ return { installed: true, path: targetPath, backup: backupPath }
55
+ }
@@ -0,0 +1,85 @@
1
+ // spec markdown → 후속 섹션 후보. 의존성 0 (Node 내장만). LLM 0.
2
+ // 분류: deferred 우선(매칭 시 active 무시) → 기본 제외. active = 할 후속.
3
+
4
+ const DEFERRED = /(Post-?MVP|범위\s*밖|비범위|비목표|미결정|미해결|보류)/
5
+ // PR-[A-Z] 는 실측에서 '(PR-A 핵심)'·'(PR-A 동반)' 같은 단순 언급 섹션까지 잡아 노이즈가 커서 제외.
6
+ // 진짜 PR 분할 후속은 'N차 PR'·'다음 단계'·'후속' 으로 대부분 커버됨(실측 2026-06-28).
7
+ const ACTIVE = /(\d+\s*차\s*PR|다음\s*단계|후속|남은|미착수)/
8
+
9
+ // GitHub 앵커 규칙: 소문자화, 공백→'-', 영숫자·'_'·'-'·한글 외 제거.
10
+ export function slugify(title) {
11
+ return title
12
+ .trim()
13
+ .toLowerCase()
14
+ .replace(/[^\w\sㄱ-ㆎ가-힣-]/g, '')
15
+ .replace(/\s+/g, '-')
16
+ }
17
+
18
+ export function classify(title) {
19
+ if (DEFERRED.test(title)) return 'deferred'
20
+ if (ACTIVE.test(title)) return 'active'
21
+ return null
22
+ }
23
+
24
+ // §A 완료 마커 인식 — 이미 끝난 후속을 후보에서 제외(spec 2026-06-28 분류 시스템화).
25
+ // ✅ 동반 완료 선언만 done(plain '구현 완료'·표 셀 ✅ 단독 제외). 미완·조건·예정 신호 우선.
26
+ const DONE = /✅[^\n]{0,40}완료|✅\s*\d{4}-\d{2}-\d{2}[^\n]{0,20}완료/
27
+ const STILL_OPEN =
28
+ /(미착수|진행\s*예정|착수\s*대기|TODO|남은\s|미구현|미완료|예정|완료\s*(시|후|되면|여부|하면|전|기준|게이트|조건|아님))/
29
+
30
+ function isDone(body) {
31
+ if (STILL_OPEN.test(body)) return false
32
+ return DONE.test(body)
33
+ }
34
+
35
+ // markdown 을 줄 단위로 훑어 H2/H3 헤더를 모은다. fenced code 안은 무시.
36
+ // 모든 H2/H3 헤더에 slug 카운터(-1,-2) 부여(후보 여부 무관 = GitHub 규칙).
37
+ export function parseFollowups(specRef, markdown) {
38
+ const lines = markdown.split(/\r?\n/)
39
+ const slugCount = new Map()
40
+ const headers = [] // {level, title, anchor, start}
41
+ let inFence = false
42
+ for (let i = 0; i < lines.length; i++) {
43
+ const line = lines[i]
44
+ if (/^\s*```/.test(line)) {
45
+ inFence = !inFence
46
+ continue
47
+ }
48
+ if (inFence) continue
49
+ const m = /^(#{2,3})\s+(.+?)\s*$/.exec(line)
50
+ if (!m) continue
51
+ const level = m[1].length
52
+ const title = m[2]
53
+ const base = slugify(title)
54
+ const n = slugCount.get(base) || 0
55
+ slugCount.set(base, n + 1)
56
+ const anchor = n === 0 ? base : `${base}-${n}`
57
+ headers.push({ level, title, anchor, start: i })
58
+ }
59
+
60
+ const out = []
61
+ for (let h = 0; h < headers.length; h++) {
62
+ const hd = headers[h]
63
+ const klass = classify(hd.title)
64
+ if (!klass) continue
65
+ // 본문: 다음 줄부터, level <= hd.level 인 다음 헤더 직전까지(하위 헤더 포함).
66
+ let end = lines.length
67
+ for (let k = h + 1; k < headers.length; k++) {
68
+ if (headers[k].level <= hd.level) {
69
+ end = headers[k].start
70
+ break
71
+ }
72
+ }
73
+ const body = lines.slice(hd.start + 1, end).join('\n').trim()
74
+ // §A: active 인데 본문에 완료 선언이 있으면 이미 끝난 후속 → 제외.
75
+ if (klass === 'active' && isDone(body)) continue
76
+ out.push({
77
+ specRef,
78
+ sectionAnchor: hd.anchor,
79
+ sectionTitle: hd.title,
80
+ sectionBody: body,
81
+ klass,
82
+ })
83
+ }
84
+ return out
85
+ }
@@ -0,0 +1,423 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+ import crypto from 'node:crypto'
5
+ import { execFileSync } from 'node:child_process'
6
+ import { configPathForCwd } from '../config.js'
7
+
8
+ // B6-3 (codex R1 P1-1): 세션 identity pin 비교 — pin(sha256 hash 앞 16자)이 있고 현재 토큰과
9
+ // 다르면 그 orphan 은 다른 계정 세션의 산물 → 현재 토큰으로 재전송하면 오귀속. bash
10
+ // identity_pin_check(config-resolve.sh)와 동일 산식·동일 fail-open(pin 없음·읽기 실패 통과).
11
+ export function identityPinMismatch(sid, token, { pinDir = path.join(os.homedir(), '.rookie', 'session-identity') } = {}) {
12
+ if (!sid || !token) return false
13
+ try {
14
+ const pinFile = path.join(pinDir, sid)
15
+ if (!fs.existsSync(pinFile)) return false
16
+ const pinned = fs.readFileSync(pinFile, 'utf8').trim()
17
+ if (!pinned) return false
18
+ const current = crypto.createHash('sha256').update(token).digest('hex').slice(0, 16)
19
+ return pinned !== current
20
+ } catch { return false }
21
+ }
22
+
23
+ const TURN_LIMIT = 200
24
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
25
+ const UUID_TAIL_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
26
+
27
+ function capTurns(out) {
28
+ let pairs = 0
29
+ for (let i = 1; i < out.length; i++) if (out[i-1].role === 'user' && out[i].role === 'assistant') pairs++
30
+ if (pairs > TURN_LIMIT) {
31
+ let dropPairs = pairs - TURN_LIMIT, dropIdx = 0
32
+ while (dropPairs > 0 && dropIdx < out.length - 1) {
33
+ if (out[dropIdx].role === 'user' && out[dropIdx+1].role === 'assistant') { dropIdx += 2; dropPairs-- }
34
+ else dropIdx++
35
+ }
36
+ out.splice(0, dropIdx)
37
+ }
38
+ return out
39
+ }
40
+
41
+ export function parseClaudeMessages(filePath) {
42
+ let raw
43
+ try { if (!filePath || !fs.existsSync(filePath)) return []; raw = fs.readFileSync(filePath, 'utf8') } catch { return [] }
44
+ const out = []
45
+ for (const line of raw.split('\n')) {
46
+ const t = line.trim(); if (!t) continue
47
+ try {
48
+ const j = JSON.parse(t); const m = j.message || j
49
+ if (m.role !== 'user' && m.role !== 'assistant') continue
50
+ let c = m.content
51
+ if (typeof c !== 'string') {
52
+ c = Array.isArray(c)
53
+ ? c.filter(b => b && b.type === 'text' && typeof b.text === 'string').map(b => b.text).join('\n').trim()
54
+ : ''
55
+ } else c = c.trim()
56
+ if (!c) continue
57
+ out.push({ role: m.role, content: c })
58
+ } catch {}
59
+ }
60
+ return capTurns(out)
61
+ }
62
+
63
+ export function parseCodexMessages(filePath) {
64
+ let raw
65
+ try { if (!filePath || !fs.existsSync(filePath)) return []; raw = fs.readFileSync(filePath, 'utf8') } catch { return [] }
66
+ const out = []
67
+ for (const line of raw.split('\n')) {
68
+ const t = line.trim(); if (!t) continue
69
+ let j; try { j = JSON.parse(t) } catch { continue }
70
+ const p = j.payload || {}
71
+ if (p.type !== 'message') continue
72
+ if (p.role !== 'user' && p.role !== 'assistant') continue
73
+ let c = p.content
74
+ if (Array.isArray(c)) {
75
+ c = c.filter(b => b && typeof b.text === 'string' && (b.type === 'input_text' || b.type === 'output_text' || b.type === 'text')).map(b => b.text).join('\n').trim()
76
+ } else if (typeof c === 'string') c = c.trim()
77
+ else c = ''
78
+ if (!c) continue
79
+ out.push({ role: p.role, content: c })
80
+ }
81
+ return capTurns(out)
82
+ }
83
+
84
+ export function codexCwd(filePath) {
85
+ try {
86
+ const fd = fs.openSync(filePath, 'r'); const buf = Buffer.alloc(65536)
87
+ const n = fs.readSync(fd, buf, 0, 65536, 0); fs.closeSync(fd)
88
+ const first = buf.toString('utf8', 0, n).split('\n').find(l => l.trim())
89
+ const j = JSON.parse(first)
90
+ if (j.type === 'session_meta' && j.payload && j.payload.cwd) return j.payload.cwd
91
+ } catch {}
92
+ return ''
93
+ }
94
+
95
+ // Claude transcript jsonl 의 첫 cwd 필드 (없으면 ''). codexCwd 와 대칭 — 이슈② repo 역산용.
96
+ // orphan 회수 시 repo 를 실행 셸 process.cwd() 가 아니라 후보 transcript 의 실제 cwd 로 잡는다.
97
+ export function claudeCwd(filePath) {
98
+ try {
99
+ for (const l of fs.readFileSync(filePath, 'utf8').split('\n')) {
100
+ if (!l.trim()) continue
101
+ try { const j = JSON.parse(l); if (j.cwd) return String(j.cwd) } catch {}
102
+ }
103
+ } catch {}
104
+ return ''
105
+ }
106
+
107
+ // cwd 의 git origin url (없으면 ''). git -C <cwd> remote get-url origin.
108
+ function gitOriginOf(cwd) {
109
+ if (!cwd) return ''
110
+ try {
111
+ return execFileSync('git', ['-C', cwd, 'remote', 'get-url', 'origin'],
112
+ { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
113
+ } catch { return '' }
114
+ }
115
+
116
+ export function sessionIdFromPath(filePath, runtime = 'claude') {
117
+ const base = String(filePath).split('/').pop().replace(/\.jsonl$/, '')
118
+ if (runtime === 'codex') { const m = base.match(UUID_TAIL_RE); return m ? m[0] : '' }
119
+ return base
120
+ }
121
+
122
+ export { UUID_RE, UUID_TAIL_RE }
123
+
124
+ // ── marker 계층 + sweep 날짜 스탬프 ─────────────────────────────────────
125
+
126
+ const DEFER_CAP = 3
127
+ const DEFAULT_ROOT = path.join(os.homedir(), '.rookie', 'orphan-recovery')
128
+ function rootOf(opts = {}) {
129
+ const base = opts.root || process.env.ROOKIE_ORPHAN_DIR || DEFAULT_ROOT
130
+ return opts.codex ? path.join(base, 'codex') : base
131
+ }
132
+ export function markerRoot(opts) { return rootOf(opts) }
133
+ const subdir = (opts, name) => path.join(rootOf(opts), name)
134
+ const isUuid = (sid) => UUID_RE.test(String(sid || ''))
135
+
136
+ function ensureDir(d) { try { fs.mkdirSync(d, { recursive: true, mode: 0o700 }); fs.chmodSync(d, 0o700) } catch {} }
137
+ function writeMarker(dir, sid, payload) {
138
+ if (!isUuid(sid)) return false
139
+ ensureDir(dir)
140
+ const tmp = path.join(dir, '.tmp.' + process.pid + '.' + Math.random().toString(36).slice(2))
141
+ try {
142
+ fs.writeFileSync(tmp, payload, { mode: 0o600 })
143
+ fs.renameSync(tmp, path.join(dir, sid + '.json'))
144
+ return true
145
+ } catch { try { fs.unlinkSync(tmp) } catch {}; return false }
146
+ }
147
+
148
+ export function markFlushed(sid, source = 'stop', mc = 0, opts) {
149
+ return writeMarker(subdir(opts, 'flushed'), sid, JSON.stringify({ status: 'flushed', source, message_count: mc }))
150
+ }
151
+ export function markBaseline(sid, reason, opts) {
152
+ return writeMarker(subdir(opts, 'baseline'), sid, JSON.stringify({ status: 'baseline_skipped', reason }))
153
+ }
154
+ // P0.4 대칭 (bash orphan_mark_low_value): 짧고 운영사실 흔적 없는 세션은 pending 대신 low_value.
155
+ // bash 와 같은 marker 루트를 공유하므로 Node 도 동일 marker 를 쓰고 isMarked 에서 제외해야 재포획이 막힌다.
156
+ export function markLowValue(sid, reason = 'low_value', mc = 0, opts) {
157
+ return writeMarker(subdir(opts, 'low_value'), sid, JSON.stringify({ status: 'low_value', reason, message_count: mc }))
158
+ }
159
+ export function isClientExtracted(sid, opts) {
160
+ if (!isUuid(sid)) return false
161
+ return fs.existsSync(path.join(subdir(opts, 'client_extracted'), sid + '.json'))
162
+ }
163
+ export function recordPending(sid, transcriptPath, opts) {
164
+ if (!isUuid(sid)) return false
165
+ if (isClientExtracted(sid, opts)) return true
166
+ return writeMarker(subdir(opts, 'pending-extraction'), sid, JSON.stringify({ status: 'pending_extraction', transcript_path: transcriptPath || '', recovered_at: new Date().toISOString() }))
167
+ }
168
+ export function markClientExtracted(sid, opts) {
169
+ if (!isUuid(sid)) return false
170
+ if (!writeMarker(subdir(opts, 'client_extracted'), sid, JSON.stringify({ status: 'client_extracted' }))) return false
171
+ try { fs.unlinkSync(path.join(subdir(opts, 'pending-extraction'), sid + '.json')) } catch {}
172
+ return true
173
+ }
174
+ export function deferBlocked(sid, opts) {
175
+ try {
176
+ const j = JSON.parse(fs.readFileSync(path.join(subdir(opts, 'deferred'), sid + '.json'), 'utf8'))
177
+ if ((j.fail_count || 0) >= DEFER_CAP) return true
178
+ const nr = Date.parse(j.next_retry_at); return !isNaN(nr) && nr / 1000 > Date.now() / 1000
179
+ } catch { return false }
180
+ }
181
+ export function recordDeferred(sid, error, opts) {
182
+ if (!isUuid(sid)) return false
183
+ const dir = subdir(opts, 'deferred')
184
+ let prev = { fail_count: 0 }
185
+ try { prev = JSON.parse(fs.readFileSync(path.join(dir, sid + '.json'), 'utf8')) } catch {}
186
+ const fc = (prev.fail_count || 0) + 1
187
+ const next = new Date(Date.now() + fc * 21600 * 1000).toISOString()
188
+ return writeMarker(dir, sid, JSON.stringify({ fail_count: fc, next_retry_at: next, last_error: error || '' }))
189
+ }
190
+ export function clearDeferred(sid, opts) {
191
+ if (!isUuid(sid)) return
192
+ try { fs.unlinkSync(path.join(subdir(opts, 'deferred'), sid + '.json')) } catch {}
193
+ }
194
+ export function isMarked(sid, opts) {
195
+ if (fs.existsSync(path.join(subdir(opts, 'flushed'), sid + '.json'))) return true
196
+ if (fs.existsSync(path.join(subdir(opts, 'baseline'), sid + '.json'))) return true
197
+ // P3 (2026-06-24 audit): bash scanner 의 marked() 와 제외 집합 통일. bash 가 쓴 low_value(짧은 세션)·
198
+ // expired(만료 pending) marker 를 Node sweep 이 못 보고 재후보화하던 비대칭 차단 — marker 루트 공유.
199
+ if (fs.existsSync(path.join(subdir(opts, 'low_value'), sid + '.json'))) return true
200
+ if (fs.existsSync(path.join(subdir(opts, 'expired'), sid + '.json'))) return true
201
+ return deferBlocked(sid, opts)
202
+ }
203
+ function localDateStamp() {
204
+ const d = new Date()
205
+ const p = (n) => String(n).padStart(2, '0')
206
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
207
+ }
208
+ function stampFile(opts) {
209
+ const base = opts && opts.root ? opts.root : (process.env.ROOKIE_ORPHAN_DIR || DEFAULT_ROOT)
210
+ return path.join(base, 'last-sweep-date')
211
+ }
212
+ export function sweepDueToday(opts) {
213
+ try { return fs.readFileSync(stampFile(opts), 'utf8').trim() !== localDateStamp() } catch { return true }
214
+ }
215
+ export function markSweptToday(opts) {
216
+ const f = stampFile(opts)
217
+ ensureDir(path.dirname(f))
218
+ try { fs.writeFileSync(f, localDateStamp(), { mode: 0o600 }) } catch {}
219
+ }
220
+
221
+ // ── scan 후보 수집 ────────────────────────────────────────────────────
222
+
223
+ const AGE_HOLD = 60, BOOTSTRAP_T = 172800, CAP = 3, MAX_BYTES = 52428800
224
+ const CODEX_DAYS = 7, CODEX_CAP = 20
225
+ // SSOT: src/lib/ai/internal-marker.ts (bash/mjs 는 import 불가 → 동일 리터럴 유지).
226
+ // drift 는 cli-provider.test.ts(TS 고정값) + sweep-core.test.mjs(mjs 고정값) 양쪽이 잡는다.
227
+ const ROOKIE_INTERNAL_MARKER = '[[ROOKIE_INTERNAL_INVOCATION::v1]]'
228
+ const LEGACY_SELF_REF = 'the Rookie의 기억 정리 모듈' // 과거 fact-extraction transcript 하위호환
229
+ const num = (envKey, dflt) => { const v = +process.env[envKey]; return Number.isFinite(v) && v > 0 ? v : dflt }
230
+
231
+ export function claudeProjectDirForCwd(cwd) { return String(cwd).replace(/[^a-zA-Z0-9-]/g, '-') }
232
+
233
+ // transcript jsonl 의 첫 user 메시지 content 문자열을 뽑는다 (없으면 '').
234
+ function firstUserContent(fp) {
235
+ let raw
236
+ try { raw = fs.readFileSync(fp, 'utf8') } catch { return '' }
237
+ for (const line of raw.split('\n')) {
238
+ const t = line.trim(); if (!t) continue
239
+ let j; try { j = JSON.parse(t) } catch { continue }
240
+ const m = j.message || j
241
+ if (m.role !== 'user') continue
242
+ const c = m.content
243
+ if (typeof c === 'string') return c
244
+ if (Array.isArray(c)) return (c.find(b => b && b.type === 'text' && typeof b.text === 'string')?.text) ?? ''
245
+ return ''
246
+ }
247
+ return ''
248
+ }
249
+
250
+ // self-reference 판정 (P1-4): 첫 user 메시지 선두가 센티넬이거나(구조적, 신규)
251
+ // 첫 user 메시지에 legacy 마커 포함(하위호환). 전역 4KB substring 폐기 — 본문 인용 false positive 차단.
252
+ export function isSelfRefTranscript(fp) {
253
+ const first = firstUserContent(fp)
254
+ return first.trimStart().startsWith(ROOKIE_INTERNAL_MARKER)
255
+ || first.includes(LEGACY_SELF_REF)
256
+ }
257
+
258
+ export function scanClaudeCandidates({ projectDir, currentSid = '', opts = {} }) {
259
+ if (!projectDir || !fs.existsSync(projectDir)) return []
260
+ const hold = num('ROOKIE_ORPHAN_AGE_HOLD', AGE_HOLD), T = num('ROOKIE_ORPHAN_BOOTSTRAP_T', BOOTSTRAP_T)
261
+ const K = num('ROOKIE_ORPHAN_CAP', CAP), maxB = num('ROOKIE_ORPHAN_MAX_BYTES', MAX_BYTES)
262
+ const now = Date.now() / 1000
263
+ const isBoot = !fs.existsSync(subdir(opts, 'flushed'))
264
+ let names = []; try { names = fs.readdirSync(projectDir) } catch { return [] }
265
+ const entries = []
266
+ for (const name of names) {
267
+ if (!name.endsWith('.jsonl')) continue
268
+ const sid = name.slice(0, -6)
269
+ if (!UUID_RE.test(sid) || sid === currentSid) continue
270
+ const fp = path.join(projectDir, name)
271
+ let st; try { st = fs.lstatSync(fp) } catch { continue }
272
+ if (!st.isFile() || st.size > maxB) continue
273
+ if ((now - st.mtimeMs / 1000) < hold) continue
274
+ if (isMarked(sid, opts)) continue
275
+ if (isSelfRefTranscript(fp)) { markBaseline(sid, 'self_reference', opts); continue }
276
+ entries.push({ fp, sid, mtime: st.mtimeMs })
277
+ }
278
+ entries.sort((a, b) => b.mtime - a.mtime)
279
+ let pick
280
+ if (isBoot) {
281
+ pick = entries.filter(e => (now - e.mtime / 1000) <= T).slice(0, K)
282
+ const picked = new Set(pick.map(e => e.sid))
283
+ for (const e of entries) if (!picked.has(e.sid)) markBaseline(e.sid, 'bootstrap_old', opts)
284
+ } else pick = entries.slice(0, K)
285
+ return pick.map(e => e.fp)
286
+ }
287
+
288
+ export function scanCodexCandidates({ sessionsDir, opts = {} }) {
289
+ const codexOpts = { ...opts, codex: true }
290
+ if (!sessionsDir || !fs.existsSync(sessionsDir)) return []
291
+ const days = num('ROOKIE_CODEX_SWEEP_DAYS', CODEX_DAYS), cap = num('ROOKIE_CODEX_SWEEP_CAP', CODEX_CAP)
292
+ const hold = num('ROOKIE_ORPHAN_AGE_HOLD', AGE_HOLD), maxB = num('ROOKIE_ORPHAN_MAX_BYTES', MAX_BYTES)
293
+ const now = Date.now() / 1000, minMtime = now - days * 86400
294
+ const found = []
295
+ const walk = (d) => {
296
+ let names = []; try { names = fs.readdirSync(d) } catch { return }
297
+ for (const n of names) {
298
+ const fp = path.join(d, n); let st; try { st = fs.lstatSync(fp) } catch { continue }
299
+ if (st.isDirectory()) { walk(fp); continue }
300
+ if (!st.isFile() || !/^rollout-.*\.jsonl$/.test(n) || st.size > maxB) continue
301
+ if (st.mtimeMs / 1000 < minMtime || (now - st.mtimeMs / 1000) < hold) continue
302
+ const m = n.slice(0, -6).match(UUID_TAIL_RE); if (!m) continue
303
+ const sid = m[0]; if (isMarked(sid, codexOpts)) continue
304
+ found.push({ fp, sid, mtime: st.mtimeMs })
305
+ }
306
+ }
307
+ walk(sessionsDir)
308
+ found.sort((a, b) => b.mtime - a.mtime)
309
+ return found.slice(0, cap).map(e => e.fp)
310
+ }
311
+
312
+ // ── HTTP flush + recover + sweepAll ──────────────────────────────────────────
313
+
314
+ const ORPHAN_BUDGET = 8, SWEEP_BUDGET = 30
315
+
316
+ export async function postFlushSession({ endpoint, token, userId, body }) {
317
+ const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), 10000)
318
+ try {
319
+ const res = await fetch(`${endpoint}/api/rookie/flush-session`, {
320
+ method: 'POST', signal: ac.signal,
321
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'x-rookie-user-id': userId || '' },
322
+ body: JSON.stringify(body),
323
+ })
324
+ const text = await res.text()
325
+ let json = null; try { json = JSON.parse(text) } catch {}
326
+ return { code: res.status, json: json ?? text }
327
+ } catch { return { code: 0, json: null } } finally { clearTimeout(timer) }
328
+ }
329
+
330
+ // P0.4 대칭 (bash orphan_pending_is_worthwhile): tail 추출 큐 적재 가치 판정.
331
+ // 메시지 >3 이면 무조건 가치. 아니면 본문에 운영사실 흔적(코드블록·SQL·URL·명령·포트·경로 등)이 있어야 적재.
332
+ const WORTHWHILE_RE = /```|\bSELECT\b|\bINSERT\b|\bUPDATE\b|\bCREATE\b|https?:\/\/|\bnpm\b|\bgit\b|\bcurl\b|\bssh\b|:\d{2,5}\b|\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+|포트|배포|결정/i
333
+ function pendingIsWorthwhile(messages) {
334
+ if (!Array.isArray(messages)) return true // 파싱 실패는 보수적으로 적재
335
+ if (messages.length > 3) return true
336
+ const text = messages.map(m => (typeof m.content === 'string' ? m.content : '')).join('\n')
337
+ return WORTHWHILE_RE.test(text)
338
+ }
339
+
340
+ async function recoverOne({ endpoint, token, userId, fp, sid, messages, hostname, repoUrl, repoPath, opts }) {
341
+ const body = { session_id: sid, hostname, repo_path: repoPath, transcript_path: fp, messages }
342
+ if (repoUrl) body.repo_url = repoUrl
343
+ const { code } = await postFlushSession({ endpoint, token, userId, body })
344
+ // P2 (2026-06-24 audit): marker 조건 = code 200 + messages>0 (chunks_inserted 무관). bash orphan 과 통일.
345
+ // messages.length 는 호출부(recoverProject/recoverCodex)가 이미 >0 보장. 멱등 재전송(chunks 0)도 marker 박아
346
+ // 이미 서버에 있는 세션이 다음 sweep 에서 영구 재후보화되지 않게 한다.
347
+ if (code === 200 && messages.length > 0) {
348
+ if (markFlushed(sid, opts.codex ? 'sweep' : 'orphan', messages.length, opts)) {
349
+ clearDeferred(sid, opts)
350
+ // P3: bash 와 동일한 가치 판정. 짧고 내용 없는 세션은 pending 적재 대신 low_value marker.
351
+ if (pendingIsWorthwhile(messages)) recordPending(sid, fp, opts)
352
+ else markLowValue(sid, 'short_no_content', messages.length, opts)
353
+ return true
354
+ }
355
+ recordDeferred(sid, 'marker_write_failed', opts)
356
+ }
357
+ return false
358
+ }
359
+
360
+ // 이슈② 재발 차단: repo 를 함수 인자로 받아 모든 후보에 공통 적용하지 않는다. 후보 transcript 마다
361
+ // 실제 cwd 를 읽어 repoPath/repoUrl 을 산정한다(Codex recoverCodex 의 codexCwd 대칭). process.cwd()/
362
+ // 인자 폴백 금지 — sweepAll 이 the-rookie 본진에서 전 프로젝트를 순회하므로 고정값이면 전부 흡수된다.
363
+ export async function recoverProject({ endpoint, token, userId, projectDir, currentSid = '', hostname = os.hostname(), opts = {} }) {
364
+ if (!endpoint || !token) return 0
365
+ const deadline = Date.now() + num('ROOKIE_ORPHAN_TIME_BUDGET', ORPHAN_BUDGET) * 1000
366
+ const cands = scanClaudeCandidates({ projectDir, currentSid, opts })
367
+ let count = 0
368
+ for (const fp of cands) {
369
+ if (Date.now() >= deadline) break
370
+ const sid = sessionIdFromPath(fp, 'claude'); if (!sid || deferBlocked(sid, opts)) continue
371
+ if (isSelfRefTranscript(fp)) continue
372
+ const messages = parseClaudeMessages(fp); if (!messages.length) continue
373
+ const repoPath = claudeCwd(fp) || undefined
374
+ // B6-4 (spec 2026-07-11 §B6): cwd 불명·디렉토리 소실(codex R1 P1-2)은 보류 — 로컬 config 를
375
+ // 못 찾는다고 전역 계정으로 폴백 flush 하지 않는다. marker 미부착이라 다음 기회 재시도.
376
+ // 타 config 소속(로컬 .rookie/ binding 하위)은 그 config 의 sweep 몫으로 skip.
377
+ if (!repoPath || !fs.existsSync(repoPath)) continue
378
+ if (opts.activeConfigPath && configPathForCwd(repoPath) !== opts.activeConfigPath) continue
379
+ // B6-3 (codex R1 P1-1): 다른 계정 세션의 orphan 은 현재 토큰으로 재전송하지 않는다.
380
+ if (identityPinMismatch(sid, token, opts)) continue
381
+ const repoUrl = gitOriginOf(repoPath)
382
+ if (await recoverOne({ endpoint, token, userId, fp, sid, messages, hostname, repoUrl, repoPath, opts })) count++
383
+ }
384
+ return count
385
+ }
386
+
387
+ export async function recoverCodex({ endpoint, token, userId, sessionsDir, hostname = os.hostname(), opts = {} }) {
388
+ if (!endpoint || !token) return 0
389
+ const codexOpts = { ...opts, codex: true }
390
+ const deadline = Date.now() + num('ROOKIE_SWEEP_TIME_BUDGET', SWEEP_BUDGET) * 1000
391
+ const cands = scanCodexCandidates({ sessionsDir, opts: codexOpts })
392
+ let count = 0
393
+ for (const fp of cands) {
394
+ if (Date.now() >= deadline) break
395
+ const sid = sessionIdFromPath(fp, 'codex'); if (!sid || deferBlocked(sid, codexOpts)) continue
396
+ const messages = parseCodexMessages(fp); if (!messages.length) continue
397
+ const repoPath = codexCwd(fp) || undefined
398
+ // B6-4: Claude 경로(recoverProject)와 대칭 — cwd 불명·디렉토리 소실 보류 + 타 config 소속 skip.
399
+ if (!repoPath || !fs.existsSync(repoPath)) continue
400
+ if (opts.activeConfigPath && configPathForCwd(repoPath) !== opts.activeConfigPath) continue
401
+ // B6-3: identity pin 불일치 보류 (recoverProject 와 대칭).
402
+ if (identityPinMismatch(sid, token, opts)) continue
403
+ if (await recoverOne({ endpoint, token, userId, fp, sid, messages, hostname, repoPath, opts: codexOpts })) count++
404
+ }
405
+ return count
406
+ }
407
+
408
+ export async function sweepAll({ endpoint, token, userId, hostname = os.hostname(),
409
+ claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects'),
410
+ codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'), opts = {} }) {
411
+ let claude = 0, codex = 0
412
+ if (endpoint && token) {
413
+ if (fs.existsSync(claudeProjectsDir)) {
414
+ for (const name of fs.readdirSync(claudeProjectsDir)) {
415
+ const projectDir = path.join(claudeProjectsDir, name)
416
+ try { if (!fs.statSync(projectDir).isDirectory()) continue } catch { continue }
417
+ claude += await recoverProject({ endpoint, token, userId, projectDir, hostname, opts })
418
+ }
419
+ }
420
+ codex = await recoverCodex({ endpoint, token, userId, sessionsDir: codexSessionsDir, hostname, opts })
421
+ }
422
+ return { total: claude + codex, claude, codex }
423
+ }