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,185 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, cpSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join, dirname } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { apiGet } from '../api.js'
6
+ import { saveClientManifest } from './manifest.js'
7
+ import { upsertCodexHookBlock } from './codex-config.js'
8
+ import { claudeHookCommand, escapeJsonStringValue } from './hook-command.js'
9
+ import { mergeHooks } from './merge-hooks.js'
10
+
11
+ const __filename = fileURLToPath(import.meta.url)
12
+ const __dirname = dirname(__filename)
13
+ const TEMPLATES = join(__dirname, '..', '..', 'templates')
14
+ const VERSION_PATH = join(__dirname, '..', '..', 'version.json')
15
+
16
+ // scope 별 설치 베이스. global=홈, local=cwd. 테스트는 home/cwd 주입으로 실홈 미터치.
17
+ function resolveBase(scope, { home = homedir(), cwd = process.cwd() } = {}) {
18
+ return scope === 'global' ? home : cwd
19
+ }
20
+ function resolveBinDir(scope, opts) {
21
+ return join(resolveBase(scope, opts), '.rookie', 'bin')
22
+ }
23
+
24
+ // ── 1층: 공유 hook 런타임(~/.rookie/bin/) — 도구 무관 공용(Claude·Codex 둘 다 이 파일을 문다).
25
+ // 어느 도구든 1개+ 설정 시 먼저 보장한다(spec 2026-06-29-unified-connect-flow §4).
26
+ export function installSharedHookRuntime({ scope, home, cwd } = {}) {
27
+ const binDir = resolveBinDir(scope, { home, cwd })
28
+ mkdirSync(binDir, { recursive: true })
29
+ cpSync(join(TEMPLATES, 'hook', 'flush-session.sh'), join(binDir, 'flush-session.sh'))
30
+ cpSync(join(TEMPLATES, 'hook', 'session-start.sh'), join(binDir, 'session-start.sh'))
31
+ chmodSync(join(binDir, 'flush-session.sh'), 0o755)
32
+ chmodSync(join(binDir, 'session-start.sh'), 0o755)
33
+ // spec 2026-06-18 Phase 3 — cross-runtime sweep 진입점(launchd/수동). 등록은 두목 승인 후 수동.
34
+ cpSync(join(TEMPLATES, 'hook', 'sweep-recovery.sh'), join(binDir, 'sweep-recovery.sh'))
35
+ chmodSync(join(binDir, 'sweep-recovery.sh'), 0o755)
36
+
37
+ // 2026-06-03 — PreToolUse(Bash) 파괴적 DB·git 명령 가드.
38
+ cpSync(join(TEMPLATES, 'hook', 'guard-destructive-db.sh'), join(binDir, 'guard-destructive-db.sh'))
39
+ chmodSync(join(binDir, 'guard-destructive-db.sh'), 0o755)
40
+
41
+ // 2026-06-24 — UserPromptSubmit rookie Skill 로드 게이트(미로드 세션 상기).
42
+ cpSync(join(TEMPLATES, 'hook', 'user-prompt-skill-gate.sh'), join(binDir, 'user-prompt-skill-gate.sh'))
43
+ chmodSync(join(binDir, 'user-prompt-skill-gate.sh'), 0o755)
44
+
45
+ // v6 P1 #5 — Stage 1 측정 hook (PreToolUse).
46
+ const pretoolBinDir = join(binDir, 'bin')
47
+ mkdirSync(pretoolBinDir, { recursive: true })
48
+ cpSync(
49
+ join(TEMPLATES, 'hook', 'bin', 'pretool-md-read.sh'),
50
+ join(pretoolBinDir, 'pretool-md-read.sh')
51
+ )
52
+ chmodSync(join(pretoolBinDir, 'pretool-md-read.sh'), 0o755)
53
+
54
+ // spec 2026-07-10 §3.B2 — PostToolUse recall nudge (교정 전용, 측정 hook 과 분리).
55
+ cpSync(
56
+ join(TEMPLATES, 'hook', 'bin', 'posttool-recall-nudge.sh'),
57
+ join(pretoolBinDir, 'posttool-recall-nudge.sh')
58
+ )
59
+ chmodSync(join(pretoolBinDir, 'posttool-recall-nudge.sh'), 0o755)
60
+
61
+ // spec post-v5-decisions §17 — hook offline queue 라이브러리 배포
62
+ const libDir = join(binDir, 'lib')
63
+ mkdirSync(libDir, { recursive: true })
64
+ cpSync(join(TEMPLATES, 'hook', 'lib', 'queue.sh'), join(libDir, 'queue.sh'))
65
+ chmodSync(join(libDir, 'queue.sh'), 0o755)
66
+ // spec post-v5-decisions §17.2 H-2.1/H-2.2 — hot write retry + partial failure 재큐 dispatcher
67
+ cpSync(join(TEMPLATES, 'hook', 'lib', 'dispatcher.sh'), join(libDir, 'dispatcher.sh'))
68
+ chmodSync(join(libDir, 'dispatcher.sh'), 0o755)
69
+ // spec 2026-06-18 crash-recovery — transcript 변환 공유 helper + orphan flush recovery
70
+ cpSync(join(TEMPLATES, 'hook', 'lib', 'transcript.sh'), join(libDir, 'transcript.sh'))
71
+ chmodSync(join(libDir, 'transcript.sh'), 0o755)
72
+ cpSync(join(TEMPLATES, 'hook', 'lib', 'orphan-recovery.sh'), join(libDir, 'orphan-recovery.sh'))
73
+ chmodSync(join(libDir, 'orphan-recovery.sh'), 0o755)
74
+ // spec 2026-06-18 Phase 3 — Codex 어댑터 + cross-runtime sweep
75
+ cpSync(join(TEMPLATES, 'hook', 'lib', 'cross-runtime-sweep.sh'), join(libDir, 'cross-runtime-sweep.sh'))
76
+ chmodSync(join(libDir, 'cross-runtime-sweep.sh'), 0o755)
77
+ // 2026-06-24 — skill 로드 게이트 marker/epoch 공용 헬퍼 (session-start 가 source).
78
+ cpSync(join(TEMPLATES, 'hook', 'lib', 'skill-gate.sh'), join(libDir, 'skill-gate.sh'))
79
+ chmodSync(join(libDir, 'skill-gate.sh'), 0o755)
80
+ // B6 (spec 2026-07-11) — config 상향 탐색 + 세션 identity pinning 공용 헬퍼.
81
+ cpSync(join(TEMPLATES, 'hook', 'lib', 'config-resolve.sh'), join(libDir, 'config-resolve.sh'))
82
+ chmodSync(join(libDir, 'config-resolve.sh'), 0o755)
83
+
84
+ return { binDir }
85
+ }
86
+
87
+ // ── 2층: Claude adapter — ~/.claude/skills + settings.json 병합 + 클라이언트 manifest.
88
+ // 공유 런타임(1층)이 먼저 깔린 것을 전제로 settings 가 binDir 스크립트 경로를 가리킨다.
89
+ export async function applyClaudeSetup({ scope, cfg, home, cwd } = {}) {
90
+ const { cli_version: CLI_VERSION, skill_version: SKILL_VERSION } = JSON.parse(readFileSync(VERSION_PATH, 'utf8'))
91
+ const base = join(resolveBase(scope, { home, cwd }), '.claude')
92
+ const skillDir = join(base, 'skills', 'rookie')
93
+ const settingsPath = join(base, 'settings.json')
94
+ const binDir = resolveBinDir(scope, { home, cwd })
95
+ const pretoolBinDir = join(binDir, 'bin')
96
+
97
+ mkdirSync(skillDir, { recursive: true })
98
+ const skillRaw = readFileSync(join(TEMPLATES, 'skill', 'SKILL.md'), 'utf8')
99
+ const skillRendered = skillRaw
100
+ .replaceAll('__VERSION__', SKILL_VERSION)
101
+ .replaceAll('__SCOPE__', scope)
102
+ .replaceAll('__INSTALLED_AT__', new Date().toISOString())
103
+ .replaceAll('__INSTALLED_BY__', `rookie-cli@${CLI_VERSION}`)
104
+ writeFileSync(join(skillDir, 'SKILL.md'), skillRendered)
105
+
106
+ // Windows 는 claudeHookCommand 가 `bash "<posix>"` 로 감싼다(아래 hook-command.js). macOS/Linux 무변경.
107
+ const settingsResult = await mergeSettings(settingsPath, join(TEMPLATES, 'hook', 'settings.partial.json'), {
108
+ flushCmd: claudeHookCommand(join(binDir, 'flush-session.sh')),
109
+ sessionStartCmd: claudeHookCommand(join(binDir, 'session-start.sh')),
110
+ pretoolMdReadCmd: claudeHookCommand(join(pretoolBinDir, 'pretool-md-read.sh')),
111
+ posttoolRecallNudgeCmd: claudeHookCommand(join(pretoolBinDir, 'posttool-recall-nudge.sh')),
112
+ guardCmd: claudeHookCommand(join(binDir, 'guard-destructive-db.sh')),
113
+ skillGateCmd: claudeHookCommand(join(binDir, 'user-prompt-skill-gate.sh')),
114
+ })
115
+
116
+ // §18 U-3 — 클라이언트 manifest 저장. 다음 hook turn 부터 미스매치 경고 비교 가능.
117
+ if (cfg?.endpoint && cfg?.token) {
118
+ try {
119
+ const status = await apiGet(cfg.endpoint, cfg.token, '/api/rookie/status')
120
+ if (status?.templates?.manifest_hash) {
121
+ saveClientManifest(status.templates)
122
+ }
123
+ } catch {
124
+ // 서버 §18 미반영 또는 도달 실패 — manifest.json 생성 안 함.
125
+ // 다음 `rookie update` 또는 다음 hook turn 의 status 호출 시 자동 채워짐.
126
+ }
127
+ }
128
+
129
+ // settings.json 파싱 실패로 hook 병합을 건너뛴 경우, 경고를 호출자(login·connect-ui)로 전파한다.
130
+ return { skillDir, settingsPath, settingsWarning: settingsResult?.merged === false ? settingsResult.reason : null }
131
+ }
132
+
133
+ // ── 3층: Codex adapter — ~/.codex/config.toml 의 managed 블록(upsertCodexHookBlock).
134
+ // scope=global 전제(D3): Codex config 는 항상 전역이고 전역 hook 을 문다. 호출 게이트는 오케스트레이터 책임.
135
+ export function applyCodexSetup({ home = homedir() } = {}) {
136
+ const codexDir = join(home, '.codex')
137
+ const configPath = join(codexDir, 'config.toml')
138
+ const hookScript = join(home, '.rookie', 'bin', 'session-start.sh')
139
+ const existing = existsSync(configPath) ? readFileSync(configPath, 'utf8') : ''
140
+ let next
141
+ try {
142
+ next = upsertCodexHookBlock(existing, hookScript)
143
+ } catch (e) {
144
+ // marker 손상(begin 만 있고 end 없음) → 파일을 건드리지 않고 경고 신호만 반환(사용자 config 보호).
145
+ if (e?.code === 'MALFORMED_CODEX_MARKER') return { configPath, hookScript, error: 'malformed_marker' }
146
+ throw e
147
+ }
148
+ mkdirSync(codexDir, { recursive: true })
149
+ writeFileSync(configPath, next)
150
+ return { configPath, hookScript }
151
+ }
152
+
153
+ // init·login 공유 wrapper: 공유 런타임(1층) + Claude adapter(2층). 반환형은 기존과 동일.
154
+ // PAT 발급은 호출자 책임(여기선 cfg 를 받기만).
155
+ export async function deployClientArtifacts({ scope, cfg, home, cwd }) {
156
+ const { binDir } = installSharedHookRuntime({ scope, home, cwd })
157
+ const { skillDir, settingsPath } = await applyClaudeSetup({ scope, cfg, home, cwd })
158
+ return { skillDir, binDir, settingsPath }
159
+ }
160
+
161
+ async function mergeSettings(settingsPath, partialPath, subs) {
162
+ // placeholder 는 JSON 문자열 안 — 값을 JSON escape 해야 win32 의 bash "..." 가 JSON.parse 를 안 깬다.
163
+ const partialRaw = readFileSync(partialPath, 'utf8')
164
+ .replaceAll('__FLUSH_SESSION_CMD__', escapeJsonStringValue(subs.flushCmd))
165
+ .replaceAll('__SESSION_START_CMD__', escapeJsonStringValue(subs.sessionStartCmd))
166
+ .replaceAll('__PRETOOL_MD_READ_CMD__', escapeJsonStringValue(subs.pretoolMdReadCmd))
167
+ .replaceAll('__POSTTOOL_RECALL_NUDGE_CMD__', escapeJsonStringValue(subs.posttoolRecallNudgeCmd))
168
+ .replaceAll('__GUARD_DESTRUCTIVE_DB_CMD__', escapeJsonStringValue(subs.guardCmd))
169
+ .replaceAll('__USER_PROMPT_SKILL_GATE_CMD__', escapeJsonStringValue(subs.skillGateCmd))
170
+ const partial = JSON.parse(partialRaw)
171
+
172
+ let current = {}
173
+ if (existsSync(settingsPath)) {
174
+ try { current = JSON.parse(readFileSync(settingsPath, 'utf8')) }
175
+ catch {
176
+ // 기존 settings.json 이 깨진 JSON — 덮어쓰면 사용자 hook·설정이 소실된다. fail-closed:
177
+ // 병합을 건너뛰고 원본을 그대로 둔다. 호출자가 경고로 수동 점검을 안내한다(P1, codex).
178
+ return { merged: false, reason: 'settings_unparsable' }
179
+ }
180
+ }
181
+ const merged = mergeHooks(current, partial)
182
+ mkdirSync(dirname(settingsPath), { recursive: true })
183
+ writeFileSync(settingsPath, JSON.stringify(merged, null, 2))
184
+ return { merged: true }
185
+ }
@@ -0,0 +1,31 @@
1
+ // Windows 에서 Claude Code·Codex 는 hook command 를 Git Bash(bash)로 실행한다(공식 문서).
2
+ // Node path.join 이 만든 backslash 절대경로(C:\Users\..\x.sh)를 bash command 에 그대로 넣으면
3
+ // backslash 가 escape 로 해석돼 경로가 깨진다. → Windows 는 forward-slash 로 변환하고,
4
+ // Claude 는 .sh 절대경로를 그대로 등록하던 것을 `bash "<path>"` 로 감싼다.
5
+ // macOS/Linux 는 무변경 — 회귀 0 (platform !== 'win32' 면 입력을 그대로 반환).
6
+
7
+ export function toPosixPath(absPath, platform = process.platform) {
8
+ if (platform !== 'win32') return absPath
9
+ return absPath.replace(/\\/g, '/')
10
+ }
11
+
12
+ // POSIX 셸 작은따옴표 quoting — 내부 ' 는 '\'' 로 escape. 경로의 $·백틱·공백·' 를 모두 리터럴화한다.
13
+ // (큰따옴표 "..." 는 $·백틱 확장 위험, 작은따옴표 단순 래핑은 O'Brien 같은 ' 경로에서 깨짐 → 표준 quoting.)
14
+ export function shSingleQuote(s) {
15
+ return `'` + s.replace(/'/g, `'\\''`) + `'`
16
+ }
17
+
18
+ // Claude settings.json 의 hook command 한 줄.
19
+ // posix: .sh 절대경로 자체(Claude 가 sh -c 로 실행) — 기존 동작 유지.
20
+ // win32: `bash '<posix-path>'` — Git Bash 가 forward-slash 드라이브 경로를 이해. 경로는 표준 quoting.
21
+ export function claudeHookCommand(absPath, platform = process.platform) {
22
+ if (platform !== 'win32') return absPath
23
+ return `bash ${shSingleQuote(toPosixPath(absPath, platform))}`
24
+ }
25
+
26
+ // settings.partial.json 은 placeholder 를 JSON 문자열("") 안에 두고 raw 치환한다.
27
+ // win32 command 값 `bash "C:/..."` 의 큰따옴표를 그대로 박으면 JSON.parse 가 깨지므로,
28
+ // 치환 직전 \ 와 " 를 JSON 문자열 escape 한다. posix 값(따옴표·역슬래시 없음)엔 no-op.
29
+ export function escapeJsonStringValue(s) {
30
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
31
+ }
@@ -0,0 +1,87 @@
1
+ // 클라이언트측 manifest 헬퍼
2
+ // spec post-v5-decisions §18 — hook 자동 동기화
3
+ // ~/.rookie/manifest.json 읽기·쓰기 + 서버 hash 와 비교.
4
+
5
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync, readdirSync, statSync } from 'node:fs'
6
+ import { createHash } from 'node:crypto'
7
+ import { homedir } from 'node:os'
8
+ import { join, relative, sep } from 'node:path'
9
+
10
+ const MANIFEST_PATH = join(homedir(), '.rookie', 'manifest.json')
11
+
12
+ export function clientManifestPath() {
13
+ return MANIFEST_PATH
14
+ }
15
+
16
+ export function loadClientManifest() {
17
+ if (!existsSync(MANIFEST_PATH)) return null
18
+ try {
19
+ return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'))
20
+ } catch {
21
+ return null
22
+ }
23
+ }
24
+
25
+ export function saveClientManifest(manifest) {
26
+ if (!manifest || typeof manifest.manifest_hash !== 'string') {
27
+ throw new Error('saveClientManifest: invalid manifest')
28
+ }
29
+ mkdirSync(join(homedir(), '.rookie'), { recursive: true })
30
+ writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2))
31
+ try { chmodSync(MANIFEST_PATH, 0o600) } catch {}
32
+ }
33
+
34
+ // task 77b93db0 — 자기 번들 템플릿의 manifest 를 서버 산식 그대로 계산.
35
+ // 서버(src/lib/templates/manifest.ts · scripts/generate-template-manifest.mjs)와 **동일해야 한다** —
36
+ // update 가 "내 번들 == 서버 선언" 을 검증해, 구 템플릿을 설치하며 신규 hash 를 선기록하는 사고를 막는다.
37
+ const SKIP_NAME = /(^\.env)|(^\.env\.)|(\.key$)|(\.pem$)|(credentials)|(^\.DS_Store$)|(\.test\.(ts|js|sh)$)|(\.spec\.(ts|js|sh)$)/i
38
+
39
+ function walkSorted(dir, base = dir) {
40
+ const out = []
41
+ let entries
42
+ try {
43
+ entries = readdirSync(dir)
44
+ } catch {
45
+ return out
46
+ }
47
+ for (const entry of entries) {
48
+ if (SKIP_NAME.test(entry)) continue
49
+ const full = join(dir, entry)
50
+ let st
51
+ try {
52
+ st = statSync(full)
53
+ } catch {
54
+ continue
55
+ }
56
+ if (st.isDirectory()) out.push(...walkSorted(full, base))
57
+ // 서버 manifest 는 POSIX 에서 생성되므로 Windows(sep='\\') 도 '/' 키로 정규화해야 hash 가 맞는다.
58
+ else if (st.isFile()) out.push(relative(base, full).split(sep).join('/'))
59
+ }
60
+ return out.sort()
61
+ }
62
+
63
+ function sha256Hex(input) {
64
+ return createHash('sha256').update(input).digest('hex')
65
+ }
66
+
67
+ export function computeBundleManifest(templatesDir, skillVersion) {
68
+ const files = {}
69
+ for (const rel of walkSorted(templatesDir)) {
70
+ files[rel] = sha256Hex(readFileSync(join(templatesDir, ...rel.split('/'))))
71
+ }
72
+ const manifest_hash = sha256Hex(
73
+ JSON.stringify({ skill_version: skillVersion, files: Object.fromEntries(Object.entries(files).sort()) })
74
+ )
75
+ return { manifest_hash, skill_version: skillVersion, files }
76
+ }
77
+
78
+ // returns { match: boolean, server_hash: string, local_hash: string|null }
79
+ export function compareManifest(serverHash, local = null) {
80
+ const localManifest = local ?? loadClientManifest()
81
+ const localHash = localManifest?.manifest_hash ?? null
82
+ return {
83
+ match: typeof localHash === 'string' && localHash === serverHash,
84
+ server_hash: serverHash,
85
+ local_hash: localHash,
86
+ }
87
+ }
@@ -0,0 +1,31 @@
1
+ // spec 2026-07-10 recall-first 회귀 수정 §3.C — settings.json hook 병합 공유 모듈.
2
+ // 배경: Claude Code 가 settings.json 을 재작성할 때 스키마 밖 키(__rookie_managed)를
3
+ // 제거한다. 마커만 믿는 병합은 기존 rookie 항목을 사용자 항목으로 오인해 append 만
4
+ // 반복 → 항목 증식(실측 3중). 판정 주축을 hook command 의 경로 서명으로 옮긴다.
5
+ // deploy-artifacts.js(init/login)와 update.js(rookie update)가 함께 import 한다 —
6
+ // 한쪽만 고치면 실배포 경로에서 자가 치유가 빠진다(codex R2-P1).
7
+
8
+ // refresh-context.sh 포함 — 과거 UserPromptSubmit 로 실배포됐던 legacy hook.
9
+ // 마커 소실 구버전 설정에서 이를 보존하면 매 turn 대용량 주입 회귀가 남는다(codex R3-P1).
10
+ export const ROOKIE_HOOK_SCRIPTS =
11
+ /[\\/]\.rookie[\\/]bin[\\/](?:bin[\\/])?(?:flush-session|session-start|user-prompt-skill-gate|guard-destructive-db|pretool-md-read|posttool-recall-nudge|sweep-recovery|refresh-context)\.sh\b/
12
+
13
+ export function isRookieEntry(entry) {
14
+ if (!entry || typeof entry !== 'object') return false
15
+ if (entry.__rookie_managed) return true
16
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : []
17
+ return hooks.some(
18
+ (h) => typeof h?.command === 'string' && ROOKIE_HOOK_SCRIPTS.test(h.command)
19
+ )
20
+ }
21
+
22
+ export function mergeHooks(existing, partial) {
23
+ const out = { ...existing }
24
+ out.hooks = { ...(existing.hooks ?? {}) }
25
+ for (const [event, entries] of Object.entries(partial.hooks ?? {})) {
26
+ const existingEntries = out.hooks[event] ?? []
27
+ const nonRookie = existingEntries.filter((e) => !isRookieEntry(e))
28
+ out.hooks[event] = [...nonRookie, ...entries]
29
+ }
30
+ return out
31
+ }
@@ -0,0 +1,113 @@
1
+ // B3 (spec 2026-07-11-memory-reliability-fix-design §B3) — orphan-extract 초안 생성 코어.
2
+ // LLM 없이 rule-base 후보만 뽑는다: 마지막 user/assistant 요약 라인 · 파일 경로 · git sha.
3
+ // 저장 판단은 세션 LLM/사용자 몫(서버 LLM 추출 금지 원칙 유지). stdout 위생은 redact-lite 경유.
4
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
5
+ import { join } from 'node:path'
6
+ import { redactDraftText, hashId8 } from './redact-lite.js'
7
+
8
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
9
+
10
+ // codex 세션은 marker 가 codex 서브루트에 있어 ROOKIE_ORPHAN_DIR 를 실어야 큐에서 빠진다.
11
+ export function markerCommand(entry) {
12
+ const prefix = entry.markerRoot ? `ROOKIE_ORPHAN_DIR="${entry.markerRoot}" ` : ''
13
+ return `${prefix}orphan_mark_client_extracted ${entry.sid}`
14
+ }
15
+
16
+ // pending-extraction marker 수집 (Claude 루트 + codex 서브루트) — 오래된 것부터.
17
+ export function listPendingExtractions({ orphanRoot }) {
18
+ const out = []
19
+ for (const runtime of ['claude', 'codex']) {
20
+ const dir = runtime === 'claude'
21
+ ? join(orphanRoot, 'pending-extraction')
22
+ : join(orphanRoot, 'codex', 'pending-extraction')
23
+ if (!existsSync(dir)) continue
24
+ let names
25
+ try { names = readdirSync(dir).filter((f) => f.endsWith('.json')) } catch { continue }
26
+ for (const name of names) {
27
+ const sid = name.slice(0, -5)
28
+ if (!UUID_RE.test(sid)) continue
29
+ try {
30
+ const m = JSON.parse(readFileSync(join(dir, name), 'utf8'))
31
+ out.push({
32
+ sid,
33
+ runtime,
34
+ transcriptPath: m.transcript_path || '',
35
+ recoveredAt: m.recovered_at || null,
36
+ // codex marker 는 codex 서브루트에 산다 — marker 명령이 이 루트를 겨냥해야
37
+ // 큐에서 실제로 빠진다(코드검토 P2). orphan-recovery.sh 의 ROOKIE_ORPHAN_DIR 규약.
38
+ markerRoot: runtime === 'codex' ? join(orphanRoot, 'codex') : null,
39
+ })
40
+ } catch { /* 깨진 marker — skip (다음 기회) */ }
41
+ }
42
+ }
43
+ out.sort((a, b) => {
44
+ const ta = a.recoveredAt ? Date.parse(a.recoveredAt) : Infinity
45
+ const tb = b.recoveredAt ? Date.parse(b.recoveredAt) : Infinity
46
+ return ta - tb
47
+ })
48
+ return out
49
+ }
50
+
51
+ // 스캐폴딩(주입 지시문) 메시지 제외 — 초안 노이즈 차단.
52
+ function isScaffold(content) {
53
+ const head = content.slice(0, 60)
54
+ return (
55
+ head.startsWith('# AGENTS.md') ||
56
+ head.startsWith('<environment_context>') ||
57
+ head.startsWith('<permissions instructions>') ||
58
+ head.startsWith('[Rookie]')
59
+ )
60
+ }
61
+
62
+ const PATH_RE = /(?:^|[\s"'`(])((?:\/[A-Za-z0-9._@-]+){2,})/g
63
+ const SHA_RE = /\b[0-9a-f]{7,40}\b/g
64
+
65
+ // messages = [{role, content}] (sweep-core parse 결과) → rule-base 초안.
66
+ export function buildDraft(messages, { userMax = 300, assistantMax = 400, pathCap = 10, shaCap = 5 } = {}) {
67
+ const real = messages.filter((m) => m.content && !isScaffold(m.content))
68
+ const lastOf = (role) => {
69
+ for (let i = real.length - 1; i >= 0; i--) if (real[i].role === role) return real[i].content
70
+ return null
71
+ }
72
+ const lastUser = lastOf('user')
73
+ const lastAssistant = lastOf('assistant')
74
+
75
+ const paths = new Set()
76
+ const shas = new Set()
77
+ for (const m of real) {
78
+ for (const match of m.content.matchAll(PATH_RE)) {
79
+ if (paths.size >= pathCap) break
80
+ paths.add(match[1])
81
+ }
82
+ for (const match of m.content.matchAll(SHA_RE)) {
83
+ if (shas.size >= shaCap) break
84
+ const s = match[0]
85
+ // uuid 조각·순수 숫자 배제 — git sha 는 대개 7~12자 + 영문자 혼재.
86
+ if (s.length > 12 || !/[a-f]/.test(s) || !/\d/.test(s)) continue
87
+ shas.add(s)
88
+ }
89
+ }
90
+ return {
91
+ lastUser: lastUser ? lastUser.slice(0, userMax) : null,
92
+ lastAssistant: lastAssistant ? lastAssistant.slice(0, assistantMax) : null,
93
+ filePaths: [...paths],
94
+ gitShas: [...shas],
95
+ }
96
+ }
97
+
98
+ // 초안 렌더 (전 라인 redact 통과). sid 는 후속 처리(orphan_mark_client_extracted)에 필요해
99
+ // 처리 명령 라인에만 원문 표기 — 본인 로컬 세션 id 로, SessionStart 🛟 블록과 동일 노출 수준.
100
+ // 본문·요약 안의 UUID·민감정보는 redact-lite 가 마스킹한다(§10d/e).
101
+ export function renderDraft(entry, draft) {
102
+ const lines = []
103
+ const clean = (s) => redactDraftText(s).redacted.replace(/\s+/g, ' ').trim()
104
+ lines.push(`◦ [${entry.runtime}] 세션 ${hashId8(entry.sid)} (recovered ${entry.recoveredAt ?? 'unknown'})`)
105
+ if (draft.lastUser) lines.push(` 마지막 user: ${clean(draft.lastUser)}`)
106
+ if (draft.lastAssistant) lines.push(` 마지막 assistant: ${clean(draft.lastAssistant)}`)
107
+ if (draft.filePaths.length) lines.push(` 파일 경로: ${draft.filePaths.map((p) => clean(p)).join(' · ')}`)
108
+ if (draft.gitShas.length) lines.push(` git sha: ${draft.gitShas.join(' · ')}`)
109
+ if (!draft.lastUser && !draft.lastAssistant) lines.push(' (요약할 본문 없음 — transcript 직접 확인 필요)')
110
+ lines.push(` → 판단 후 저장(batch-mutate) → 완료 marker: ${markerCommand(entry)}`)
111
+ lines.push(` transcript: ${entry.transcriptPath || '(경로 없음)'}`)
112
+ return lines
113
+ }
@@ -0,0 +1,49 @@
1
+ // P4.3 2차-b (plan 2026-06-22-memory-reliability-recurrence-prevention §P4) —
2
+ // orphan_pending_age 관측. 비정상 종료 세션의 tail 추출 대기 큐
3
+ // (~/.rookie/orphan-recovery/pending-extraction/*.json) 가 얼마나 오래/많이 밀렸는지
4
+ // doctor 가 한 줄로 보여준다. 데이터 소스가 서버 DB 가 아닌 로컬 hook 파일이라 doctor 영역.
5
+ // pending marker 는 {status,transcript_path,recovered_at,message_count} (orphan-recovery.sh:119).
6
+
7
+ const DAY_MS = 24 * 60 * 60 * 1000
8
+
9
+ // markers = 파싱된 pending marker 객체 배열({recovered_at} 사용). nowMs 기준 최고령 산출.
10
+ // 반환: count(전체 대기) / oldestRecoveredAt(ISO|null) / oldestAgeDays(floor, 없으면 0)
11
+ export function summarizeOrphanPending(markers, nowMs) {
12
+ let oldestTs = null
13
+ for (const m of markers) {
14
+ const ts = m && m.recovered_at ? Date.parse(m.recovered_at) : NaN
15
+ if (Number.isNaN(ts)) continue // 시점 불명 → 최고령에서 보수적 제외(count 엔 포함)
16
+ if (oldestTs === null || ts < oldestTs) oldestTs = ts
17
+ }
18
+ return {
19
+ count: markers.length,
20
+ oldestRecoveredAt: oldestTs === null ? null : new Date(oldestTs).toISOString(),
21
+ oldestAgeDays: oldestTs === null ? 0 : Math.floor((nowMs - oldestTs) / DAY_MS),
22
+ }
23
+ }
24
+
25
+ // ── B3 (spec 2026-07-11-memory-reliability-fix-design §B3) — 적체 임계 + expired 손실 ──
26
+
27
+ const HOUR_MS = 60 * 60 * 1000
28
+
29
+ // B3 임계: 미추출 > 5건 또는 최고령 > 24h (tail TTL 3일보다 짧게 — codex 1R P1-3).
30
+ export const BACKLOG_MAX_COUNT = 5
31
+ export const BACKLOG_MAX_AGE_HOURS = 24
32
+
33
+ // pending marker 배열(멀티 루트 합산) + expired 누적 → backlog 요약.
34
+ // oldestAgeHours 는 floor — 24h 임계 비교용 정밀도.
35
+ export function summarizeOrphanBacklog({ pendingMarkers, expiredCount, nowMs }) {
36
+ const base = summarizeOrphanPending(pendingMarkers, nowMs)
37
+ let oldestTs = base.oldestRecoveredAt === null ? null : Date.parse(base.oldestRecoveredAt)
38
+ return {
39
+ count: base.count,
40
+ oldestRecoveredAt: base.oldestRecoveredAt,
41
+ oldestAgeDays: base.oldestAgeDays,
42
+ oldestAgeHours: oldestTs === null ? 0 : Math.floor((nowMs - oldestTs) / HOUR_MS),
43
+ expiredCount: Number.isFinite(expiredCount) ? expiredCount : 0,
44
+ }
45
+ }
46
+
47
+ export function orphanBacklogWarn(s) {
48
+ return s.count > BACKLOG_MAX_COUNT || s.oldestAgeHours > BACKLOG_MAX_AGE_HOURS
49
+ }
@@ -0,0 +1,11 @@
1
+ // SessionStart hook stdin 의 cwd 를 안전 추출. Claude·Codex 공통 페이로드 필드.
2
+ // Codex hook 은 실행 디렉토리가 프로젝트와 다를 수 있어 stdin cwd 가 정확하다.
3
+ export function parseHookCwd(stdinBody, fallback) {
4
+ try {
5
+ const j = JSON.parse(stdinBody)
6
+ const cwd = (j.cwd || '').toString().trim()
7
+ return cwd || fallback
8
+ } catch {
9
+ return fallback
10
+ }
11
+ }
@@ -0,0 +1,14 @@
1
+ // device/token 폴링 응답 → CLI 다음 동작. 네트워크와 분리해 테스트 가능하게.
2
+ export function interpretPoll(resp, interval) {
3
+ switch (resp?.status) {
4
+ case 'approved':
5
+ return { action: 'done', token: resp.token, user_id: resp.user_id, label: resp.label ?? null, agent_name: resp.agent_name ?? null, interval }
6
+ case 'authorization_pending':
7
+ return { action: 'wait', interval }
8
+ case 'slow_down':
9
+ return { action: 'wait', interval: interval + 5 }
10
+ case 'expired':
11
+ default:
12
+ return { action: 'stop', reason: 'expired', interval }
13
+ }
14
+ }
@@ -0,0 +1,55 @@
1
+ // B3 (spec 2026-07-11-memory-reliability-fix-design §B3) — orphan-extract stdout 위생.
2
+ // 서버 redactor(src/lib/privacy/redactor.ts REDACTION_PATTERNS)의 CLI 포팅.
3
+ // CLI 는 Node 내장만 쓰므로 서버 모듈을 import 하지 못한다 — 패턴을 동일하게 복제하고,
4
+ // 서버 쪽이 바뀌면 여기도 함께 갱신한다(패턴 원본 = redactor.ts, 순서 동일).
5
+ import { createHash } from 'node:crypto'
6
+
7
+ // 순서 중요: 구체 패턴 먼저 (서버 REDACTION_PATTERNS 와 동일).
8
+ export const REDACTION_PATTERNS = [
9
+ { kind: 'pem_block', pattern: /-----BEGIN [A-Z ]+-----[\s\S]+?-----END [A-Z ]+-----/g },
10
+ { kind: 'anthropic_key', pattern: /sk-ant-[A-Za-z0-9_-]{20,}/g },
11
+ { kind: 'openai_key', pattern: /\bsk-(?!ant-)[A-Za-z0-9_-]{20,}\b/g },
12
+ { kind: 'slack_token', pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g },
13
+ { kind: 'github_token', pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{60,})\b/g },
14
+ { kind: 'rookie_pat', pattern: /\brk_[a-fA-F0-9]{64}\b/g },
15
+ { kind: 'aws_access_key', pattern: /\bAKIA[0-9A-Z]{16}\b/g },
16
+ {
17
+ kind: 'aws_secret',
18
+ pattern: /\b(?:aws_secret_access_key|AWS_SECRET_ACCESS_KEY)\s*[:=]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/g,
19
+ },
20
+ { kind: 'jwt', pattern: /\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g },
21
+ { kind: 'ssn_kr', pattern: /\b\d{6}-[1-4]\d{6}\b/g },
22
+ { kind: 'credit_card', pattern: /\b(?:\d{4}[ -]){3}\d{4}\b/g },
23
+ {
24
+ kind: 'hex_secret_labeled',
25
+ pattern: /(?:token|key|secret|password|pw|api[_-]?key|auth|session)\s*[:=]\s*['"]?[a-fA-F0-9]{24,}['"]?/gi,
26
+ },
27
+ { kind: 'env_assignment', pattern: /^[A-Z][A-Z0-9_]{3,}=\S{8,}$/gm },
28
+ {
29
+ kind: 'password_label',
30
+ pattern: /(?:password|passwd|비밀번호|pw)\s*[:=]\s*(?:['"][^'"\n]{6,}['"]|[^\s'"\n][^\n]{5,})/gi,
31
+ },
32
+ ]
33
+
34
+ // 서버 log-redact.ts hashId8 과 동일 산식 (sha256 hex 앞 8자).
35
+ export function hashId8(id) {
36
+ if (!id) return '∅'
37
+ return createHash('sha256').update(String(id)).digest('hex').slice(0, 8)
38
+ }
39
+
40
+ const UUID_IN_TEXT_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi
41
+
42
+ // stdout 초안 위생: 민감정보 → [REDACTED:<kind>], 본문 내 UUID → hashId8 마스킹 (§10d/e).
43
+ export function redactDraftText(text) {
44
+ if (!text) return { redacted: text ?? '', totalHits: 0 }
45
+ let out = text
46
+ let totalHits = 0
47
+ for (const p of REDACTION_PATTERNS) {
48
+ out = out.replace(p.pattern, () => {
49
+ totalHits++
50
+ return `[REDACTED:${p.kind}]`
51
+ })
52
+ }
53
+ out = out.replace(UUID_IN_TEXT_RE, (m) => `uuid:${hashId8(m.toLowerCase())}`)
54
+ return { redacted: out, totalHits }
55
+ }
@@ -0,0 +1,8 @@
1
+ // endpoint 결정 우선순위: 플래그 > env(ROOKIE_ENDPOINT) > 기존 config > 기본값(therookie.kr).
2
+ // 공개 사용자가 endpoint 를 손입력하지 않게, install.sh 는 ROOKIE_ENDPOINT 로 비대화 주입.
3
+ const DEFAULT_ENDPOINT = 'https://therookie.kr'
4
+
5
+ export function resolveEndpoint({ flag, env, config } = {}) {
6
+ const pick = flag || env || config || DEFAULT_ENDPOINT
7
+ return pick.replace(/\/$/, '')
8
+ }