@wooojin/forgen 0.4.10 → 0.4.12

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 (76) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +62 -0
  3. package/README.md +33 -1
  4. package/assets/claude/agents/forgen-verify.md +65 -0
  5. package/assets/claude/workflows/compound-extract.js +136 -0
  6. package/assets/claude/workflows/evidence-gate-audit.js +107 -0
  7. package/assets/shared/hook-registry.json +1 -0
  8. package/dist/checks/_shared/meta-guard-dispatch.d.ts +38 -0
  9. package/dist/checks/_shared/meta-guard-dispatch.js +80 -0
  10. package/dist/checks/_shared/text-sanitizer.js +15 -0
  11. package/dist/cli.js +57 -2
  12. package/dist/core/changelog-cli.d.ts +7 -0
  13. package/dist/core/changelog-cli.js +100 -0
  14. package/dist/core/doctor.d.ts +3 -0
  15. package/dist/core/doctor.js +38 -0
  16. package/dist/core/effort-advisory.d.ts +23 -0
  17. package/dist/core/effort-advisory.js +29 -0
  18. package/dist/core/explain-cli.d.ts +6 -0
  19. package/dist/core/explain-cli.js +99 -0
  20. package/dist/core/health-cli.d.ts +23 -0
  21. package/dist/core/health-cli.js +86 -0
  22. package/dist/core/probe-workflow-cli.d.ts +72 -0
  23. package/dist/core/probe-workflow-cli.js +282 -0
  24. package/dist/core/spawn.d.ts +13 -0
  25. package/dist/core/spawn.js +36 -8
  26. package/dist/core/stats-cli.d.ts +22 -9
  27. package/dist/core/stats-cli.js +149 -0
  28. package/dist/core/watch-cli.d.ts +7 -0
  29. package/dist/core/watch-cli.js +185 -0
  30. package/dist/core/workflows-cli.d.ts +26 -0
  31. package/dist/core/workflows-cli.js +120 -0
  32. package/dist/engine/compound-export.d.ts +12 -0
  33. package/dist/engine/compound-export.js +136 -14
  34. package/dist/engine/compound-extractor.d.ts +12 -43
  35. package/dist/engine/compound-extractor.js +27 -756
  36. package/dist/engine/extraction-diff.d.ts +11 -0
  37. package/dist/engine/extraction-diff.js +105 -0
  38. package/dist/engine/extraction-gates.d.ts +37 -0
  39. package/dist/engine/extraction-gates.js +100 -0
  40. package/dist/engine/extraction-git.d.ts +20 -0
  41. package/dist/engine/extraction-git.js +75 -0
  42. package/dist/engine/extraction-persistence.d.ts +27 -0
  43. package/dist/engine/extraction-persistence.js +140 -0
  44. package/dist/engine/extraction-session.d.ts +26 -0
  45. package/dist/engine/extraction-session.js +230 -0
  46. package/dist/engine/lifecycle/types.d.ts +1 -1
  47. package/dist/engine/meta-learning/matcher-weight-loader.d.ts +16 -0
  48. package/dist/engine/meta-learning/matcher-weight-loader.js +45 -0
  49. package/dist/engine/precision-guards.d.ts +14 -0
  50. package/dist/engine/precision-guards.js +39 -0
  51. package/dist/engine/ranking-pipeline.d.ts +45 -0
  52. package/dist/engine/ranking-pipeline.js +66 -0
  53. package/dist/engine/relevance-scorer.d.ts +43 -0
  54. package/dist/engine/relevance-scorer.js +81 -0
  55. package/dist/engine/scoring-algorithms.d.ts +31 -0
  56. package/dist/engine/scoring-algorithms.js +109 -0
  57. package/dist/engine/solution-matcher-eval.d.ts +97 -0
  58. package/dist/engine/solution-matcher-eval.js +122 -0
  59. package/dist/engine/solution-matcher.d.ts +21 -380
  60. package/dist/engine/solution-matcher.js +27 -828
  61. package/dist/fgx.js +1 -1
  62. package/dist/hooks/notepad-injector.js +7 -0
  63. package/dist/hooks/post-tool-use.js +8 -1
  64. package/dist/hooks/secret-filter.d.ts +1 -0
  65. package/dist/hooks/secret-filter.js +17 -7
  66. package/dist/hooks/shared/preflight-check.d.ts +15 -0
  67. package/dist/hooks/shared/preflight-check.js +51 -0
  68. package/dist/hooks/stop-guard.js +19 -60
  69. package/dist/hooks/subagent-stop-guard.d.ts +23 -0
  70. package/dist/hooks/subagent-stop-guard.js +158 -0
  71. package/dist/hooks/subagent-tracker.d.ts +36 -3
  72. package/dist/hooks/subagent-tracker.js +86 -39
  73. package/hooks/hooks.json +6 -1
  74. package/package.json +7 -7
  75. package/plugin.json +1 -1
  76. package/scripts/postinstall.js +10 -7
@@ -0,0 +1,185 @@
1
+ /**
2
+ * forgen watch — real-time hook event stream.
3
+ *
4
+ * Tails hook-timing.jsonl, enforcement/violations.jsonl, and
5
+ * match-eval-log.jsonl to show live forgen activity in a terminal pane.
6
+ */
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
9
+ import { STATE_DIR } from './paths.js';
10
+ const isTTY = process.stdout.isTTY;
11
+ const C = {
12
+ reset: isTTY ? '\x1b[0m' : '',
13
+ dim: isTTY ? '\x1b[2m' : '',
14
+ cyan: isTTY ? '\x1b[36m' : '',
15
+ yellow: isTTY ? '\x1b[33m' : '',
16
+ green: isTTY ? '\x1b[32m' : '',
17
+ red: isTTY ? '\x1b[31m' : '',
18
+ magenta: isTTY ? '\x1b[35m' : '',
19
+ };
20
+ function formatTimestamp(isoOrMs) {
21
+ try {
22
+ const d = typeof isoOrMs === 'number' ? new Date(isoOrMs) : new Date(isoOrMs);
23
+ return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
24
+ }
25
+ catch {
26
+ return '??:??:??';
27
+ }
28
+ }
29
+ function formatHookTiming(e) {
30
+ const hook = String(e.hook ?? '');
31
+ const ms = Number(e.ms ?? 0);
32
+ const event = String(e.event ?? '');
33
+ const ts = formatTimestamp(e.at);
34
+ const speedColor = ms > 500 ? C.yellow : ms > 1000 ? C.red : C.dim;
35
+ return `${C.dim}${ts}${C.reset} ${C.cyan}hook${C.reset} ${hook} ${C.dim}(${event})${C.reset} ${speedColor}${ms}ms${C.reset}`;
36
+ }
37
+ function formatViolation(e) {
38
+ const rule = String(e.rule ?? e.guard ?? e.source ?? 'unknown');
39
+ const kind = String(e.kind ?? 'block');
40
+ const ts = formatTimestamp(String(e.at ?? ''));
41
+ const icon = kind === 'block' ? `${C.red}BLOCK${C.reset}` : `${C.yellow}${kind}${C.reset}`;
42
+ return `${C.dim}${ts}${C.reset} ${icon} ${C.magenta}${rule}${C.reset}`;
43
+ }
44
+ function formatMatchEval(e) {
45
+ const source = String(e.source ?? '');
46
+ const ranked = e.rankedTopN;
47
+ const ts = formatTimestamp(String(e.ts ?? ''));
48
+ if (!ranked || !Array.isArray(ranked) || ranked.length === 0)
49
+ return null;
50
+ // rankedTopN entries can be strings (names) or objects {name: ...}
51
+ const names = ranked.slice(0, 3).map(r => {
52
+ if (typeof r === 'string')
53
+ return r;
54
+ if (typeof r === 'object' && r !== null && 'name' in r) {
55
+ return String(r.name);
56
+ }
57
+ return '?';
58
+ }).filter(Boolean).join(', ');
59
+ if (!names)
60
+ return null;
61
+ return `${C.dim}${ts}${C.reset} ${C.green}match${C.reset} ${C.dim}(${source})${C.reset} ${names}`;
62
+ }
63
+ function tailFile(filePath, fromEnd) {
64
+ try {
65
+ if (!fs.existsSync(filePath))
66
+ return [];
67
+ const content = fs.readFileSync(filePath, 'utf-8');
68
+ const lines = content.trim().split('\n');
69
+ const tail = lines.slice(-fromEnd);
70
+ const out = [];
71
+ for (const line of tail) {
72
+ try {
73
+ out.push(JSON.parse(line));
74
+ }
75
+ catch { /* skip */ }
76
+ }
77
+ return out;
78
+ }
79
+ catch {
80
+ return [];
81
+ }
82
+ }
83
+ function watchFile(filePath, onLine) {
84
+ let lastSize = 0;
85
+ try {
86
+ if (fs.existsSync(filePath)) {
87
+ lastSize = fs.statSync(filePath).size;
88
+ }
89
+ }
90
+ catch { /* ok */ }
91
+ try {
92
+ return fs.watch(filePath, () => {
93
+ try {
94
+ const stat = fs.statSync(filePath);
95
+ if (stat.size <= lastSize) {
96
+ lastSize = stat.size;
97
+ return;
98
+ }
99
+ const fd = fs.openSync(filePath, 'r');
100
+ const buf = Buffer.alloc(stat.size - lastSize);
101
+ fs.readSync(fd, buf, 0, buf.length, lastSize);
102
+ fs.closeSync(fd);
103
+ lastSize = stat.size;
104
+ const chunk = buf.toString('utf-8');
105
+ for (const line of chunk.split('\n')) {
106
+ if (!line.trim())
107
+ continue;
108
+ try {
109
+ onLine(JSON.parse(line));
110
+ }
111
+ catch { /* skip */ }
112
+ }
113
+ }
114
+ catch { /* fail-open */ }
115
+ });
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ export async function handleWatch() {
122
+ const sources = [
123
+ {
124
+ label: 'hooks',
125
+ path: path.join(STATE_DIR, 'hook-timing.jsonl'),
126
+ format: formatHookTiming,
127
+ },
128
+ {
129
+ label: 'enforcement',
130
+ path: path.join(STATE_DIR, 'enforcement', 'violations.jsonl'),
131
+ format: formatViolation,
132
+ },
133
+ {
134
+ label: 'matches',
135
+ path: path.join(STATE_DIR, 'match-eval-log.jsonl'),
136
+ format: formatMatchEval,
137
+ },
138
+ ];
139
+ console.log(`\n ${C.cyan}forgen watch${C.reset} — real-time event stream`);
140
+ console.log(` ${C.dim}Watching: hook-timing, violations, match-eval-log${C.reset}`);
141
+ console.log(` ${C.dim}Press Ctrl+C to stop${C.reset}\n`);
142
+ // Show recent events (last 10 per source)
143
+ const recent = [];
144
+ for (const src of sources) {
145
+ const entries = tailFile(src.path, 10);
146
+ for (const e of entries) {
147
+ const line = src.format(e);
148
+ if (!line)
149
+ continue;
150
+ const ts = typeof e.at === 'number' ? e.at
151
+ : typeof e.at === 'string' ? Date.parse(e.at)
152
+ : typeof e.ts === 'string' ? Date.parse(e.ts)
153
+ : 0;
154
+ recent.push({ ts, line });
155
+ }
156
+ }
157
+ recent.sort((a, b) => a.ts - b.ts);
158
+ if (recent.length > 0) {
159
+ console.log(` ${C.dim}── recent ──${C.reset}`);
160
+ for (const r of recent.slice(-15)) {
161
+ console.log(` ${r.line}`);
162
+ }
163
+ console.log(` ${C.dim}── live ──${C.reset}\n`);
164
+ }
165
+ // Watch for new events
166
+ const watchers = [];
167
+ for (const src of sources) {
168
+ const watcher = watchFile(src.path, (entry) => {
169
+ const line = src.format(entry);
170
+ if (line)
171
+ console.log(` ${line}`);
172
+ });
173
+ if (watcher)
174
+ watchers.push(watcher);
175
+ }
176
+ // Keep process alive
177
+ await new Promise((resolve) => {
178
+ process.on('SIGINT', () => {
179
+ for (const w of watchers)
180
+ w.close();
181
+ console.log(`\n ${C.dim}watch stopped${C.reset}\n`);
182
+ resolve();
183
+ });
184
+ });
185
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * forgen workflows — dynamic-workflow 템플릿 설치/조회 (ADR-009 §3).
3
+ *
4
+ * dynamic workflows 는 플러그인 매니페스트로 번들할 수 없고(research preview,
5
+ * `.claude/workflows/` 에만 저장됨), forgen 은 철학을 인코딩한 canonical 템플릿을
6
+ * `assets/claude/workflows/*.js` 로 동봉한다. 이 명령이 그것을 사용자의
7
+ * `.claude/workflows/` 로 복사한다 → `/<name>` 으로 실행 가능.
8
+ *
9
+ * forgen-verify 에이전트(verify 스테이지용)는 플러그인 `agents` 키로 자동 배포되므로
10
+ * 별도 설치가 필요 없다.
11
+ *
12
+ * forgen workflows install ~/.claude/workflows/ (개인, 모든 프로젝트)
13
+ * forgen workflows install --project <cwd>/.claude/workflows/ (저장소 공유)
14
+ * forgen workflows list 동봉 템플릿 + 설치 여부
15
+ */
16
+ /** forgen pkg root 의 assets/claude/workflows 디렉토리. invoke-agent 와 동일 walk-up. */
17
+ export declare function findTemplatesDir(): string;
18
+ /** 템플릿 파일명(.js) 목록. 실패 시 []. */
19
+ export declare function listTemplates(templatesDir: string): string[];
20
+ export interface InstallResult {
21
+ installed: string[];
22
+ targetDir: string;
23
+ }
24
+ /** 템플릿을 targetDir 로 복사 (DI — 테스트 주입용). 기존 파일은 덮어쓴다. */
25
+ export declare function installWorkflows(templatesDir: string, targetDir: string): InstallResult;
26
+ export declare function handleWorkflows(args: string[]): Promise<void>;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * forgen workflows — dynamic-workflow 템플릿 설치/조회 (ADR-009 §3).
3
+ *
4
+ * dynamic workflows 는 플러그인 매니페스트로 번들할 수 없고(research preview,
5
+ * `.claude/workflows/` 에만 저장됨), forgen 은 철학을 인코딩한 canonical 템플릿을
6
+ * `assets/claude/workflows/*.js` 로 동봉한다. 이 명령이 그것을 사용자의
7
+ * `.claude/workflows/` 로 복사한다 → `/<name>` 으로 실행 가능.
8
+ *
9
+ * forgen-verify 에이전트(verify 스테이지용)는 플러그인 `agents` 키로 자동 배포되므로
10
+ * 별도 설치가 필요 없다.
11
+ *
12
+ * forgen workflows install ~/.claude/workflows/ (개인, 모든 프로젝트)
13
+ * forgen workflows install --project <cwd>/.claude/workflows/ (저장소 공유)
14
+ * forgen workflows list 동봉 템플릿 + 설치 여부
15
+ */
16
+ import * as fs from 'node:fs';
17
+ import * as os from 'node:os';
18
+ import * as path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ const isTTY = process.stdout.isTTY;
21
+ const C = {
22
+ reset: isTTY ? '\x1b[0m' : '',
23
+ bold: isTTY ? '\x1b[1m' : '',
24
+ dim: isTTY ? '\x1b[2m' : '',
25
+ green: isTTY ? '\x1b[32m' : '',
26
+ cyan: isTTY ? '\x1b[36m' : '',
27
+ };
28
+ /** forgen pkg root 의 assets/claude/workflows 디렉토리. invoke-agent 와 동일 walk-up. */
29
+ export function findTemplatesDir() {
30
+ let dir = path.dirname(fileURLToPath(import.meta.url));
31
+ for (let depth = 0; depth < 8; depth += 1) {
32
+ const pkgJson = path.join(dir, 'package.json');
33
+ if (fs.existsSync(pkgJson)) {
34
+ try {
35
+ const pkg = JSON.parse(fs.readFileSync(pkgJson, 'utf-8'));
36
+ if (pkg.name === '@wooojin/forgen') {
37
+ const candidate = path.join(dir, 'assets', 'claude', 'workflows');
38
+ if (fs.existsSync(candidate))
39
+ return candidate;
40
+ }
41
+ }
42
+ catch { /* fallthrough */ }
43
+ }
44
+ const parent = path.dirname(dir);
45
+ if (parent === dir)
46
+ break;
47
+ dir = parent;
48
+ }
49
+ throw new Error('forgen workflows: pkg root + assets/claude/workflows/ not found');
50
+ }
51
+ /** 템플릿 파일명(.js) 목록. 실패 시 []. */
52
+ export function listTemplates(templatesDir) {
53
+ try {
54
+ return fs.readdirSync(templatesDir).filter((f) => f.endsWith('.js')).sort();
55
+ }
56
+ catch {
57
+ return [];
58
+ }
59
+ }
60
+ /** 템플릿을 targetDir 로 복사 (DI — 테스트 주입용). 기존 파일은 덮어쓴다. */
61
+ export function installWorkflows(templatesDir, targetDir) {
62
+ fs.mkdirSync(targetDir, { recursive: true });
63
+ const installed = [];
64
+ for (const file of listTemplates(templatesDir)) {
65
+ fs.copyFileSync(path.join(templatesDir, file), path.join(targetDir, file));
66
+ installed.push(file);
67
+ }
68
+ return { installed, targetDir };
69
+ }
70
+ function resolveTargetBase(projectScope) {
71
+ return projectScope ? process.cwd() : os.homedir();
72
+ }
73
+ export async function handleWorkflows(args) {
74
+ const sub = args[0] ?? 'list';
75
+ const projectScope = args.includes('--project');
76
+ let templatesDir;
77
+ try {
78
+ templatesDir = findTemplatesDir();
79
+ }
80
+ catch {
81
+ console.log(`\n ${C.dim}워크플로우 템플릿을 찾을 수 없습니다 (forgen 설치 확인).${C.reset}\n`);
82
+ process.exitCode = 1;
83
+ return;
84
+ }
85
+ if (sub === 'install') {
86
+ const targetDir = path.join(resolveTargetBase(projectScope), '.claude', 'workflows');
87
+ const { installed } = installWorkflows(templatesDir, targetDir);
88
+ if (installed.length === 0) {
89
+ console.log(`\n ${C.dim}동봉된 템플릿이 없습니다.${C.reset}\n`);
90
+ return;
91
+ }
92
+ console.log(`\n ${C.green}✓${C.reset} ${installed.length} workflow templates → ${C.cyan}${targetDir}${C.reset}`);
93
+ for (const f of installed)
94
+ console.log(` /${f.replace(/\.js$/, '')}`);
95
+ console.log(`\n ${C.dim}Claude Code 에서 /<name> 으로 실행. verify 스테이지는 forgen-verify 에이전트 사용.${C.reset}\n`);
96
+ return;
97
+ }
98
+ if (sub === 'list') {
99
+ const templates = listTemplates(templatesDir);
100
+ const homeDir = path.join(os.homedir(), '.claude', 'workflows');
101
+ const projDir = path.join(process.cwd(), '.claude', 'workflows');
102
+ console.log(`\n ${C.bold}forgen workflow templates${C.reset}\n`);
103
+ for (const f of templates) {
104
+ const name = f.replace(/\.js$/, '');
105
+ const inHome = fs.existsSync(path.join(homeDir, f));
106
+ const inProj = fs.existsSync(path.join(projDir, f));
107
+ const where = [inHome && 'user', inProj && 'project'].filter(Boolean).join('+') || 'not installed';
108
+ console.log(` ${inHome || inProj ? C.green + '✓' + C.reset : C.dim + '·' + C.reset} /${name} ${C.dim}(${where})${C.reset}`);
109
+ }
110
+ console.log(`\n ${C.dim}install: forgen workflows install [--project]${C.reset}\n`);
111
+ return;
112
+ }
113
+ console.log(`
114
+ ${C.bold}forgen workflows${C.reset} — dynamic-workflow 템플릿 (ADR-009 §3)
115
+
116
+ Usage:
117
+ forgen workflows install [--project] 템플릿을 .claude/workflows/ 로 복사
118
+ forgen workflows list 동봉 템플릿 + 설치 여부
119
+ `);
120
+ }
@@ -50,6 +50,18 @@ export declare function exportKnowledge(outputPath?: string): ExportResult;
50
50
  * added.
51
51
  */
52
52
  export declare function importKnowledge(archivePath: string): ImportResult;
53
+ /**
54
+ * Selective export — filter to specific categories.
55
+ * `--only solutions,rules` exports only those two directories.
56
+ */
57
+ export declare function exportKnowledgeSelective(categories: string[], outputPath?: string): ExportResult;
58
+ /**
59
+ * Import with merge mode — updates existing files instead of skipping them.
60
+ * Newer files in the archive overwrite older local files.
61
+ */
62
+ export declare function importKnowledgeMerge(archivePath: string): ImportResult & {
63
+ merged: number;
64
+ };
53
65
  /** CLI handler: forgen compound export */
54
66
  export declare function handleExport(args: string[]): Promise<void>;
55
67
  /** CLI handler: forgen compound import */
@@ -152,14 +152,116 @@ export function importKnowledge(archivePath) {
152
152
  fs.rmSync(tmpDir, { recursive: true, force: true });
153
153
  }
154
154
  }
155
+ /**
156
+ * Selective export — filter to specific categories.
157
+ * `--only solutions,rules` exports only those two directories.
158
+ */
159
+ export function exportKnowledgeSelective(categories, outputPath) {
160
+ const date = new Date().toISOString().split('T')[0];
161
+ const resolved = outputPath ?? path.join(process.cwd(), `forgen-knowledge-${date}.tar.gz`);
162
+ const validCategories = categories.filter(c => KNOWLEDGE_DIRS.includes(c));
163
+ if (validCategories.length === 0) {
164
+ throw new Error(`No valid categories. Choose from: ${KNOWLEDGE_DIRS.join(', ')}`);
165
+ }
166
+ const counts = {};
167
+ const existingDirs = [];
168
+ for (const name of validCategories) {
169
+ const dir = path.join(ME_DIR, name);
170
+ const count = countFiles(dir);
171
+ counts[name] = count;
172
+ if (fs.existsSync(dir)) {
173
+ existingDirs.push(name);
174
+ }
175
+ }
176
+ const totalFiles = Object.values(counts).reduce((a, b) => a + b, 0);
177
+ if (existingDirs.length === 0) {
178
+ throw new Error('No knowledge directories found to export.');
179
+ }
180
+ const outDir = path.dirname(resolved);
181
+ fs.mkdirSync(outDir, { recursive: true });
182
+ execFileSync('tar', ['czf', resolved, ...existingDirs], {
183
+ cwd: ME_DIR,
184
+ timeout: 30000,
185
+ stdio: ['pipe', 'pipe', 'pipe'],
186
+ });
187
+ return { outputPath: resolved, counts, totalFiles };
188
+ }
189
+ /**
190
+ * Import with merge mode — updates existing files instead of skipping them.
191
+ * Newer files in the archive overwrite older local files.
192
+ */
193
+ export function importKnowledgeMerge(archivePath) {
194
+ if (!fs.existsSync(archivePath)) {
195
+ throw new Error(`Archive not found: ${archivePath}`);
196
+ }
197
+ const listOutput = execFileSync('tar', ['tzf', archivePath], {
198
+ timeout: 30000,
199
+ encoding: 'utf-8',
200
+ stdio: ['pipe', 'pipe', 'pipe'],
201
+ });
202
+ const archiveFiles = listOutput
203
+ .split('\n')
204
+ .map(f => f.trim())
205
+ .filter(f => f && !f.endsWith('/'));
206
+ const tmpDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'forgen-merge-'));
207
+ try {
208
+ execFileSync('tar', ['xzf', archivePath, '-C', tmpDir], {
209
+ timeout: 30000,
210
+ stdio: ['pipe', 'pipe', 'pipe'],
211
+ });
212
+ const result = { imported: 0, skipped: 0, merged: 0, details: [] };
213
+ const meDirCanon = path.resolve(ME_DIR) + path.sep;
214
+ const tmpDirCanon = path.resolve(tmpDir) + path.sep;
215
+ for (const relFile of archiveFiles) {
216
+ const srcPath = path.resolve(path.join(tmpDir, relFile));
217
+ const destPath = path.resolve(path.join(ME_DIR, relFile));
218
+ if (!isPathInside(meDirCanon, destPath) || !isPathInside(tmpDirCanon, srcPath)) {
219
+ result.skipped++;
220
+ result.details.push({ file: relFile, action: 'skipped' });
221
+ continue;
222
+ }
223
+ if (fs.existsSync(destPath)) {
224
+ const srcMtime = fs.statSync(srcPath).mtimeMs;
225
+ const destMtime = fs.statSync(destPath).mtimeMs;
226
+ if (srcMtime > destMtime) {
227
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
228
+ fs.copyFileSync(srcPath, destPath);
229
+ result.merged++;
230
+ result.details.push({ file: relFile, action: 'imported' });
231
+ }
232
+ else {
233
+ result.skipped++;
234
+ result.details.push({ file: relFile, action: 'skipped' });
235
+ }
236
+ }
237
+ else {
238
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
239
+ fs.copyFileSync(srcPath, destPath);
240
+ result.imported++;
241
+ result.details.push({ file: relFile, action: 'imported' });
242
+ }
243
+ }
244
+ return result;
245
+ }
246
+ finally {
247
+ fs.rmSync(tmpDir, { recursive: true, force: true });
248
+ }
249
+ }
155
250
  /** CLI handler: forgen compound export */
156
251
  export async function handleExport(args) {
157
252
  const outputIdx = args.indexOf('--output');
158
253
  const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : undefined;
254
+ const onlyIdx = args.indexOf('--only');
255
+ const onlyCategories = onlyIdx !== -1 ? (args[onlyIdx + 1] ?? '').split(',').filter(Boolean) : [];
159
256
  try {
160
- const result = exportKnowledge(outputPath);
257
+ const result = onlyCategories.length > 0
258
+ ? exportKnowledgeSelective(onlyCategories, outputPath)
259
+ : exportKnowledge(outputPath);
161
260
  console.log('\n Compound Knowledge Export\n');
162
261
  console.log(` Output: ${result.outputPath}`);
262
+ if (onlyCategories.length > 0) {
263
+ console.log(` Filter: ${onlyCategories.join(', ')}`);
264
+ }
163
265
  console.log();
164
266
  for (const [category, count] of Object.entries(result.counts)) {
165
267
  console.log(` ${category}: ${count} files`);
@@ -173,23 +275,43 @@ export async function handleExport(args) {
173
275
  }
174
276
  /** CLI handler: forgen compound import */
175
277
  export async function handleImport(args) {
176
- const archivePath = args[0];
177
- if (!archivePath || archivePath.startsWith('--')) {
178
- console.log(' Usage: forgen compound import <path-to-archive>\n');
278
+ const filteredArgs = args.filter(a => !a.startsWith('--'));
279
+ const archivePath = filteredArgs[0];
280
+ const mergeMode = args.includes('--merge');
281
+ if (!archivePath) {
282
+ console.log(' Usage: forgen compound import <path-to-archive> [--merge]\n');
283
+ console.log(' --merge Update existing files if archive version is newer');
179
284
  return;
180
285
  }
181
286
  try {
182
287
  const resolved = path.resolve(archivePath);
183
- const result = importKnowledge(resolved);
184
- console.log('\n Compound Knowledge Import\n');
185
- console.log(` Archive: ${resolved}`);
186
- console.log(` Imported: ${result.imported} new files`);
187
- console.log(` Skipped: ${result.skipped} existing files`);
188
- if (result.details.length > 0 && result.details.length <= 20) {
189
- console.log();
190
- for (const d of result.details) {
191
- const icon = d.action === 'imported' ? '+' : '-';
192
- console.log(` ${icon} ${d.file}`);
288
+ if (mergeMode) {
289
+ const result = importKnowledgeMerge(resolved);
290
+ console.log('\n Compound Knowledge Import (merge mode)\n');
291
+ console.log(` Archive: ${resolved}`);
292
+ console.log(` New: ${result.imported} files`);
293
+ console.log(` Merged: ${result.merged} files (newer version overwrote local)`);
294
+ console.log(` Skipped: ${result.skipped} files (local is same or newer)`);
295
+ if (result.details.length > 0 && result.details.length <= 20) {
296
+ console.log();
297
+ for (const d of result.details) {
298
+ const icon = d.action === 'imported' ? '+' : '-';
299
+ console.log(` ${icon} ${d.file}`);
300
+ }
301
+ }
302
+ }
303
+ else {
304
+ const result = importKnowledge(resolved);
305
+ console.log('\n Compound Knowledge Import\n');
306
+ console.log(` Archive: ${resolved}`);
307
+ console.log(` Imported: ${result.imported} new files`);
308
+ console.log(` Skipped: ${result.skipped} existing files`);
309
+ if (result.details.length > 0 && result.details.length <= 20) {
310
+ console.log();
311
+ for (const d of result.details) {
312
+ const icon = d.action === 'imported' ? '+' : '-';
313
+ console.log(` ${icon} ${d.file}`);
314
+ }
193
315
  }
194
316
  }
195
317
  console.log();
@@ -1,54 +1,24 @@
1
1
  /**
2
- * Forgen — Compound Knowledge Extractor
2
+ * Forgen — Compound Knowledge Extractor (facade)
3
3
  *
4
- * Extracts reusable patterns and decisions from git history and session context.
5
- * Runs quality gates (structure, toxicity, trivial, dedup) before persisting solutions.
4
+ * Orchestrates extraction pipeline: git analysis → quality gates →
5
+ * pattern extraction → persistence. Re-exports from decomposed modules.
6
6
  *
7
- * Module Structure:
8
- * - Lines 1-50: Imports, constants, SHA validation, LastExtraction/ExtractedSolution interfaces
9
- * - Lines 50-115: Git helpers — getNewCommits, getCommitMessages, getGitDiff, getDiffStats
10
- * - Lines 115-190: Quality Gates — gate0 (worth extracting), gate1 (structure), gate2 (toxicity),
11
- * gateTrivial (trivial rejection), gate3 (dedup)
12
- * - Lines 190-275: extractFromDiff — pattern extraction from git diff (modules, errors, imports, commits)
13
- * - Lines 275-395: extractFromSessionContext — prompt/write history analysis (actions, hotspots, tech)
14
- * - Lines 396-475: saveExtractedSolution, updateReExtractedCounter — solution persistence
15
- * - Lines 477-555: runExtraction — main entry point orchestrating gates + extraction + state
16
- * - Lines 557-634: processExtractionResults, isExtractionPaused, pauseExtraction, resumeExtraction
7
+ * Module layout (post-decomposition):
8
+ * extraction-git.ts — getNewCommits, getCommitMessages, getGitDiff, getDiffStats
9
+ * extraction-gates.ts — gate0-gate3, gateTrivial, evaluateExtractedSolution, ExtractedSolution
10
+ * extraction-diff.ts — extractFromDiff, findCommonPrefix
11
+ * extraction-session.ts — extractFromSessionContext, loadClaudeProjectSessionContext
12
+ * extraction-persistence.ts — saveExtractedSolution, updateReExtractedCounter, LastExtraction
17
13
  */
18
- import type { SolutionType } from './solution-format.js';
19
- interface ExtractedSolution {
20
- name: string;
21
- type: SolutionType;
22
- tags: string[];
23
- identifiers: string[];
24
- context: string;
25
- content: string;
26
- }
27
- interface WriteContextEntry {
28
- filePath: string;
29
- contentSnippet: string;
30
- fileExtension: string;
31
- }
32
- /**
33
- * Load Claude session prompts + writes correlated to `cwd`.
34
- *
35
- * Exported primarily for test assertions (the `claude-session-context`
36
- * tests need to verify that correlation picks the right project's
37
- * sessions and ignores unrelated ones). Before C4 the tests could
38
- * observe this indirectly via the now-removed `recurring-task-pattern`
39
- * extractor; now they check this loader directly. Not intended for
40
- * production callers outside the extractor pipeline.
41
- */
42
- export declare function loadClaudeProjectSessionContext(cwd: string, lastExtractedAt: string): {
43
- prompts: string[];
44
- writes: WriteContextEntry[];
45
- };
14
+ import type { ExtractedSolution } from './extraction-gates.js';
15
+ export { loadClaudeProjectSessionContext } from './extraction-session.js';
16
+ export type { ExtractedSolution } from './extraction-gates.js';
46
17
  export declare function previewExtraction(cwd: string): Promise<{
47
18
  preview: ExtractedSolution[];
48
19
  skipped: string[];
49
20
  reason?: string;
50
21
  }>;
51
- /** Main extraction function — called from SessionStart or CLI */
52
22
  export declare function runExtraction(cwd: string, sessionId: string): Promise<{
53
23
  extracted: string[];
54
24
  skipped: string[];
@@ -65,4 +35,3 @@ export declare function isExtractionPaused(): boolean;
65
35
  export declare function pauseExtraction(): void;
66
36
  /** Resume auto-extraction */
67
37
  export declare function resumeExtraction(): void;
68
- export {};