@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.
- package/CHANGELOG.md +28 -0
- package/README.ja.md +79 -14
- package/README.ko.md +89 -14
- package/README.md +77 -14
- package/README.zh.md +79 -14
- package/commands/deep-interview.md +171 -0
- package/commands/specify.md +128 -0
- package/dist/cli.js +11 -2
- package/dist/core/auto-compound-runner.js +34 -1
- package/dist/core/dashboard.d.ts +91 -0
- package/dist/core/dashboard.js +385 -0
- package/dist/core/doctor.js +157 -1
- package/dist/core/drift-score.d.ts +49 -0
- package/dist/core/drift-score.js +87 -0
- package/dist/core/inspect-cli.js +54 -1
- package/dist/core/mcp-config.d.ts +2 -0
- package/dist/core/mcp-config.js +6 -1
- package/dist/core/paths.d.ts +1 -1
- package/dist/core/paths.js +1 -1
- package/dist/core/spawn.d.ts +7 -2
- package/dist/core/spawn.js +45 -7
- package/dist/core/v1-bootstrap.js +9 -2
- package/dist/engine/compound-export.d.ts +41 -0
- package/dist/engine/compound-export.js +169 -0
- package/dist/engine/compound-extractor.js +49 -0
- package/dist/engine/compound-loop.js +18 -0
- package/dist/engine/solution-matcher.d.ts +23 -0
- package/dist/engine/solution-matcher.js +124 -11
- package/dist/forge/mismatch-detector.js +3 -0
- package/dist/hooks/context-guard.d.ts +10 -0
- package/dist/hooks/context-guard.js +105 -49
- package/dist/hooks/db-guard.js +2 -2
- package/dist/hooks/hook-config.d.ts +27 -1
- package/dist/hooks/hook-config.js +72 -12
- package/dist/hooks/intent-classifier.js +29 -4
- package/dist/hooks/keyword-detector.js +114 -106
- package/dist/hooks/notepad-injector.js +2 -2
- package/dist/hooks/permission-handler.js +2 -2
- package/dist/hooks/post-tool-failure.js +12 -6
- package/dist/hooks/post-tool-handlers.d.ts +1 -1
- package/dist/hooks/post-tool-handlers.js +14 -11
- package/dist/hooks/post-tool-use.d.ts +11 -0
- package/dist/hooks/post-tool-use.js +184 -71
- package/dist/hooks/pre-compact.d.ts +11 -1
- package/dist/hooks/pre-compact.js +113 -3
- package/dist/hooks/pre-tool-use.js +86 -56
- package/dist/hooks/rate-limiter.js +3 -3
- package/dist/hooks/secret-filter.js +2 -2
- package/dist/hooks/session-recovery.js +256 -236
- package/dist/hooks/shared/hook-response.d.ts +7 -0
- package/dist/hooks/shared/hook-response.js +20 -0
- package/dist/hooks/shared/hook-timing.d.ts +15 -0
- package/dist/hooks/shared/hook-timing.js +64 -0
- package/dist/hooks/skill-injector.js +41 -12
- package/dist/hooks/slop-detector.js +3 -3
- package/dist/hooks/solution-injector.js +224 -197
- package/dist/hooks/subagent-tracker.js +2 -2
- package/dist/mcp/tools.js +114 -0
- package/dist/renderer/rule-renderer.js +9 -11
- package/dist/store/evidence-store.d.ts +8 -0
- package/dist/store/evidence-store.js +51 -0
- package/dist/store/rule-store.d.ts +5 -0
- package/dist/store/rule-store.js +22 -0
- package/package.json +1 -1
- package/skills/deep-interview/SKILL.md +166 -0
- 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
|
+
}
|
package/dist/core/inspect-cli.js
CHANGED
|
@@ -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') {
|
package/dist/core/mcp-config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/core/paths.d.ts
CHANGED
|
@@ -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 — 팀 팩 연결 파일 */
|
package/dist/core/paths.js
CHANGED
|
@@ -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) {
|
package/dist/core/spawn.d.ts
CHANGED
|
@@ -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<
|
|
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>;
|
package/dist/core/spawn.js
CHANGED
|
@@ -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
|
-
|
|
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;
|