@wooojin/forgen 0.1.1 → 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 (66) 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 +11 -2
  9. package/dist/core/auto-compound-runner.js +34 -1
  10. package/dist/core/dashboard.d.ts +91 -0
  11. package/dist/core/dashboard.js +385 -0
  12. package/dist/core/doctor.js +157 -1
  13. package/dist/core/drift-score.d.ts +49 -0
  14. package/dist/core/drift-score.js +87 -0
  15. package/dist/core/inspect-cli.js +54 -1
  16. package/dist/core/mcp-config.d.ts +2 -0
  17. package/dist/core/mcp-config.js +6 -1
  18. package/dist/core/paths.d.ts +1 -1
  19. package/dist/core/paths.js +1 -1
  20. package/dist/core/spawn.d.ts +7 -2
  21. package/dist/core/spawn.js +45 -7
  22. package/dist/core/v1-bootstrap.js +9 -2
  23. package/dist/engine/compound-export.d.ts +41 -0
  24. package/dist/engine/compound-export.js +169 -0
  25. package/dist/engine/compound-extractor.js +49 -0
  26. package/dist/engine/compound-loop.js +18 -0
  27. package/dist/engine/solution-matcher.d.ts +23 -0
  28. package/dist/engine/solution-matcher.js +124 -11
  29. package/dist/forge/mismatch-detector.js +3 -0
  30. package/dist/hooks/context-guard.d.ts +10 -0
  31. package/dist/hooks/context-guard.js +105 -49
  32. package/dist/hooks/db-guard.js +2 -2
  33. package/dist/hooks/hook-config.d.ts +27 -1
  34. package/dist/hooks/hook-config.js +72 -12
  35. package/dist/hooks/intent-classifier.js +29 -4
  36. package/dist/hooks/keyword-detector.js +114 -106
  37. package/dist/hooks/notepad-injector.js +2 -2
  38. package/dist/hooks/permission-handler.js +2 -2
  39. package/dist/hooks/post-tool-failure.js +12 -6
  40. package/dist/hooks/post-tool-handlers.d.ts +1 -1
  41. package/dist/hooks/post-tool-handlers.js +14 -11
  42. package/dist/hooks/post-tool-use.d.ts +11 -0
  43. package/dist/hooks/post-tool-use.js +184 -71
  44. package/dist/hooks/pre-compact.d.ts +11 -1
  45. package/dist/hooks/pre-compact.js +113 -3
  46. package/dist/hooks/pre-tool-use.js +86 -56
  47. package/dist/hooks/rate-limiter.js +3 -3
  48. package/dist/hooks/secret-filter.js +2 -2
  49. package/dist/hooks/session-recovery.js +256 -236
  50. package/dist/hooks/shared/hook-response.d.ts +7 -0
  51. package/dist/hooks/shared/hook-response.js +20 -0
  52. package/dist/hooks/shared/hook-timing.d.ts +15 -0
  53. package/dist/hooks/shared/hook-timing.js +64 -0
  54. package/dist/hooks/skill-injector.js +41 -12
  55. package/dist/hooks/slop-detector.js +3 -3
  56. package/dist/hooks/solution-injector.js +224 -197
  57. package/dist/hooks/subagent-tracker.js +2 -2
  58. package/dist/mcp/tools.js +114 -0
  59. package/dist/renderer/rule-renderer.js +9 -11
  60. package/dist/store/evidence-store.d.ts +8 -0
  61. package/dist/store/evidence-store.js +51 -0
  62. package/dist/store/rule-store.d.ts +5 -0
  63. package/dist/store/rule-store.js +22 -0
  64. package/package.json +1 -1
  65. package/skills/deep-interview/SKILL.md +166 -0
  66. package/skills/specify/SKILL.md +122 -0
@@ -0,0 +1,87 @@
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
+ const DEFAULTS = {
11
+ alpha: 0.35,
12
+ warningEdits: 15,
13
+ criticalEdits: 30,
14
+ criticalReverts: 2,
15
+ hardCapEdits: 50,
16
+ warningCooldownMs: 5 * 60 * 1000,
17
+ criticalCooldownMs: 10 * 60 * 1000,
18
+ };
19
+ /** EWMA 업데이트 (순수 함수) */
20
+ export function updateEwma(prev, sample, alpha) {
21
+ return alpha * sample + (1 - alpha) * prev;
22
+ }
23
+ /** 새 DriftState 생성 */
24
+ export function createDriftState(sessionId) {
25
+ return {
26
+ sessionId,
27
+ totalEdits: 0,
28
+ totalReverts: 0,
29
+ ewmaEditRate: 0,
30
+ ewmaRevertRate: 0,
31
+ lastWarningAt: 0,
32
+ lastCriticalAt: 0,
33
+ hardCapReached: false,
34
+ };
35
+ }
36
+ /**
37
+ * 도구 호출 이벤트로 drift 상태를 갱신하고 평가 결과를 반환.
38
+ * @param state 현재 상태 (mutate됨)
39
+ * @param isEdit Write/Edit 도구 호출 여부
40
+ * @param isRevert revert 감지 여부
41
+ * @param thresholds 커스텀 임계치 (hook-config에서 로드)
42
+ */
43
+ export function evaluateDrift(state, isEdit, isRevert, thresholds = {}) {
44
+ const t = { ...DEFAULTS, ...thresholds };
45
+ const now = Date.now();
46
+ // Update counters
47
+ if (isEdit)
48
+ state.totalEdits++;
49
+ if (isRevert)
50
+ state.totalReverts++;
51
+ // Update EWMA
52
+ state.ewmaEditRate = updateEwma(state.ewmaEditRate, isEdit ? 1 : 0, t.alpha);
53
+ state.ewmaRevertRate = updateEwma(state.ewmaRevertRate, isRevert ? 1 : 0, t.alpha);
54
+ // Calculate drift score: edit rate 65% + revert rate 35%
55
+ const rawScore = (state.ewmaEditRate * 65) + (state.ewmaRevertRate * 35);
56
+ const score = Math.min(100, Math.max(0, Math.round(rawScore)));
57
+ // Hard cap
58
+ if (state.totalEdits >= t.hardCapEdits) {
59
+ state.hardCapReached = true;
60
+ return {
61
+ level: 'hardcap',
62
+ score: 100,
63
+ message: `[Forgen] ⛔ Session drift hard cap reached (${state.totalEdits} edits). Stop and reassess the approach before continuing.`,
64
+ };
65
+ }
66
+ // Critical: 2+ reverts OR 30+ edits OR score >= 78
67
+ if ((state.totalReverts >= t.criticalReverts || state.totalEdits >= t.criticalEdits || score >= 78) &&
68
+ (now - state.lastCriticalAt > t.criticalCooldownMs)) {
69
+ state.lastCriticalAt = now;
70
+ return {
71
+ level: 'critical',
72
+ score,
73
+ message: `[Forgen] ⚠ High drift detected (score: ${score}, edits: ${state.totalEdits}, reverts: ${state.totalReverts}). Consider stopping to redesign the approach.`,
74
+ };
75
+ }
76
+ // Warning: 15+ edits OR score >= 52
77
+ if ((state.totalEdits >= t.warningEdits || score >= 52) &&
78
+ (now - state.lastWarningAt > t.warningCooldownMs)) {
79
+ state.lastWarningAt = now;
80
+ return {
81
+ level: 'warning',
82
+ score,
83
+ message: `[Forgen] Drift building up (score: ${score}, edits: ${state.totalEdits}). Review your approach if changes feel repetitive.`,
84
+ };
85
+ }
86
+ return { level: 'normal', score, message: null };
87
+ }
@@ -4,11 +4,15 @@
4
4
  * forgen inspect profile|rules|evidence|session
5
5
  * Authoritative: docs/plans/2026-04-03-forgen-rule-renderer-spec.md §6
6
6
  */
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
7
9
  import { loadProfile } from '../store/profile-store.js';
8
- import { loadAllRules } from '../store/rule-store.js';
10
+ import { loadAllRules, loadActiveRules } from '../store/rule-store.js';
9
11
  import { loadRecentEvidence } from '../store/evidence-store.js';
10
12
  import { loadRecentSessions } from '../store/session-state-store.js';
11
13
  import * as inspect from '../renderer/inspect-renderer.js';
14
+ import { ME_BEHAVIOR, ME_SOLUTIONS, STATE_DIR } from './paths.js';
15
+ import { safeReadJSON } from '../hooks/shared/atomic-write.js';
12
16
  export async function handleInspect(args) {
13
17
  const sub = args[0];
14
18
  if (sub === 'profile') {
@@ -18,6 +22,55 @@ export async function handleInspect(args) {
18
22
  return;
19
23
  }
20
24
  console.log('\n' + inspect.renderProfile(profile) + '\n');
25
+ // ── Learning Loop Status ──
26
+ const activeRules = loadActiveRules();
27
+ const rulesByScope = {
28
+ me: activeRules.filter(r => r.scope === 'me').length,
29
+ session: activeRules.filter(r => r.scope === 'session').length,
30
+ };
31
+ const evidenceCount = (() => {
32
+ if (!fs.existsSync(ME_BEHAVIOR))
33
+ return 0;
34
+ return fs.readdirSync(ME_BEHAVIOR).filter(f => f.endsWith('.json')).length;
35
+ })();
36
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
37
+ const recentEvidence = loadRecentEvidence(100);
38
+ const recentCount = recentEvidence.filter(e => e.timestamp >= sevenDaysAgo).length;
39
+ const solutionCount = (() => {
40
+ if (!fs.existsSync(ME_SOLUTIONS))
41
+ return 0;
42
+ return fs.readdirSync(ME_SOLUTIONS).filter(f => f.endsWith('.md')).length;
43
+ })();
44
+ const lastExtractionData = safeReadJSON(path.join(STATE_DIR, 'last-extraction.json'), null);
45
+ const lastExtractionTs = lastExtractionData?.timestamp ?? lastExtractionData?.date;
46
+ const lastExtractionLabel = (() => {
47
+ if (!lastExtractionTs)
48
+ return 'never';
49
+ const d = new Date(lastExtractionTs);
50
+ const diffDays = Math.floor((Date.now() - d.getTime()) / (24 * 60 * 60 * 1000));
51
+ const dateStr = d.toISOString().slice(0, 10);
52
+ return diffDays === 0 ? `${dateStr} (today)` : `${dateStr} (${diffDays} day${diffDays > 1 ? 's' : ''} ago)`;
53
+ })();
54
+ console.log('── Learning Loop Status ──');
55
+ console.log(`Rules: ${activeRules.length} active (${rulesByScope.me} me, ${rulesByScope.session} session)`);
56
+ console.log(`Evidence: ${evidenceCount} corrections (last 7 days: ${recentCount})`);
57
+ console.log(`Compound: ${solutionCount} solutions`);
58
+ console.log(`Last extraction: ${lastExtractionLabel}`);
59
+ console.log('');
60
+ // ── Recent Corrections ──
61
+ const corrections = recentEvidence
62
+ .filter(e => e.type === 'explicit_correction')
63
+ .slice(0, 3);
64
+ if (corrections.length > 0) {
65
+ console.log('── Recent Corrections ──');
66
+ for (const ev of corrections) {
67
+ const kind = ev.raw_payload?.kind;
68
+ const axis = ev.axis_refs[0] ?? 'general';
69
+ const dateStr = ev.timestamp.slice(0, 10);
70
+ console.log(`• [${axis}] ${ev.summary} (${kind ?? 'correction'}, ${dateStr})`);
71
+ }
72
+ console.log('');
73
+ }
21
74
  return;
22
75
  }
23
76
  if (sub === 'rules') {
@@ -11,6 +11,8 @@ export interface McpServerConfig {
11
11
  command: string;
12
12
  args: string[];
13
13
  env?: Record<string, string>;
14
+ /** HTTP/SSE transport URL (alternative to command+args) */
15
+ url?: string;
14
16
  }
15
17
  /**
16
18
  * 기본 MCP 서버 템플릿 목록 반환
@@ -101,7 +101,12 @@ export async function handleMcp(args) {
101
101
  for (const name of names) {
102
102
  const cfg = installed[name];
103
103
  console.log(` ${name}`);
104
- console.log(` command: ${cfg.command} ${cfg.args.join(' ')}`);
104
+ if (cfg.command) {
105
+ console.log(` command: ${cfg.command} ${(cfg.args ?? []).join(' ')}`);
106
+ }
107
+ else if (cfg.url) {
108
+ console.log(` url: ${cfg.url}`);
109
+ }
105
110
  if (cfg.env && Object.keys(cfg.env).length > 0) {
106
111
  console.log(` env: ${JSON.stringify(cfg.env)}`);
107
112
  }
@@ -74,7 +74,7 @@ export declare const V1_RAW_LOGS_DIR: string;
74
74
  /** @deprecated use GLOBAL_CONFIG */
75
75
  export declare const V1_GLOBAL_CONFIG: string;
76
76
  /** 모든 실행 모드 이름 (cancel/recovery 시 사용) */
77
- export declare const ALL_MODES: readonly ["ralph", "autopilot", "ultrawork", "team", "pipeline", "ccg", "ralplan", "deep-interview", "ecomode"];
77
+ export declare const ALL_MODES: readonly ["ralph", "autopilot", "ultrawork", "team", "pipeline", "ccg", "ralplan", "deep-interview", "ecomode", "specify"];
78
78
  /** {repo}/.compound/ — 프로젝트 로컬 디렉토리 */
79
79
  export declare function projectDir(cwd: string): string;
80
80
  /** {repo}/.compound/pack.link — 팀 팩 연결 파일 */
@@ -81,7 +81,7 @@ export const V1_GLOBAL_CONFIG = GLOBAL_CONFIG;
81
81
  /** 모든 실행 모드 이름 (cancel/recovery 시 사용) */
82
82
  export const ALL_MODES = [
83
83
  'ralph', 'autopilot', 'ultrawork', 'team', 'pipeline',
84
- 'ccg', 'ralplan', 'deep-interview', 'ecomode',
84
+ 'ccg', 'ralplan', 'deep-interview', 'ecomode', 'specify',
85
85
  ];
86
86
  /** {repo}/.compound/ — 프로젝트 로컬 디렉토리 */
87
87
  export function projectDir(cwd) {
@@ -1,3 +1,8 @@
1
1
  import type { V1HarnessContext } from './harness.js';
2
- /** Claude Code를 하네스 환경으로 실행 */
3
- export declare function spawnClaude(args: string[], context: V1HarnessContext): Promise<void>;
2
+ /** Claude Code를 하네스 환경으로 실행. exit code를 반환. */
3
+ export declare function spawnClaude(args: string[], context: V1HarnessContext): Promise<number>;
4
+ /**
5
+ * 토큰 한도 도달 시 자동 재시작을 지원하는 claude 실행 래퍼.
6
+ * context-guard가 pending-resume.json 마커를 생성하면 쿨다운 후 재시작.
7
+ */
8
+ export declare function spawnClaudeWithResume(args: string[], context: V1HarnessContext, contextFactory: () => Promise<V1HarnessContext>): Promise<void>;
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
6
6
  import { buildEnv } from './config-injector.js';
7
7
  import { loadGlobalConfig } from './global-config.js';
8
8
  import { createLogger } from './logger.js';
9
+ import { STATE_DIR } from './paths.js';
9
10
  const log = createLogger('spawn');
10
11
  /** claude CLI 경로 탐색 */
11
12
  function findClaude() {
@@ -58,7 +59,7 @@ async function indexTranscriptToFTS(cwd, transcriptPath, sessionId) {
58
59
  log.debug('FTS5 인덱싱 실패 (session-store 미구현 시 정상)', e);
59
60
  }
60
61
  }
61
- /** Claude Code를 하네스 환경으로 실행 */
62
+ /** Claude Code를 하네스 환경으로 실행. exit code를 반환. */
62
63
  export async function spawnClaude(args, context) {
63
64
  const claudePath = findClaude();
64
65
  const env = buildEnv(context.cwd);
@@ -124,12 +125,49 @@ export async function spawnClaude(args, context) {
124
125
  catch (e) {
125
126
  console.error('[forgen] 세션 종료 후 처리 실패:', e instanceof Error ? e.message : e);
126
127
  }
127
- if (code === 0 || code === null) {
128
- resolve();
129
- }
130
- else {
131
- process.exit(code);
132
- }
128
+ resolve(code ?? 0);
133
129
  });
134
130
  });
135
131
  }
132
+ const RESUME_COOLDOWN_MS = 30_000;
133
+ const MAX_RESUMES = 3;
134
+ /**
135
+ * 토큰 한도 도달 시 자동 재시작을 지원하는 claude 실행 래퍼.
136
+ * context-guard가 pending-resume.json 마커를 생성하면 쿨다운 후 재시작.
137
+ */
138
+ export async function spawnClaudeWithResume(args, context, contextFactory) {
139
+ let resumeCount = 0;
140
+ let currentContext = context;
141
+ while (true) {
142
+ const exitCode = await spawnClaude(args, currentContext);
143
+ const resumePath = path.join(STATE_DIR, 'pending-resume.json');
144
+ if (!fs.existsSync(resumePath)) {
145
+ if (exitCode !== 0)
146
+ process.exit(exitCode);
147
+ break;
148
+ }
149
+ try {
150
+ const marker = JSON.parse(fs.readFileSync(resumePath, 'utf-8'));
151
+ fs.unlinkSync(resumePath);
152
+ if (marker.reason !== 'token-limit') {
153
+ if (exitCode !== 0)
154
+ process.exit(exitCode);
155
+ break;
156
+ }
157
+ if (resumeCount >= MAX_RESUMES) {
158
+ console.log(`[forgen] 최대 자동 재시작 횟수(${MAX_RESUMES}) 도달. 수동으로 다시 시작하세요.`);
159
+ break;
160
+ }
161
+ resumeCount++;
162
+ console.log(`[forgen] 토큰 한도 도달. ${RESUME_COOLDOWN_MS / 1000}초 후 자동 재시작합니다... (${resumeCount}/${MAX_RESUMES})`);
163
+ await new Promise(resolve => setTimeout(resolve, RESUME_COOLDOWN_MS));
164
+ console.log('[forgen] 세션 재시작 중...');
165
+ currentContext = await contextFactory();
166
+ }
167
+ catch {
168
+ if (exitCode !== 0)
169
+ process.exit(exitCode);
170
+ break;
171
+ }
172
+ }
173
+ }
@@ -19,7 +19,7 @@ import { FORGEN_HOME, V1_ME_DIR, V1_RULES_DIR, V1_EVIDENCE_DIR, V1_RECOMMENDATIO
19
19
  import { checkLegacyProfile, runLegacyCutover } from './legacy-detector.js';
20
20
  import { detectRuntimeCapability } from './runtime-detector.js';
21
21
  import { loadProfile, profileExists } from '../store/profile-store.js';
22
- import { loadActiveRules } from '../store/rule-store.js';
22
+ import { loadActiveRules, cleanupStaleSessionRules } from '../store/rule-store.js';
23
23
  import { composeSession } from '../preset/preset-manager.js';
24
24
  import { renderRules, DEFAULT_CONTEXT } from '../renderer/rule-renderer.js';
25
25
  import { saveSessionState, loadRecentSessions } from '../store/session-state-store.js';
@@ -61,8 +61,15 @@ export function bootstrapV1Session() {
61
61
  // 3. Runtime capability 감지
62
62
  const runtime = detectRuntimeCapability();
63
63
  // 4. Rules 로드 + Session 합성
64
- const personalRules = loadActiveRules();
65
64
  const sessionId = crypto.randomUUID();
65
+ // 이전 세션의 scope:'session' 임시 규칙 정리
66
+ try {
67
+ cleanupStaleSessionRules(sessionId);
68
+ }
69
+ catch {
70
+ // 정리 실패는 세션 시작을 막지 않음
71
+ }
72
+ const personalRules = loadActiveRules();
66
73
  const session = composeSession({
67
74
  session_id: sessionId,
68
75
  profile,
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Forgen — Compound Knowledge Export/Import
3
+ *
4
+ * Provides backup, migration, and sharing of accumulated personal knowledge
5
+ * stored under ~/.forgen/me/ (solutions/, rules/, behavior/).
6
+ *
7
+ * Export creates a tar.gz archive; Import extracts it while skipping existing
8
+ * files to prevent accidental overwrites.
9
+ */
10
+ export interface ExportResult {
11
+ outputPath: string;
12
+ counts: Record<string, number>;
13
+ totalFiles: number;
14
+ }
15
+ export interface ImportResult {
16
+ imported: number;
17
+ skipped: number;
18
+ details: {
19
+ file: string;
20
+ action: 'imported' | 'skipped';
21
+ }[];
22
+ }
23
+ /**
24
+ * Export knowledge directories to a tar.gz archive.
25
+ *
26
+ * Uses `tar czf` via child_process for simplicity and reliability.
27
+ * Only archives solutions/, rules/, behavior/ under ME_DIR.
28
+ */
29
+ export declare function exportKnowledge(outputPath?: string): ExportResult;
30
+ /**
31
+ * Import knowledge from a tar.gz archive.
32
+ *
33
+ * For each file in the archive, if a file with the same name already exists
34
+ * in the target directory, it is SKIPPED (no overwrite). Only new files are
35
+ * added.
36
+ */
37
+ export declare function importKnowledge(archivePath: string): ImportResult;
38
+ /** CLI handler: forgen compound export */
39
+ export declare function handleExport(args: string[]): Promise<void>;
40
+ /** CLI handler: forgen compound import */
41
+ export declare function handleImport(args: string[]): Promise<void>;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Forgen — Compound Knowledge Export/Import
3
+ *
4
+ * Provides backup, migration, and sharing of accumulated personal knowledge
5
+ * stored under ~/.forgen/me/ (solutions/, rules/, behavior/).
6
+ *
7
+ * Export creates a tar.gz archive; Import extracts it while skipping existing
8
+ * files to prevent accidental overwrites.
9
+ */
10
+ import * as fs from 'node:fs';
11
+ import * as os from 'node:os';
12
+ import * as path from 'node:path';
13
+ import { execFileSync } from 'node:child_process';
14
+ import { ME_DIR } from '../core/paths.js';
15
+ /** Directories within ME_DIR to include in the archive. */
16
+ const KNOWLEDGE_DIRS = ['solutions', 'rules', 'behavior'];
17
+ /**
18
+ * Count .md files in a directory (non-recursive).
19
+ * Returns 0 if the directory does not exist.
20
+ */
21
+ function countFiles(dir) {
22
+ try {
23
+ if (!fs.existsSync(dir))
24
+ return 0;
25
+ return fs.readdirSync(dir).filter(f => f.endsWith('.md')).length;
26
+ }
27
+ catch {
28
+ return 0;
29
+ }
30
+ }
31
+ /**
32
+ * Export knowledge directories to a tar.gz archive.
33
+ *
34
+ * Uses `tar czf` via child_process for simplicity and reliability.
35
+ * Only archives solutions/, rules/, behavior/ under ME_DIR.
36
+ */
37
+ export function exportKnowledge(outputPath) {
38
+ const date = new Date().toISOString().split('T')[0];
39
+ const resolved = outputPath ?? path.join(process.cwd(), `forgen-knowledge-${date}.tar.gz`);
40
+ // Gather counts before archiving
41
+ const counts = {};
42
+ const existingDirs = [];
43
+ for (const name of KNOWLEDGE_DIRS) {
44
+ const dir = path.join(ME_DIR, name);
45
+ const count = countFiles(dir);
46
+ counts[name] = count;
47
+ if (fs.existsSync(dir)) {
48
+ existingDirs.push(name);
49
+ }
50
+ }
51
+ const totalFiles = Object.values(counts).reduce((a, b) => a + b, 0);
52
+ if (existingDirs.length === 0) {
53
+ throw new Error('No knowledge directories found to export.');
54
+ }
55
+ // Ensure output directory exists
56
+ const outDir = path.dirname(resolved);
57
+ fs.mkdirSync(outDir, { recursive: true });
58
+ // Create tar.gz relative to ME_DIR so archive paths are solutions/*, rules/*, behavior/*
59
+ execFileSync('tar', ['czf', resolved, ...existingDirs], {
60
+ cwd: ME_DIR,
61
+ timeout: 30000,
62
+ stdio: ['pipe', 'pipe', 'pipe'],
63
+ });
64
+ return { outputPath: resolved, counts, totalFiles };
65
+ }
66
+ /**
67
+ * Import knowledge from a tar.gz archive.
68
+ *
69
+ * For each file in the archive, if a file with the same name already exists
70
+ * in the target directory, it is SKIPPED (no overwrite). Only new files are
71
+ * added.
72
+ */
73
+ export function importKnowledge(archivePath) {
74
+ if (!fs.existsSync(archivePath)) {
75
+ throw new Error(`Archive not found: ${archivePath}`);
76
+ }
77
+ // List files in the archive
78
+ const listOutput = execFileSync('tar', ['tzf', archivePath], {
79
+ timeout: 30000,
80
+ encoding: 'utf-8',
81
+ stdio: ['pipe', 'pipe', 'pipe'],
82
+ });
83
+ const archiveFiles = listOutput
84
+ .split('\n')
85
+ .map(f => f.trim())
86
+ .filter(f => f && !f.endsWith('/'));
87
+ // Extract to a temp directory first, then selectively copy
88
+ const tmpDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'forgen-import-'));
89
+ try {
90
+ execFileSync('tar', ['xzf', archivePath, '-C', tmpDir], {
91
+ timeout: 30000,
92
+ stdio: ['pipe', 'pipe', 'pipe'],
93
+ });
94
+ const result = { imported: 0, skipped: 0, details: [] };
95
+ for (const relFile of archiveFiles) {
96
+ const srcPath = path.join(tmpDir, relFile);
97
+ const destPath = path.join(ME_DIR, relFile);
98
+ // Security: ensure the dest path stays within ME_DIR
99
+ const realDest = path.resolve(destPath);
100
+ if (!realDest.startsWith(ME_DIR)) {
101
+ result.skipped++;
102
+ result.details.push({ file: relFile, action: 'skipped' });
103
+ continue;
104
+ }
105
+ if (fs.existsSync(destPath)) {
106
+ result.skipped++;
107
+ result.details.push({ file: relFile, action: 'skipped' });
108
+ }
109
+ else {
110
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
111
+ fs.copyFileSync(srcPath, destPath);
112
+ result.imported++;
113
+ result.details.push({ file: relFile, action: 'imported' });
114
+ }
115
+ }
116
+ return result;
117
+ }
118
+ finally {
119
+ // Clean up temp directory
120
+ fs.rmSync(tmpDir, { recursive: true, force: true });
121
+ }
122
+ }
123
+ /** CLI handler: forgen compound export */
124
+ export async function handleExport(args) {
125
+ const outputIdx = args.indexOf('--output');
126
+ const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : undefined;
127
+ try {
128
+ const result = exportKnowledge(outputPath);
129
+ console.log('\n Compound Knowledge Export\n');
130
+ console.log(` Output: ${result.outputPath}`);
131
+ console.log();
132
+ for (const [category, count] of Object.entries(result.counts)) {
133
+ console.log(` ${category}: ${count} files`);
134
+ }
135
+ console.log(`\n Total: ${result.totalFiles} files exported.\n`);
136
+ }
137
+ catch (e) {
138
+ console.error(`\n Export failed: ${e.message}\n`);
139
+ process.exit(1);
140
+ }
141
+ }
142
+ /** CLI handler: forgen compound import */
143
+ export async function handleImport(args) {
144
+ const archivePath = args[0];
145
+ if (!archivePath || archivePath.startsWith('--')) {
146
+ console.log(' Usage: forgen compound import <path-to-archive>\n');
147
+ return;
148
+ }
149
+ try {
150
+ const resolved = path.resolve(archivePath);
151
+ const result = importKnowledge(resolved);
152
+ console.log('\n Compound Knowledge Import\n');
153
+ console.log(` Archive: ${resolved}`);
154
+ console.log(` Imported: ${result.imported} new files`);
155
+ console.log(` Skipped: ${result.skipped} existing files`);
156
+ if (result.details.length > 0 && result.details.length <= 20) {
157
+ console.log();
158
+ for (const d of result.details) {
159
+ const icon = d.action === 'imported' ? '+' : '-';
160
+ console.log(` ${icon} ${d.file}`);
161
+ }
162
+ }
163
+ console.log();
164
+ }
165
+ catch (e) {
166
+ console.error(`\n Import failed: ${e.message}\n`);
167
+ process.exit(1);
168
+ }
169
+ }
@@ -636,6 +636,41 @@ function updateReExtractedCounter(tags) {
636
636
  return;
637
637
  }
638
638
  }
639
+ /**
640
+ * Optional LLM enrichment for thin solution content.
641
+ * Uses execFileSync (synchronous) to keep callers synchronous.
642
+ * Completely fail-open: any error returns null and the regex-extracted content is kept.
643
+ * Budget: max 2 calls per extraction run, 15s timeout each.
644
+ */
645
+ function enrichSolutionContent(solution, diffSnippet) {
646
+ try {
647
+ const prompt = [
648
+ '다음 코드 변경에서 감지된 패턴을 2-3문장으로 설명해주세요.',
649
+ '무엇이 바뀌었는지가 아니라, **왜 이 패턴이 유용한지**와 **언제 적용해야 하는지**를 설명하세요.',
650
+ '',
651
+ `패턴 이름: ${solution.name}`,
652
+ `감지된 컨텍스트: ${solution.context}`,
653
+ `태그: ${solution.tags.join(', ')}`,
654
+ '',
655
+ '코드 변경 (일부):',
656
+ diffSnippet.slice(0, 2000),
657
+ ].join('\n');
658
+ const result = execFileSync('claude', ['-p', prompt, '--model', 'haiku'], {
659
+ timeout: 15000,
660
+ encoding: 'utf-8',
661
+ stdio: ['pipe', 'pipe', 'pipe'],
662
+ });
663
+ const enriched = result.trim();
664
+ if (enriched.length > 30 && enriched.length < 1000) {
665
+ return enriched;
666
+ }
667
+ return null;
668
+ }
669
+ catch {
670
+ // fail-open: LLM enrichment failure should never block extraction
671
+ return null;
672
+ }
673
+ }
639
674
  /** Main extraction function — called from SessionStart or CLI */
640
675
  function analyzeExtraction(cwd, options) {
641
676
  const state = loadLastExtraction();
@@ -732,6 +767,7 @@ function analyzeExtraction(cwd, options) {
732
767
  extracted,
733
768
  stats,
734
769
  persistStateWithoutSaving: false,
770
+ gitDiff,
735
771
  };
736
772
  }
737
773
  function evaluateExtractedSolution(sol) {
@@ -784,6 +820,19 @@ export async function runExtraction(cwd, sessionId) {
784
820
  return { ...result, reason: analysis.reason };
785
821
  }
786
822
  if (analysis.extracted.length > 0) {
823
+ // Enrich thin solutions with LLM context — max 2 per run, fail-open
824
+ let enrichCount = 0;
825
+ for (const sol of analysis.extracted) {
826
+ if (enrichCount >= 2)
827
+ break;
828
+ if (sol.content.length < 100 && analysis.state.extractionsToday < MAX_EXTRACTIONS_PER_DAY) {
829
+ const enriched = enrichSolutionContent(sol, analysis.gitDiff ?? '');
830
+ if (enriched) {
831
+ sol.content = enriched;
832
+ enrichCount++;
833
+ }
834
+ }
835
+ }
787
836
  const { saved, skipped } = processExtractionResults(JSON.stringify(analysis.extracted), sessionId);
788
837
  result.extracted = saved;
789
838
  result.skipped = skipped;
@@ -190,6 +190,11 @@ export async function handleCompound(args) {
190
190
  forgen compound --lifecycle Run promotion/demotion/circuit-breaker check
191
191
  forgen compound --verify <name> Manually promote solution to verified
192
192
 
193
+ Export/Import:
194
+ forgen compound export [--output path]
195
+ Export knowledge to tar.gz archive
196
+ forgen compound import <path> Import knowledge from archive (skip existing)
197
+
193
198
  Auto-extraction:
194
199
  forgen compound --pause-auto Pause auto-extraction
195
200
  forgen compound --resume-auto Resume auto-extraction
@@ -199,6 +204,18 @@ export async function handleCompound(args) {
199
204
  `);
200
205
  return;
201
206
  }
207
+ // --- export command ---
208
+ if (args[0] === 'export') {
209
+ const { handleExport } = await import('./compound-export.js');
210
+ await handleExport(args.slice(1));
211
+ return;
212
+ }
213
+ // --- import command ---
214
+ if (args[0] === 'import') {
215
+ const { handleImport } = await import('./compound-export.js');
216
+ await handleImport(args.slice(1));
217
+ return;
218
+ }
202
219
  // --pause-auto / --resume-auto
203
220
  if (args.includes('--pause-auto') || args.includes('pause-auto')) {
204
221
  const { pauseExtraction } = await import('./compound-extractor.js');
@@ -375,6 +392,7 @@ export async function handleCompound(args) {
375
392
  '--lifecycle', '--verify', '--save', '--interactive',
376
393
  'list', 'inspect', 'remove', 'rollback', 'retag', 'lifecycle',
377
394
  '--list', '--inspect', '--remove', '--rollback', '--retag', '--since', 'interactive',
395
+ 'export', 'import', '--output',
378
396
  ];
379
397
  const hasTypeFlag = knownFlags.some(f => args.includes(f));
380
398
  if (!hasTypeFlag) {
@@ -6,6 +6,27 @@ import type { SolutionStatus, SolutionType } from './solution-format.js';
6
6
  * `synonym-tfidf.test.ts` and any external consumers.
7
7
  */
8
8
  export declare function expandTagsWithSynonyms(tags: string[]): string[];
9
+ /**
10
+ * Compute the Dice coefficient between two strings using character bigrams.
11
+ *
12
+ * Dice = 2 * |intersection| / (|A| + |B|)
13
+ *
14
+ * Both strings are lowercased and whitespace-stripped before bigram generation.
15
+ * Returns 0 for empty strings or single-character strings (no bigrams possible).
16
+ * Returns 1.0 for identical non-trivial strings.
17
+ *
18
+ * This is used as a lightweight fuzzy matching signal for borderline cases
19
+ * where the TF-IDF tag intersection produces a low score but the query and
20
+ * solution tags are character-similar (e.g., "database" vs "데이터베이스"
21
+ * won't match, but "database" vs "databse" will get a high score).
22
+ */
23
+ export declare function bigramSimilarity(a: string, b: string): number;
24
+ /**
25
+ * Simplified BM25 score for a single query-document pair.
26
+ * Uses tag overlap with term frequency normalization.
27
+ * k1=1.2, b=0.75 (standard BM25 parameters).
28
+ */
29
+ export declare function bm25Score(queryTags: string[], docTags: string[], avgDocLength: number): number;
9
30
  /** Apply IDF-like weight: common tags get reduced weight */
10
31
  export declare function tagWeight(tag: string): number;
11
32
  export interface SolutionMatch {
@@ -42,6 +63,8 @@ export interface CalculateRelevanceOptions {
42
63
  * pair — `solutionTagsExpanded` MUST be a superset of `solutionTags`.
43
64
  */
44
65
  solutionTagsExpanded?: string[];
66
+ /** Average document (solution) tag count for BM25 normalization. Defaults to 6. */
67
+ avgDocLength?: number;
45
68
  }
46
69
  export declare function calculateRelevance(promptTags: string[], solutionTags: string[], confidence: number, options?: CalculateRelevanceOptions): {
47
70
  relevance: number;