claude-distill 0.2.0

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 parksubeom
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,184 @@
1
+ # claude-distill
2
+
3
+ > Claude한테 같은 함정을 세 번째 설명하고 계신가요?
4
+
5
+ 세션마다 트레이드오프 결정 (`A 대신 B 선택, 이유는…`), 환경 함정 (`Cursor webview는 confirm() 차단됨`), 같은 실수 (`Claude Code JSONL의 promptId는 항상 null`)가 쌓입니다. 그런데 세션이 끝나면 다 잊혀집니다. CLAUDE.md를 매번 갱신하면 좋겠지만 — 솔직히 안 하죠.
6
+
7
+ `claude-distill`은 **Stop hook**입니다. 한 번 설치하면:
8
+
9
+ 1. 세션 끝날 때마다 transcript를 자동으로 분석
10
+ 2. confidence:high 판례 + 사고 보고서를 markdown에 자동 누적
11
+ 3. 다음 세션부터 Claude가 자동으로 참조 (CLAUDE.md `@reference` 통해)
12
+
13
+ 사용자가 하는 일: `claude-distill init` **한 번. 끝.**
14
+
15
+ ---
16
+
17
+ ## 어떤 게 자동으로 누적되나
18
+
19
+ 세션 한 번 했더니 이런 entry들이 알아서 추출돼서 `~/.claude/gotchas.md` / `knowledge.md`에 추가됐습니다 (모두 진짜 dogfood 결과, 편집 없음):
20
+
21
+ ```
22
+ ⚠️ npm link가 macOS 기본 prefix에서 sudo 없이 실패 — 절대경로로 우회
23
+ ⚠️ Claude Code JSONL의 promptId가 항상 null — uuid + parentUuid 체인 사용
24
+ ⚠️ Cursor 빌트인 Claude는 PATH에 `claude` 바이너리를 노출 안 함
25
+ 🧠 ffmpeg cropdetect의 limit은 어두운 padding에서 ≥32 필요
26
+ 🧠 Transcript를 마지막 user marker부터 slice하면 분석 prompt ~80% 감소
27
+ 🧠 CSP `connect-src 'none'`이 webview의 외부 fetch를 이중 차단
28
+ ```
29
+
30
+ 각 entry는 `Symptom → Trap → Cause → Workaround` 4단으로 자동 정리됩니다 — 다음 세션의 Claude가 그대로 읽고 참조 가능한 형태로. 분석기 prompt가 보수적이라 자명한 사실 / 프로젝트 internal trivia / 검증 안 된 추측은 제외됩니다.
31
+
32
+ 전체 markdown은 IDE에서 그냥 열어보면 됩니다.
33
+
34
+ ---
35
+
36
+ ## 비유
37
+
38
+ | 파일 | 역할 |
39
+ |---|---|
40
+ | **`CLAUDE.md`** | 법률 — 변하지 않는 보편 규칙 (직접 작성) |
41
+ | **`knowledge.md`** | 판례 — "이 상황엔 이렇게 했다" (자동 누적) |
42
+ | **`gotchas.md`** | 사고 보고서 — "같은 실수 반복 금지" (자동 누적) |
43
+
44
+ 법률은 사람이 쓰지만, 판례와 사고 보고서는 매일 쌓이는 거니까 — 그건 자동화될 수 있습니다.
45
+
46
+ ---
47
+
48
+ ## 설치
49
+
50
+ ```bash
51
+ npm install -g claude-distill
52
+ claude-distill init
53
+ ```
54
+
55
+ 분석을 위한 LLM 호출 경로 둘 중 하나가 필요합니다:
56
+
57
+ ### 옵션 A — Claude Code CLI 사용자 (사전 설치 필요)
58
+
59
+ ```bash
60
+ npm install -g @anthropic-ai/claude-code
61
+ # 끝. distill이 자동으로 `claude --print` 호출.
62
+ ```
63
+
64
+ ### 옵션 B — Claude Code IDE 익스텐션 사용자 (Cursor / VS Code)
65
+
66
+ 빌트인 Claude는 PATH에 노출되지 않으니 **API key**를 사용:
67
+
68
+ ```bash
69
+ # https://console.anthropic.com/ 에서 API key 발급 후
70
+ echo 'export ANTHROPIC_API_KEY=sk-ant-...' >> ~/.zshrc
71
+ source ~/.zshrc
72
+ # distill이 환경변수 감지 시 자동으로 API 호출.
73
+ ```
74
+
75
+ (또는 옵션 A처럼 CLI 추가 설치도 가능 — IDE와 별개 동작.)
76
+
77
+ ---
78
+
79
+ `init`이 idempotent하게 두 가지를 등록:
80
+
81
+ 1. `~/.claude/settings.json` 의 **Stop hook** — 세션마다 자동 분석 호출
82
+ 2. `~/.claude/CLAUDE.md` 끝의 **`@knowledge.md` / `@gotchas.md` 참조** — 다음 세션부터 Claude가 자동 참조
83
+
84
+ 끝입니다. 더 이상 손 안 댑니다.
85
+
86
+ ---
87
+
88
+ ## 어떻게 동작
89
+
90
+ ```
91
+ 세션 끝
92
+ └─ Stop hook이 `claude-distill analyze --quiet` 자동 실행
93
+ ├─ 마지막 user marker 이후 turn slice (보통 ~120 turns / ~85K chars)
94
+ ├─ `claude --print`로 analyzer prompt 전달
95
+ ├─ JSON 응답 파싱
96
+ ├─ confidence:high entry → ~/.claude/knowledge.md / gotchas.md 즉시 append
97
+ └─ medium / low → drop (사용자 손 안 가게)
98
+
99
+ 다음 세션 시작
100
+ └─ CLAUDE.md의 @reference로 누적된 markdown이 system prompt에 inject
101
+ └─ Claude가 자연스럽게 참조 — 같은 함정 안 빠짐
102
+ ```
103
+
104
+ ---
105
+
106
+ ## 카테고리
107
+
108
+ 분석기가 entry마다 다음 11개 중 하나로 분류합니다:
109
+
110
+ **판례 (knowledge)**
111
+ `trade_off_decision` · `environment_quirk` · `scale_transition` · `tooling_insight` · `performance_insight`
112
+
113
+ **사고 (gotcha)**
114
+ `api_quirk` · `type_shape` · `concurrency_race` · `build_deploy` · `privacy_security` · `ux_regression`
115
+
116
+ ---
117
+
118
+ ## 결과 보기
119
+
120
+ 별도 UI 없습니다. 두 markdown 파일을 그냥 IDE에서 열어보세요:
121
+
122
+ ```bash
123
+ ~/.claude/knowledge.md
124
+ ~/.claude/gotchas.md
125
+ ```
126
+
127
+ 마음에 안 드는 entry는 그 줄을 그냥 삭제하면 됩니다 (markdown이라 자유 편집). 다음 세션부터 그 entry는 더 이상 inject되지 않음.
128
+
129
+ ---
130
+
131
+ ## CLI 명령 (3개)
132
+
133
+ | 명령 | 용도 | 빈도 |
134
+ |---|---|---|
135
+ | `claude-distill init` | hook + CLAUDE.md reference 등록 | **한 번** |
136
+ | `claude-distill where` | path / 존재 여부 확인 (디버깅) | 가끔 |
137
+ | `claude-distill analyze` | 수동 분석 | **거의 안 씀** (hook이 자동) |
138
+
139
+ `analyze`의 옵션 (잘 안 쓸 것):
140
+ - `--no-auto` — 자동 누적 대신 stdout에 JSON 출력
141
+ - `--mock` — claude CLI 호출 없이 가짜 entry 1건 (파이프라인 검증용)
142
+ - `--session=<file>` — 특정 jsonl 직접 지정
143
+ - `--quiet` — 출력 억제 (hook이 사용)
144
+
145
+ ---
146
+
147
+ ## 프라이버시
148
+
149
+ - 모든 추출이 사용자 머신에서 진행. transcript는 사용자가 (또는 hook이) 호출할 때만 Claude API로 전달.
150
+ - 결과는 plain markdown. git ignore 규칙 그대로 따름 (전역 파일이라 default ignore).
151
+ - hook은 `~/.claude/settings.json`에서 직접 비활성화 가능.
152
+ - 같은 transcript를 두 번 분석하지 않도록 `~/.claude/.distill/analyzed.json`에 sha hash만 저장.
153
+
154
+ ---
155
+
156
+ ## FAQ
157
+
158
+ **Q. CLAUDE.md를 직접 작성하면 안 되나?**
159
+ A. 직접 작성 가능. `claude-distill`은 매일 쌓이는 자잘한 판례/사고를 자동으로 잡아내는 보조 도구. CLAUDE.md는 변하지 않는 보편 규칙용으로 그대로 쓰시면 됩니다.
160
+
161
+ **Q. 토큰 비용은?**
162
+ A. 세션당 1회 분석. 보통 prompt ~85K chars (~20K tokens) input, 응답 ~2K tokens output. Sonnet 기준 세션당 약 $0.10. 가벼운 세션은 더 적음.
163
+
164
+ **Q. confidence:medium / low는 왜 drop?**
165
+ A. 노이즈 누적이 가장 큰 실패 패턴이라 보수적으로 시작. 향후 `--keep-medium` 옵션 추가 가능.
166
+
167
+ **Q. 프로젝트별 누적은?**
168
+ A. 전역이 기본. 프로젝트별 원하면 `<project>/.claude/CLAUDE.md`에 직접 `@.claude/knowledge.md` 추가. v0.3+에서 `--scope=project` 옵션 자동화 예정.
169
+
170
+ **Q. Cursor / VS Code Claude Code 익스텐션 사용자도 됨?**
171
+ A. **됩니다.** transcript는 익스텐션도 같은 위치(`~/.claude/projects/`)에 저장. 분석을 위한 LLM 호출만 별도 경로가 필요한데, `ANTHROPIC_API_KEY`만 환경변수로 export하면 distill이 자동으로 Anthropic API 직접 호출 (Node 18+ 빌트인 fetch).
172
+
173
+ **Q. 다른 LLM 백엔드?**
174
+ A. 현재 `claude` CLI + Anthropic API 두 가지. `--backend=cli|api|auto` 옵션. 기본 `auto`는 `ANTHROPIC_API_KEY` 있으면 API 우선, 없으면 CLI fallback. OpenAI / 로컬 LLM 지원은 v0.3+.
175
+
176
+ ---
177
+
178
+ ## 상태
179
+
180
+ v0.2 — 자동 누적 모델로 재정비. 실 사용 결과 알려주시면 prompt 튜닝 / 카테고리 조정 진행합니다.
181
+
182
+ ## License
183
+
184
+ MIT © parksubeom
package/bin/distill.js ADDED
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ // claude-distill — zero-effort knowledge accumulator for Claude Code.
3
+ //
4
+ // 명령은 단 3개:
5
+ // init — Stop hook + CLAUDE.md @reference 한 번 등록
6
+ // analyze — 자동 분석 (Hook이 호출, 직접 호출 불필요)
7
+ // where — 모든 경로 sanity check
8
+ //
9
+ // 그 외(review/list/search/archive)는 의도적으로 없습니다. markdown 파일을
10
+ // 그냥 IDE에서 열어 보시면 됩니다.
11
+
12
+ const cmd = process.argv[2];
13
+ const args = process.argv.slice(3);
14
+
15
+ const help = `claude-distill — Knowledge + Gotchas auto-accumulator for Claude Code
16
+
17
+ 설치:
18
+ claude-distill init Stop hook + CLAUDE.md reference 등록 (한 번만)
19
+
20
+ 확인:
21
+ claude-distill where 모든 파일 경로 / 존재 여부
22
+
23
+ 수동 분석 (보통 Hook이 자동으로 함):
24
+ claude-distill analyze [--no-auto] [--mock] [--quiet]
25
+
26
+ 결과는 plain markdown:
27
+ ~/.claude/knowledge.md 판례 (전역)
28
+ ~/.claude/gotchas.md 사고 보고서 (전역)
29
+ 또는 프로젝트별:
30
+ <project>/.claude/knowledge.md
31
+ <project>/.claude/gotchas.md
32
+
33
+ editor에서 직접 열어보시면 됩니다 — review UI 없습니다.`;
34
+
35
+ const dispatch = {
36
+ init: () => require('../lib/init').run(args),
37
+ analyze: () => require('../lib/analyze').run(args),
38
+ where: () => require('../lib/where').run(args),
39
+ '--help': () => console.log(help),
40
+ '-h': () => console.log(help),
41
+ };
42
+
43
+ if (!cmd || !dispatch[cmd]) {
44
+ console.log(help);
45
+ process.exit(cmd ? 1 : 0);
46
+ }
47
+
48
+ Promise.resolve(dispatch[cmd]()).catch((err) => {
49
+ console.error('claude-distill: ' + (err && err.message ? err.message : err));
50
+ process.exit(1);
51
+ });
package/lib/analyze.js ADDED
@@ -0,0 +1,273 @@
1
+ // `claude-distill analyze [--no-auto] [--mock] [--quiet] [--session=X]`
2
+ //
3
+ // SessionEnd hook이 자동 호출. 사용자가 직접 부를 일은 거의 없음.
4
+ //
5
+ // 동작 (기본):
6
+ // 1. 가장 최근 세션 jsonl을 slice
7
+ // 2. claude CLI에 분석 prompt 전달
8
+ // 3. confidence:high entry만 markdown에 자동 append
9
+ // 4. medium/low entry는 그냥 drop (사용자 손 가지 않게)
10
+ //
11
+ // 옵션:
12
+ // --no-auto 추출만 하고 stdout에 JSON 출력 (디버깅용)
13
+ // --mock claude CLI 호출 없이 fake entry 1건 생성
14
+ // --quiet Hook용 — 아무 출력 안 함
15
+ // --session=X 특정 jsonl 직접 지정
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { execFileSync } = require('child_process');
20
+ const crypto = require('crypto');
21
+ const cfg = require('./config');
22
+ const transcript = require('./transcript');
23
+ const store = require('./store');
24
+
25
+ function parseFlags(args) {
26
+ const flags = {
27
+ quiet: false,
28
+ session: null,
29
+ dryRun: false,
30
+ mock: false,
31
+ auto: true, // 기본값 — 자동 누적 (CLI 핵심)
32
+ backend: 'auto', // 'auto' | 'cli' | 'api' — auto는 ANTHROPIC_API_KEY 있으면 api, 없으면 cli
33
+ };
34
+ for (let i = 0; i < args.length; i++) {
35
+ const a = args[i];
36
+ if (a === '--quiet') flags.quiet = true;
37
+ else if (a === '--dry-run') flags.dryRun = true;
38
+ else if (a === '--mock') flags.mock = true;
39
+ else if (a === '--no-auto') flags.auto = false;
40
+ else if (a.startsWith('--backend=')) flags.backend = a.slice('--backend='.length);
41
+ else if (a === '--backend') flags.backend = args[++i];
42
+ else if (a.startsWith('--session=')) flags.session = a.slice('--session='.length);
43
+ else if (a === '--session') flags.session = args[++i];
44
+ }
45
+ return flags;
46
+ }
47
+
48
+ function log(flags, ...a) { if (!flags.quiet) console.log(...a); }
49
+
50
+ // 기존 markdown 파일을 dedup 컨텍스트로 분석기에 전달.
51
+ // 같은 lesson을 두 번 추출하지 않도록.
52
+ function existingForDedup() {
53
+ const out = { knowledge: '', gotchas: '' };
54
+ try { out.knowledge = fs.readFileSync(cfg.GLOBAL_KNOWLEDGE, 'utf8'); } catch {}
55
+ try { out.gotchas = fs.readFileSync(cfg.GLOBAL_GOTCHAS, 'utf8'); } catch {}
56
+ return out;
57
+ }
58
+
59
+ function buildAnalyzerPrompt(promptText, existing, t) {
60
+ const exBlock = `<existing>\n# knowledge.md\n${existing.knowledge}\n\n# gotchas.md\n${existing.gotchas}\n</existing>`;
61
+ const txBlock = `<transcript file="${t.source}" total_lines="${t.totalLines}" slice_from="${t.sliceFrom}">\n${JSON.stringify(t.turns, null, 2)}\n</transcript>`;
62
+ return `${promptText}\n\n---\n\n${exBlock}\n\n${txBlock}\n\nReturn the JSON array now. No prose, no markdown fences.`;
63
+ }
64
+
65
+ function mockExtract() {
66
+ return [{
67
+ type: 'gotcha',
68
+ category: 'api_quirk',
69
+ title: 'Sample mock gotcha — replace with real run',
70
+ context: 'Mock entry from --mock so you can verify the pipeline without an LLM call.',
71
+ insight: 'Real entries come from `claude-distill analyze` against an actual session.',
72
+ basis: 'Generated by lib/analyze.js mockExtract().',
73
+ application: 'Run `claude-distill analyze` after a real session to replace this.',
74
+ tags: ['mock', 'sample'],
75
+ confidence: 'high',
76
+ }];
77
+ }
78
+
79
+ function callClaudeCli(promptText) {
80
+ try {
81
+ return execFileSync('claude', ['--print'], {
82
+ input: promptText,
83
+ timeout: 120000,
84
+ maxBuffer: 16 * 1024 * 1024,
85
+ encoding: 'utf8',
86
+ });
87
+ } catch (e) {
88
+ if (e.code === 'ENOENT') {
89
+ const hint = [
90
+ '`claude` CLI를 PATH에서 찾을 수 없습니다.',
91
+ '',
92
+ '두 가지 해결 옵션:',
93
+ ' 1) CLI 설치: npm install -g @anthropic-ai/claude-code',
94
+ ' 2) API key 사용: export ANTHROPIC_API_KEY=sk-ant-...',
95
+ ' claude-distill analyze --backend=api',
96
+ '',
97
+ 'IDE 빌트인 Claude(Cursor / VS Code 익스텐션)는 PATH에 노출되지 않습니다 —',
98
+ '그래서 별도 CLI 설치 또는 API key가 필요합니다. transcript 자체는',
99
+ '익스텐션도 ~/.claude/projects/에 저장하므로 distill이 읽을 수 있습니다.',
100
+ ].join('\n');
101
+ throw new Error(hint);
102
+ }
103
+ throw new Error('claude CLI 실행 실패: ' + (e.stderr || e.message || String(e)));
104
+ }
105
+ }
106
+
107
+ // Anthropic API 직접 호출 — Claude Code CLI 없는 환경 (Cursor / VS Code
108
+ // 익스텐션 사용자) 용. Node 18+의 빌트인 fetch 사용.
109
+ async function callClaudeApi(promptText) {
110
+ const apiKey = process.env.ANTHROPIC_API_KEY;
111
+ if (!apiKey) {
112
+ throw new Error('ANTHROPIC_API_KEY 환경변수 미설정. https://console.anthropic.com/ 에서 키 발급 후 export.');
113
+ }
114
+ const model = process.env.CLAUDE_DISTILL_MODEL || 'claude-sonnet-4-6';
115
+ let res;
116
+ try {
117
+ res = await fetch('https://api.anthropic.com/v1/messages', {
118
+ method: 'POST',
119
+ headers: {
120
+ 'x-api-key': apiKey,
121
+ 'anthropic-version': '2023-06-01',
122
+ 'content-type': 'application/json',
123
+ },
124
+ body: JSON.stringify({
125
+ model,
126
+ max_tokens: 4096,
127
+ messages: [{ role: 'user', content: promptText }],
128
+ }),
129
+ });
130
+ } catch (e) {
131
+ throw new Error('Anthropic API 네트워크 호출 실패: ' + e.message);
132
+ }
133
+ if (!res.ok) {
134
+ const body = await res.text();
135
+ throw new Error('Anthropic API ' + res.status + ': ' + body.slice(0, 400));
136
+ }
137
+ const data = await res.json();
138
+ const block = (data.content || []).find((c) => c.type === 'text');
139
+ if (!block) throw new Error('Anthropic API 응답에 text block 없음');
140
+ return block.text;
141
+ }
142
+
143
+ // backend 결정: 'auto'면 환경변수 보고 결정. CLI 없는 사용자 (익스텐션
144
+ // 전용)도 API key만 있으면 자동으로 api 백엔드.
145
+ async function callBackend(promptText, backend) {
146
+ if (backend === 'cli') return callClaudeCli(promptText);
147
+ if (backend === 'api') return await callClaudeApi(promptText);
148
+ // 'auto'
149
+ if (process.env.ANTHROPIC_API_KEY) return await callClaudeApi(promptText);
150
+ return callClaudeCli(promptText);
151
+ }
152
+
153
+ function tryParseJson(s) {
154
+ let t = s.trim()
155
+ .replace(/^```json\s*/i, '')
156
+ .replace(/^```\s*/, '')
157
+ .replace(/```\s*$/, '')
158
+ .trim();
159
+ const start = t.indexOf('[');
160
+ const end = t.lastIndexOf(']');
161
+ if (start === -1 || end === -1 || end <= start) return null;
162
+ try { return JSON.parse(t.slice(start, end + 1)); } catch { return null; }
163
+ }
164
+
165
+ function transcriptHash(t) {
166
+ const h = crypto.createHash('sha1');
167
+ h.update(JSON.stringify(t.turns));
168
+ return h.digest('hex').slice(0, 12);
169
+ }
170
+
171
+ // 같은 세션 같은 슬라이스면 중복 분석 방지 (Hook이 여러 번 호출돼도 안전).
172
+ function readDedupLog() {
173
+ const f = path.join(cfg.STATE_DIR, 'analyzed.json');
174
+ if (!fs.existsSync(f)) return new Set();
175
+ try { return new Set(JSON.parse(fs.readFileSync(f, 'utf8'))); } catch { return new Set(); }
176
+ }
177
+ function writeDedupLog(set) {
178
+ cfg.ensureStateDir();
179
+ const f = path.join(cfg.STATE_DIR, 'analyzed.json');
180
+ fs.writeFileSync(f, JSON.stringify([...set], null, 2) + '\n');
181
+ }
182
+
183
+ async function run(args) {
184
+ const flags = parseFlags(args);
185
+ cfg.ensureStateDir();
186
+
187
+ let file = flags.session || transcript.latestSessionFile();
188
+ let t;
189
+ let sessionId;
190
+ if (flags.mock && !file) {
191
+ file = '<mock-session>';
192
+ t = { source: file, turns: [], totalLines: 0, sliceFrom: 0, commandMarkers: [] };
193
+ sessionId = 'mock-' + Date.now();
194
+ log(flags, 'Mock 모드 — 실제 세션 없이 진행');
195
+ } else {
196
+ if (!file) { log(flags, '세션 jsonl을 찾을 수 없음 (' + cfg.PROJECTS_DIR + ')'); return; }
197
+ if (!fs.existsSync(file)) { log(flags, '세션 파일 없음: ' + file); return; }
198
+ t = transcript.extractRelevant(file);
199
+ if (!t.turns.length && !flags.mock) { log(flags, '분석할 turn 없음'); return; }
200
+ log(flags, '분석: ' + path.basename(file) + ' · ' + t.turns.length + ' turns');
201
+ sessionId = path.basename(file, '.jsonl');
202
+ }
203
+
204
+ const dedupKey = sessionId + ':' + transcriptHash(t);
205
+ const seen = readDedupLog();
206
+ if (seen.has(dedupKey)) {
207
+ log(flags, '이 세션 슬라이스는 이미 분석됨 — 새로 추가할 것 없음');
208
+ return;
209
+ }
210
+
211
+ // LLM 호출
212
+ let candidates;
213
+ if (flags.mock) {
214
+ candidates = mockExtract();
215
+ } else {
216
+ const prompt = fs.readFileSync(cfg.PROMPT_FILE, 'utf8');
217
+ const existing = existingForDedup();
218
+ const fullPrompt = buildAnalyzerPrompt(prompt, existing, t);
219
+ if (flags.dryRun) {
220
+ log(flags, '[--dry-run] prompt 준비됨 (' + fullPrompt.length + ' chars). LLM 호출 생략.');
221
+ return;
222
+ }
223
+ let raw;
224
+ try { raw = await callBackend(fullPrompt, flags.backend); }
225
+ catch (e) { log(flags, '분석 실패: ' + e.message); return; }
226
+ candidates = tryParseJson(raw) || [];
227
+ }
228
+
229
+ if (!Array.isArray(candidates) || !candidates.length) {
230
+ log(flags, '추출할 만한 entry 없음 (빈 배열도 정상 응답)');
231
+ seen.add(dedupKey);
232
+ writeDedupLog(seen);
233
+ return;
234
+ }
235
+
236
+ // 자동 누적 (기본 동작) — confidence:high만, 나머지는 drop
237
+ if (flags.auto) {
238
+ const cwd = process.cwd();
239
+ let appended = 0;
240
+ let dropped = 0;
241
+ for (const c of candidates) {
242
+ if (c.confidence !== 'high') { dropped++; continue; }
243
+ const annotated = {
244
+ id: crypto.randomUUID(),
245
+ ...c,
246
+ scope: 'global',
247
+ source: {
248
+ sessionId,
249
+ project: path.basename(cwd),
250
+ cwd,
251
+ timestamp: new Date().toISOString(),
252
+ relatedCommands: c.related_commands || [],
253
+ },
254
+ };
255
+ try {
256
+ const dest = store.appendEntry(annotated);
257
+ appended++;
258
+ log(flags, ' ✓ ' + (c.title || c.id) + ' → ' + dest);
259
+ } catch (err) {
260
+ log(flags, ' 실패: ' + (c.title || c.id) + ' — ' + err.message);
261
+ }
262
+ }
263
+ log(flags, `자동 추가 ${appended}건${dropped ? ` · 신뢰도 부족 ${dropped}건 drop` : ''}`);
264
+ } else {
265
+ // --no-auto — JSON 그대로 stdout (디버깅 용도)
266
+ process.stdout.write(JSON.stringify(candidates, null, 2) + '\n');
267
+ }
268
+
269
+ seen.add(dedupKey);
270
+ writeDedupLog(seen);
271
+ }
272
+
273
+ module.exports = { run };
package/lib/config.js ADDED
@@ -0,0 +1,55 @@
1
+ // Resolved paths and constants used across every subcommand.
2
+ // Keeps the rest of lib/ free of path string-building.
3
+
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+
8
+ const HOME = os.homedir();
9
+ const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR
10
+ ? path.resolve(process.env.CLAUDE_CONFIG_DIR)
11
+ : path.join(HOME, '.claude');
12
+
13
+ // 유일한 state file은 analyzed.json (같은 슬라이스 중복 분석 방지).
14
+ // pending/archive/stats 같은 review-driven state 시스템은 v0.2부터 모두 제거.
15
+ const STATE_DIR = path.join(CLAUDE_DIR, '.distill');
16
+
17
+ const SETTINGS_FILE = path.join(CLAUDE_DIR, 'settings.json');
18
+
19
+ const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
20
+
21
+ // Knowledge / gotchas live in two scopes — global (always) and per-project
22
+ // (under <project>/.claude/). globalStore() and projectStore() in lib/store.js
23
+ // pick the right one based on entry.scope.
24
+ const GLOBAL_KNOWLEDGE = path.join(CLAUDE_DIR, 'knowledge.md');
25
+ const GLOBAL_GOTCHAS = path.join(CLAUDE_DIR, 'gotchas.md');
26
+
27
+ // Where the package keeps its bundled prompt + templates after install.
28
+ const PACKAGE_ROOT = path.resolve(__dirname, '..');
29
+ const PROMPT_FILE = path.join(PACKAGE_ROOT, 'prompts', 'extract.md');
30
+ const TEMPLATE_DIR = path.join(PACKAGE_ROOT, 'templates');
31
+
32
+ function ensureStateDir() {
33
+ fs.mkdirSync(STATE_DIR, { recursive: true });
34
+ }
35
+
36
+ // `cwd` → project slug used in JSONL paths (~/.claude/projects/<slug>/).
37
+ // Mirrors Claude Code's own slugging: absolute path with separators replaced.
38
+ function cwdToProjectSlug(cwd) {
39
+ return cwd.replace(/[\\\/]/g, '-');
40
+ }
41
+
42
+ module.exports = {
43
+ HOME,
44
+ CLAUDE_DIR,
45
+ STATE_DIR,
46
+ SETTINGS_FILE,
47
+ PROJECTS_DIR,
48
+ GLOBAL_KNOWLEDGE,
49
+ GLOBAL_GOTCHAS,
50
+ PACKAGE_ROOT,
51
+ PROMPT_FILE,
52
+ TEMPLATE_DIR,
53
+ ensureStateDir,
54
+ cwdToProjectSlug,
55
+ };
package/lib/init.js ADDED
@@ -0,0 +1,114 @@
1
+ // `claude-distill init [--no-claude-md]`
2
+ //
3
+ // 두 가지를 한 번에 등록:
4
+ // 1. ~/.claude/settings.json 의 Stop hook (자동 분석)
5
+ // 2. ~/.claude/CLAUDE.md 끝에 @knowledge.md / @gotchas.md 참조 라인
6
+ // → 다음 세션부터 Claude가 누적된 판례/사고를 자연스럽게 참조
7
+ //
8
+ // idempotent — 두 번 실행해도 중복 등록 안 함.
9
+ //
10
+ // --no-claude-md 옵션: CLAUDE.md 자동 편집 건너뛰기 (사용자가 직접 추가)
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { execSync } = require('child_process');
15
+ const cfg = require('./config');
16
+
17
+ // PATH에 `claude-distill`이 있으면 그대로, 없으면 자기 자신의 절대 경로 사용.
18
+ // npm 글로벌 install 없이도 init 한 번으로 끝나게 만드는 핵심.
19
+ function resolveHookCommand() {
20
+ try {
21
+ execSync('which claude-distill', { stdio: 'ignore' });
22
+ return 'claude-distill analyze --quiet';
23
+ } catch {
24
+ const binPath = path.join(cfg.PACKAGE_ROOT, 'bin', 'distill.js');
25
+ return `node ${binPath} analyze --quiet`;
26
+ }
27
+ }
28
+
29
+ const HOOK_COMMAND = resolveHookCommand();
30
+ const CLAUDE_MD = path.join(cfg.CLAUDE_DIR, 'CLAUDE.md');
31
+ const REFERENCE_BLOCK = `
32
+ <!-- claude-distill auto-references — accumulated lessons from past sessions -->
33
+ @~/.claude/knowledge.md
34
+ @~/.claude/gotchas.md
35
+ `;
36
+
37
+ function readSettings() {
38
+ if (!fs.existsSync(cfg.SETTINGS_FILE)) return {};
39
+ try { return JSON.parse(fs.readFileSync(cfg.SETTINGS_FILE, 'utf8')); }
40
+ catch { return {}; }
41
+ }
42
+
43
+ function writeSettings(s) {
44
+ fs.mkdirSync(path.dirname(cfg.SETTINGS_FILE), { recursive: true });
45
+ fs.writeFileSync(cfg.SETTINGS_FILE, JSON.stringify(s, null, 2) + '\n');
46
+ }
47
+
48
+ function hookAlreadyInstalled(s) {
49
+ const stop = (s.hooks && s.hooks.Stop) || [];
50
+ for (const matcher of stop) {
51
+ for (const h of (matcher.hooks || [])) {
52
+ if (h.command && h.command.includes('claude-distill')) return true;
53
+ }
54
+ }
55
+ return false;
56
+ }
57
+
58
+ function ensureHook(quiet) {
59
+ const s = readSettings();
60
+ if (hookAlreadyInstalled(s)) {
61
+ if (!quiet) console.log('· Hook 이미 등록됨 — 건너뜀');
62
+ return false;
63
+ }
64
+ s.hooks = s.hooks || {};
65
+ s.hooks.Stop = s.hooks.Stop || [];
66
+ s.hooks.Stop.push({
67
+ matcher: '*',
68
+ hooks: [{ type: 'command', command: HOOK_COMMAND }],
69
+ });
70
+ writeSettings(s);
71
+ if (!quiet) console.log('✓ Stop hook 등록 → ' + cfg.SETTINGS_FILE);
72
+ return true;
73
+ }
74
+
75
+ function ensureClaudeMdReference(quiet) {
76
+ let body = '';
77
+ if (fs.existsSync(CLAUDE_MD)) {
78
+ body = fs.readFileSync(CLAUDE_MD, 'utf8');
79
+ }
80
+ if (body.includes('claude-distill auto-references')) {
81
+ if (!quiet) console.log('· CLAUDE.md @reference 이미 등록됨 — 건너뜀');
82
+ return false;
83
+ }
84
+ // 빈 파일이면 헤더부터, 있으면 뒤에 append
85
+ const trailer = body.endsWith('\n') || body === '' ? '' : '\n';
86
+ fs.mkdirSync(path.dirname(CLAUDE_MD), { recursive: true });
87
+ fs.appendFileSync(CLAUDE_MD, trailer + REFERENCE_BLOCK);
88
+ if (!quiet) console.log('✓ ' + CLAUDE_MD + ' 끝에 @reference 추가');
89
+ return true;
90
+ }
91
+
92
+ function parseFlags(args) {
93
+ const flags = { noClaudeMd: false, quiet: false };
94
+ for (const a of args) {
95
+ if (a === '--no-claude-md') flags.noClaudeMd = true;
96
+ if (a === '--quiet') flags.quiet = true;
97
+ }
98
+ return flags;
99
+ }
100
+
101
+ function run(args) {
102
+ const flags = parseFlags(args);
103
+ cfg.ensureStateDir();
104
+ ensureHook(flags.quiet);
105
+ if (!flags.noClaudeMd) ensureClaudeMdReference(flags.quiet);
106
+ if (!flags.quiet) {
107
+ console.log('');
108
+ console.log('이제 끝입니다. 세션 끝낼 때마다 알아서 분석 + 누적합니다.');
109
+ console.log('확인하고 싶으면: ' + cfg.GLOBAL_KNOWLEDGE);
110
+ console.log(' : ' + cfg.GLOBAL_GOTCHAS);
111
+ }
112
+ }
113
+
114
+ module.exports = { run };
package/lib/store.js ADDED
@@ -0,0 +1,77 @@
1
+ // Resolves the destination markdown file for an entry (global vs project)
2
+ // and appends a formatted block. Files are pure markdown so users can edit
3
+ // them by hand any time.
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const cfg = require('./config');
8
+
9
+ function resolveTarget(entry) {
10
+ const isGotcha = entry.type === 'gotcha';
11
+ if (entry.scope === 'project' && entry.source && entry.source.cwd) {
12
+ const projectClaudeDir = path.join(entry.source.cwd, '.claude');
13
+ fs.mkdirSync(projectClaudeDir, { recursive: true });
14
+ return path.join(projectClaudeDir, isGotcha ? 'gotchas.md' : 'knowledge.md');
15
+ }
16
+ return isGotcha ? cfg.GLOBAL_GOTCHAS : cfg.GLOBAL_KNOWLEDGE;
17
+ }
18
+
19
+ function ensureHeader(file, isGotcha) {
20
+ if (fs.existsSync(file)) return;
21
+ const header = isGotcha
22
+ ? `# Gotchas — incident reports
23
+
24
+ > Mistakes worth not repeating. Maintained by [claude-distill](https://github.com/parksubeom/claude-distill).
25
+
26
+ `
27
+ : `# Knowledge — case law
28
+
29
+ > Judgment calls worth remembering. Maintained by [claude-distill](https://github.com/parksubeom/claude-distill).
30
+
31
+ `;
32
+ fs.mkdirSync(path.dirname(file), { recursive: true });
33
+ fs.writeFileSync(file, header);
34
+ }
35
+
36
+ function formatEntry(e) {
37
+ const title = e.title || '(untitled)';
38
+ const date = (e.source && e.source.timestamp) ? e.source.timestamp.slice(0, 10) : new Date().toISOString().slice(0, 10);
39
+ const sessionId = (e.source && e.source.sessionId) || 'unknown';
40
+ const project = (e.source && e.source.project) || 'unknown';
41
+ const cmds = (e.source && e.source.relatedCommands && e.source.relatedCommands.length)
42
+ ? ' · cmds: ' + e.source.relatedCommands.join(', ')
43
+ : '';
44
+ const tags = (e.tags || []).map((t) => '`' + t + '`').join(' · ');
45
+ const conf = e.confidence || 'medium';
46
+ const symptomLabel = e.type === 'gotcha' ? 'Symptom' : 'Context';
47
+ const insightLabel = e.type === 'gotcha' ? 'Trap' : 'Insight';
48
+
49
+ return [
50
+ `## ${title}`,
51
+ `**Category**: \`${e.category}\` · **Confidence**: ${conf} · **Date**: ${date}`,
52
+ `**Source**: session \`${sessionId.slice(0, 8)}\` · project \`${project}\`${cmds}`,
53
+ '',
54
+ `**${symptomLabel}**: ${e.context || ''}`,
55
+ '',
56
+ `**${insightLabel}**: ${e.insight || ''}`,
57
+ '',
58
+ `**Basis**: ${e.basis || ''}`,
59
+ '',
60
+ `**Application**: ${e.application || ''}`,
61
+ '',
62
+ `**Tags**: ${tags}`,
63
+ '',
64
+ '---',
65
+ '',
66
+ ].join('\n');
67
+ }
68
+
69
+ function appendEntry(entry) {
70
+ const file = resolveTarget(entry);
71
+ const isGotcha = entry.type === 'gotcha';
72
+ ensureHeader(file, isGotcha);
73
+ fs.appendFileSync(file, formatEntry(entry));
74
+ return file;
75
+ }
76
+
77
+ module.exports = { resolveTarget, appendEntry, formatEntry };
@@ -0,0 +1,148 @@
1
+ // Transcript reader — given a session JSONL, return a compact "relevant
2
+ // turns" array suitable for sending to the analyzer. Two strategies:
3
+ //
4
+ // 1. Find the latest <command-name> marker and take from there to end.
5
+ // (Cleanest signal — that's "what the user asked for".)
6
+ // 2. Fallback: take the last N turns (default 30).
7
+ //
8
+ // Either way we strip large fields (full tool_result content, big diffs)
9
+ // to keep the analyzer prompt under a sensible token budget.
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const cfg = require('./config');
14
+
15
+ const DEFAULT_TAIL_TURNS = 30;
16
+ const MAX_TURNS_HARD_CAP = 120; // Even if a single command marker stretches 800 turns,
17
+ // we only send the last 120 to the analyzer.
18
+ const MAX_CONTENT_CHARS = 1500; // per-turn cap (reduced — long tool outputs were dominating)
19
+ const MAX_TOOL_RESULT_CHARS = 400;
20
+ const COMMAND_RE = /<command-name>\/?([\w.\-:]+)<\/command-name>/;
21
+
22
+ function listSessionsForCwd(cwd) {
23
+ const slug = cfg.cwdToProjectSlug(cwd);
24
+ const dir = path.join(cfg.PROJECTS_DIR, slug);
25
+ if (!fs.existsSync(dir)) return [];
26
+ return fs.readdirSync(dir)
27
+ .filter((f) => f.endsWith('.jsonl'))
28
+ .map((f) => path.join(dir, f));
29
+ }
30
+
31
+ function listAllSessions() {
32
+ if (!fs.existsSync(cfg.PROJECTS_DIR)) return [];
33
+ const out = [];
34
+ for (const proj of fs.readdirSync(cfg.PROJECTS_DIR)) {
35
+ const projDir = path.join(cfg.PROJECTS_DIR, proj);
36
+ let stat;
37
+ try { stat = fs.statSync(projDir); } catch { continue; }
38
+ if (!stat.isDirectory()) continue;
39
+ let entries;
40
+ try { entries = fs.readdirSync(projDir); } catch { continue; }
41
+ for (const f of entries) if (f.endsWith('.jsonl')) out.push(path.join(projDir, f));
42
+ }
43
+ return out;
44
+ }
45
+
46
+ function latestSessionFile() {
47
+ const all = listAllSessions();
48
+ if (!all.length) return null;
49
+ let best = all[0];
50
+ let bestM = 0;
51
+ for (const f of all) {
52
+ try {
53
+ const m = fs.statSync(f).mtimeMs;
54
+ if (m > bestM) { bestM = m; best = f; }
55
+ } catch {}
56
+ }
57
+ return best;
58
+ }
59
+
60
+ // Read entire jsonl, return parsed lines (skip malformed).
61
+ function readLines(file) {
62
+ const txt = fs.readFileSync(file, 'utf8');
63
+ const out = [];
64
+ for (const line of txt.split('\n')) {
65
+ if (!line) continue;
66
+ try { out.push(JSON.parse(line)); } catch {}
67
+ }
68
+ return out;
69
+ }
70
+
71
+ // Trim a turn's content for the analyzer. Keeps the shape but caps long
72
+ // strings; keeps tool_use names and tool_result first ~600 chars; drops
73
+ // raw images.
74
+ function compactTurn(obj) {
75
+ const out = {
76
+ type: obj.type,
77
+ uuid: obj.uuid,
78
+ parentUuid: obj.parentUuid,
79
+ timestamp: obj.timestamp,
80
+ };
81
+ const m = obj.message || {};
82
+ if (typeof m.content === 'string') {
83
+ out.content = m.content.length > MAX_CONTENT_CHARS
84
+ ? m.content.slice(0, MAX_CONTENT_CHARS) + ' …[truncated]'
85
+ : m.content;
86
+ } else if (Array.isArray(m.content)) {
87
+ out.content = m.content.map((c) => {
88
+ if (c.type === 'text') {
89
+ const t = c.text || '';
90
+ return { type: 'text', text: t.length > MAX_CONTENT_CHARS ? t.slice(0, MAX_CONTENT_CHARS) + ' …[truncated]' : t };
91
+ }
92
+ if (c.type === 'tool_use') return { type: 'tool_use', name: c.name, input_keys: Object.keys(c.input || {}) };
93
+ if (c.type === 'tool_result') {
94
+ const txt = typeof c.content === 'string'
95
+ ? c.content
96
+ : (Array.isArray(c.content) ? c.content.filter((x) => x.type === 'text').map((x) => x.text).join('\n') : '');
97
+ return { type: 'tool_result', text_preview: txt.slice(0, MAX_TOOL_RESULT_CHARS) };
98
+ }
99
+ if (c.type === 'image') return { type: 'image', omitted: true };
100
+ return { type: c.type };
101
+ });
102
+ }
103
+ if (m.usage) out.usage = m.usage;
104
+ return out;
105
+ }
106
+
107
+ function extractRelevant(file, opts) {
108
+ const lines = readLines(file);
109
+ if (!lines.length) return { source: file, turns: [], commandMarkers: [] };
110
+ const tailTurns = (opts && opts.tail) || DEFAULT_TAIL_TURNS;
111
+ // Find the last command marker — start from there.
112
+ let startIdx = -1;
113
+ const commandMarkers = [];
114
+ for (let i = 0; i < lines.length; i++) {
115
+ const obj = lines[i];
116
+ if (obj.type === 'user' && obj.message && typeof obj.message.content === 'string') {
117
+ const m = COMMAND_RE.exec(obj.message.content);
118
+ if (m) {
119
+ startIdx = i;
120
+ commandMarkers.push({ index: i, command: m[1], timestamp: obj.timestamp });
121
+ }
122
+ }
123
+ }
124
+ if (startIdx === -1) startIdx = Math.max(0, lines.length - tailTurns);
125
+ // Hard cap — protect the analyzer from a single command marker that
126
+ // stretches across hundreds of turns (long-running session). We keep
127
+ // the most recent ones since they're closest to the lessons learned.
128
+ if (lines.length - startIdx > MAX_TURNS_HARD_CAP) {
129
+ startIdx = lines.length - MAX_TURNS_HARD_CAP;
130
+ }
131
+ const slice = lines.slice(startIdx);
132
+ return {
133
+ source: file,
134
+ turns: slice.map(compactTurn),
135
+ commandMarkers,
136
+ totalLines: lines.length,
137
+ sliceFrom: startIdx,
138
+ };
139
+ }
140
+
141
+ module.exports = {
142
+ listSessionsForCwd,
143
+ listAllSessions,
144
+ latestSessionFile,
145
+ readLines,
146
+ compactTurn,
147
+ extractRelevant,
148
+ };
package/lib/where.js ADDED
@@ -0,0 +1,26 @@
1
+ // `claude-distill where` — 모든 path / 존재 여부 한 번에.
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const cfg = require('./config');
6
+
7
+ const CLAUDE_MD = path.join(cfg.CLAUDE_DIR, 'CLAUDE.md');
8
+
9
+ function exists(p) { return fs.existsSync(p) ? '✓' : '·'; }
10
+
11
+ function run() {
12
+ console.log('CLAUDE_DIR ' + exists(cfg.CLAUDE_DIR) + ' ' + cfg.CLAUDE_DIR);
13
+ console.log('settings.json ' + exists(cfg.SETTINGS_FILE) + ' ' + cfg.SETTINGS_FILE);
14
+ console.log('CLAUDE.md ' + exists(CLAUDE_MD) + ' ' + CLAUDE_MD);
15
+ console.log('projects/ ' + exists(cfg.PROJECTS_DIR) + ' ' + cfg.PROJECTS_DIR);
16
+ console.log('');
17
+ console.log('knowledge.md ' + exists(cfg.GLOBAL_KNOWLEDGE) + ' ' + cfg.GLOBAL_KNOWLEDGE);
18
+ console.log('gotchas.md ' + exists(cfg.GLOBAL_GOTCHAS) + ' ' + cfg.GLOBAL_GOTCHAS);
19
+ console.log('');
20
+ console.log('.distill/ ' + exists(cfg.STATE_DIR) + ' ' + cfg.STATE_DIR);
21
+ console.log(' analyzed.json ' + exists(path.join(cfg.STATE_DIR, 'analyzed.json')) + ' 중복 분석 방지 로그');
22
+ console.log('');
23
+ console.log('extract prompt ' + exists(cfg.PROMPT_FILE) + ' ' + cfg.PROMPT_FILE);
24
+ }
25
+
26
+ module.exports = { run };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "claude-distill",
3
+ "version": "0.2.0",
4
+ "description": "Distill knowledge and gotchas from Claude Code session transcripts. Hook-based feedback loop: session → extract → review → accumulate.",
5
+ "bin": {
6
+ "claude-distill": "bin/distill.js",
7
+ "distill": "bin/distill.js"
8
+ },
9
+ "main": "lib/index.js",
10
+ "engines": {
11
+ "node": ">=18.0.0"
12
+ },
13
+ "scripts": {
14
+ "test": "node test/smoke.js"
15
+ },
16
+ "keywords": [
17
+ "claude",
18
+ "claude-code",
19
+ "claude-code-hook",
20
+ "anthropic",
21
+ "ai-coding",
22
+ "ai-assistant",
23
+ "knowledge-management",
24
+ "session-transcript",
25
+ "post-mortem",
26
+ "developer-tools",
27
+ "productivity",
28
+ "hook",
29
+ "meta-tooling",
30
+ "learning-tool",
31
+ "markdown",
32
+ "cli"
33
+ ],
34
+ "author": "parksubeom",
35
+ "license": "MIT",
36
+ "homepage": "https://github.com/parksubeom/claude-distill#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/parksubeom/claude-distill/issues"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "https://github.com/parksubeom/claude-distill.git"
43
+ },
44
+ "files": [
45
+ "bin",
46
+ "lib",
47
+ "prompts",
48
+ "templates",
49
+ "README.md",
50
+ "LICENSE"
51
+ ],
52
+ "dependencies": {}
53
+ }
@@ -0,0 +1,66 @@
1
+ # Knowledge / Gotcha Extractor
2
+
3
+ You are reading a developer's session transcript with Claude Code. Your job is to extract a small set of high-signal entries that the developer will want to remember next time.
4
+
5
+ There are two extraction targets, with hard rules.
6
+
7
+ ## KNOWLEDGE — judgment calls worth remembering
8
+
9
+ A *knowledge* entry captures a *deliberate decision* the user (or you) made, with reasoning that would still be relevant the next time the same situation comes up. Pick from:
10
+
11
+ - `trade_off_decision` — picked option A over B with a stated reason
12
+ - `environment_quirk` — discovered a tool / runtime / IDE behavior that affects future choices
13
+ - `scale_transition` — found a threshold (lines of code, traffic, dataset size) where the right answer changes
14
+ - `tooling_insight` — figured out a tool flag, command, or workflow that solves a recurring problem
15
+ - `performance_insight` — measured a number and identified the cause
16
+
17
+ ## GOTCHAS — mistakes worth not repeating
18
+
19
+ A *gotcha* entry captures a *trap* the user fell into or narrowly avoided, with enough context that future sessions can avoid it. Pick from:
20
+
21
+ - `api_quirk` — undocumented or counter-intuitive library / API / format behavior
22
+ - `type_shape` — a data shape that broke an assumption
23
+ - `concurrency_race` — async / ordering / lifecycle bug
24
+ - `build_deploy` — build pipeline, packaging, or deploy step that bit you
25
+ - `privacy_security` — a security issue you spotted or fixed
26
+ - `ux_regression` — a UX pattern that regressed unexpectedly
27
+
28
+ ## HARD RULES
29
+
30
+ 1. **Bar is high.** Most sessions produce 0 entries. Empty array is a fine answer.
31
+ 2. **Exclude self-evident facts.** "JSON.parse can throw" is not a gotcha.
32
+ 3. **Exclude project-internal trivia with no transferable lesson.** "We renamed `foo` to `bar`" is not knowledge.
33
+ 4. **Exclude speculation.** Every entry must reference behavior you actually saw in the transcript. If the user said "I think X", that's not enough — there must be a confirming command output, error, or observation.
34
+ 5. **Exclude duplicates of existing entries.** Existing `knowledge.md` and `gotchas.md` content is provided as `<existing>`. If a candidate restates an existing entry, drop it.
35
+ 6. **Confidence: high / medium / low.** `high` requires a directly observed test/output. `medium` is a strong inference. `low` is a pattern you suspect but haven't fully validated.
36
+ 7. **Be specific.** "Use the right ffmpeg flags" is useless. "ffmpeg cropdetect needs `limit=32` or higher when padding RGB is dim grey, otherwise it returns the source dimensions unchanged" is useful.
37
+
38
+ ## OUTPUT FORMAT
39
+
40
+ Strict JSON array. Each entry has these fields exactly:
41
+
42
+ ```json
43
+ {
44
+ "type": "knowledge" | "gotcha",
45
+ "category": "trade_off_decision" | "environment_quirk" | "scale_transition" | "tooling_insight" | "performance_insight" | "api_quirk" | "type_shape" | "concurrency_race" | "build_deploy" | "privacy_security" | "ux_regression",
46
+ "title": "≤80 chars, single sentence, imperative or descriptive",
47
+ "context": "the situation / symptom (2-4 sentences)",
48
+ "insight": "the decision / trap (2-4 sentences)",
49
+ "basis": "the evidence — quote a command output or filename if you saw one",
50
+ "application": "when this applies / how to handle it next time (1-2 sentences)",
51
+ "tags": ["3-6 tags, lowercase-with-hyphens"],
52
+ "confidence": "high" | "medium" | "low",
53
+ "related_commands": ["/command-name", ...] // optional
54
+ }
55
+ ```
56
+
57
+ Limit: at most 5 entries total. If there's nothing meeting the bar, return `[]`.
58
+
59
+ ## INPUT
60
+
61
+ You will receive:
62
+
63
+ 1. `<existing>` — current knowledge.md + gotchas.md content (for de-duplication)
64
+ 2. `<transcript>` — relevant turns from the session
65
+
66
+ Read both, then output the JSON array. No commentary, no markdown, only the JSON.