@wooojin/forgen 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.ja.md +79 -14
  3. package/README.ko.md +89 -14
  4. package/README.md +77 -14
  5. package/README.zh.md +79 -14
  6. package/commands/deep-interview.md +171 -0
  7. package/commands/specify.md +128 -0
  8. package/dist/cli.js +9 -0
  9. package/dist/core/dashboard.d.ts +91 -0
  10. package/dist/core/dashboard.js +385 -0
  11. package/dist/core/doctor.js +151 -21
  12. package/dist/core/drift-score.d.ts +49 -0
  13. package/dist/core/drift-score.js +87 -0
  14. package/dist/core/mcp-config.d.ts +2 -0
  15. package/dist/core/mcp-config.js +6 -1
  16. package/dist/core/paths.d.ts +1 -1
  17. package/dist/core/paths.js +1 -1
  18. package/dist/engine/compound-export.d.ts +41 -0
  19. package/dist/engine/compound-export.js +169 -0
  20. package/dist/engine/compound-loop.js +18 -0
  21. package/dist/engine/solution-matcher.d.ts +23 -0
  22. package/dist/engine/solution-matcher.js +124 -11
  23. package/dist/hooks/context-guard.d.ts +10 -0
  24. package/dist/hooks/context-guard.js +104 -58
  25. package/dist/hooks/db-guard.js +2 -2
  26. package/dist/hooks/hook-config.d.ts +27 -1
  27. package/dist/hooks/hook-config.js +72 -12
  28. package/dist/hooks/intent-classifier.d.ts +0 -2
  29. package/dist/hooks/intent-classifier.js +32 -18
  30. package/dist/hooks/keyword-detector.js +117 -111
  31. package/dist/hooks/notepad-injector.js +2 -2
  32. package/dist/hooks/permission-handler.js +2 -2
  33. package/dist/hooks/post-tool-failure.js +12 -6
  34. package/dist/hooks/post-tool-handlers.d.ts +1 -1
  35. package/dist/hooks/post-tool-handlers.js +14 -11
  36. package/dist/hooks/post-tool-use.d.ts +11 -0
  37. package/dist/hooks/post-tool-use.js +184 -71
  38. package/dist/hooks/pre-compact.d.ts +11 -1
  39. package/dist/hooks/pre-compact.js +112 -37
  40. package/dist/hooks/pre-tool-use.js +86 -56
  41. package/dist/hooks/rate-limiter.js +3 -3
  42. package/dist/hooks/secret-filter.js +2 -2
  43. package/dist/hooks/session-recovery.js +256 -236
  44. package/dist/hooks/shared/hook-response.d.ts +4 -4
  45. package/dist/hooks/shared/hook-response.js +13 -24
  46. package/dist/hooks/shared/hook-timing.d.ts +15 -0
  47. package/dist/hooks/shared/hook-timing.js +64 -0
  48. package/dist/hooks/skill-injector.js +41 -12
  49. package/dist/hooks/slop-detector.js +3 -3
  50. package/dist/hooks/solution-injector.js +224 -197
  51. package/dist/hooks/subagent-tracker.js +2 -2
  52. package/dist/renderer/rule-renderer.js +9 -11
  53. package/package.json +1 -1
  54. package/skills/deep-interview/SKILL.md +166 -0
  55. package/skills/specify/SKILL.md +122 -0
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Forgen — Compound Dashboard
3
+ *
4
+ * Provides a rich terminal overview of the compound knowledge system:
5
+ * knowledge inventory, injection activity, lifecycle transitions,
6
+ * session history, and hook health.
7
+ *
8
+ * Data is collected from:
9
+ * - ME_SOLUTIONS, ME_RULES, ME_BEHAVIOR (knowledge files)
10
+ * - MATCH_EVAL_LOG_PATH (injection/matching decisions)
11
+ * - STATE_DIR (hook-errors.json, last-extraction.json)
12
+ * - Solution frontmatter (lifecycle evidence fields)
13
+ */
14
+ import * as fs from 'node:fs';
15
+ import * as path from 'node:path';
16
+ import { ME_SOLUTIONS, ME_RULES, ME_BEHAVIOR, STATE_DIR, } from './paths.js';
17
+ import { parseFrontmatterOnly } from '../engine/solution-format.js';
18
+ import { readMatchEvalLog } from '../engine/match-eval-log.js';
19
+ // ── ANSI color helpers ──
20
+ const BOLD = '\x1b[1m';
21
+ const DIM = '\x1b[2m';
22
+ const RED = '\x1b[31m';
23
+ const GREEN = '\x1b[32m';
24
+ const YELLOW = '\x1b[33m';
25
+ const CYAN = '\x1b[36m';
26
+ const RESET = '\x1b[0m';
27
+ function bold(s) { return `${BOLD}${s}${RESET}`; }
28
+ function green(s) { return `${GREEN}${s}${RESET}`; }
29
+ function yellow(s) { return `${YELLOW}${s}${RESET}`; }
30
+ function red(s) { return `${RED}${s}${RESET}`; }
31
+ function dim(s) { return `${DIM}${s}${RESET}`; }
32
+ function cyan(s) { return `${CYAN}${s}${RESET}`; }
33
+ // ── Box-drawing table helpers ──
34
+ function tableRow(cols, widths) {
35
+ return ' │ ' + cols.map((c, i) => c.padEnd(widths[i])).join(' │ ') + ' │';
36
+ }
37
+ function tableSep(widths, top = false, bottom = false) {
38
+ const left = top ? '┌' : bottom ? '└' : '├';
39
+ const mid = top ? '┬' : bottom ? '┴' : '┼';
40
+ const right = top ? '┐' : bottom ? '┘' : '┤';
41
+ return ' ' + left + widths.map(w => '─'.repeat(w + 2)).join(mid) + right;
42
+ }
43
+ // ── Data Collection Functions ──
44
+ /** Read all .md files in a directory and return their frontmatter. */
45
+ function readFrontmatters(dir) {
46
+ const results = [];
47
+ try {
48
+ if (!fs.existsSync(dir))
49
+ return results;
50
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
51
+ for (const file of files) {
52
+ try {
53
+ const content = fs.readFileSync(path.join(dir, file), 'utf-8');
54
+ const fm = parseFrontmatterOnly(content);
55
+ if (fm)
56
+ results.push(fm);
57
+ }
58
+ catch { /* skip unreadable files */ }
59
+ }
60
+ }
61
+ catch { /* skip unreadable directories */ }
62
+ return results;
63
+ }
64
+ /** Count files in a directory (non-recursive). */
65
+ function countDirFiles(dir, ext) {
66
+ try {
67
+ if (!fs.existsSync(dir))
68
+ return 0;
69
+ const files = fs.readdirSync(dir);
70
+ return ext ? files.filter(f => f.endsWith(ext)).length : files.length;
71
+ }
72
+ catch {
73
+ return 0;
74
+ }
75
+ }
76
+ /** Collect knowledge overview data. */
77
+ export function collectKnowledgeOverview() {
78
+ const solutionFms = readFrontmatters(ME_SOLUTIONS);
79
+ const ruleFms = readFrontmatters(ME_RULES);
80
+ const byStatus = {
81
+ experiment: 0, candidate: 0, verified: 0, mature: 0, retired: 0,
82
+ };
83
+ for (const fm of solutionFms) {
84
+ if (fm.status in byStatus)
85
+ byStatus[fm.status]++;
86
+ }
87
+ // Rules categorized by type
88
+ const ruleCategories = {};
89
+ for (const fm of ruleFms) {
90
+ const key = fm.type ?? 'unknown';
91
+ ruleCategories[key] = (ruleCategories[key] ?? 0) + 1;
92
+ }
93
+ // Behavior file count
94
+ const behaviorCount = countDirFiles(ME_BEHAVIOR, '.md') + countDirFiles(ME_BEHAVIOR, '.json');
95
+ // Date range across all frontmatters
96
+ const allFms = [...solutionFms, ...ruleFms];
97
+ let oldest = null;
98
+ let newest = null;
99
+ for (const fm of allFms) {
100
+ if (!oldest || fm.created < oldest)
101
+ oldest = fm.created;
102
+ if (!newest || fm.updated > newest)
103
+ newest = fm.updated;
104
+ }
105
+ return {
106
+ solutions: { total: solutionFms.length, byStatus },
107
+ rules: { total: ruleFms.length, categories: ruleCategories },
108
+ behavior: { total: behaviorCount },
109
+ dateRange: { oldest, newest },
110
+ };
111
+ }
112
+ /** Collect injection activity from match-eval-log. */
113
+ export function collectInjectionActivity() {
114
+ const records = readMatchEvalLog();
115
+ // Recent injections (last 10)
116
+ const sorted = [...records].sort((a, b) => b.ts.localeCompare(a.ts));
117
+ const recentInjections = sorted.slice(0, 10).flatMap(r => r.rankedTopN.map(name => ({ name, ts: r.ts, source: r.source }))).slice(0, 10);
118
+ // Top 5 most frequently injected solutions
119
+ const freq = new Map();
120
+ for (const r of records) {
121
+ for (const name of r.rankedTopN) {
122
+ freq.set(name, (freq.get(name) ?? 0) + 1);
123
+ }
124
+ }
125
+ const topSolutions = [...freq.entries()]
126
+ .sort((a, b) => b[1] - a[1])
127
+ .slice(0, 5)
128
+ .map(([name, count]) => ({ name, count }));
129
+ const hookCount = records.filter(r => r.source === 'hook').length;
130
+ const mcpCount = records.filter(r => r.source === 'mcp').length;
131
+ return {
132
+ totalRecords: records.length,
133
+ recentInjections,
134
+ topSolutions,
135
+ hookCount,
136
+ mcpCount,
137
+ };
138
+ }
139
+ /** Collect code reflection data from solution evidence. */
140
+ export function collectReflectionData() {
141
+ const fms = readFrontmatters(ME_SOLUTIONS);
142
+ const reflected = fms.filter(fm => fm.evidence.reflected > 0).length;
143
+ const unreflected = fms.filter(fm => fm.evidence.reflected === 0 && fm.status !== 'retired').length;
144
+ const activeFms = fms.filter(fm => fm.status !== 'retired');
145
+ const rate = activeFms.length > 0 ? (reflected / activeFms.length) * 100 : 0;
146
+ return {
147
+ totalSolutions: fms.length,
148
+ reflectedCount: reflected,
149
+ unreflectedCount: unreflected,
150
+ reflectionRate: rate,
151
+ };
152
+ }
153
+ /** Collect lifecycle activity data. */
154
+ export function collectLifecycleActivity() {
155
+ const fms = readFrontmatters(ME_SOLUTIONS);
156
+ const statusDistribution = {
157
+ experiment: 0, candidate: 0, verified: 0, mature: 0, retired: 0,
158
+ };
159
+ for (const fm of fms) {
160
+ if (fm.status in statusDistribution)
161
+ statusDistribution[fm.status]++;
162
+ }
163
+ // Solutions approaching promotion (high evidence, not yet promoted)
164
+ const candidates = fms
165
+ .filter(fm => fm.status !== 'retired' && fm.status !== 'mature')
166
+ .map(fm => ({
167
+ name: fm.name,
168
+ status: fm.status,
169
+ evidence: {
170
+ reflected: fm.evidence.reflected,
171
+ sessions: fm.evidence.sessions,
172
+ negative: fm.evidence.negative,
173
+ },
174
+ }))
175
+ .sort((a, b) => b.evidence.reflected - a.evidence.reflected)
176
+ .slice(0, 5);
177
+ return { recentPromotionCandidates: candidates, statusDistribution };
178
+ }
179
+ /** Collect session extraction history. */
180
+ export function collectSessionHistory() {
181
+ const lastExtractionPath = path.join(STATE_DIR, 'last-extraction.json');
182
+ try {
183
+ if (fs.existsSync(lastExtractionPath)) {
184
+ const data = JSON.parse(fs.readFileSync(lastExtractionPath, 'utf-8'));
185
+ return {
186
+ lastExtraction: {
187
+ date: data.lastExtractedAt ?? 'unknown',
188
+ extractionsToday: data.extractionsToday ?? 0,
189
+ },
190
+ };
191
+ }
192
+ }
193
+ catch { /* skip */ }
194
+ return { lastExtraction: null };
195
+ }
196
+ /** Collect hook error data. */
197
+ export function collectHookHealth() {
198
+ const errorPath = path.join(STATE_DIR, 'hook-errors.json');
199
+ try {
200
+ if (fs.existsSync(errorPath)) {
201
+ const data = JSON.parse(fs.readFileSync(errorPath, 'utf-8'));
202
+ const errors = Object.entries(data).map(([hookName, info]) => ({
203
+ hookName,
204
+ count: info.count,
205
+ lastAt: info.lastAt,
206
+ }));
207
+ return { errors };
208
+ }
209
+ }
210
+ catch { /* skip */ }
211
+ return { errors: [] };
212
+ }
213
+ // ── Rendering ──
214
+ function renderKnowledgeOverview(data) {
215
+ const lines = [];
216
+ lines.push(` ${bold(cyan('Knowledge Overview'))}`);
217
+ lines.push('');
218
+ // Solutions table
219
+ const statusWidths = [14, 6];
220
+ lines.push(tableSep(statusWidths, true));
221
+ lines.push(tableRow(['Status', 'Count'], statusWidths));
222
+ lines.push(tableSep(statusWidths));
223
+ for (const [status, count] of Object.entries(data.solutions.byStatus)) {
224
+ if (count === 0 && status === 'retired')
225
+ continue;
226
+ const colorFn = status === 'mature' ? green
227
+ : status === 'verified' ? green
228
+ : status === 'experiment' ? yellow
229
+ : status === 'retired' ? red
230
+ : (s) => s;
231
+ lines.push(tableRow([colorFn(status), String(count)], statusWidths));
232
+ }
233
+ lines.push(tableSep(statusWidths, false, true));
234
+ lines.push(` Solutions: ${bold(String(data.solutions.total))} Rules: ${bold(String(data.rules.total))} Behavior: ${bold(String(data.behavior.total))}`);
235
+ if (data.dateRange.oldest && data.dateRange.newest) {
236
+ lines.push(` Date range: ${dim(data.dateRange.oldest)} → ${dim(data.dateRange.newest)}`);
237
+ }
238
+ return lines.join('\n');
239
+ }
240
+ function renderInjectionActivity(data) {
241
+ const lines = [];
242
+ lines.push(` ${bold(cyan('Injection Activity'))}`);
243
+ lines.push('');
244
+ if (data.totalRecords === 0) {
245
+ lines.push(` ${dim('No injection records found.')}`);
246
+ return lines.join('\n');
247
+ }
248
+ lines.push(` Total decisions: ${bold(String(data.totalRecords))} (hook: ${data.hookCount}, mcp: ${data.mcpCount})`);
249
+ lines.push('');
250
+ if (data.topSolutions.length > 0) {
251
+ lines.push(` ${bold('Top injected solutions:')}`);
252
+ for (const s of data.topSolutions) {
253
+ const bar = '█'.repeat(Math.min(s.count, 30));
254
+ lines.push(` ${s.name.padEnd(35)} ${green(bar)} ${s.count}`);
255
+ }
256
+ }
257
+ if (data.recentInjections.length > 0) {
258
+ lines.push('');
259
+ lines.push(` ${bold('Recent injections:')}`);
260
+ for (const inj of data.recentInjections.slice(0, 5)) {
261
+ const date = inj.ts.slice(0, 16).replace('T', ' ');
262
+ lines.push(` ${dim(date)} [${inj.source}] ${inj.name}`);
263
+ }
264
+ }
265
+ return lines.join('\n');
266
+ }
267
+ function renderReflectionData(data) {
268
+ const lines = [];
269
+ lines.push(` ${bold(cyan('Code Reflection'))}`);
270
+ lines.push('');
271
+ if (data.totalSolutions === 0) {
272
+ lines.push(` ${dim('No solutions to analyze.')}`);
273
+ return lines.join('\n');
274
+ }
275
+ const rateColor = data.reflectionRate >= 50 ? green
276
+ : data.reflectionRate >= 20 ? yellow
277
+ : red;
278
+ lines.push(` Reflection rate: ${rateColor(`${data.reflectionRate.toFixed(1)}%`)}`);
279
+ lines.push(` Reflected in code: ${green(String(data.reflectedCount))} Not reflected: ${data.unreflectedCount > 0 ? yellow(String(data.unreflectedCount)) : String(data.unreflectedCount)}`);
280
+ return lines.join('\n');
281
+ }
282
+ function renderLifecycleActivity(data) {
283
+ const lines = [];
284
+ lines.push(` ${bold(cyan('Lifecycle Activity'))}`);
285
+ lines.push('');
286
+ // Status distribution bar
287
+ const total = Object.values(data.statusDistribution).reduce((a, b) => a + b, 0);
288
+ if (total > 0) {
289
+ const barWidth = 30;
290
+ const segments = [];
291
+ for (const [status, count] of Object.entries(data.statusDistribution)) {
292
+ if (count === 0)
293
+ continue;
294
+ const width = Math.max(1, Math.round((count / total) * barWidth));
295
+ const char = status === 'mature' ? `${GREEN}${'█'.repeat(width)}${RESET}`
296
+ : status === 'verified' ? `${GREEN}${'▓'.repeat(width)}${RESET}`
297
+ : status === 'candidate' ? `${CYAN}${'▒'.repeat(width)}${RESET}`
298
+ : status === 'experiment' ? `${YELLOW}${'░'.repeat(width)}${RESET}`
299
+ : `${RED}${'·'.repeat(width)}${RESET}`;
300
+ segments.push(char);
301
+ }
302
+ lines.push(` ${segments.join('')}`);
303
+ lines.push(` ${dim('█ mature ▓ verified ▒ candidate ░ experiment · retired')}`);
304
+ }
305
+ if (data.recentPromotionCandidates.length > 0) {
306
+ lines.push('');
307
+ lines.push(` ${bold('Approaching promotion:')}`);
308
+ for (const c of data.recentPromotionCandidates) {
309
+ const ev = c.evidence;
310
+ const negStr = ev.negative > 0 ? red(` neg:${ev.negative}`) : '';
311
+ lines.push(` ${c.name.padEnd(35)} [${c.status}] ref:${ev.reflected} sess:${ev.sessions}${negStr}`);
312
+ }
313
+ }
314
+ return lines.join('\n');
315
+ }
316
+ function renderSessionHistory(data) {
317
+ const lines = [];
318
+ lines.push(` ${bold(cyan('Session History'))}`);
319
+ lines.push('');
320
+ if (!data.lastExtraction) {
321
+ lines.push(` ${dim('No extraction history found.')}`);
322
+ return lines.join('\n');
323
+ }
324
+ const ext = data.lastExtraction;
325
+ lines.push(` Last extraction: ${dim(ext.date)}`);
326
+ lines.push(` Extractions today: ${bold(String(ext.extractionsToday))}`);
327
+ return lines.join('\n');
328
+ }
329
+ function renderHookHealth(data) {
330
+ const lines = [];
331
+ lines.push(` ${bold(cyan('Hook Health'))}`);
332
+ lines.push('');
333
+ if (data.errors.length === 0) {
334
+ lines.push(` ${green('All hooks healthy — no errors recorded.')}`);
335
+ return lines.join('\n');
336
+ }
337
+ const widths = [25, 6, 20];
338
+ lines.push(tableSep(widths, true));
339
+ lines.push(tableRow(['Hook', 'Errors', 'Last Error'], widths));
340
+ lines.push(tableSep(widths));
341
+ for (const err of data.errors.sort((a, b) => b.count - a.count)) {
342
+ const lastDate = err.lastAt.slice(0, 16).replace('T', ' ');
343
+ lines.push(tableRow([
344
+ err.hookName,
345
+ red(String(err.count)),
346
+ dim(lastDate),
347
+ ], widths));
348
+ }
349
+ lines.push(tableSep(widths, false, true));
350
+ return lines.join('\n');
351
+ }
352
+ // ── Main Dashboard Renderer ──
353
+ export function renderDashboard() {
354
+ const knowledge = collectKnowledgeOverview();
355
+ const injection = collectInjectionActivity();
356
+ const reflection = collectReflectionData();
357
+ const lifecycle = collectLifecycleActivity();
358
+ const session = collectSessionHistory();
359
+ const hookHealth = collectHookHealth();
360
+ const divider = ` ${dim('─'.repeat(50))}`;
361
+ const sections = [
362
+ '',
363
+ ` ${BOLD}${CYAN}╔══════════════════════════════════════════════╗${RESET}`,
364
+ ` ${BOLD}${CYAN}║ Forgen Compound Dashboard ║${RESET}`,
365
+ ` ${BOLD}${CYAN}╚══════════════════════════════════════════════╝${RESET}`,
366
+ '',
367
+ renderKnowledgeOverview(knowledge),
368
+ divider,
369
+ renderInjectionActivity(injection),
370
+ divider,
371
+ renderReflectionData(reflection),
372
+ divider,
373
+ renderLifecycleActivity(lifecycle),
374
+ divider,
375
+ renderSessionHistory(session),
376
+ divider,
377
+ renderHookHealth(hookHealth),
378
+ '',
379
+ ];
380
+ return sections.join('\n');
381
+ }
382
+ /** CLI handler: forgen dashboard */
383
+ export async function handleDashboard() {
384
+ console.log(renderDashboard());
385
+ }
@@ -2,7 +2,8 @@ import * as fs from 'node:fs';
2
2
  import * as os from 'node:os';
3
3
  import * as path from 'node:path';
4
4
  import { execFileSync } from 'node:child_process';
5
- import { FORGEN_HOME, LAB_DIR, ME_BEHAVIOR, ME_DIR, ME_PHILOSOPHY, ME_SOLUTIONS, ME_RULES, PACKS_DIR, SESSIONS_DIR, STATE_DIR } from './paths.js';
5
+ import { FORGEN_HOME, LAB_DIR, ME_BEHAVIOR, ME_DIR, ME_PHILOSOPHY, ME_SOLUTIONS, ME_RULES, ME_SKILLS, PACKS_DIR, SESSIONS_DIR, STATE_DIR } from './paths.js';
6
+ import { getTimingStats } from '../hooks/shared/hook-timing.js';
6
7
  /** ~/.claude/projects/ — Claude Code 세션 저장 경로 */
7
8
  const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
8
9
  function check(label, condition, hint) {
@@ -104,6 +105,60 @@ export async function runDoctor() {
104
105
  }
105
106
  console.log(` Claude Code sessions: ${CLAUDE_PROJECTS_DIR}`);
106
107
  console.log();
108
+ // Hook Health: recent error tracking
109
+ console.log(' [Hook Health]');
110
+ try {
111
+ const hookErrorsPath = path.join(STATE_DIR, 'hook-errors.jsonl');
112
+ if (exists(hookErrorsPath)) {
113
+ const content = fs.readFileSync(hookErrorsPath, 'utf-8');
114
+ const entries = content.trim().split('\n')
115
+ .map(line => { try {
116
+ return JSON.parse(line);
117
+ }
118
+ catch {
119
+ return null;
120
+ } })
121
+ .filter(Boolean);
122
+ const byHook = new Map();
123
+ for (const e of entries) {
124
+ byHook.set(e.hook, (byHook.get(e.hook) ?? 0) + 1);
125
+ }
126
+ if (byHook.size === 0) {
127
+ console.log(' No hook errors recorded.');
128
+ }
129
+ else {
130
+ for (const [hook, count] of [...byHook.entries()].sort((a, b) => b[1] - a[1])) {
131
+ console.log(` ${hook}: ${count} error(s)`);
132
+ }
133
+ }
134
+ }
135
+ else {
136
+ console.log(' No hook errors recorded.');
137
+ }
138
+ }
139
+ catch {
140
+ console.log(' Unable to read hook error log.');
141
+ }
142
+ console.log();
143
+ // Hook Timing: performance stats
144
+ console.log(' [Hook Timing]');
145
+ const timingStats = getTimingStats();
146
+ if (timingStats.length === 0) {
147
+ console.log(' No timing data collected yet.');
148
+ }
149
+ else {
150
+ console.log(' Hook Count p50ms p95ms max ms');
151
+ console.log(' ' + '-'.repeat(56));
152
+ for (const s of timingStats) {
153
+ const hook = s.hook.padEnd(22);
154
+ const count = String(s.count).padStart(5);
155
+ const p50 = String(s.p50).padStart(7);
156
+ const p95 = String(s.p95).padStart(7);
157
+ const max = String(s.max).padStart(8);
158
+ console.log(` ${hook}${count}${p50}${p95}${max}`);
159
+ }
160
+ }
161
+ console.log();
107
162
  console.log();
108
163
  // v1: 팀 팩 시스템 제거. 개인 모드만 지원.
109
164
  console.log(' [Pack Connections]');
@@ -149,32 +204,107 @@ export async function runDoctor() {
149
204
  console.log();
150
205
  }
151
206
  }
152
- // 에러 카운트
153
- console.log(' [Hook Health]');
154
- const hookErrorPath = path.join(STATE_DIR, 'hook-errors.json');
155
- if (fs.existsSync(hookErrorPath)) {
156
- try {
157
- const errors = JSON.parse(fs.readFileSync(hookErrorPath, 'utf-8'));
158
- const entries = Object.entries(errors);
159
- if (entries.length === 0) {
160
- console.log(' No hook errors recorded');
161
- }
162
- else {
163
- for (const [hookName, { count, lastAt }] of entries) {
164
- const icon = count === 0 ? '✓' : '⚠';
165
- const lastDate = lastAt ? lastAt.split('T')[0] : 'unknown';
166
- console.log(` ${icon} ${hookName}: ${count} error${count !== 1 ? 's' : ''}${count > 0 ? ` (last: ${lastDate})` : ''}`);
207
+ // Harness Maturity section
208
+ console.log(' [Harness Maturity]');
209
+ const cwd = process.cwd();
210
+ // 1. Preparation
211
+ const hasClaude = fs.existsSync(path.join(cwd, 'CLAUDE.md'));
212
+ let rulesCount = 0;
213
+ try {
214
+ const rulesDir = path.join(cwd, '.claude', 'rules');
215
+ if (fs.existsSync(rulesDir)) {
216
+ rulesCount = fs.readdirSync(rulesDir).filter(f => f.endsWith('.md')).length;
217
+ }
218
+ }
219
+ catch { /* fail-open */ }
220
+ let hooksActive = 0;
221
+ try {
222
+ const hooksJsonPath = path.join(cwd, 'hooks', 'hooks.json');
223
+ if (fs.existsSync(hooksJsonPath)) {
224
+ const hooksData = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf-8'));
225
+ if (hooksData.hooks && typeof hooksData.hooks === 'object') {
226
+ for (const eventHooks of Object.values(hooksData.hooks)) {
227
+ if (Array.isArray(eventHooks)) {
228
+ for (const group of eventHooks) {
229
+ if (Array.isArray(group.hooks)) {
230
+ hooksActive += (group.hooks).length;
231
+ }
232
+ }
233
+ }
167
234
  }
168
235
  }
169
236
  }
170
- catch {
171
- console.log(' (hook-errors.json read failed)');
172
- }
173
237
  }
174
- else {
175
- console.log(' No hook errors recorded');
238
+ catch { /* fail-open */ }
239
+ const prepL = hasClaude && rulesCount >= 3 && hooksActive > 0 ? 'L3' : hasClaude && hooksActive > 0 ? 'L2' : hasClaude ? 'L1' : 'L0';
240
+ // 2. Context
241
+ let solutionsCount = 0;
242
+ try {
243
+ if (exists(ME_SOLUTIONS))
244
+ solutionsCount = fs.readdirSync(ME_SOLUTIONS).filter(f => f.endsWith('.md')).length;
245
+ }
246
+ catch { /* fail-open */ }
247
+ let behaviorCount = 0;
248
+ try {
249
+ if (exists(ME_BEHAVIOR))
250
+ behaviorCount = fs.readdirSync(ME_BEHAVIOR).filter(f => f.endsWith('.md')).length;
176
251
  }
252
+ catch { /* fail-open */ }
253
+ const ctxL = solutionsCount >= 5 && behaviorCount >= 3 ? 'L3' : solutionsCount >= 3 || behaviorCount >= 1 ? 'L2' : solutionsCount > 0 || behaviorCount > 0 ? 'L1' : 'L0';
254
+ // 3. Execution
255
+ const hasSkills = exists(ME_SKILLS);
256
+ const execL = hasSkills ? 'L2' : 'L1';
257
+ // 4. Validation
258
+ const hasTests = fs.existsSync(path.join(cwd, 'tests'));
259
+ const hasCI = fs.existsSync(path.join(cwd, '.github', 'workflows'));
260
+ const validL = hasTests && hasCI ? 'L3' : hasTests ? 'L2' : 'L1';
261
+ // 5. Improvement: reflection rate from solutions
262
+ let reflectionRate = 0;
263
+ try {
264
+ if (exists(ME_SOLUTIONS)) {
265
+ const solFiles = fs.readdirSync(ME_SOLUTIONS).filter(f => f.endsWith('.md'));
266
+ if (solFiles.length > 0) {
267
+ let reflected = 0;
268
+ for (const f of solFiles) {
269
+ try {
270
+ const content = fs.readFileSync(path.join(ME_SOLUTIONS, f), 'utf-8');
271
+ const match = content.match(/reflected:\s*(\d+)/);
272
+ if (match && parseInt(match[1], 10) > 0)
273
+ reflected++;
274
+ }
275
+ catch { /* skip */ }
276
+ }
277
+ reflectionRate = Math.round((reflected / solFiles.length) * 100);
278
+ }
279
+ }
280
+ }
281
+ catch { /* fail-open */ }
282
+ const improvL = reflectionRate > 0 ? 'L3' : solutionsCount > 0 ? 'L2' : 'L1';
283
+ const levelIcon = (l) => l === 'L3' ? '✓' : l === 'L2' ? '✓' : l === 'L1' ? '✗' : '✗';
284
+ console.log(` Axis Level Detail`);
285
+ console.log(` ${'─'.repeat(55)}`);
286
+ console.log(` ${levelIcon(prepL)} Preparation ${prepL} CLAUDE.md:${hasClaude ? 'yes' : 'no'}, rules:${rulesCount}, hooks:${hooksActive}`);
287
+ console.log(` ${levelIcon(ctxL)} Context ${ctxL} solutions:${solutionsCount}, behavior:${behaviorCount}`);
288
+ console.log(` ${levelIcon(execL)} Execution ${execL} skills:${hasSkills ? 'yes' : 'no'}`);
289
+ console.log(` ${levelIcon(validL)} Validation ${validL} tests:${hasTests ? 'yes' : 'no'}, CI:${hasCI ? 'yes' : 'no'}`);
290
+ console.log(` ${levelIcon(improvL)} Improvement ${improvL} reflection:${reflectionRate}%`);
177
291
  console.log();
292
+ // Quick wins: suggest for lowest scoring axes
293
+ const axes = [
294
+ { name: 'Preparation', level: prepL, hint: 'Add CLAUDE.md + .claude/rules/ files' },
295
+ { name: 'Context', level: ctxL, hint: 'Run /compound to accumulate solutions' },
296
+ { name: 'Execution', level: execL, hint: 'Promote solutions to skills' },
297
+ { name: 'Validation', level: validL, hint: 'Add tests/ dir and .github/workflows' },
298
+ { name: 'Improvement', level: improvL, hint: 'Reflect on existing solutions' },
299
+ ];
300
+ const quickWins = axes.filter(a => a.level === 'L0' || a.level === 'L1').slice(0, 3);
301
+ if (quickWins.length > 0) {
302
+ console.log(' Quick Wins (Top 3):');
303
+ for (const win of quickWins) {
304
+ console.log(` → ${win.name}: ${win.hint}`);
305
+ }
306
+ console.log();
307
+ }
178
308
  // 현재 디렉토리 git 정보
179
309
  console.log(' [Git]');
180
310
  try {
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Forgen — Drift Score (Session Drift Detection)
3
+ *
4
+ * 세션 내 수정 패턴을 추적하여 drift(산만/반복 수정)를 감지.
5
+ * EWMA(Exponentially Weighted Moving Average) 기반 이동평균으로
6
+ * 최근 수정 강도를 측정하고, 임계값 초과 시 경고.
7
+ *
8
+ * Codex 합의: DriftState + evaluateDrift 2개만. 최소 인터페이스.
9
+ */
10
+ /** Drift 상태 (세션 단위, STATE_DIR에 저장) */
11
+ export interface DriftState {
12
+ sessionId: string;
13
+ totalEdits: number;
14
+ totalReverts: number;
15
+ /** EWMA edit rate (0~1, 높을수록 최근 수정 빈도 높음) */
16
+ ewmaEditRate: number;
17
+ /** EWMA revert rate (0~1) */
18
+ ewmaRevertRate: number;
19
+ /** 최근 경고 timestamp (쿨다운용) */
20
+ lastWarningAt: number;
21
+ lastCriticalAt: number;
22
+ hardCapReached: boolean;
23
+ }
24
+ export interface DriftResult {
25
+ level: 'normal' | 'warning' | 'critical' | 'hardcap';
26
+ score: number;
27
+ message: string | null;
28
+ }
29
+ export interface DriftThresholds {
30
+ alpha?: number;
31
+ warningEdits?: number;
32
+ criticalEdits?: number;
33
+ criticalReverts?: number;
34
+ hardCapEdits?: number;
35
+ warningCooldownMs?: number;
36
+ criticalCooldownMs?: number;
37
+ }
38
+ /** EWMA 업데이트 (순수 함수) */
39
+ export declare function updateEwma(prev: number, sample: number, alpha: number): number;
40
+ /** 새 DriftState 생성 */
41
+ export declare function createDriftState(sessionId: string): DriftState;
42
+ /**
43
+ * 도구 호출 이벤트로 drift 상태를 갱신하고 평가 결과를 반환.
44
+ * @param state 현재 상태 (mutate됨)
45
+ * @param isEdit Write/Edit 도구 호출 여부
46
+ * @param isRevert revert 감지 여부
47
+ * @param thresholds 커스텀 임계치 (hook-config에서 로드)
48
+ */
49
+ export declare function evaluateDrift(state: DriftState, isEdit: boolean, isRevert: boolean, thresholds?: DriftThresholds): DriftResult;