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