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,710 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { execSync } from 'node:child_process'
5
+ import { detectGlobalInstall, detectLocalInstall, deepScanForLocalInstalls } from '../detect.js'
6
+ import { resolveConfig, getHostname } from '../config.js'
7
+ import { apiGet } from '../api.js'
8
+ import { accountLine, accountWarning } from '../lib/account-ui.js'
9
+ import { loadClientManifest } from '../lib/manifest.js'
10
+ import {
11
+ summarizeOrphanBacklog,
12
+ orphanBacklogWarn,
13
+ BACKLOG_MAX_COUNT,
14
+ BACKLOG_MAX_AGE_HOURS,
15
+ } from '../lib/orphan-pending.js'
16
+ import { classifyDeadLetters, readDeadLetterEntries } from '../lib/dead-letter.js'
17
+ import { isRookieEntry } from '../lib/merge-hooks.js'
18
+ import { computeReport } from '../lib/context-diet.js'
19
+ import { printHuman as printDietReport } from './context-diet.js'
20
+
21
+ // spec 2026-07-13 NFC — 서버 local_path 는 NFC 통일, macOS cwd 는 NFD 라 raw === 는
22
+ // 한글 경로에서 false warning("현재 cwd 미매핑")을 낸다. 양쪽 NFC 로 맞춰 비교.
23
+ export function samePathNFC(a, b) {
24
+ return a.normalize('NFC') === b.normalize('NFC')
25
+ }
26
+
27
+ export async function runDoctor(flags) {
28
+ // B3 (spec 2026-07-11 §B3) — 사유별 집계 상세 모드. 전체 진단 없이 dead letter 만 본다.
29
+ if (flags['dead-letters']) {
30
+ printDeadLetterReport()
31
+ return
32
+ }
33
+ const g = detectGlobalInstall()
34
+ const l = detectLocalInstall()
35
+ console.log('== Rookie Doctor ==')
36
+ console.log(`hostname: ${getHostname()}`)
37
+
38
+ console.log(g ? `✓ 글로벌 Skill (v${g.version})` : '✗ 글로벌 Skill 없음')
39
+ console.log(l ? `✓ 로컬 Skill (v${l.version})` : ` 로컬 Skill 없음 (${process.cwd()})`)
40
+
41
+ const gSettings = join(homedir(), '.claude', 'settings.json')
42
+ const lSettings = join(process.cwd(), '.claude', 'settings.json')
43
+ console.log(hasRookieHook(gSettings) ? '✓ 글로벌 hook 등록됨' : '✗ 글로벌 hook 없음')
44
+ console.log(hasRookieHook(lSettings) ? '✓ 로컬 hook 등록됨' : ' 로컬 hook 없음')
45
+
46
+ if (g && l && g.version !== l.version) {
47
+ console.log(`⚠ 버전 불일치: 글로벌 v${g.version} vs 로컬 v${l.version}`)
48
+ console.log(' → 해결: rookie unlink --local 또는 rookie init --local')
49
+ }
50
+
51
+ // B6-1: env > 로컬(상향 탐색) > 전역 해석 — 어느 config 로 진단 중인지 표시.
52
+ const resolvedCfg = resolveConfig()
53
+ const cfg = resolvedCfg.config
54
+ if (cfg && resolvedCfg.scope !== 'global') {
55
+ console.log(`— config: [${resolvedCfg.scope}] ${resolvedCfg.path ?? '(env 주입)'}`)
56
+ }
57
+ let serverStatus = null
58
+ if (cfg) {
59
+ try {
60
+ serverStatus = await apiGet(cfg.endpoint, cfg.token, '/api/rookie/status')
61
+ console.log(`✓ 앱 연결 (version=${serverStatus.version})`)
62
+ // B1-2 (spec 2026-07-11 §B1): 계정 요약 + 빈-계정 경고(⚠️ 레벨, 중립 — 금지).
63
+ const acctLine = accountLine(serverStatus?.account)
64
+ if (acctLine) console.log(acctLine)
65
+ const acctWarn = accountWarning(serverStatus?.account)
66
+ if (acctWarn) console.log(acctWarn)
67
+ } catch (e) {
68
+ console.log(`✗ 앱 연결 실패: ${e.message}`)
69
+ }
70
+ try {
71
+ const { paths } = await apiGet(
72
+ cfg.endpoint,
73
+ cfg.token,
74
+ `/api/rookie/projects/local-paths?hostname=${encodeURIComponent(getHostname())}`
75
+ )
76
+ if (paths.length > 0) {
77
+ console.log(`— DB 등록된 로컬 경로 ${paths.length}개`)
78
+ const cwd = process.cwd()
79
+ let staleCount = 0
80
+ let cwdMatched = false
81
+ for (const p of paths) {
82
+ const exists = existsSync(p.local_path)
83
+ const isCwd = samePathNFC(p.local_path, cwd)
84
+ if (isCwd) cwdMatched = true
85
+ if (!exists) staleCount++
86
+ const marker = !exists ? '⚠ stale' : isCwd ? '◎ 현재 cwd' : ' '
87
+ console.log(` ${marker} ${p.local_path} (${p.install_scope})`)
88
+ }
89
+ if (staleCount > 0) {
90
+ console.log(
91
+ ` → stale 매핑 ${staleCount}건: 디렉토리가 사라졌거나 이전됨. 정정은 별도 운영 작업 필요.`
92
+ )
93
+ }
94
+ if (!cwdMatched) {
95
+ console.log(
96
+ ` → 현재 cwd 미매핑: ${cwd}. repo_url 1차 매칭이 살아 있으면 운영 영향 없음. 백업 매칭 필요시 'rookie link'.`
97
+ )
98
+ }
99
+ } else {
100
+ // B2 (spec 2026-07-11 §B2): 무귀속 열화 모드 — cwd 가 git repo(remote 존재)인데
101
+ // 이 hostname 등록 경로가 0 이면 중립이 아니라 ⚠️ + 처방(rookie link).
102
+ let hasRemote = false
103
+ try {
104
+ hasRemote = !!execSync('git remote get-url origin', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
105
+ } catch {}
106
+ if (hasRemote) {
107
+ console.log(`⚠️ DB 등록된 로컬 경로 0개 (이 hostname 기준) — 기억이 프로젝트 무귀속(열화 모드)으로 저장됩니다. 처방: rookie link`)
108
+ } else {
109
+ console.log(`— DB 등록된 로컬 경로 0개 (이 hostname 기준)`)
110
+ }
111
+ }
112
+ } catch {}
113
+ } else {
114
+ console.log('✗ ~/.rookie/config.json 없음 — rookie init 먼저')
115
+ }
116
+
117
+ printTemplateManifestStatus(serverStatus)
118
+ printQueueStatus()
119
+ printOrphanPending()
120
+ printIdentityMismatch()
121
+ await printStage1Metric(cfg)
122
+ await printDedupMetric(cfg)
123
+ await printRecallMetric(cfg)
124
+ await printDirectiveMetric(cfg)
125
+ await printHotWriteMetric(cfg)
126
+ await printAttributionMetric(cfg)
127
+ await printPromotionMetric(cfg)
128
+ await printViolationTop(cfg)
129
+ await printEmbeddingGuard(cfg)
130
+ await printInjectionSize(cfg)
131
+ printContextDiet()
132
+
133
+ if (flags['deep-scan']) {
134
+ const found = deepScanForLocalInstalls([homedir()], 3)
135
+ if (found.length > 0) {
136
+ console.log(`— deep-scan 발견 로컬 Skill ${found.length}개`)
137
+ for (const f of found) console.log(` - ${f.projectDir} v${f.version}`)
138
+ }
139
+ }
140
+ }
141
+
142
+ // spec 2026-07-10-context-diet-design.md §2.4 — 상시 출력. 파일 크기 측정은 fresh,
143
+ // transcript 사용 이력만 7일 캐시 재사용(cache-only — doctor 가 전체 transcript 를 다시 훑지 않는다).
144
+ function printContextDiet() {
145
+ console.log('— 컨텍스트 다이어트 (설치 자산 proxy)')
146
+ try {
147
+ const report = computeReport({ usageMode: 'cache-only' })
148
+ printDietReport(report, (line) => console.log(` ${line}`))
149
+ const snoozeKeys = Object.keys(report.snooze || {})
150
+ if (snoozeKeys.length) console.log(` snooze 중: ${snoozeKeys.join(', ')}`)
151
+ } catch (e) {
152
+ console.log(` ✗ 측정 실패: ${e.message}`)
153
+ }
154
+ }
155
+
156
+ function hasRookieHook(settingsPath) {
157
+ if (!existsSync(settingsPath)) return false
158
+ try {
159
+ const s = JSON.parse(readFileSync(settingsPath, 'utf8'))
160
+ const events = s.hooks ?? {}
161
+ for (const entries of Object.values(events)) {
162
+ for (const entry of entries ?? []) {
163
+ // spec 2026-07-10 — Claude Code 가 마커를 제거해도 경로 서명으로 감지.
164
+ if (isRookieEntry(entry)) return true
165
+ }
166
+ }
167
+ return false
168
+ } catch {
169
+ return false
170
+ }
171
+ }
172
+
173
+ // spec 2026-07-10 §3.D — SessionStart 주입 크기 점검. Claude Code 는 hook 출력이
174
+ // 10,000자를 넘으면 인라인 대신 파일 persist + preview 만 주입한다(v2.1.191+).
175
+ // hook 과 동일한 inject_budget 으로 context 를 받아 임계 초과(=예산제 회귀·구서버)를 잡는다.
176
+ const CLAUDE_HOOK_OUTPUT_CAP = 10000
177
+ const HOOK_INJECT_BUDGET = 8500
178
+ async function printInjectionSize(cfg) {
179
+ if (!cfg?.endpoint || !cfg?.token) return
180
+ try {
181
+ const ctx = await apiGet(
182
+ cfg.endpoint,
183
+ cfg.token,
184
+ `/api/rookie/context?inject_budget=${HOOK_INJECT_BUDGET}`
185
+ )
186
+ const size = typeof ctx?.system_prompt === 'string' ? ctx.system_prompt.length : null
187
+ if (size === null) return
188
+ if (size > CLAUDE_HOOK_OUTPUT_CAP) {
189
+ console.log(
190
+ `⚠ SessionStart 주입 ${size}자 — Claude Code ${CLAUDE_HOOK_OUTPUT_CAP}자 임계 초과, 인라인 미주입(회귀). 서버 inject_budget 미반영 여부 확인 필요`
191
+ )
192
+ } else {
193
+ console.log(`✓ SessionStart 주입 크기 ${size}자 (임계 ${CLAUDE_HOOK_OUTPUT_CAP}자 이내)`)
194
+ }
195
+ } catch {
196
+ // silent — endpoint 미응답 시 본 라인만 누락.
197
+ }
198
+ }
199
+
200
+ // spec post-v5-decisions §18 — templates manifest 비교 가시화
201
+ function printTemplateManifestStatus(serverStatus) {
202
+ const local = loadClientManifest()
203
+ const localHash = local?.manifest_hash ?? null
204
+ const serverHash = serverStatus?.templates?.manifest_hash ?? null
205
+
206
+ if (!serverHash) {
207
+ if (localHash) {
208
+ console.log(`— templates: 로컬 manifest ${localHash.slice(0, 12)}… / 서버 미응답`)
209
+ }
210
+ return
211
+ }
212
+
213
+ if (!localHash) {
214
+ console.log(`⚠ templates: 로컬 manifest 없음 (옛 install) / 서버 ${serverHash.slice(0, 12)}…`)
215
+ console.log(` → \`rookie update\` 실행 권장`)
216
+ return
217
+ }
218
+
219
+ if (localHash === serverHash) {
220
+ console.log(`✓ templates manifest 동기화 (${localHash.slice(0, 12)}…)`)
221
+ } else {
222
+ console.log(`⚠ templates manifest 미스매치`)
223
+ console.log(` 서버: ${serverHash.slice(0, 12)}…`)
224
+ console.log(` 로컬: ${localHash.slice(0, 12)}…`)
225
+ console.log(` → \`rookie update\` 실행 권장`)
226
+ }
227
+ }
228
+
229
+ // spec post-v5-decisions §17 (2026-05-05 v6 P1 #3 §17.3 보강) — hook offline queue 가시화
230
+ // 형식: endpoint 별 카운트 + oldest_queued_at ISO + age + dead_letters + last_success
231
+ function printQueueStatus() {
232
+ const queueDir = join(homedir(), '.rookie', 'pending')
233
+ const deadDir = join(homedir(), '.rookie', 'dead-letter')
234
+ const lastSuccessFile = join(homedir(), '.rookie', 'last_success')
235
+
236
+ let lastSuccessLine = ''
237
+ if (existsSync(lastSuccessFile)) {
238
+ try {
239
+ const ts = readFileSync(lastSuccessFile, 'utf8').trim()
240
+ const t = Date.parse(ts)
241
+ if (!isNaN(t)) {
242
+ const minsAgo = Math.max(0, Math.floor((Date.now() - t) / 60000))
243
+ lastSuccessLine = `last hook success: ${minsAgo}분 전 (${ts})`
244
+ }
245
+ } catch {}
246
+ }
247
+ if (!lastSuccessLine) lastSuccessLine = 'last hook success: 이력 없음'
248
+ console.log(`— ${lastSuccessLine}`)
249
+
250
+ // B3 — 카운트만이 아니라 최근 실패 사유까지 라벨로. 상세 집계는 doctor --dead-letters.
251
+ const dead = classifyDeadLetters(readDeadLetterEntries(deadDir))
252
+ const deadLabel =
253
+ dead.total === 0
254
+ ? '0'
255
+ : `${dead.total} (최근 ${dead.recent ? `${dead.recent.kind}/${dead.recent.bucket}` : 'unknown'} — 상세: rookie doctor --dead-letters)`
256
+
257
+ if (!existsSync(queueDir)) {
258
+ console.log(`— queue: clean (디렉토리 없음) / dead_letters: ${deadLabel}`)
259
+ return
260
+ }
261
+ let files
262
+ try {
263
+ files = readdirSync(queueDir).filter((f) => f.endsWith('.jsonl'))
264
+ } catch {
265
+ console.log('— queue: 읽기 실패')
266
+ return
267
+ }
268
+ if (files.length === 0) {
269
+ console.log(`— queue: clean / dead_letters: ${deadLabel}`)
270
+ return
271
+ }
272
+ // §17.2 H-2.2 — endpoint 분류 (mutate/retry/flush)
273
+ const endpoints = { 'batch-mutate': 0, 'mutate-retry': 0, 'flush-session': 0 }
274
+ let totalLines = 0
275
+ let oldestTs = null
276
+ let maxRetry = 0
277
+ for (const f of files) {
278
+ if (f.endsWith('-mutate-retry.jsonl')) endpoints['mutate-retry']++
279
+ else if (f.endsWith('-mutate.jsonl')) endpoints['batch-mutate']++
280
+ else endpoints['flush-session']++
281
+ try {
282
+ const data = readFileSync(join(queueDir, f), 'utf8')
283
+ for (const line of data.split('\n').filter(Boolean)) {
284
+ totalLines++
285
+ try {
286
+ const obj = JSON.parse(line)
287
+ const t = Date.parse(obj.queued_at)
288
+ if (!isNaN(t) && (oldestTs === null || t < oldestTs)) oldestTs = t
289
+ if ((obj.retry_count || 0) > maxRetry) maxRetry = obj.retry_count
290
+ } catch {}
291
+ }
292
+ } catch {}
293
+ }
294
+ const oldestIso = oldestTs === null ? 'unknown' : new Date(oldestTs).toISOString()
295
+ const ageStr =
296
+ oldestTs === null
297
+ ? 'unknown'
298
+ : (() => {
299
+ const secs = Math.max(0, Math.floor((Date.now() - oldestTs) / 1000))
300
+ if (secs < 60) return `${secs}초`
301
+ const mins = Math.floor(secs / 60)
302
+ if (mins < 60) return `${mins}분 ${secs % 60}초`
303
+ const hours = Math.floor(mins / 60)
304
+ return `${hours}시간 ${mins % 60}분`
305
+ })()
306
+ console.log(
307
+ `⚠ queue: ${totalLines}건 / ${files.length}세션 / endpoints { batch-mutate: ${endpoints['batch-mutate']}, mutate-retry: ${endpoints['mutate-retry']}, flush-session: ${endpoints['flush-session']} }`
308
+ )
309
+ console.log(` oldest_queued_at: ${oldestIso} (age ${ageStr})`)
310
+ console.log(` max retry: ${maxRetry} / dead_letters: ${deadLabel}`)
311
+ console.log(` → 큐 위치: ${queueDir}`)
312
+ }
313
+
314
+ // B3 (spec 2026-07-11 §B3) — dead letter 사유별 집계 상세 모드 (rookie doctor --dead-letters).
315
+ // mutate_invalid(4xx 정책 거부)는 재전송해도 같은 이유로 거부되므로 본문 수정이 처방,
316
+ // 5xx/network 는 서버·네트워크 복구 후 수동 재전송 검토 대상 — 처방이 달라 사유를 나눠 보여준다.
317
+ function printDeadLetterReport() {
318
+ const deadDir = join(homedir(), '.rookie', 'dead-letter')
319
+ const entries = readDeadLetterEntries(deadDir)
320
+ if (entries.length === 0) {
321
+ console.log('dead letters: clean (0건)')
322
+ return
323
+ }
324
+ const s = classifyDeadLetters(entries)
325
+ const fmt = (obj) =>
326
+ Object.entries(obj)
327
+ .sort((a, b) => b[1] - a[1])
328
+ .map(([k, v]) => `${k} ${v}`)
329
+ .join(' / ')
330
+ console.log(`== Dead letters — ${s.total}건 (${deadDir}) ==`)
331
+ console.log(`— 종류: ${fmt(s.byKind)}`)
332
+ console.log(`— 사유: ${fmt(s.byStatus)}`)
333
+ if (s.recent) console.log(`— 최근: ${s.recent.kind}/${s.recent.bucket} (${s.recent.at})`)
334
+ if (s.byStatus.http_4xx) {
335
+ console.log(' → http_4xx(정책 거부)는 재전송 무의미 — 본문에서 거부 원인(메타 카드·민감정보 마커 등) 제거 후 수동 재저장.')
336
+ }
337
+ if (s.byStatus.http_5xx || s.byStatus.network) {
338
+ console.log(' → http_5xx/network 는 일시 장애 산물 — 서버 복구 확인 후 수동 재전송 검토.')
339
+ }
340
+ }
341
+
342
+ // P4.3 2차-b → B3 (spec 2026-07-11 §B3) — orphan tail 적체 가시화.
343
+ // 데이터 소스 = 로컬 hook 파일(~/.rookie/orphan-recovery/, codex 서브루트 포함), 서버 DB 아님.
344
+ // 임계(>5건 또는 최고령>24h — tail TTL 3일보다 짧게) 초과 시 ⚠️ + 예상 손실 설명.
345
+ // expired(TTL 초과로 이미 손실된 tail) 는 별도 손실 카운터 — 손실이 보이지 않으면 RC1 재발.
346
+ function readOrphanDir(dir) {
347
+ if (!existsSync(dir)) return { markers: [], readFailed: false }
348
+ let files
349
+ try {
350
+ files = readdirSync(dir).filter((f) => f.endsWith('.json'))
351
+ } catch {
352
+ return { markers: [], readFailed: true }
353
+ }
354
+ const markers = []
355
+ for (const f of files) {
356
+ try {
357
+ markers.push(JSON.parse(readFileSync(join(dir, f), 'utf8')))
358
+ } catch {
359
+ markers.push({}) // 깨진 marker 도 count 엔 포함(fail-soft), 최고령엔 제외
360
+ }
361
+ }
362
+ return { markers, readFailed: false }
363
+ }
364
+
365
+ function countDirEntries(dir, suffix = '.json') {
366
+ if (!existsSync(dir)) return 0
367
+ try {
368
+ return readdirSync(dir).filter((f) => f.endsWith(suffix)).length
369
+ } catch {
370
+ return 0
371
+ }
372
+ }
373
+
374
+ function printOrphanPending() {
375
+ const root = join(homedir(), '.rookie', 'orphan-recovery')
376
+ const claude = readOrphanDir(join(root, 'pending-extraction'))
377
+ const codex = readOrphanDir(join(root, 'codex', 'pending-extraction'))
378
+ if (claude.readFailed && codex.readFailed) {
379
+ console.log('— orphan pending: 읽기 실패')
380
+ return
381
+ }
382
+ const expiredCount =
383
+ countDirEntries(join(root, 'expired')) + countDirEntries(join(root, 'codex', 'expired'))
384
+ const s = summarizeOrphanBacklog({
385
+ pendingMarkers: [...claude.markers, ...codex.markers],
386
+ expiredCount,
387
+ nowMs: Date.now(),
388
+ })
389
+ if (s.count === 0) {
390
+ console.log('— orphan pending: clean (대기 없음)')
391
+ } else {
392
+ const warn = orphanBacklogWarn(s)
393
+ const prefix = warn ? '⚠️ ' : '— '
394
+ const age = s.oldestAgeHours >= 48 ? `${s.oldestAgeDays}일` : `${s.oldestAgeHours}시간`
395
+ console.log(
396
+ `${prefix}orphan pending: ${s.count}건 대기 (Claude ${claude.markers.length} / Codex ${codex.markers.length}), 최고령 ${age} (${s.oldestRecoveredAt ?? 'unknown'})`
397
+ )
398
+ if (warn) {
399
+ console.log(
400
+ ` → 임계 초과(>${BACKLOG_MAX_COUNT}건 또는 >${BACKLOG_MAX_AGE_HOURS}h). tail TTL(3일) 초과분은 expired 로 넘어가 손실됩니다 — rookie orphan-extract --batch 5 로 지금 처리하세요.`
401
+ )
402
+ }
403
+ }
404
+ if (s.expiredCount > 0) {
405
+ console.log(`— orphan expired(손실): 누적 ${s.expiredCount}건 — TTL 초과로 tail 미추출 상태로 만료된 세션`)
406
+ }
407
+ }
408
+
409
+ // B6-3 (spec 2026-07-11 §B6): 세션 identity pinning 위반 가시화 — 세션 도중 계정 전환으로
410
+ // flush 가 중단된 이력. Stop hook(flush-session.sh)이 남긴 로그를 집계한다. 0건이면 침묵.
411
+ function printIdentityMismatch() {
412
+ const logPath = join(homedir(), '.rookie', 'identity-mismatch.log')
413
+ if (!existsSync(logPath)) return
414
+ try {
415
+ const lines = readFileSync(logPath, 'utf8').split('\n').filter(Boolean)
416
+ if (lines.length === 0) return
417
+ const last = lines[lines.length - 1]
418
+ console.log(`⚠️ 세션 identity 불일치 ${lines.length}건 — 세션 도중 계정 전환으로 flush 중단(기억 오귀속 차단). 최근: ${last}`)
419
+ console.log(` → 로그: ${logPath}. 반복되면 rookie login --local 로 프로젝트 전용 binding 을 쓰세요.`)
420
+ } catch { /* fail-soft */ }
421
+ }
422
+
423
+ // v6 P1 #6 — Stage 1 위반 카운트 한 줄 가시화.
424
+ // spec §11.1 / plan Task 4
425
+ // fail-tolerant: summary endpoint 도달 실패 시 silent skip (다른 doctor 출력 영향 0).
426
+ async function printStage1Metric(cfg) {
427
+ if (!cfg?.endpoint || !cfg?.token) return
428
+ try {
429
+ const summary = await apiGet(cfg.endpoint, cfg.token, '/api/rookie/metric/md-read/summary')
430
+ const total = summary?.totalCount ?? 0
431
+ const reached = !!summary?.reachedThreshold
432
+ const window = summary?.windowDays ?? 7
433
+ const byTool = (summary?.byHostnameTool ?? []).reduce((acc, r) => {
434
+ acc[r.tool_name] = (acc[r.tool_name] ?? 0) + r.count
435
+ return acc
436
+ }, {})
437
+ const prefix = reached ? '⚠️ ' : '— '
438
+ console.log(
439
+ `${prefix}stage1: ${total}건/${window}일 (Read ${byTool.Read ?? 0} / Bash ${byTool.Bash ?? 0})`
440
+ )
441
+ } catch {
442
+ // silent — endpoint 미응답 또는 미land 시 본 라인만 누락. 다른 doctor 출력 영향 0.
443
+ }
444
+ }
445
+
446
+ // v6 P1 #9 — Hot Write 호출 메트릭 한 줄 가시화.
447
+ // spec §11.2 / plan Task 7
448
+ // fail-tolerant: summary endpoint 도달 실패 시 silent skip.
449
+ // callFailedRatio < 0.05 → 💪 안정적, 0.2+ → ⚠️ 실패 잦음, 그 외 — 정상.
450
+ async function printHotWriteMetric(cfg) {
451
+ if (!cfg?.endpoint || !cfg?.token) return
452
+ try {
453
+ const summary = await apiGet(cfg.endpoint, cfg.token, '/api/rookie/metric/hot-write/summary')
454
+ const c = summary?.callCount ?? 0
455
+ const f = summary?.failedCallCount ?? 0
456
+ const window = summary?.windowDays ?? 7
457
+ const ratio = summary?.callFailedRatio ?? 0
458
+ let prefix = '— '
459
+ if (c > 0) {
460
+ if (ratio >= 0.2) prefix = '⚠️ '
461
+ else if (ratio < 0.05) prefix = '💪 '
462
+ }
463
+ const routeStr = (summary?.byRoute ?? [])
464
+ .map((r) => `${r.route} ${r.count}`)
465
+ .join(' / ')
466
+ const routePart = routeStr ? ` [${routeStr}]` : ''
467
+ console.log(
468
+ `${prefix}hot-write: ${c}건/${window}일, 실패 ${f}건 (${(ratio * 100).toFixed(0)}%)${routePart}`
469
+ )
470
+ } catch {
471
+ // silent — endpoint 미응답 또는 미land 시 본 라인만 누락.
472
+ }
473
+ }
474
+
475
+ // 귀속 불변식 감시 (spec 2026-07-13-attribution-invariant-metric M2) — 최근 7일 신규 진입카드 중
476
+ // 미귀속(NULL) 비율 한 줄. 임계 판정은 서버 exceeded 만 신뢰(임계 이원화 금지).
477
+ // endpoint 미도달(구 서버·네트워크)만 silent skip — 응답이 온 경우엔 조회 실패도 출력한다(침묵 실패 금지).
478
+ async function printAttributionMetric(cfg) {
479
+ if (!cfg?.endpoint || !cfg?.token) return
480
+ try {
481
+ const s = await apiGet(cfg.endpoint, cfg.token, '/api/rookie/metric/attribution/summary')
482
+ if (s?.fetchFailed) {
483
+ console.log('⚠️ 귀속: 조회 실패 — 서버 로그 확인')
484
+ return
485
+ }
486
+ const total = s?.total ?? 0
487
+ const nul = s?.unattributed ?? 0
488
+ const window = s?.windowDays ?? 7
489
+ const ratio = s?.ratio ?? 0
490
+ let prefix = '— '
491
+ let suffix = ''
492
+ if (s?.exceeded) {
493
+ prefix = '⚠️ '
494
+ suffix = ' — 누수 의심: rookie link 상태·최근 세션 귀속 점검'
495
+ } else if (total > 0 && ratio < 0.05) {
496
+ prefix = '💪 '
497
+ }
498
+ console.log(
499
+ `${prefix}귀속: 신규 ${total}건/${window}일, 미귀속 ${nul}건 (${(ratio * 100).toFixed(0)}%)${suffix}`
500
+ )
501
+ } catch {
502
+ // silent — endpoint 미응답 또는 미land 시 본 라인만 누락.
503
+ }
504
+ }
505
+
506
+ // v6 P1 #8 — Recall 호출 메트릭 한 줄 가시화.
507
+ // spec §11.2 / plan Task 6
508
+ // fail-tolerant: summary endpoint 도달 실패 시 silent skip.
509
+ // missRatio < 0.2 → 💪 강함, 0.5+ → ⚠️ 0건 반환 잦음, 그 외 — 정상.
510
+ // B4 (spec 2026-07-11 §B4): "miss" 라벨은 관련성 실패처럼 읽혀 의미 왜곡 — "0건 반환"(hit_count=0)으로 개명. 계산 동일.
511
+ async function printRecallMetric(cfg) {
512
+ if (!cfg?.endpoint || !cfg?.token) return
513
+ try {
514
+ const summary = await apiGet(cfg.endpoint, cfg.token, '/api/rookie/metric/recall/summary')
515
+ const c = summary?.callCount ?? 0
516
+ const m = summary?.missCount ?? 0
517
+ const window = summary?.windowDays ?? 7
518
+ const ratio = summary?.missRatio ?? 0
519
+ let prefix = '— '
520
+ if (c > 0) {
521
+ if (ratio >= 0.5) prefix = '⚠️ '
522
+ else if (ratio < 0.2) prefix = '💪 '
523
+ }
524
+ const scopeStr = (summary?.byScope ?? [])
525
+ .map((s) => `${s.scope} ${s.count}`)
526
+ .join(' / ')
527
+ const scopePart = scopeStr ? ` [${scopeStr}]` : ''
528
+ console.log(
529
+ `${prefix}recall: ${c}건/${window}일, 0건 반환 ${m}건 (0건 반환율 ${(ratio * 100).toFixed(0)}%)${scopePart}`
530
+ )
531
+ // P4.3 2차-b-2 recall_stale_hit — downrank 후에도 은퇴 카드가 회상에 섞여 나온 누적 건수(>0 일 때만).
532
+ const stale = summary?.staleHitCount ?? 0
533
+ if (stale > 0) {
534
+ console.log(` retired hit: ${stale}건 (은퇴 카드 회상 노출 — downrank 관찰)`)
535
+ }
536
+ // 재발 방지 (2026-05-22) — 임베딩 가동률이 낮으면 회상이 silent 하게 FTS-only 로 폴백한다.
537
+ // Cohere quota 소진·키 무효 시 한국어 회상이 깨지므로 능동 경고 (과거 17일 방치 사례).
538
+ const embRatio = summary?.embeddingUsedRatio
539
+ if (c >= 5 && typeof embRatio === 'number' && embRatio < 0.5) {
540
+ console.log(
541
+ `🚨 임베딩 가동률 ${(embRatio * 100).toFixed(0)}% — Cohere 키/quota 점검 필요 (회상이 FTS-only 로 폴백 중)`
542
+ )
543
+ }
544
+ } catch {
545
+ // silent — endpoint 미응답 또는 미land 시 본 라인만 누락.
546
+ }
547
+ }
548
+
549
+ // B5 (spec 2026-07-11 §B5) — directive 준수율 한 줄 가시화.
550
+ // served(지시 노출)/acted(이행) 쌍 — link 제안·orphan tail 추출 지시가 실제로 이행되는지의 proxy.
551
+ // 계측 전용(acted 오탐 가능) — 의사결정 단독 근거 금지. 이벤트 0건이면 조용히 생략.
552
+ // fail-tolerant: summary endpoint 도달 실패 시 silent skip.
553
+ async function printDirectiveMetric(cfg) {
554
+ if (!cfg?.endpoint || !cfg?.token) return
555
+ try {
556
+ const s = await apiGet(cfg.endpoint, cfg.token, '/api/rookie/metric/directive/summary')
557
+ const rows = s?.byDirective ?? []
558
+ if (!rows.length) return
559
+ // link_prompt 는 서버가 aggregation-time 24h 짝짓기한 linkPairing 사용(fulfilled ≤ served 보장).
560
+ // orphan_extract 의 acted 는 세션당 여러 건(마킹 건수)이라 비율이 아닌 병기 — "노출 N회 · 추출 M건".
561
+ const parts = rows.map((r) => {
562
+ if (r.directive === 'link_prompt') {
563
+ const lp = s?.linkPairing ?? { served: r.served, fulfilled: 0 }
564
+ return `link 제안 ${lp.served}회 중 ${lp.fulfilled}회 이행`
565
+ }
566
+ return `${r.directive === 'orphan_extract' ? 'orphan 추출 지시' : r.directive} 노출 ${r.served}회 · 이행 ${r.acted}건`
567
+ })
568
+ const surfaceStr = (s?.bySurface ?? [])
569
+ .map((x) => `${x.surface ?? '?'} ${x.served}/${x.acted}`)
570
+ .join(' · ')
571
+ const surfacePart = surfaceStr ? ` [표면 served/acted: ${surfaceStr}]` : ''
572
+ console.log(`— 준수율: ${parts.join(' / ')} (${s?.windowDays ?? 7}일, 계측 전용)${surfacePart}`)
573
+ } catch {
574
+ // silent — endpoint 미응답 또는 미land 시 본 라인만 누락.
575
+ }
576
+ }
577
+
578
+ // v6 P6 L1 PR-C — Promotion 호출 메트릭 한 줄 가시화.
579
+ // spec §11.x P6 / plan Step 3.8
580
+ // fail-tolerant: summary endpoint 도달 실패 시 silent skip.
581
+ // totalCalls 0 → ⚙️ 미가동, totalSkipped/totalExamined ≥ 0.8 → ⚠️ skip 잦음, 그 외 ✓.
582
+ // Cohere 임베딩 비용 자동차단(회로) 상태 한 줄 가시화.
583
+ // plan: docs/superpowers/plans/2026-06-20-cohere-cost-runaway-prevention.md Task B4b
584
+ // tripped → 🚨 빨강(수동 reset 안내). 정상 → ✓ + 사용량/중복률. 외부 알람 미설정이면 ⚠️.
585
+ // fail-tolerant: endpoint 미응답 시 silent skip(미land 환경).
586
+ async function printEmbeddingGuard(cfg) {
587
+ if (!cfg?.endpoint || !cfg?.token) return
588
+ try {
589
+ const s = await apiGet(
590
+ cfg.endpoint,
591
+ cfg.token,
592
+ '/api/rookie/metric/embedding-usage'
593
+ )
594
+ if (!s) return
595
+ if (s.tripped) {
596
+ const reason = s.trip_reason ?? '?'
597
+ console.log(
598
+ `🚨 embedding guard: 차단됨 (${reason}) — Cohere 호출 중단됨. 원인 확인 후 대시보드에서 수동 reset 필요`
599
+ )
600
+ return
601
+ }
602
+ const cur = s.current_bucket_texts ?? 0
603
+ const today = s.today_texts ?? 0
604
+ const dup = s.dup_ratio ?? 0
605
+ let line = `✓ embedding guard: 정상 (시간당 ${cur} / 오늘 ${today} 텍스트`
606
+ if (dup > 0) line += `, 중복률 ${Math.round(dup * 100)}%`
607
+ line += ')'
608
+ if (s.alert_email_configured === false) line += ' ⚠️ 외부 알람(이메일) 미설정'
609
+ console.log(line)
610
+ } catch {
611
+ // silent — endpoint 미응답 또는 미land 시 본 라인만 누락.
612
+ }
613
+ }
614
+
615
+ async function printPromotionMetric(cfg) {
616
+ if (!cfg?.endpoint || !cfg?.token) return
617
+ try {
618
+ const summary = await apiGet(
619
+ cfg.endpoint,
620
+ cfg.token,
621
+ '/api/rookie/metric/promotion/summary'
622
+ )
623
+ const calls = summary?.totalCalls ?? 0
624
+ const examined = summary?.totalExamined ?? 0
625
+ const promoted = summary?.totalPromoted ?? 0
626
+ const skipped = summary?.totalSkipped ?? 0
627
+ const window = summary?.windowDays ?? 7
628
+ if (calls === 0) {
629
+ console.log(`⚙️ promotion: 0건/${window}일 (미가동)`)
630
+ return
631
+ }
632
+ const skippedRatio = examined > 0 ? skipped / examined : 0
633
+ const promotionPct =
634
+ examined > 0 ? Math.round((promoted / examined) * 100) : 0
635
+ let prefix = '✓ '
636
+ if (skippedRatio >= 0.8) prefix = '⚠️ '
637
+ console.log(
638
+ `${prefix}promotion: ${calls}건/${window}일, examined ${examined} / promoted ${promoted} / skipped ${skipped} (승격률 ${promotionPct}%)`
639
+ )
640
+ } catch {
641
+ // silent — endpoint 미응답 또는 미land 시 본 라인만 누락.
642
+ }
643
+ }
644
+
645
+ // v6 P6 L2 — Self-rule 위반 누적 top 1 한 줄 가시화.
646
+ // spec §11.x P6 L2 / plan Task 4.14
647
+ // fail-tolerant: summary endpoint 도달 실패 시 silent skip.
648
+ // 위반 누적 ≥ 5 → 🚨, ≥ 1 → ⚠️, 0 → 출력 생략 (clean 시 line 자체 없음).
649
+ async function printViolationTop(cfg) {
650
+ if (!cfg?.endpoint || !cfg?.token) return
651
+ try {
652
+ const summary = await apiGet(
653
+ cfg.endpoint,
654
+ cfg.token,
655
+ '/api/rookie/metric/self-rule-violations/summary?top_n=1'
656
+ )
657
+ const top = summary?.topRules?.[0]
658
+ const total = summary?.totalRulesWithViolation ?? 0
659
+ if (!top || total === 0) return
660
+ const cnt = top.violation_count ?? 0
661
+ const cat = top.category ?? '?'
662
+ const ruleText = (top.rule ?? '').slice(0, 40).replace(/\s+/g, ' ')
663
+ const last = top.last_violated_at
664
+ let when = ''
665
+ if (last) {
666
+ const t = Date.parse(last)
667
+ if (!isNaN(t)) {
668
+ const diffSec = Math.max(0, Math.floor((Date.now() - t) / 1000))
669
+ if (diffSec < 60) when = `${diffSec}s 전`
670
+ else if (diffSec < 3600) when = `${Math.floor(diffSec / 60)}m 전`
671
+ else if (diffSec < 86400) when = `${Math.floor(diffSec / 3600)}h 전`
672
+ else when = `${Math.floor(diffSec / 86400)}d 전`
673
+ }
674
+ }
675
+ const prefix = cnt >= 5 ? '🚨 ' : '⚠️ '
676
+ const totalSuffix = total > 1 ? ` (총 ${total}건 누적)` : ''
677
+ console.log(
678
+ `${prefix}위반: ${cat} ${cnt}회 (rule "${ruleText}"${when ? `, ${when}` : ''})${totalSuffix}`
679
+ )
680
+ } catch {
681
+ // silent — endpoint 미응답 또는 미land 시 본 라인만 누락.
682
+ }
683
+ }
684
+
685
+ // v6 P1 #7 — Dedup 메트릭 누적 한 줄 가시화.
686
+ // spec §11.2 / plan Task 6
687
+ // fail-tolerant: summary endpoint 도달 실패 시 silent skip.
688
+ // ratio 0.7+ → ⚠️ recall 신뢰 의심, 0.3 미만 → 💧 dedup 약함, 그 외 — 정상.
689
+ async function printDedupMetric(cfg) {
690
+ if (!cfg?.endpoint || !cfg?.token) return
691
+ try {
692
+ const summary = await apiGet(cfg.endpoint, cfg.token, '/api/rookie/metric/dedup/summary')
693
+ const r = summary?.totalColdRecovered ?? 0
694
+ const s = summary?.totalColdSkipped ?? 0
695
+ const f = summary?.totalMutateFailed ?? 0
696
+ const window = summary?.windowDays ?? 7
697
+ const sessions = summary?.sessionCount ?? 0
698
+ const ratio = summary?.dedupEfficiencyRatio ?? 0
699
+ let prefix = '— '
700
+ if (sessions > 0) {
701
+ if (ratio >= 0.7) prefix = '⚠️ '
702
+ else if (ratio < 0.3) prefix = '💧 '
703
+ }
704
+ console.log(
705
+ `${prefix}dedup: ${r} recovered / ${s} skipped / ${f} mutate-failed (${sessions}세션·${window}일, 비율 ${(ratio * 100).toFixed(0)}%)`
706
+ )
707
+ } catch {
708
+ // silent — endpoint 미응답 또는 미land 시 본 라인만 누락.
709
+ }
710
+ }