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,122 @@
|
|
|
1
|
+
// done-scan 경로 매칭 — src/lib/forgetting/task-match.ts 의 pathTier 규칙 최소 포트.
|
|
2
|
+
// spec docs/superpowers/specs/2026-07-01-completion-linkage-remove-vs-improve.md §5.4.
|
|
3
|
+
//
|
|
4
|
+
// 완료된 doing task 를 git 커밋 산출물로 결정론적 감지한다. spec_ref 있는 task 만
|
|
5
|
+
// 경로 강신호(tier≥3)로 매칭 — 오연결 < 미연결(보수적). LLM 판단 없음.
|
|
6
|
+
|
|
7
|
+
// spec_ref 는 follow-up task 에서 `specs/a.md#3차-pr` 처럼 `#fragment` 가 붙는다(spec-followup-core).
|
|
8
|
+
// git 파일경로엔 fragment 가 없으니 spec_ref 매칭 전 제거해야 exact/render-pair tier 가 산다.
|
|
9
|
+
// (커밋 파일 경로엔 적용 안 함 — 파일명 내 `#` 오절단 방지.)
|
|
10
|
+
export function stripPathFragment(p) {
|
|
11
|
+
return String(p).replace(/#.*$/, '')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// 비교 전용 경로 정규화: 소문자 + `\`→`/` + 중복 slash 축약 + trailing slash 제거 + `.`·빈 segment 제거.
|
|
15
|
+
export function normalizePathForMatch(p) {
|
|
16
|
+
return String(p)
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.replace(/\\/g, '/')
|
|
19
|
+
.replace(/\/+/g, '/')
|
|
20
|
+
.replace(/\/$/, '')
|
|
21
|
+
.split('/')
|
|
22
|
+
.filter((seg) => seg.length > 0 && seg !== '.')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function splitExt(fileSeg) {
|
|
26
|
+
const dot = fileSeg.lastIndexOf('.')
|
|
27
|
+
if (dot <= 0) return { stem: fileSeg, ext: '' }
|
|
28
|
+
return { stem: fileSeg.slice(0, dot), ext: fileSeg.slice(dot + 1) }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// short 가 long 의 trailing suffix 인가(segment 단위).
|
|
32
|
+
function isSuffix(short, long) {
|
|
33
|
+
if (short.length > long.length) return false
|
|
34
|
+
const offset = long.length - short.length
|
|
35
|
+
for (let i = 0; i < short.length; i++) {
|
|
36
|
+
if (short[i] !== long[offset + i]) return false
|
|
37
|
+
}
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const RENDER_EXTS = new Set(['md', 'html', 'htm', 'mdx'])
|
|
42
|
+
|
|
43
|
+
// 두 정규화 경로의 점수 계층(0~4). task-match.ts §10.3 그대로.
|
|
44
|
+
export function pathTier(aSegs, sSegs) {
|
|
45
|
+
if (aSegs.length === 0 || sSegs.length === 0) return 0
|
|
46
|
+
const [short, long] = aSegs.length <= sSegs.length ? [aSegs, sSegs] : [sSegs, aSegs]
|
|
47
|
+
|
|
48
|
+
// exact — 최소 디렉토리 1개(짧은 길이 ≥ 2)로 1-segment 오연결 차단.
|
|
49
|
+
if (short.length >= 2 && isSuffix(short, long)) return 4
|
|
50
|
+
|
|
51
|
+
const aFile = splitExt(aSegs[aSegs.length - 1])
|
|
52
|
+
const sFile = splitExt(sSegs[sSegs.length - 1])
|
|
53
|
+
const aDir = aSegs.slice(0, -1)
|
|
54
|
+
const sDir = sSegs.slice(0, -1)
|
|
55
|
+
const [shortDir, longDir] = aDir.length <= sDir.length ? [aDir, sDir] : [sDir, aDir]
|
|
56
|
+
|
|
57
|
+
// same-stem — render pair 만, 디렉토리 suffix ≥ 1.
|
|
58
|
+
if (
|
|
59
|
+
aFile.stem === sFile.stem &&
|
|
60
|
+
aFile.ext !== sFile.ext &&
|
|
61
|
+
RENDER_EXTS.has(aFile.ext) &&
|
|
62
|
+
RENDER_EXTS.has(sFile.ext) &&
|
|
63
|
+
shortDir.length >= 1 &&
|
|
64
|
+
isSuffix(shortDir, longDir)
|
|
65
|
+
) {
|
|
66
|
+
return 3
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// basename — 파일명(확장자 포함) 일치.
|
|
70
|
+
if (aSegs[aSegs.length - 1] === sSegs[sSegs.length - 1]) return 2
|
|
71
|
+
|
|
72
|
+
// parent-dir — 디렉토리 suffix ≥ 1 일치.
|
|
73
|
+
if (shortDir.length >= 1 && isSuffix(shortDir, longDir)) return 1
|
|
74
|
+
|
|
75
|
+
return 0
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// task.detail 의 __done_defer:<sha> 머신 토큰 → Set<sha>. rookie_task 에 tags 컬럼이 없어 detail 에 둔다.
|
|
79
|
+
const DEFER_RE = /__done_defer:([0-9a-f]{7,40})/g
|
|
80
|
+
export function parseDeferShas(detail) {
|
|
81
|
+
const out = new Set()
|
|
82
|
+
if (!detail) return out
|
|
83
|
+
let m
|
|
84
|
+
while ((m = DEFER_RE.exec(detail)) !== null) out.add(m[1])
|
|
85
|
+
return out
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// deferSha(7~40 hex prefix)가 commit sha 의 prefix 면 그 (task,commit) 은 보류됨.
|
|
89
|
+
function isDeferred(commitSha, deferShas) {
|
|
90
|
+
for (const d of deferShas) {
|
|
91
|
+
if (commitSha.startsWith(d)) return true
|
|
92
|
+
}
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 강매칭(tier≥3): task.spec_ref 와 commit 파일 중 하나라도.
|
|
97
|
+
function commitMatchesSpec(specSegs, commit) {
|
|
98
|
+
for (const f of commit.files ?? []) {
|
|
99
|
+
if (pathTier(normalizePathForMatch(f), specSegs) >= 3) return true
|
|
100
|
+
}
|
|
101
|
+
return false
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// doing task × commits → done 후보. spec_ref 있는 task 만, 보류 커밋 제외, 최신 매칭 커밋 1건.
|
|
105
|
+
// commits 는 최신순(git log 기본) 가정.
|
|
106
|
+
export function matchDoneCandidates(tasks, commits) {
|
|
107
|
+
const out = []
|
|
108
|
+
for (const t of tasks) {
|
|
109
|
+
if (!t.spec_ref) continue
|
|
110
|
+
const specSegs = normalizePathForMatch(stripPathFragment(t.spec_ref))
|
|
111
|
+
if (specSegs.length === 0) continue
|
|
112
|
+
const deferShas = parseDeferShas(t.detail)
|
|
113
|
+
for (const c of commits) {
|
|
114
|
+
if (isDeferred(c.sha, deferShas)) continue
|
|
115
|
+
if (commitMatchesSpec(specSegs, c)) {
|
|
116
|
+
out.push({ taskId: t.id, taskTitle: t.title, sha: c.sha, subject: c.subject })
|
|
117
|
+
break
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out
|
|
122
|
+
}
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import readline from 'node:readline'
|
|
2
|
+
|
|
3
|
+
export async function ask(question, { defaultValue = '' } = {}) {
|
|
4
|
+
if (!process.stdin.isTTY) {
|
|
5
|
+
throw new Error(`non-interactive stdin — '${question}' 을 답할 수 없습니다. --yes 또는 --force 플래그를 지정하세요.`)
|
|
6
|
+
}
|
|
7
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
8
|
+
const answer = await new Promise((resolve) => {
|
|
9
|
+
rl.question(defaultValue ? `${question} (${defaultValue}) ` : `${question} `, resolve)
|
|
10
|
+
})
|
|
11
|
+
rl.close()
|
|
12
|
+
return answer.trim() || defaultValue
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// echo 없이 비밀번호 입력 받기. stdin raw mode로 문자별 수신, * 같은 표시도 생략해
|
|
16
|
+
// 길이조차 노출되지 않도록 한다. TTY 없으면 거절.
|
|
17
|
+
export async function askPassword(question) {
|
|
18
|
+
if (!process.stdin.isTTY) {
|
|
19
|
+
throw new Error(`non-interactive stdin — '${question}' 은 TTY 필요.`)
|
|
20
|
+
}
|
|
21
|
+
process.stdout.write(`${question} `)
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const stdin = process.stdin
|
|
24
|
+
stdin.setRawMode(true)
|
|
25
|
+
stdin.resume()
|
|
26
|
+
stdin.setEncoding('utf8')
|
|
27
|
+
let buf = ''
|
|
28
|
+
const onData = (ch) => {
|
|
29
|
+
// Ctrl+D(EOT, ) or Enter → submit
|
|
30
|
+
if (ch === '\r' || ch === '\n' || ch === '') {
|
|
31
|
+
cleanup()
|
|
32
|
+
process.stdout.write('\n')
|
|
33
|
+
resolve(buf)
|
|
34
|
+
} else if (ch === '') {
|
|
35
|
+
// Ctrl+C → abort
|
|
36
|
+
cleanup()
|
|
37
|
+
process.stdout.write('\n')
|
|
38
|
+
reject(new Error('aborted'))
|
|
39
|
+
} else if (ch === '' || ch === '\b') {
|
|
40
|
+
// DEL or backspace
|
|
41
|
+
if (buf.length > 0) buf = buf.slice(0, -1)
|
|
42
|
+
} else {
|
|
43
|
+
buf += ch
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const cleanup = () => {
|
|
47
|
+
stdin.removeListener('data', onData)
|
|
48
|
+
stdin.setRawMode(false)
|
|
49
|
+
stdin.pause()
|
|
50
|
+
}
|
|
51
|
+
stdin.on('data', onData)
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function confirm(question, { defaultYes = false } = {}) {
|
|
56
|
+
const hint = defaultYes ? 'Y/n' : 'y/N'
|
|
57
|
+
const ans = (await ask(`${question} [${hint}]`)).toLowerCase()
|
|
58
|
+
if (!ans) return defaultYes
|
|
59
|
+
return ans === 'y' || ans === 'yes'
|
|
60
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PostToolUse recall nudge — 로컬 기억 문서 읽기 감지 시 "recall 먼저" 1줄 주입.
|
|
3
|
+
# spec: docs/superpowers/specs/2026-07-10-recall-first-injection-regression-fix.md §3.B2
|
|
4
|
+
#
|
|
5
|
+
# 책임: Read/Bash/Grep 이 worklog·archive·specs·MEMORY.md 를 읽은 직후,
|
|
6
|
+
# 세션당 1회 additionalContext 로 교정 신호를 주입한다.
|
|
7
|
+
# 측정(pretool-md-read.sh, PreToolUse)과 책임 분리 — 이 hook 은 교정 전용.
|
|
8
|
+
# 차단 절대 X — 항상 exit 0.
|
|
9
|
+
|
|
10
|
+
# IMPORTANT: set -e 금지. fail-fast 가 Claude Code tool 흐름을 깨는 위험.
|
|
11
|
+
set +e
|
|
12
|
+
|
|
13
|
+
# self-reference 방지(spec O-4) 1차 방어선:
|
|
14
|
+
# 차차 internal LLM 호출(`claude -p` from cli-provider)에서 발사된 hook은 즉시 종료.
|
|
15
|
+
if [ "$ROOKIE_INTERNAL_INVOCATION" = "1" ]; then
|
|
16
|
+
exit 0
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
INPUT=$(cat 2>/dev/null) || exit 0
|
|
20
|
+
[ -z "$INPUT" ] && exit 0
|
|
21
|
+
|
|
22
|
+
# 빠른 pre-filter: 매칭 키워드 없으면 즉시 종료. node spawn 회피 (~5ms).
|
|
23
|
+
case "$INPUT" in
|
|
24
|
+
*worklog*|*work-log*|*archive/*.md*|*docs/superpowers/specs*|*MEMORY.md*) ;;
|
|
25
|
+
*) exit 0 ;;
|
|
26
|
+
esac
|
|
27
|
+
|
|
28
|
+
command -v node >/dev/null 2>&1 || exit 0
|
|
29
|
+
|
|
30
|
+
NUDGE_DIR="${ROOKIE_RECALL_NUDGE_DIR:-$HOME/.rookie/recall-nudge}"
|
|
31
|
+
ERR_LOG="$HOME/.rookie/metric-errors.log"
|
|
32
|
+
mkdir -p "$HOME/.rookie" 2>/dev/null # ERR_LOG redirect 대상 디렉토리 보장 (부재 시 redirect 실패로 hook 전체가 죽음)
|
|
33
|
+
|
|
34
|
+
# PostToolUse payload 는 tool_response(Read 결과 전문)까지 포함해 argv 로 넘기면
|
|
35
|
+
# ARG_MAX 초과로 node 가 스폰조차 실패한다(codex 코드검토 P1) — stdin 파이프로 전달.
|
|
36
|
+
# printf 는 bash builtin 이라 ARG_MAX 무관.
|
|
37
|
+
printf '%s' "$INPUT" | ROOKIE_NUDGE_DIR="$NUDGE_DIR" node -e '
|
|
38
|
+
let raw = "";
|
|
39
|
+
process.stdin.on("data", (c) => { raw += c; });
|
|
40
|
+
process.stdin.on("end", () => {
|
|
41
|
+
try {
|
|
42
|
+
const nudgeDir = process.env.ROOKIE_NUDGE_DIR;
|
|
43
|
+
|
|
44
|
+
let j;
|
|
45
|
+
try { j = JSON.parse(raw); } catch { process.exit(0); }
|
|
46
|
+
if (!j || typeof j !== "object") process.exit(0);
|
|
47
|
+
|
|
48
|
+
const tool = j.tool_name;
|
|
49
|
+
const input = j.tool_input || {};
|
|
50
|
+
|
|
51
|
+
// 기억 문서 서명 — pretool-md-read.sh 매칭과 정합 + work-log.md/MEMORY.md 확장.
|
|
52
|
+
const isMemoryDocPath = (fp) => {
|
|
53
|
+
if (/\/CLAUDE(?:-[\w-]+)?\.md$/.test(fp)) return false; // CLAUDE.md 류는 설정 문서 — 제외
|
|
54
|
+
return /(\/|^)work-?log\.md$/.test(fp) ||
|
|
55
|
+
/\/worklog\/archive\/.+\.md$/.test(fp) ||
|
|
56
|
+
/\/docs\/superpowers\/specs\/.+\.md$/.test(fp) ||
|
|
57
|
+
/(\/|^)MEMORY\.md$/.test(fp);
|
|
58
|
+
};
|
|
59
|
+
const hasMemoryDocSignature = (s) =>
|
|
60
|
+
/work-?log|\/archive\/.*\.md|\/docs\/superpowers\/specs|MEMORY\.md/.test(s);
|
|
61
|
+
|
|
62
|
+
let match = false;
|
|
63
|
+
if (tool === "Read" && typeof input.file_path === "string") {
|
|
64
|
+
match = isMemoryDocPath(input.file_path);
|
|
65
|
+
} else if (tool === "Bash" && typeof input.command === "string") {
|
|
66
|
+
const cmd = input.command;
|
|
67
|
+
const verb = /\b(cat|head|tail|grep|sed|awk|less|more)\b/.test(cmd);
|
|
68
|
+
if (verb && hasMemoryDocSignature(cmd) && !/\/CLAUDE(?:-[\w-]+)?\.md/.test(cmd)) match = true;
|
|
69
|
+
} else if (tool === "Grep") {
|
|
70
|
+
const probe = [input.path, input.glob, input.pattern]
|
|
71
|
+
.filter((v) => typeof v === "string").join("\n");
|
|
72
|
+
match = probe.length > 0 && hasMemoryDocSignature(probe);
|
|
73
|
+
}
|
|
74
|
+
if (!match) process.exit(0);
|
|
75
|
+
|
|
76
|
+
// 세션 throttle 1회 — sid 폴백: payload.session_id → sessionId → $CLAUDE_SESSION_ID (codex B2-P2).
|
|
77
|
+
const sid = String(j.session_id || j.sessionId || process.env.CLAUDE_SESSION_ID || "");
|
|
78
|
+
if (!sid) process.exit(0); // sid 부재 → 미주입 (unknown 공유 marker 영구 침묵보다 안전)
|
|
79
|
+
|
|
80
|
+
const fs = require("fs"), path = require("path"), crypto = require("crypto");
|
|
81
|
+
const key = crypto.createHash("sha256").update(sid).digest("hex");
|
|
82
|
+
const marker = path.join(nudgeDir, key + ".nudged");
|
|
83
|
+
if (fs.existsSync(marker)) process.exit(0);
|
|
84
|
+
fs.mkdirSync(nudgeDir, { recursive: true, mode: 0o700 });
|
|
85
|
+
fs.writeFileSync(marker, JSON.stringify({ session_id: sid, nudged_at: new Date().toISOString() }), { mode: 0o600 });
|
|
86
|
+
|
|
87
|
+
const MSG = "🧠 방금 로컬 기억 문서를 읽었습니다. 과거 작업 맥락·결정·방법을 찾는 중이라면 더루키 recall 이 로컬 grep 보다 정확합니다 — GET /api/rookie/recall 을 먼저 호출하세요. (문서 자체를 편집·집필 중이면 무시하세요.)";
|
|
88
|
+
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: MSG } }));
|
|
89
|
+
} catch (e) {
|
|
90
|
+
// silent — fail-safe. tool 흐름 차단 절대 X.
|
|
91
|
+
process.exit(0);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
' 2>>"$ERR_LOG"
|
|
95
|
+
|
|
96
|
+
exit 0
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# v6 P1 #5 — Stage 1 측정 hook (PreToolUse).
|
|
3
|
+
# spec: docs/superpowers/specs/2026-05-05-v6-recall-and-hot-write-design.md §7.1
|
|
4
|
+
# plan: docs/superpowers/plans/2026-05-05-v6-p1-5-stage1-metric-hook.md Task 3
|
|
5
|
+
#
|
|
6
|
+
# 책임: Read tool 의 file_path 가 worklog/archive/specs 매칭 시,
|
|
7
|
+
# 또는 Bash tool 의 command 에 cat/head/tail/grep/sed/awk + worklog 키워드 매칭 시
|
|
8
|
+
# 로컬 buffer (`~/.rookie/metric-pending/<session>-md-read.jsonl`) 에 1줄 append.
|
|
9
|
+
# 차단 절대 X — 항상 exit 0.
|
|
10
|
+
#
|
|
11
|
+
# 안전장치: set +e + stderr redirect. PreToolUse 실패가 tool call 차단 유발하지 않도록.
|
|
12
|
+
|
|
13
|
+
# IMPORTANT: set -e 금지. fail-fast 가 Claude Code tool 차단 위험.
|
|
14
|
+
set +e
|
|
15
|
+
|
|
16
|
+
# self-reference 방지(spec O-4) 1차 방어선:
|
|
17
|
+
# 차차 internal LLM 호출(`claude -p` from cli-provider)에서 발사된 hook은 즉시 종료.
|
|
18
|
+
if [ "$ROOKIE_INTERNAL_INVOCATION" = "1" ]; then
|
|
19
|
+
exit 0
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
# stdin JSON 읽기. cat 실패하면 그대로 종료 (silent fail).
|
|
23
|
+
INPUT=$(cat 2>/dev/null) || exit 0
|
|
24
|
+
[ -z "$INPUT" ] && exit 0
|
|
25
|
+
|
|
26
|
+
# 빠른 pre-filter: input 본문에 매칭 키워드 없으면 즉시 종료. node spawn 회피.
|
|
27
|
+
# 99% 의 tool call 이 여기서 끝남 (~5ms).
|
|
28
|
+
case "$INPUT" in
|
|
29
|
+
*worklog*|*archive/*.md*|*docs/superpowers/specs*) ;;
|
|
30
|
+
*) exit 0 ;;
|
|
31
|
+
esac
|
|
32
|
+
|
|
33
|
+
# 매칭 후보 — node 로 정밀 파싱. node 미설치 → silent fail.
|
|
34
|
+
command -v node >/dev/null 2>&1 || exit 0
|
|
35
|
+
|
|
36
|
+
# JSON 파싱 + 매칭 + buffer 적재 — node 1회 호출로 처리.
|
|
37
|
+
# stderr 는 metric-errors.log 로 redirect (silent fail 검증).
|
|
38
|
+
HOST=$(hostname 2>/dev/null) || HOST="unknown"
|
|
39
|
+
SESSION="${CLAUDE_SESSION_ID:-unknown}"
|
|
40
|
+
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || TS="1970-01-01T00:00:00Z"
|
|
41
|
+
BUF_DIR="$HOME/.rookie/metric-pending"
|
|
42
|
+
BUF_FILE="$BUF_DIR/${SESSION}-md-read.jsonl"
|
|
43
|
+
ERR_LOG="$HOME/.rookie/metric-errors.log"
|
|
44
|
+
|
|
45
|
+
mkdir -m 700 -p "$BUF_DIR" 2>/dev/null
|
|
46
|
+
|
|
47
|
+
node -e '
|
|
48
|
+
try {
|
|
49
|
+
const raw = process.argv[1];
|
|
50
|
+
const host = process.argv[2];
|
|
51
|
+
const session = process.argv[3];
|
|
52
|
+
const ts = process.argv[4];
|
|
53
|
+
const bufFile = process.argv[5];
|
|
54
|
+
|
|
55
|
+
let j;
|
|
56
|
+
try { j = JSON.parse(raw); } catch { process.exit(0); }
|
|
57
|
+
if (!j || typeof j !== "object") process.exit(0);
|
|
58
|
+
|
|
59
|
+
const tool = j.tool_name;
|
|
60
|
+
const input = j.tool_input || {};
|
|
61
|
+
let match = false;
|
|
62
|
+
let filePath = null;
|
|
63
|
+
let cmdHint = null;
|
|
64
|
+
|
|
65
|
+
if (tool === "Read" && typeof input.file_path === "string") {
|
|
66
|
+
const fp = input.file_path;
|
|
67
|
+
// CLAUDE.md 류는 build/style 설정 — 카운트 제외.
|
|
68
|
+
if (/\/CLAUDE(?:-[\w-]+)?\.md$/.test(fp)) {
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
// 매칭 패턴: worklog.md / worklog/archive/*.md / docs/superpowers/specs/*.md
|
|
72
|
+
if (/(\/|^)worklog\.md$/.test(fp) ||
|
|
73
|
+
/\/worklog\/archive\/.+\.md$/.test(fp) ||
|
|
74
|
+
/\/docs\/superpowers\/specs\/.+\.md$/.test(fp)) {
|
|
75
|
+
match = true;
|
|
76
|
+
filePath = fp;
|
|
77
|
+
}
|
|
78
|
+
} else if (tool === "Bash" && typeof input.command === "string") {
|
|
79
|
+
const cmd = input.command;
|
|
80
|
+
// cat/head/tail/grep/sed/awk + worklog/archive/specs 키워드 동시 포함 시 매칭.
|
|
81
|
+
const verb = /\b(cat|head|tail|grep|sed|awk|less|more)\b/.test(cmd);
|
|
82
|
+
const target = /worklog|\/archive\/.*\.md|\/docs\/superpowers\/specs/.test(cmd);
|
|
83
|
+
if (verb && target) {
|
|
84
|
+
match = true;
|
|
85
|
+
cmdHint = cmd.slice(0, 200);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!match) process.exit(0);
|
|
90
|
+
|
|
91
|
+
const fs = require("fs");
|
|
92
|
+
const entry = {
|
|
93
|
+
hostname: host,
|
|
94
|
+
tool_name: tool,
|
|
95
|
+
file_path: filePath,
|
|
96
|
+
command_hint: cmdHint,
|
|
97
|
+
session_external_id: session,
|
|
98
|
+
observed_at: ts,
|
|
99
|
+
};
|
|
100
|
+
fs.appendFileSync(bufFile, JSON.stringify(entry) + "\n", { mode: 0o600 });
|
|
101
|
+
} catch (e) {
|
|
102
|
+
// silent — fail-safe. tool 차단 절대 X.
|
|
103
|
+
process.exit(0);
|
|
104
|
+
}
|
|
105
|
+
' "$INPUT" "$HOST" "$SESSION" "$TS" "$BUF_FILE" 2>>"$ERR_LOG"
|
|
106
|
+
|
|
107
|
+
exit 0
|