@wooojin/forgen 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import * as fs from 'node:fs';
8
8
  import * as path from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { prepareHarness, isFirstRun } from './core/harness.js';
11
- import { spawnClaude } from './core/spawn.js';
11
+ import { spawnClaudeWithResume } from './core/spawn.js';
12
12
  // global-config is used by harness internally
13
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
14
  const pkgJson = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8'));
@@ -245,7 +245,7 @@ async function main() {
245
245
  ${dim}Scope: v1(${context.v1.session?.quality_pack ?? 'onboarding needed'})${reset}
246
246
  `);
247
247
  console.log('[forgen] Starting Claude Code...\n');
248
- await spawnClaude(args, context);
248
+ await spawnClaudeWithResume(args, context, () => prepareHarness(process.cwd()));
249
249
  }
250
250
  catch (err) {
251
251
  const msg = err instanceof Error ? err.message : String(err);
@@ -14,6 +14,8 @@ import * as path from 'node:path';
14
14
  import * as os from 'node:os';
15
15
  import { execFileSync } from 'node:child_process';
16
16
  import { containsPromptInjection, filterSolutionContent } from '../hooks/prompt-injection-filter.js';
17
+ import { createEvidence, saveEvidence, promoteSessionCandidates } from '../store/evidence-store.js';
18
+ import { loadProfile } from '../store/profile-store.js';
17
19
  /** Auto-compound에 사용할 모델 — background 추출이므로 haiku로 충분 */
18
20
  const COMPOUND_MODEL = 'haiku';
19
21
  /** execFileSync wrapper: transient 에러(ETIMEDOUT 등) 시 1회 재시도 */
@@ -349,6 +351,20 @@ ${sanitizedSummary.slice(0, 4000)}
349
351
  fs.writeFileSync(behaviorPath, content);
350
352
  }
351
353
  }
354
+ // behavior_observation evidence 저장 (mismatch detector 신호 확대)
355
+ const behaviorEvidence = createEvidence({
356
+ type: 'behavior_observation',
357
+ session_id: sessionId,
358
+ source_component: 'auto-compound-runner',
359
+ summary: trimmed.slice(0, 200),
360
+ axis_refs: kind === 'workflow' ? ['judgment_philosophy']
361
+ : kind === 'preference' ? ['communication_style']
362
+ : kind === 'thinking' ? ['judgment_philosophy']
363
+ : [],
364
+ confidence: 0.6,
365
+ raw_payload: { kind, observedCount: 1 },
366
+ });
367
+ saveEvidence(behaviorEvidence);
352
368
  }
353
369
  }
354
370
  catch (e) {
@@ -361,8 +377,15 @@ ${sanitizedSummary.slice(0, 4000)}
361
377
  const V1_PROFILE = path.join(V1_ME_DIR, 'forge-profile.json');
362
378
  const V1_EVIDENCE_DIR = path.join(V1_ME_DIR, 'behavior');
363
379
  if (fs.existsSync(V1_PROFILE)) {
380
+ const currentProfile = loadProfile();
381
+ let profileContext = '';
382
+ if (currentProfile) {
383
+ const qf = currentProfile.axes.quality_safety.facets;
384
+ const af = currentProfile.axes.autonomy.facets;
385
+ profileContext = `\n현재 프로필:\n- 팩: quality=${currentProfile.base_packs.quality_pack}, autonomy=${currentProfile.base_packs.autonomy_pack}\n- quality_safety facets: verification_depth=${qf.verification_depth.toFixed(2)}, stop_threshold=${qf.stop_threshold.toFixed(2)}, change_conservatism=${qf.change_conservatism.toFixed(2)}\n- autonomy facets: confirmation_independence=${af.confirmation_independence.toFixed(2)}, assumption_tolerance=${af.assumption_tolerance.toFixed(2)}, scope_expansion_tolerance=${af.scope_expansion_tolerance.toFixed(2)}, approval_threshold=${af.approval_threshold.toFixed(2)}\n`;
386
+ }
364
387
  const learningSummaryPrompt = `다음 Claude Code 세션 대화를 분석하여 사용자의 개인화 학습 요약을 JSON으로 출력해주세요.
365
-
388
+ ${profileContext}
366
389
  출력 형식 (JSON만, 설명 없이):
367
390
  {
368
391
  "corrections": ["사용자가 명시적으로 교정한 내용 목록"],
@@ -450,6 +473,16 @@ ${sanitizedSummary.slice(0, 4000)}
450
473
  catch (e) {
451
474
  process.stderr.write(`[forgen-auto-compound] session learning: ${e instanceof Error ? e.message : String(e)}\n`);
452
475
  }
476
+ // Step 4: prefer-from-now / avoid-this 교정 → scope:'me' 영구 규칙 승격
477
+ try {
478
+ const promotedCount = promoteSessionCandidates(sessionId);
479
+ if (promotedCount > 0) {
480
+ process.stderr.write(`[forgen-auto-compound] promoted ${promotedCount} correction(s) to permanent rules\n`);
481
+ }
482
+ }
483
+ catch (e) {
484
+ process.stderr.write(`[forgen-auto-compound] rule promotion: ${e instanceof Error ? e.message : String(e)}\n`);
485
+ }
453
486
  // 완료 기록
454
487
  const statePath = path.join(FORGEN_HOME, 'state', 'last-auto-compound.json');
455
488
  fs.mkdirSync(path.dirname(statePath), { recursive: true });
@@ -2,7 +2,7 @@ import * as fs from 'node:fs';
2
2
  import * as os from 'node:os';
3
3
  import * as path from 'node:path';
4
4
  import { execFileSync } from 'node:child_process';
5
- import { FORGEN_HOME, LAB_DIR, ME_BEHAVIOR, ME_DIR, ME_PHILOSOPHY, ME_SOLUTIONS, ME_RULES, PACKS_DIR, SESSIONS_DIR } from './paths.js';
5
+ import { FORGEN_HOME, LAB_DIR, ME_BEHAVIOR, ME_DIR, ME_PHILOSOPHY, ME_SOLUTIONS, ME_RULES, PACKS_DIR, SESSIONS_DIR, STATE_DIR } from './paths.js';
6
6
  /** ~/.claude/projects/ — Claude Code 세션 저장 경로 */
7
7
  const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
8
8
  function check(label, condition, hint) {
@@ -149,6 +149,32 @@ export async function runDoctor() {
149
149
  console.log();
150
150
  }
151
151
  }
152
+ // 훅 에러 카운트
153
+ console.log(' [Hook Health]');
154
+ const hookErrorPath = path.join(STATE_DIR, 'hook-errors.json');
155
+ if (fs.existsSync(hookErrorPath)) {
156
+ try {
157
+ const errors = JSON.parse(fs.readFileSync(hookErrorPath, 'utf-8'));
158
+ const entries = Object.entries(errors);
159
+ if (entries.length === 0) {
160
+ console.log(' No hook errors recorded');
161
+ }
162
+ else {
163
+ for (const [hookName, { count, lastAt }] of entries) {
164
+ const icon = count === 0 ? '✓' : '⚠';
165
+ const lastDate = lastAt ? lastAt.split('T')[0] : 'unknown';
166
+ console.log(` ${icon} ${hookName}: ${count} error${count !== 1 ? 's' : ''}${count > 0 ? ` (last: ${lastDate})` : ''}`);
167
+ }
168
+ }
169
+ }
170
+ catch {
171
+ console.log(' (hook-errors.json read failed)');
172
+ }
173
+ }
174
+ else {
175
+ console.log(' No hook errors recorded');
176
+ }
177
+ console.log();
152
178
  // 현재 디렉토리 git 정보
153
179
  console.log(' [Git]');
154
180
  try {
@@ -1,4 +1,5 @@
1
1
  import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
2
3
  import { GLOBAL_CONFIG, V1_GLOBAL_CONFIG } from './paths.js';
3
4
  /** v1 config 로드 (~/.forgen/config.json 우선, 레거시 폴백) */
4
5
  export function loadGlobalConfig() {
@@ -20,6 +21,6 @@ export function loadGlobalConfig() {
20
21
  }
21
22
  /** v1 config 저장 (~/.forgen/config.json) */
22
23
  export function saveGlobalConfig(config) {
23
- fs.mkdirSync(require('node:path').dirname(V1_GLOBAL_CONFIG), { recursive: true });
24
+ fs.mkdirSync(path.dirname(V1_GLOBAL_CONFIG), { recursive: true });
24
25
  fs.writeFileSync(V1_GLOBAL_CONFIG, JSON.stringify(config, null, 2));
25
26
  }
@@ -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') {
@@ -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
+ }
@@ -13,12 +13,13 @@
13
13
  * 5. Rule Renderer가 자연어 규칙 세트 생성
14
14
  */
15
15
  import * as fs from 'node:fs';
16
+ import * as path from 'node:path';
16
17
  import * as crypto from 'node:crypto';
17
18
  import { FORGEN_HOME, V1_ME_DIR, V1_RULES_DIR, V1_EVIDENCE_DIR, V1_RECOMMENDATIONS_DIR, V1_SESSIONS_DIR, V1_STATE_DIR, V1_RAW_LOGS_DIR, V1_SOLUTIONS_DIR } from './paths.js';
18
19
  import { checkLegacyProfile, runLegacyCutover } from './legacy-detector.js';
19
20
  import { detectRuntimeCapability } from './runtime-detector.js';
20
21
  import { loadProfile, profileExists } from '../store/profile-store.js';
21
- import { loadActiveRules } from '../store/rule-store.js';
22
+ import { loadActiveRules, cleanupStaleSessionRules } from '../store/rule-store.js';
22
23
  import { composeSession } from '../preset/preset-manager.js';
23
24
  import { renderRules, DEFAULT_CONTEXT } from '../renderer/rule-renderer.js';
24
25
  import { saveSessionState, loadRecentSessions } from '../store/session-state-store.js';
@@ -60,8 +61,15 @@ export function bootstrapV1Session() {
60
61
  // 3. Runtime capability 감지
61
62
  const runtime = detectRuntimeCapability();
62
63
  // 4. Rules 로드 + Session 합성
63
- const personalRules = loadActiveRules();
64
64
  const sessionId = crypto.randomUUID();
65
+ // 이전 세션의 scope:'session' 임시 규칙 정리
66
+ try {
67
+ cleanupStaleSessionRules(sessionId);
68
+ }
69
+ catch {
70
+ // 정리 실패는 세션 시작을 막지 않음
71
+ }
72
+ const personalRules = loadActiveRules();
65
73
  const session = composeSession({
66
74
  session_id: sessionId,
67
75
  profile,
@@ -116,7 +124,7 @@ export function bootstrapV1Session() {
116
124
  // 8. Raw Log 기록 + TTL sweep (7일)
117
125
  try {
118
126
  // 세션 시작 로그
119
- const rawLogPath = require('node:path').join(V1_RAW_LOGS_DIR, `${sessionId}.jsonl`);
127
+ const rawLogPath = path.join(V1_RAW_LOGS_DIR, `${sessionId}.jsonl`);
120
128
  fs.appendFileSync(rawLogPath, JSON.stringify({
121
129
  event: 'session-started',
122
130
  session_id: sessionId,
@@ -131,7 +139,7 @@ export function bootstrapV1Session() {
131
139
  const TTL_MS = 7 * 24 * 60 * 60 * 1000;
132
140
  const now = Date.now();
133
141
  for (const file of fs.readdirSync(V1_RAW_LOGS_DIR)) {
134
- const filePath = require('node:path').join(V1_RAW_LOGS_DIR, file);
142
+ const filePath = path.join(V1_RAW_LOGS_DIR, file);
135
143
  try {
136
144
  const stat = fs.statSync(filePath);
137
145
  if (now - stat.mtimeMs > TTL_MS) {
@@ -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;
@@ -34,6 +34,9 @@ export function computeSessionSignals(sessionId, corrections, summaries, newStro
34
34
  if (direction === 'opposite') {
35
35
  signals.push({ session_id: sessionId, axis, score: 2, reason: `반대 방향 correction: ${c.summary}` });
36
36
  }
37
+ if (direction === 'same' && (axis === 'quality_safety' || axis === 'autonomy')) {
38
+ signals.push({ session_id: sessionId, axis, score: 1, reason: `교정 누적: ${c.summary}` });
39
+ }
37
40
  }
38
41
  }
39
42
  }
@@ -71,6 +71,16 @@ export async function main() {
71
71
  const errorMsg = input.error;
72
72
  if (/context.*limit|token.*limit|conversation.*too.*long/i.test(errorMsg)) {
73
73
  saveHandoff(sessionId, 'context-limit', errorMsg);
74
+ try {
75
+ const resumePath = path.join(STATE_DIR, 'pending-resume.json');
76
+ fs.writeFileSync(resumePath, JSON.stringify({
77
+ reason: 'token-limit',
78
+ sessionId,
79
+ savedAt: new Date().toISOString(),
80
+ cwd: process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd(),
81
+ }, null, 2));
82
+ }
83
+ catch { /* fail-open */ }
74
84
  console.log(approveWithWarning(`[Forgen] Context limit reached. Current state has been saved to ~/.forgen/handoffs/.\nThe previous work will be automatically recovered in the next session.`));
75
85
  return;
76
86
  }
@@ -28,14 +28,6 @@ export interface HookEntry {
28
28
  /** compound 피드백 루프에 필수인 훅인지 */
29
29
  compoundCritical: boolean;
30
30
  }
31
- /**
32
- * 단일 소스 오브 트루스: hooks/hook-registry.json
33
- *
34
- * 순서가 중요함:
35
- * - pre-tool-use는 db-guard/rate-limiter보다 앞에 위치
36
- * (Code Reflection + permission hints 주입 타이밍)
37
- * - 같은 이벤트 내 훅은 배열 순서대로 실행됨
38
- */
39
31
  export declare const HOOK_REGISTRY: HookEntry[];
40
32
  /** 티어별 훅 목록 조회 */
41
33
  export declare function getHooksByTier(tier: HookTier): HookEntry[];
@@ -20,7 +20,8 @@ const require = createRequire(import.meta.url);
20
20
  * (Code Reflection + permission hints 주입 타이밍)
21
21
  * - 같은 이벤트 내 훅은 배열 순서대로 실행됨
22
22
  */
23
- export const HOOK_REGISTRY = require('../../hooks/hook-registry.json');
23
+ import registryData from '../../hooks/hook-registry.json' with { type: 'json' };
24
+ export const HOOK_REGISTRY = registryData;
24
25
  /** 티어별 훅 목록 조회 */
25
26
  export function getHooksByTier(tier) {
26
27
  return HOOK_REGISTRY.filter(h => h.tier === tier);
@@ -10,3 +10,5 @@
10
10
  */
11
11
  export type Intent = 'implement' | 'debug' | 'refactor' | 'explain' | 'review' | 'explore' | 'design' | 'general';
12
12
  export declare function classifyIntent(prompt: string): Intent;
13
+ /** 프롬프트에 매칭되는 모든 의도를 반환. 없으면 ['general']. */
14
+ export declare function classifyAllIntents(prompt: string): Intent[];
@@ -38,6 +38,16 @@ export function classifyIntent(prompt) {
38
38
  }
39
39
  return 'general';
40
40
  }
41
+ /** 프롬프트에 매칭되는 모든 의도를 반환. 없으면 ['general']. */
42
+ export function classifyAllIntents(prompt) {
43
+ const matches = [];
44
+ for (const rule of INTENT_RULES) {
45
+ if (rule.pattern.test(prompt)) {
46
+ matches.push(rule.intent);
47
+ }
48
+ }
49
+ return matches.length > 0 ? matches : ['general'];
50
+ }
41
51
  async function main() {
42
52
  const input = await readStdinJSON();
43
53
  if (!isHookEnabled('intent-classifier')) {
@@ -48,13 +58,14 @@ async function main() {
48
58
  console.log(approve());
49
59
  return;
50
60
  }
51
- const intent = classifyIntent(input.prompt);
52
- if (intent === 'general') {
61
+ const intents = classifyAllIntents(input.prompt);
62
+ if (intents.length === 1 && intents[0] === 'general') {
53
63
  console.log(approve());
54
64
  return;
55
65
  }
56
- const hint = INTENT_HINTS[intent];
57
- console.log(approveWithContext(`[intent: ${intent}] ${hint}`, 'UserPromptSubmit'));
66
+ const label = intents.join('+');
67
+ const hint = INTENT_HINTS[intents[0]];
68
+ console.log(approveWithContext(`[intent: ${label}] ${hint}`, 'UserPromptSubmit'));
58
69
  }
59
70
  main().catch((e) => {
60
71
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
@@ -65,8 +65,9 @@ export const KEYWORD_PATTERNS = [
65
65
  { pattern: /\b(debug[- ]?detective|디버그\s*탐정|체계적\s*디버깅)\b/i, keyword: 'debug-detective', type: 'skill', skill: 'debug-detective' },
66
66
  { pattern: /\b(refactor|리팩토링|리팩터)\s*(?:mode|모드|해|해줘|시작|실행|진행)/i, keyword: 'refactor', type: 'skill', skill: 'refactor' },
67
67
  ];
68
- // ── 인젝션 메시지 ──
69
- const INJECT_MESSAGES = {
68
+ // ── 인젝션 메시지 (폴백 전용) ──
69
+ // commands/*.md 파일이 없을 때만 사용. commands/*.md가 단일 진실 공급원.
70
+ const FALLBACK_INJECT_MESSAGES = {
70
71
  ultrathink: `<compound-think-mode>
71
72
  EXTENDED THINKING MODE ACTIVATED.
72
73
  Before responding, engage in deep, thorough reasoning. Consider multiple approaches,
@@ -226,10 +227,11 @@ export function detectKeyword(prompt) {
226
227
  return { type: 'cancel', keyword: entry.keyword, message: '[Forgen] Mode cancelled.' };
227
228
  }
228
229
  if (entry.type === 'inject') {
230
+ const fileContent = loadSkillContent(entry.keyword);
229
231
  return {
230
232
  type: 'inject',
231
233
  keyword: entry.keyword,
232
- message: INJECT_MESSAGES[entry.keyword] ?? '',
234
+ message: fileContent ?? FALLBACK_INJECT_MESSAGES[entry.keyword] ?? '',
233
235
  };
234
236
  }
235
237
  return {
@@ -68,6 +68,35 @@ function saveCompactionSnapshot(sessionId) {
68
68
  fs.writeFileSync(snapshotPath, lines.join('\n'));
69
69
  return snapshotPath;
70
70
  }
71
+ /** context-guard.json에서 현재 promptCount 읽기 */
72
+ function readPromptCount() {
73
+ try {
74
+ const guardPath = path.join(STATE_DIR, 'context-guard.json');
75
+ if (fs.existsSync(guardPath)) {
76
+ const data = JSON.parse(fs.readFileSync(guardPath, 'utf-8'));
77
+ return typeof data.promptCount === 'number' ? data.promptCount : 0;
78
+ }
79
+ }
80
+ catch { /* fail-open */ }
81
+ return 0;
82
+ }
83
+ /**
84
+ * 백그라운드 compound 추출 트리거 (non-blocking).
85
+ * compound extract 서브커맨드가 없으므로 pending-compound.json 마커를 씀.
86
+ * session-recovery가 다음 세션 시작 시 이 마커를 읽고 추출을 트리거함.
87
+ */
88
+ function triggerBackgroundExtraction(promptCount) {
89
+ try {
90
+ const pendingPath = path.join(STATE_DIR, 'pending-compound.json');
91
+ fs.mkdirSync(STATE_DIR, { recursive: true });
92
+ fs.writeFileSync(pendingPath, JSON.stringify({
93
+ reason: 'pre-compact',
94
+ promptCount,
95
+ detectedAt: new Date().toISOString(),
96
+ }, null, 2));
97
+ }
98
+ catch { /* fail-open */ }
99
+ }
71
100
  /** 7일 이상 된 handoff 파일 정리 */
72
101
  function cleanOldHandoffs() {
73
102
  if (!fs.existsSync(HANDOFFS_DIR))
@@ -146,6 +175,12 @@ Rules:
146
175
  - Skip patterns that are trivially obvious ("uses TypeScript")
147
176
  - Each pattern must be specific enough to change Claude's behavior in future sessions${existingList}
148
177
  </forgen-compound-extract>`;
178
+ // promptCount >= 20이면 백그라운드 추출 마커 기록
179
+ const promptCount = readPromptCount();
180
+ if (promptCount >= 20) {
181
+ triggerBackgroundExtraction(promptCount);
182
+ log.debug(`Pre-compact: promptCount=${promptCount} >= 20, pending-compound.json 기록`);
183
+ }
149
184
  // 스냅샷 저장
150
185
  try {
151
186
  const snapshotPath = saveCompactionSnapshot(sessionId);
@@ -31,3 +31,10 @@ export declare function deny(reason: string): string;
31
31
  export declare function ask(reason: string): string;
32
32
  /** fail-open: 에러 시 안전하게 통과 */
33
33
  export declare function failOpen(): string;
34
+ /** 훅별 에러 카운트를 STATE_DIR/hook-errors.json에 누적 */
35
+ export declare function incrementHookErrorCount(hookName: string): void;
36
+ /**
37
+ * fail-open + 에러 카운트 누적.
38
+ * 훅의 main().catch() 블록에서 명시적으로 호출.
39
+ */
40
+ export declare function failOpenWithTracking(hookName: string): string;
@@ -13,6 +13,9 @@
13
13
  * systemMessage 필드는 UI 표시용으로만 사용되며 모델에 전달되지 않음.
14
14
  * 모델에 컨텍스트를 주입하려면 반드시 additionalContext를 사용해야 함.
15
15
  */
16
+ import * as fs from 'node:fs';
17
+ import * as path from 'node:path';
18
+ import { STATE_DIR } from '../../core/paths.js';
16
19
  /** 통과 응답 (컨텍스트 없음, 모든 이벤트 공통) */
17
20
  export function approve() {
18
21
  return JSON.stringify({ continue: true });
@@ -60,3 +63,31 @@ export function ask(reason) {
60
63
  export function failOpen() {
61
64
  return JSON.stringify({ continue: true });
62
65
  }
66
+ /** 훅별 에러 카운트를 STATE_DIR/hook-errors.json에 누적 */
67
+ export function incrementHookErrorCount(hookName) {
68
+ try {
69
+ const errorPath = path.join(STATE_DIR, 'hook-errors.json');
70
+ let errors = {};
71
+ try {
72
+ if (fs.existsSync(errorPath)) {
73
+ errors = JSON.parse(fs.readFileSync(errorPath, 'utf-8'));
74
+ }
75
+ }
76
+ catch { /* start fresh */ }
77
+ if (!errors[hookName])
78
+ errors[hookName] = { count: 0, lastAt: '' };
79
+ errors[hookName].count++;
80
+ errors[hookName].lastAt = new Date().toISOString();
81
+ fs.mkdirSync(STATE_DIR, { recursive: true });
82
+ fs.writeFileSync(errorPath, JSON.stringify(errors, null, 2));
83
+ }
84
+ catch { /* meta-error in error tracking — ignore */ }
85
+ }
86
+ /**
87
+ * fail-open + 에러 카운트 누적.
88
+ * 훅의 main().catch() 블록에서 명시적으로 호출.
89
+ */
90
+ export function failOpenWithTracking(hookName) {
91
+ incrementHookErrorCount(hookName);
92
+ return JSON.stringify({ continue: true });
93
+ }
@@ -4,6 +4,9 @@
4
4
  * 내부 타입은 한글 유지 (QualityPack = '보수형' | ...).
5
5
  * 사용자 대면 출력만 로케일에 따라 전환.
6
6
  */
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
9
+ import * as os from 'node:os';
7
10
  // ── Pack Display Names ──
8
11
  const QUALITY_NAMES = {
9
12
  ko: { '보수형': '보수형', '균형형': '균형형', '속도형': '속도형' },
@@ -212,8 +215,10 @@ export function getLocale() { return _currentLocale; }
212
215
  /** GlobalConfig에서 locale을 읽어 설정. 없으면 'en' 기본값. */
213
216
  export function initLocaleFromConfig() {
214
217
  try {
215
- const { loadGlobalConfig } = require('../core/global-config.js');
216
- const config = loadGlobalConfig();
218
+ const configPath = path.join(os.homedir(), '.forgen', 'config.json');
219
+ if (!fs.existsSync(configPath))
220
+ return;
221
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
217
222
  if (config.locale === 'ko' || config.locale === 'en') {
218
223
  _currentLocale = config.locale;
219
224
  }
package/dist/mcp/tools.js CHANGED
@@ -15,6 +15,8 @@
15
15
  import { z } from 'zod';
16
16
  import { searchSolutions, listSolutions, readSolution, getSolutionStats, defaultSolutionDirs, } from './solution-reader.js';
17
17
  import { processCorrection } from '../forge/evidence-processor.js';
18
+ import { loadProfile } from '../store/profile-store.js';
19
+ import { loadActiveRules } from '../store/rule-store.js';
18
20
  function getCwd() {
19
21
  return process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? undefined;
20
22
  }
@@ -299,4 +301,116 @@ export function registerTools(server) {
299
301
  };
300
302
  }
301
303
  });
304
+ // ── profile-read ──
305
+ server.registerTool('profile-read', {
306
+ description: 'Read current user personalization profile — packs, facet scores, trust policy. Use this to understand how you are configured before suggesting profile changes.',
307
+ annotations: { readOnlyHint: true },
308
+ }, async () => {
309
+ const profile = loadProfile();
310
+ if (!profile) {
311
+ return {
312
+ content: [{
313
+ type: 'text',
314
+ text: 'No profile configured. Run forgen onboarding.',
315
+ }],
316
+ };
317
+ }
318
+ const { base_packs, axes, trust_preferences, metadata } = profile;
319
+ const lines = [
320
+ `# Forgen Profile (user: ${profile.user_id})`,
321
+ '',
322
+ '## Packs',
323
+ ` quality: ${base_packs.quality_pack}`,
324
+ ` autonomy: ${base_packs.autonomy_pack}`,
325
+ ` judgment: ${base_packs.judgment_pack}`,
326
+ ` communication: ${base_packs.communication_pack}`,
327
+ '',
328
+ '## Facets',
329
+ ' quality_safety:',
330
+ ` verification_depth: ${axes.quality_safety.facets.verification_depth.toFixed(2)}`,
331
+ ` stop_threshold: ${axes.quality_safety.facets.stop_threshold.toFixed(2)}`,
332
+ ` change_conservatism: ${axes.quality_safety.facets.change_conservatism.toFixed(2)}`,
333
+ ` (score: ${axes.quality_safety.score.toFixed(2)}, confidence: ${axes.quality_safety.confidence.toFixed(2)})`,
334
+ ' autonomy:',
335
+ ` confirmation_independence: ${axes.autonomy.facets.confirmation_independence.toFixed(2)}`,
336
+ ` assumption_tolerance: ${axes.autonomy.facets.assumption_tolerance.toFixed(2)}`,
337
+ ` scope_expansion_tolerance: ${axes.autonomy.facets.scope_expansion_tolerance.toFixed(2)}`,
338
+ ` approval_threshold: ${axes.autonomy.facets.approval_threshold.toFixed(2)}`,
339
+ ` (score: ${axes.autonomy.score.toFixed(2)}, confidence: ${axes.autonomy.confidence.toFixed(2)})`,
340
+ ' judgment_philosophy:',
341
+ ` minimal_change_bias: ${axes.judgment_philosophy.facets.minimal_change_bias.toFixed(2)}`,
342
+ ` abstraction_bias: ${axes.judgment_philosophy.facets.abstraction_bias.toFixed(2)}`,
343
+ ` evidence_first_bias: ${axes.judgment_philosophy.facets.evidence_first_bias.toFixed(2)}`,
344
+ ` (score: ${axes.judgment_philosophy.score.toFixed(2)}, confidence: ${axes.judgment_philosophy.confidence.toFixed(2)})`,
345
+ ' communication_style:',
346
+ ` verbosity: ${axes.communication_style.facets.verbosity.toFixed(2)}`,
347
+ ` structure: ${axes.communication_style.facets.structure.toFixed(2)}`,
348
+ ` teaching_bias: ${axes.communication_style.facets.teaching_bias.toFixed(2)}`,
349
+ ` (score: ${axes.communication_style.score.toFixed(2)}, confidence: ${axes.communication_style.confidence.toFixed(2)})`,
350
+ '',
351
+ '## Trust Policy',
352
+ ` ${trust_preferences.desired_policy} (source: ${trust_preferences.source})`,
353
+ '',
354
+ '## Metadata',
355
+ ` created: ${metadata.created_at.slice(0, 10)}`,
356
+ ` last_updated: ${metadata.updated_at.slice(0, 10)}`,
357
+ ];
358
+ return {
359
+ content: [{
360
+ type: 'text',
361
+ text: lines.join('\n'),
362
+ }],
363
+ };
364
+ });
365
+ // ── rule-list ──
366
+ server.registerTool('rule-list', {
367
+ description: 'List active personalization rules grouped by category. Shows scope, strength, and source of each rule.',
368
+ inputSchema: {
369
+ category: z.enum(['quality', 'autonomy', 'workflow', 'all']).optional()
370
+ .describe('Filter by rule category (default: all)'),
371
+ },
372
+ annotations: { readOnlyHint: true },
373
+ }, async ({ category }) => {
374
+ const rules = loadActiveRules();
375
+ if (rules.length === 0) {
376
+ return {
377
+ content: [{
378
+ type: 'text',
379
+ text: 'No active rules found.',
380
+ }],
381
+ };
382
+ }
383
+ const filtered = (category && category !== 'all')
384
+ ? rules.filter(r => r.category === category)
385
+ : rules;
386
+ if (filtered.length === 0) {
387
+ return {
388
+ content: [{
389
+ type: 'text',
390
+ text: `No active rules found for category: ${category}.`,
391
+ }],
392
+ };
393
+ }
394
+ // Group by category
395
+ const grouped = new Map();
396
+ for (const rule of filtered) {
397
+ const group = grouped.get(rule.category) ?? [];
398
+ group.push(rule);
399
+ grouped.set(rule.category, group);
400
+ }
401
+ const lines = [`Active rules (${filtered.length} total):`];
402
+ for (const [cat, catRules] of grouped) {
403
+ lines.push('', `## ${cat}`);
404
+ for (const rule of catRules) {
405
+ const refs = rule.evidence_refs.length > 0 ? ` (source: ${rule.evidence_refs.join(', ')})` : '';
406
+ lines.push(` [${rule.scope}] [${rule.strength}] ${rule.policy}${refs}`);
407
+ }
408
+ }
409
+ return {
410
+ content: [{
411
+ type: 'text',
412
+ text: lines.join('\n'),
413
+ }],
414
+ };
415
+ });
302
416
  }
@@ -21,3 +21,11 @@ export declare function loadAllEvidence(): Evidence[];
21
21
  export declare function loadEvidenceBySession(sessionId: string): Evidence[];
22
22
  export declare function loadEvidenceByType(type: EvidenceType): Evidence[];
23
23
  export declare function loadRecentEvidence(limit?: number): Evidence[];
24
+ /** prefer-from-now / avoid-this 교정 evidence를 모두 반환 (규칙 승격 후보) */
25
+ export declare function loadPromotionCandidates(): Evidence[];
26
+ /**
27
+ * 특정 세션의 promotion 후보를 scope:'me' 영구 규칙으로 승격.
28
+ * 동일 render_key를 가진 scope:'me' 규칙이 이미 있으면 건너뜀.
29
+ * @returns 승격된 규칙 수
30
+ */
31
+ export declare function promoteSessionCandidates(sessionId: string): number;
@@ -9,6 +9,7 @@ import * as path from 'node:path';
9
9
  import * as crypto from 'node:crypto';
10
10
  import { V1_EVIDENCE_DIR } from '../core/paths.js';
11
11
  import { atomicWriteJSON, safeReadJSON } from '../hooks/shared/atomic-write.js';
12
+ import { createRule, saveRule, loadActiveRules } from './rule-store.js';
12
13
  function evidencePath(evidenceId) {
13
14
  return path.join(V1_EVIDENCE_DIR, `${evidenceId}.json`);
14
15
  }
@@ -56,3 +57,53 @@ export function loadRecentEvidence(limit = 20) {
56
57
  .sort((a, b) => b.timestamp.localeCompare(a.timestamp))
57
58
  .slice(0, limit);
58
59
  }
60
+ /** prefer-from-now / avoid-this 교정 evidence를 모두 반환 (규칙 승격 후보) */
61
+ export function loadPromotionCandidates() {
62
+ return loadAllEvidence().filter(e => {
63
+ if (e.type !== 'explicit_correction')
64
+ return false;
65
+ const kind = e.raw_payload?.kind;
66
+ return kind === 'prefer-from-now' || kind === 'avoid-this';
67
+ });
68
+ }
69
+ /**
70
+ * 특정 세션의 promotion 후보를 scope:'me' 영구 규칙으로 승격.
71
+ * 동일 render_key를 가진 scope:'me' 규칙이 이미 있으면 건너뜀.
72
+ * @returns 승격된 규칙 수
73
+ */
74
+ export function promoteSessionCandidates(sessionId) {
75
+ const candidates = loadPromotionCandidates().filter(e => e.session_id === sessionId);
76
+ if (candidates.length === 0)
77
+ return 0;
78
+ const activeRules = loadActiveRules();
79
+ const existingRenderKeys = new Set(activeRules.filter(r => r.scope === 'me').map(r => r.render_key));
80
+ let promoted = 0;
81
+ for (const candidate of candidates) {
82
+ const payload = candidate.raw_payload;
83
+ const axisHint = payload?.axis_hint;
84
+ const target = payload?.target;
85
+ const kind = payload?.kind;
86
+ if (!target)
87
+ continue;
88
+ const renderKey = `${axisHint ?? 'workflow'}.${target.toLowerCase().replace(/\s+/g, '-').slice(0, 30)}`;
89
+ if (existingRenderKeys.has(renderKey))
90
+ continue;
91
+ const category = axisHint === 'quality_safety' ? 'quality'
92
+ : axisHint === 'autonomy' ? 'autonomy'
93
+ : 'workflow';
94
+ const rule = createRule({
95
+ category,
96
+ scope: 'me',
97
+ trigger: target,
98
+ policy: candidate.summary,
99
+ strength: kind === 'avoid-this' ? 'strong' : 'default',
100
+ source: 'explicit_correction',
101
+ evidence_refs: [candidate.evidence_id],
102
+ render_key: renderKey,
103
+ });
104
+ saveRule(rule);
105
+ existingRenderKeys.add(renderKey);
106
+ promoted++;
107
+ }
108
+ return promoted;
109
+ }
@@ -20,3 +20,8 @@ export declare function loadRule(ruleId: string): Rule | null;
20
20
  export declare function loadAllRules(): Rule[];
21
21
  export declare function loadActiveRules(): Rule[];
22
22
  export declare function updateRuleStatus(ruleId: string, status: RuleStatus): boolean;
23
+ /**
24
+ * 현재 세션 ID와 다른 scope:'session' 규칙을 비활성화.
25
+ * 이전 세션의 임시 규칙이 새 세션에서 영향을 미치지 않도록 정리.
26
+ */
27
+ export declare function cleanupStaleSessionRules(_currentSessionId: string): number;
@@ -60,3 +60,25 @@ export function updateRuleStatus(ruleId, status) {
60
60
  saveRule(rule);
61
61
  return true;
62
62
  }
63
+ /**
64
+ * 현재 세션 ID와 다른 scope:'session' 규칙을 비활성화.
65
+ * 이전 세션의 임시 규칙이 새 세션에서 영향을 미치지 않도록 정리.
66
+ */
67
+ export function cleanupStaleSessionRules(_currentSessionId) {
68
+ if (!fs.existsSync(V1_RULES_DIR))
69
+ return 0;
70
+ let cleaned = 0;
71
+ for (const file of fs.readdirSync(V1_RULES_DIR)) {
72
+ if (!file.endsWith('.json'))
73
+ continue;
74
+ const filePath = path.join(V1_RULES_DIR, file);
75
+ const rule = safeReadJSON(filePath, null);
76
+ if (rule && rule.scope === 'session' && rule.status === 'active') {
77
+ rule.status = 'suppressed';
78
+ rule.updated_at = new Date().toISOString();
79
+ atomicWriteJSON(filePath, rule, { pretty: true });
80
+ cleaned++;
81
+ }
82
+ }
83
+ return cleaned;
84
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wooojin/forgen",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "main": "dist/lib.js",
5
5
  "types": "./dist/lib.d.ts",
6
6
  "exports": {