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,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
|
+
}
|