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,628 @@
1
+ import {
2
+ readFileSync,
3
+ readdirSync,
4
+ statSync,
5
+ existsSync,
6
+ writeFileSync,
7
+ openSync,
8
+ readSync,
9
+ closeSync,
10
+ } from 'node:fs'
11
+ import { homedir } from 'node:os'
12
+ import { join, dirname, resolve, basename } from 'node:path'
13
+
14
+ // spec 2026-07-10-context-diet-design.md — 모든 지표는 "실제 토큰 주입량"이 아니라
15
+ // 설치 자산 기준 proxy 다(§1). 판정 목적은 정확한 토큰 수가 아니라 "정리할 가치가 있는
16
+ // 비대 자산" 탐지.
17
+ export const THRESHOLDS = {
18
+ claude_md_project: 20000,
19
+ claude_md_total: 40000,
20
+ skills_agents_desc: 30000,
21
+ rookie_self_skill: 15000,
22
+ rookie_self_injected: 10000,
23
+ unused_skills_min: 5,
24
+ }
25
+ export const USAGE_WINDOW_DAYS = 60
26
+ export const USAGE_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
27
+ export const NEW_SKILL_GRACE_MS = 30 * 24 * 60 * 60 * 1000
28
+ export const INJECTION_STALE_MS = 30 * 24 * 60 * 60 * 1000
29
+
30
+ // Claude Code 메모리 로딩 파일명 (§2.1 fixture 고정, codex 2차 P2).
31
+ // CLAUDE.local.md: 공식 문서상 deprecated 이지만 여전히 자동 로드된다(2026-07 기준
32
+ // memory 문서 — imports 로의 이전 권고일 뿐 로딩은 유지). proxy 측정이므로 포함.
33
+ export const MEMORY_FILE_PATTERNS = ['CLAUDE.md', 'CLAUDE.local.md', join('.claude', 'CLAUDE.md')]
34
+ export const IMPORT_MAX_DEPTH = 3
35
+
36
+ export function usageCachePath(home) {
37
+ return join(home, '.rookie', 'context-diet-usage.json')
38
+ }
39
+ export function snoozePath(home) {
40
+ return join(home, '.rookie', 'context-diet-snooze.json')
41
+ }
42
+ export function keepPath(home) {
43
+ return join(home, '.rookie', 'context-diet-keep.json')
44
+ }
45
+ export function throttleMarkerPath(home) {
46
+ return join(home, '.rookie', 'context-diet.last')
47
+ }
48
+ export function lastInjectionPath(home) {
49
+ return join(home, '.rookie', 'last-injection.json')
50
+ }
51
+
52
+ function readTextSafe(p) {
53
+ try {
54
+ return readFileSync(p, 'utf8')
55
+ } catch {
56
+ return null
57
+ }
58
+ }
59
+ function readJsonSafe(p) {
60
+ try {
61
+ return JSON.parse(readFileSync(p, 'utf8'))
62
+ } catch {
63
+ return null
64
+ }
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // frontmatter 서브셋 파서 (§2.1) — YAML 전체가 아닌 서브셋만:
69
+ // description: 단일라인 / 인용부호 / block scalar(|, >) 연속 들여쓰기 / plain 멀티라인.
70
+ // 그 외 형태는 parse_error 로 별도 카운트(0 침묵 금지).
71
+ // ---------------------------------------------------------------------------
72
+ export function parseFrontmatter(md) {
73
+ const out = { description: null, disabled: false, error: null }
74
+ if (typeof md !== 'string' || !/^---\s*\r?\n/.test(md)) {
75
+ out.error = 'no_frontmatter'
76
+ return out
77
+ }
78
+ const endMatch = md.slice(3).match(/\r?\n---\s*(\r?\n|$)/)
79
+ if (!endMatch) {
80
+ out.error = 'unterminated_frontmatter'
81
+ return out
82
+ }
83
+ const fm = md.slice(3, 3 + endMatch.index)
84
+ const lines = fm.split(/\r?\n/)
85
+ let descFound = false
86
+ for (let i = 0; i < lines.length; i++) {
87
+ const dm = lines[i].match(/^(disable-model-invocation)\s*:\s*(.+?)\s*$/)
88
+ if (dm && dm[2].replace(/['"]/g, '') === 'true') out.disabled = true
89
+
90
+ const m = descFound ? null : lines[i].match(/^(description)\s*:\s*(.*)$/)
91
+ if (!m) continue
92
+ descFound = true
93
+ let value = m[2].trim()
94
+ if (/^[|>][+-]?\s*$/.test(value)) {
95
+ // block scalar — 이후 더 깊게 들여쓴 연속 라인
96
+ const block = []
97
+ for (let j = i + 1; j < lines.length; j++) {
98
+ if (/^\s+\S/.test(lines[j]) || lines[j].trim() === '') {
99
+ if (lines[j].trim() === '' && !/^\s+\S/.test(lines[j + 1] ?? '')) break
100
+ block.push(lines[j].trim())
101
+ } else break
102
+ }
103
+ out.description = block.join(' ').trim()
104
+ } else {
105
+ // plain 멀티라인 — 다음 라인이 key 형태가 아니고 들여쓰기면 연속으로 본다
106
+ const cont = []
107
+ for (let j = i + 1; j < lines.length; j++) {
108
+ if (/^\s+\S/.test(lines[j]) && !/^\s*[\w-]+\s*:/.test(lines[j])) cont.push(lines[j].trim())
109
+ else break
110
+ }
111
+ if (cont.length) value = [value, ...cont].join(' ')
112
+ const q = value.match(/^"(.*)"$/s) || value.match(/^'(.*)'$/s)
113
+ out.description = (q ? q[1] : value).trim()
114
+ }
115
+ if (!out.description) {
116
+ out.error = 'parse_error'
117
+ out.description = null
118
+ }
119
+ }
120
+ if (!descFound) out.error = 'no_description'
121
+ return out
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // CLAUDE.md 로딩 규칙 합산 (§2.1 claude_md_total)
126
+ // @import 확장 depth ≤3, visited set 으로 순환·중복 방지.
127
+ // ---------------------------------------------------------------------------
128
+ function parseImports(content) {
129
+ const refs = []
130
+ let inFence = false
131
+ for (const line of content.split('\n')) {
132
+ if (/^\s*(```|~~~)/.test(line)) {
133
+ inFence = !inFence
134
+ continue
135
+ }
136
+ if (inFence) continue
137
+ for (const m of line.matchAll(/(^|\s)@([~A-Za-z0-9_][^\s`)\]]*)/g)) {
138
+ refs.push(m[2])
139
+ }
140
+ }
141
+ return refs
142
+ }
143
+
144
+ function expandFileChars(filePath, home, visited, depth) {
145
+ let real = filePath
146
+ try {
147
+ real = statSync(filePath).isFile() ? resolve(filePath) : null
148
+ } catch {
149
+ return 0
150
+ }
151
+ if (!real || visited.has(real)) return 0
152
+ visited.add(real)
153
+ const content = readTextSafe(real)
154
+ if (content == null) return 0
155
+ let chars = content.length
156
+ if (depth < IMPORT_MAX_DEPTH) {
157
+ for (const ref of parseImports(content)) {
158
+ const target = ref.startsWith('~/')
159
+ ? join(home, ref.slice(2))
160
+ : resolve(dirname(real), ref)
161
+ chars += expandFileChars(target, home, visited, depth + 1)
162
+ }
163
+ }
164
+ return chars
165
+ }
166
+
167
+ function listRuleFiles(dir) {
168
+ try {
169
+ return readdirSync(dir)
170
+ .filter((f) => f.endsWith('.md'))
171
+ .map((f) => join(dir, f))
172
+ } catch {
173
+ return []
174
+ }
175
+ }
176
+
177
+ export function measureMemory({ cwd, home }) {
178
+ // project — cwd 직속 메모리 파일 raw 합산 (import 확장은 total 에서)
179
+ const projectFiles = MEMORY_FILE_PATTERNS.map((p) => join(cwd, p)).filter((p) => existsSync(p))
180
+ let projectChars = 0
181
+ let projectLines = 0
182
+ for (const f of projectFiles) {
183
+ const t = readTextSafe(f)
184
+ if (t == null) continue
185
+ projectChars += t.length
186
+ projectLines += t.split('\n').length
187
+ }
188
+
189
+ // total — 글로벌 + cwd→루트 상위 디렉토리 + rules + @import 확장.
190
+ // 상한을 $HOME 이 아니라 루트로 잡는 이유: Claude Code 실제 로딩 규칙이 cwd 에서
191
+ // 루트 직전까지 재귀 탐색이고, 이 repo 처럼 cwd 가 $HOME 밖($/Volumes/..)인 경우
192
+ // $HOME 상한으로는 워크스페이스 CLAUDE.md 를 놓친다 (spec §2.1 의 "cwd→$HOME" 문구를
193
+ // 실측 기준으로 보정 — spec changelog 2026-07-10 3차 참조).
194
+ const candidates = new Set([join(home, '.claude', 'CLAUDE.md')])
195
+ let dir = cwd
196
+ for (;;) {
197
+ for (const pat of MEMORY_FILE_PATTERNS) candidates.add(join(dir, pat))
198
+ const parent = dirname(dir)
199
+ if (parent === dir) break
200
+ dir = parent
201
+ }
202
+ for (const f of listRuleFiles(join(home, '.claude', 'rules'))) candidates.add(f)
203
+ for (const f of listRuleFiles(join(cwd, '.claude', 'rules'))) candidates.add(f)
204
+
205
+ const visited = new Set()
206
+ let totalChars = 0
207
+ let totalFiles = 0
208
+ for (const f of candidates) {
209
+ if (!existsSync(f)) continue
210
+ totalFiles++
211
+ totalChars += expandFileChars(f, home, visited, 0)
212
+ }
213
+ return {
214
+ project: { chars: projectChars, lines: projectLines, files: projectFiles.length },
215
+ total: { chars: totalChars, files: totalFiles },
216
+ }
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // 스킬·에이전트 description 자산 수집 (§2.1 skills_agents_desc)
221
+ // 활성 자산만: 로컬 skills/agents + enabled 플러그인(installed_plugins.json v2 +
222
+ // settings.enabledPlugins). identity 는 네임스페이스 포함(plugin:skill).
223
+ // ---------------------------------------------------------------------------
224
+ function birthtimeMs(p) {
225
+ try {
226
+ const st = statSync(p)
227
+ return st.birthtimeMs || st.mtimeMs
228
+ } catch {
229
+ return 0
230
+ }
231
+ }
232
+
233
+ function collectSkillDirs(base, prefix, source, items) {
234
+ let entries
235
+ try {
236
+ entries = readdirSync(base, { withFileTypes: true })
237
+ } catch {
238
+ return
239
+ }
240
+ for (const e of entries) {
241
+ if (!e.isDirectory() && !e.isSymbolicLink()) continue
242
+ const skillMd = join(base, e.name, 'SKILL.md')
243
+ const md = readTextSafe(skillMd)
244
+ if (md == null) continue
245
+ const fm = parseFrontmatter(md)
246
+ items.push({
247
+ identity: prefix ? `${prefix}:${e.name}` : e.name,
248
+ kind: 'skill',
249
+ source,
250
+ path: skillMd,
251
+ descChars: fm.description ? fm.description.length : 0,
252
+ disabled: fm.disabled,
253
+ parseError: fm.error === 'parse_error' || fm.error === 'unterminated_frontmatter' || fm.error === 'no_frontmatter' ? fm.error : null,
254
+ birthtimeMs: birthtimeMs(join(base, e.name)),
255
+ })
256
+ }
257
+ }
258
+
259
+ function collectAgentFiles(base, prefix, source, items) {
260
+ let entries
261
+ try {
262
+ entries = readdirSync(base)
263
+ } catch {
264
+ return
265
+ }
266
+ for (const f of entries) {
267
+ if (!f.endsWith('.md') || f.startsWith('_') || f.startsWith('.')) continue
268
+ const p = join(base, f)
269
+ const md = readTextSafe(p)
270
+ if (md == null) continue
271
+ const fm = parseFrontmatter(md)
272
+ const name = basename(f, '.md')
273
+ items.push({
274
+ identity: prefix ? `${prefix}:${name}` : name,
275
+ kind: 'agent',
276
+ source,
277
+ path: p,
278
+ descChars: fm.description ? fm.description.length : 0,
279
+ disabled: fm.disabled,
280
+ parseError: fm.error === 'parse_error' || fm.error === 'unterminated_frontmatter' || fm.error === 'no_frontmatter' ? fm.error : null,
281
+ birthtimeMs: birthtimeMs(p),
282
+ })
283
+ }
284
+ }
285
+
286
+ // settings 로 꺼진 스킬 (§2.1 "disable-model-invocation·settings 로 꺼진 스킬은 제외").
287
+ // Claude Code 의 per-skill disable 설정 키는 버전에 따라 다를 수 있어 알려진 형태를
288
+ // 관용적으로 읽는다: skillOverrides { "<name>": false | {enabled:false} } / disabledSkills[] /
289
+ // skills.disabled[]. 미지원 키는 조용히 무시(빈 set).
290
+ export function loadSettingsDisabledSkills(home) {
291
+ const s = readJsonSafe(join(home, '.claude', 'settings.json')) || {}
292
+ const out = new Set()
293
+ if (s.skillOverrides && typeof s.skillOverrides === 'object') {
294
+ for (const [k, v] of Object.entries(s.skillOverrides)) {
295
+ if (v === false || (v && typeof v === 'object' && v.enabled === false)) out.add(k)
296
+ }
297
+ }
298
+ for (const arr of [s.disabledSkills, s.skills && s.skills.disabled]) {
299
+ if (Array.isArray(arr)) for (const k of arr) if (typeof k === 'string') out.add(k)
300
+ }
301
+ return out
302
+ }
303
+
304
+ // enabled 판정의 실제 소스 (실측, spec §2.1 문구 보정): installed_plugins.json v2 에는
305
+ // enabled 필드가 없다 — 설치 경로(installPath)만 담는다. enabled 상태는
306
+ // settings.json 의 enabledPlugins { "<name>@<marketplace>": true } 가 SSOT.
307
+ export function resolveEnabledPluginInstalls({ home, cwd }) {
308
+ const settings = readJsonSafe(join(home, '.claude', 'settings.json')) || {}
309
+ const enabled = settings.enabledPlugins || {}
310
+ const registry = readJsonSafe(join(home, '.claude', 'plugins', 'installed_plugins.json'))
311
+ const plugins = (registry && registry.plugins) || {}
312
+ const out = []
313
+ for (const [key, installs] of Object.entries(plugins)) {
314
+ if (enabled[key] !== true) continue
315
+ const list = Array.isArray(installs) ? installs : []
316
+ // user 스코프 우선, project 스코프는 현재 cwd 가 그 projectPath 아래일 때만
317
+ const pick =
318
+ list.find((i) => i.scope === 'user') ||
319
+ list.find(
320
+ (i) =>
321
+ i.scope === 'project' &&
322
+ typeof i.projectPath === 'string' &&
323
+ (cwd === i.projectPath || cwd.startsWith(i.projectPath + '/'))
324
+ )
325
+ if (pick && pick.installPath) out.push({ key, name: key.split('@')[0], installPath: pick.installPath })
326
+ }
327
+ return out
328
+ }
329
+
330
+ export function collectAssets({ home, cwd }) {
331
+ const items = []
332
+ collectSkillDirs(join(home, '.claude', 'skills'), '', 'local', items)
333
+ collectAgentFiles(join(home, '.claude', 'agents'), '', 'local', items)
334
+ for (const pl of resolveEnabledPluginInstalls({ home, cwd })) {
335
+ collectSkillDirs(join(pl.installPath, 'skills'), pl.name, `plugin:${pl.key}`, items)
336
+ collectAgentFiles(join(pl.installPath, 'agents'), pl.name, `plugin:${pl.key}`, items)
337
+ }
338
+ const settingsDisabled = loadSettingsDisabledSkills(home)
339
+ if (settingsDisabled.size) {
340
+ for (const it of items) {
341
+ const short = it.identity.includes(':') ? it.identity.split(':').pop() : null
342
+ if (settingsDisabled.has(it.identity) || (short && settingsDisabled.has(short))) it.disabled = true
343
+ }
344
+ }
345
+ return items
346
+ }
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // 사용 이력 스캔 (§2.1 unused_skills) — 데이터 최소화: 이름·카운트만 추출.
350
+ // transcript 본문 저장·출력·전송 금지. 매칭 3종: ① Skill tool_use input.skill
351
+ // ② "Launching skill: <name>" ③ <command-name>/<name></command-name>
352
+ // ---------------------------------------------------------------------------
353
+ // 64KB chunk 동기 라인 스트리밍. deadline 초과 시 false(미완주) 반환.
354
+ // 스킬 이름 매칭 패턴은 전부 ASCII 라 chunk 경계의 멀티바이트 분절은 매칭에 영향 없음.
355
+ function scanFileLines(filePath, onLine, deadline) {
356
+ let fd
357
+ try {
358
+ fd = openSync(filePath, 'r')
359
+ } catch {
360
+ return true // 못 여는 파일은 skip — 완주로 취급
361
+ }
362
+ try {
363
+ const buf = Buffer.alloc(64 * 1024)
364
+ let carry = ''
365
+ for (;;) {
366
+ let n
367
+ try {
368
+ n = readSync(fd, buf, 0, buf.length, null)
369
+ } catch {
370
+ return true
371
+ }
372
+ if (n <= 0) break
373
+ const text = carry + buf.toString('utf8', 0, n)
374
+ const lines = text.split('\n')
375
+ carry = lines.pop()
376
+ for (const l of lines) onLine(l)
377
+ if (Date.now() > deadline) return false
378
+ }
379
+ if (carry) onLine(carry)
380
+ return true
381
+ } finally {
382
+ try {
383
+ closeSync(fd)
384
+ } catch {
385
+ /* noop */
386
+ }
387
+ }
388
+ }
389
+
390
+ function walkJsonl(dir, out) {
391
+ let entries
392
+ try {
393
+ entries = readdirSync(dir, { withFileTypes: true })
394
+ } catch {
395
+ return
396
+ }
397
+ for (const e of entries) {
398
+ const p = join(dir, e.name)
399
+ if (e.isDirectory()) walkJsonl(p, out)
400
+ else if (e.name.endsWith('.jsonl')) out.push(p)
401
+ }
402
+ }
403
+
404
+ export function scanSkillUsage({ home, windowDays = USAGE_WINDOW_DAYS, now = Date.now(), timeBudgetMs = 10000 }) {
405
+ // deadline 은 walk/stat/sort 이전에 시작 — 파일 목록 단계가 길어도 총 소요가 budget 을 크게 넘지 않게
406
+ const deadline = Date.now() + timeBudgetMs
407
+ const root = join(home, '.claude', 'projects')
408
+ const all = []
409
+ walkJsonl(root, all)
410
+ const cutoff = now - windowDays * 24 * 60 * 60 * 1000
411
+ const recent = []
412
+ for (const p of all) {
413
+ try {
414
+ const st = statSync(p)
415
+ if (st.mtimeMs >= cutoff) recent.push({ p, mtimeMs: st.mtimeMs })
416
+ } catch {
417
+ /* skip */
418
+ }
419
+ }
420
+ recent.sort((a, b) => b.mtimeMs - a.mtimeMs)
421
+
422
+ const counts = {}
423
+ const bump = (name) => {
424
+ if (!name) return
425
+ counts[name] = (counts[name] || 0) + 1
426
+ }
427
+ const onLine = (line) => {
428
+ if (line.includes('"name":"Skill"') || line.includes('"name": "Skill"')) {
429
+ const m = line.match(/"skill"\s*:\s*"([^"]+)"/)
430
+ if (m) bump(m[1])
431
+ }
432
+ if (line.includes('Launching skill:')) {
433
+ for (const m of line.matchAll(/Launching skill:\s*([A-Za-z0-9:_-]+)/g)) bump(m[1])
434
+ }
435
+ if (line.includes('<command-name>')) {
436
+ for (const m of line.matchAll(/<command-name>\/?([A-Za-z0-9:_-]+)<\/command-name>/g)) bump(m[1])
437
+ }
438
+ }
439
+ let scanned = 0
440
+ let partial = false
441
+ for (const { p } of recent) {
442
+ if (Date.now() > deadline) {
443
+ partial = true
444
+ break
445
+ }
446
+ // 라인 스트리밍(§2.1) — 파일 전체를 메모리에 올리지 않고 chunk 단위로 읽으며
447
+ // budget 을 chunk 마다 검사한다. 큰 jsonl 1개가 예산·메모리를 독점하지 않게.
448
+ const completed = scanFileLines(p, onLine, deadline)
449
+ if (!completed) {
450
+ partial = true
451
+ break
452
+ }
453
+ scanned++
454
+ }
455
+ return {
456
+ counts,
457
+ window_days: windowDays,
458
+ files_total: recent.length,
459
+ files_scanned: scanned,
460
+ partial,
461
+ }
462
+ }
463
+
464
+ export function loadUsageCache(home, now = Date.now()) {
465
+ const cache = readJsonSafe(usageCachePath(home))
466
+ if (!cache || typeof cache.scanned_at !== 'number') return null
467
+ if (now - cache.scanned_at > USAGE_CACHE_MAX_AGE_MS) return null
468
+ return cache
469
+ }
470
+
471
+ export function saveUsageCache(home, usage, now = Date.now()) {
472
+ try {
473
+ writeFileSync(usageCachePath(home), JSON.stringify({ scanned_at: now, ...usage }))
474
+ } catch {
475
+ /* best-effort — 미기록이면 다음에 재스캔 (§4) */
476
+ }
477
+ }
478
+
479
+ // ---------------------------------------------------------------------------
480
+ // 미사용 스킬 후보 (§2.1 오탐 안전장치 포함)
481
+ // ---------------------------------------------------------------------------
482
+ export function findUnusedSkills({ items, counts, keep = [], now = Date.now() }) {
483
+ const used = (identity) => {
484
+ if ((counts[identity] || 0) > 0) return true
485
+ const short = identity.includes(':') ? identity.split(':').pop() : null
486
+ return short ? (counts[short] || 0) > 0 : false
487
+ }
488
+ const keepSet = new Set(keep)
489
+ return items
490
+ .filter((it) => it.kind === 'skill' && !it.disabled && !it.parseError)
491
+ .filter((it) => it.identity !== 'rookie' && !it.identity.endsWith(':rookie'))
492
+ .filter((it) => !keepSet.has(it.identity))
493
+ .filter((it) => !(it.birthtimeMs && now - it.birthtimeMs < NEW_SKILL_GRACE_MS))
494
+ .filter((it) => !used(it.identity))
495
+ .map((it) => it.identity)
496
+ .sort()
497
+ }
498
+
499
+ export function loadSnooze(home, now = Date.now()) {
500
+ const raw = readJsonSafe(snoozePath(home)) || {}
501
+ const active = {}
502
+ for (const [k, until] of Object.entries(raw)) {
503
+ const t = Date.parse(until)
504
+ if (Number.isFinite(t) && t > now) active[k] = until
505
+ }
506
+ return active
507
+ }
508
+
509
+ // ---------------------------------------------------------------------------
510
+ // 리포트 조립 (§2.1 5항목 + 임계 판정 + snooze)
511
+ // usageMode: 'cache-or-scan'(CLI 기본) | 'force-scan'(--force) | 'cache-only'(doctor)
512
+ // ---------------------------------------------------------------------------
513
+ export function computeReport({
514
+ cwd = process.cwd(),
515
+ home = homedir(),
516
+ now = Date.now(),
517
+ usageMode = 'cache-or-scan',
518
+ } = {}) {
519
+ const snooze = loadSnooze(home, now)
520
+ const items = {}
521
+
522
+ // 1+2) claude_md_project / claude_md_total
523
+ let memory = null
524
+ try {
525
+ memory = measureMemory({ cwd, home })
526
+ } catch {
527
+ /* 부분 실패 허용 (§4) — 해당 항목 null */
528
+ }
529
+ items.claude_md_project = memory
530
+ ? {
531
+ chars: memory.project.chars,
532
+ lines: memory.project.lines,
533
+ files: memory.project.files,
534
+ threshold: THRESHOLDS.claude_md_project,
535
+ exceeded: memory.project.chars > THRESHOLDS.claude_md_project,
536
+ }
537
+ : null
538
+ items.claude_md_total = memory
539
+ ? {
540
+ chars: memory.total.chars,
541
+ files: memory.total.files,
542
+ threshold: THRESHOLDS.claude_md_total,
543
+ exceeded: memory.total.chars > THRESHOLDS.claude_md_total,
544
+ }
545
+ : null
546
+
547
+ // 3) skills_agents_desc
548
+ let assets = null
549
+ let parseErrors = 0
550
+ try {
551
+ assets = collectAssets({ home, cwd })
552
+ parseErrors = assets.filter((a) => a.parseError).length
553
+ const chars = assets.filter((a) => !a.disabled).reduce((s, a) => s + a.descChars, 0)
554
+ items.skills_agents_desc = {
555
+ chars,
556
+ count: assets.length,
557
+ parse_errors: parseErrors,
558
+ threshold: THRESHOLDS.skills_agents_desc,
559
+ exceeded: chars > THRESHOLDS.skills_agents_desc,
560
+ }
561
+ } catch {
562
+ items.skills_agents_desc = null
563
+ }
564
+
565
+ // 4) rookie_self
566
+ try {
567
+ const skillMd = readTextSafe(join(home, '.claude', 'skills', 'rookie', 'SKILL.md'))
568
+ const inj = readJsonSafe(lastInjectionPath(home))
569
+ const injAt = inj && inj.at ? Date.parse(inj.at) : NaN
570
+ const injStale = !Number.isFinite(injAt) || now - injAt > INJECTION_STALE_MS
571
+ const skillChars = skillMd ? skillMd.length : 0
572
+ const injChars = inj && typeof inj.chars === 'number' ? inj.chars : null
573
+ items.rookie_self = {
574
+ skill_chars: skillChars,
575
+ injected_chars: injChars,
576
+ injection_stale: injStale,
577
+ threshold_skill: THRESHOLDS.rookie_self_skill,
578
+ threshold_injected: THRESHOLDS.rookie_self_injected,
579
+ // stale 주입 메타는 판정에서 제외 (§2.1)
580
+ exceeded:
581
+ skillChars > THRESHOLDS.rookie_self_skill ||
582
+ (!injStale && injChars != null && injChars > THRESHOLDS.rookie_self_injected),
583
+ }
584
+ } catch {
585
+ items.rookie_self = null
586
+ }
587
+
588
+ // 5) unused_skills — usage 는 캐시 7일 재사용 (§2.1 데이터 최소화)
589
+ let usage = usageMode === 'force-scan' ? null : loadUsageCache(home, now)
590
+ let usageFromCache = !!usage
591
+ if (!usage && usageMode !== 'cache-only') {
592
+ try {
593
+ usage = scanSkillUsage({ home, now })
594
+ saveUsageCache(home, usage, now)
595
+ } catch {
596
+ usage = null
597
+ }
598
+ }
599
+ if (usage && assets) {
600
+ const keep = readJsonSafe(keepPath(home)) || []
601
+ const unused = findUnusedSkills({ items: assets, counts: usage.counts || {}, keep, now })
602
+ items.unused_skills = {
603
+ names: unused,
604
+ count: unused.length,
605
+ window_days: usage.window_days ?? USAGE_WINDOW_DAYS,
606
+ usage_partial: !!usage.partial,
607
+ usage_cached: usageFromCache,
608
+ usage_scanned_at: usage.scanned_at ?? now,
609
+ threshold: THRESHOLDS.unused_skills_min,
610
+ // partial 스캔이면 "안 쓴 걸로 오인" 위험 → 판정 제외 (§4 부분 실패 허용)
611
+ exceeded: !usage.partial && unused.length >= THRESHOLDS.unused_skills_min,
612
+ }
613
+ } else {
614
+ items.unused_skills = null
615
+ }
616
+
617
+ for (const [key, item] of Object.entries(items)) {
618
+ if (item) item.snoozed = key in snooze
619
+ }
620
+ const ok = Object.values(items).some((v) => v != null)
621
+ return {
622
+ ok,
623
+ measured_at: now,
624
+ proxy_note: '설치 자산 기준 proxy — 실제 토큰 주입량과 다를 수 있음',
625
+ items,
626
+ snooze,
627
+ }
628
+ }
@@ -0,0 +1,73 @@
1
+ // B3 (spec 2026-07-11-memory-reliability-fix-design §B3) — dead letter 성격 분류.
2
+ // 데이터 소스 = ~/.rookie/dead-letter/*.jsonl (hook queue 의 retry 소진·정책 거부 산물).
3
+ // 파일명 규약(queue.sh): <sid>-mutate-invalid-<ts>.jsonl(4xx 정책 거부) /
4
+ // <sid>-mutate.jsonl(batch-mutate retry 소진) / <sid>.jsonl(flush-session retry 소진).
5
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+
8
+ export function deadLetterKind(fileName) {
9
+ if (fileName.includes('-mutate-invalid-')) return 'mutate_invalid'
10
+ if (fileName.endsWith('-mutate.jsonl') || fileName.endsWith('-mutate-retry.jsonl')) return 'batch_mutate'
11
+ return 'flush_session'
12
+ }
13
+
14
+ // last_error_status → 사람이 읽는 사유 버킷.
15
+ export function statusBucket(status) {
16
+ const s = String(status ?? '').trim()
17
+ if (s === '' || s === 'unknown') return 'unknown'
18
+ if (s === '000' || s === '0') return 'network'
19
+ const n = Number(s)
20
+ if (Number.isFinite(n) && n >= 400 && n < 500) return `http_4xx`
21
+ if (Number.isFinite(n) && n >= 500) return `http_5xx`
22
+ return 'unknown'
23
+ }
24
+
25
+ // entries = [{name, lastErrorStatus, deadLetterAt}] → 사유별 집계 (순수 — 테스트 대상).
26
+ export function classifyDeadLetters(entries) {
27
+ const byKind = {}
28
+ const byStatus = {}
29
+ let recent = null
30
+ for (const e of entries) {
31
+ const kind = deadLetterKind(e.name)
32
+ byKind[kind] = (byKind[kind] ?? 0) + 1
33
+ // mutate-invalid 파일은 dispatcher 가 raw payload 만 남긴다(상태 필드 없음) —
34
+ // 4xx 정책 거부 산물이므로 파일명 kind 로 http_4xx 에 귀속(코드검토 P2).
35
+ const raw = statusBucket(e.lastErrorStatus)
36
+ const bucket = raw === 'unknown' && kind === 'mutate_invalid' ? 'http_4xx' : raw
37
+ byStatus[bucket] = (byStatus[bucket] ?? 0) + 1
38
+ const at = e.deadLetterAt ? Date.parse(e.deadLetterAt) : NaN
39
+ if (!Number.isNaN(at) && (recent === null || at > recent.ts)) {
40
+ recent = { ts: at, kind, bucket, at: e.deadLetterAt }
41
+ }
42
+ }
43
+ return { total: entries.length, byKind, byStatus, recent }
44
+ }
45
+
46
+ // dead-letter 디렉토리 스캔 (fail-soft). 각 파일의 마지막 유효 라인에서 상태를 뽑는다.
47
+ export function readDeadLetterEntries(dir = join(process.env.HOME ?? '', '.rookie', 'dead-letter')) {
48
+ if (!existsSync(dir)) return []
49
+ let names
50
+ try {
51
+ names = readdirSync(dir).filter((f) => f.endsWith('.jsonl'))
52
+ } catch {
53
+ return []
54
+ }
55
+ const entries = []
56
+ for (const name of names) {
57
+ let lastErrorStatus = null
58
+ let deadLetterAt = null
59
+ try {
60
+ const lines = readFileSync(join(dir, name), 'utf8').split('\n').filter(Boolean)
61
+ for (let i = lines.length - 1; i >= 0; i--) {
62
+ try {
63
+ const obj = JSON.parse(lines[i])
64
+ lastErrorStatus = obj.last_error_status ?? obj.status ?? null
65
+ deadLetterAt = obj.dead_letter_at ?? obj.queued_at ?? null
66
+ break
67
+ } catch { /* 깨진 라인 — 더 위 라인 시도 */ }
68
+ }
69
+ } catch { /* 읽기 실패 — 파일명만으로 집계 */ }
70
+ entries.push({ name, lastErrorStatus, deadLetterAt })
71
+ }
72
+ return entries
73
+ }