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