@wooojin/forgen 0.4.1 → 0.4.3

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 (140) hide show
  1. package/.claude-plugin/plugin.json +5 -5
  2. package/CHANGELOG.md +164 -15
  3. package/CONTRIBUTING.md +2 -2
  4. package/README.ja.md +17 -9
  5. package/README.ko.md +20 -12
  6. package/README.md +46 -12
  7. package/README.zh.md +17 -9
  8. package/assets/README.md +86 -0
  9. package/assets/architecture.svg +100 -0
  10. package/assets/banner.png +0 -0
  11. package/assets/banner.svg +53 -0
  12. package/assets/demo/01-install.gif +0 -0
  13. package/assets/demo/01-install.tape +54 -0
  14. package/assets/demo/02-compound-learning.gif +0 -0
  15. package/assets/demo/02-compound-learning.tape +50 -0
  16. package/assets/demo/03-forge-personalization.gif +0 -0
  17. package/assets/demo/03-forge-personalization.tape +64 -0
  18. package/assets/demo/before-after.gif +0 -0
  19. package/assets/demo/before-after.tape +98 -0
  20. package/assets/demo-preview.svg +96 -0
  21. package/assets/icon.png +0 -0
  22. package/{hooks → assets/shared}/hook-registry.json +2 -1
  23. package/dist/cli.js +78 -6
  24. package/dist/core/auto-compound-runner.js +62 -38
  25. package/dist/core/behavior-classifier.d.ts +28 -0
  26. package/dist/core/behavior-classifier.js +46 -0
  27. package/dist/core/dashboard.d.ts +7 -0
  28. package/dist/core/dashboard.js +32 -0
  29. package/dist/core/doctor.js +92 -0
  30. package/dist/core/git-stats.d.ts +36 -0
  31. package/dist/core/git-stats.js +79 -0
  32. package/dist/core/harness.d.ts +1 -1
  33. package/dist/core/harness.js +27 -20
  34. package/dist/core/host-detect.d.ts +42 -0
  35. package/dist/core/host-detect.js +68 -0
  36. package/dist/core/installer.js +2 -2
  37. package/dist/core/migrate-cli.d.ts +1 -0
  38. package/dist/core/migrate-cli.js +19 -0
  39. package/dist/core/migrate-evidence-host.d.ts +36 -0
  40. package/dist/core/migrate-evidence-host.js +49 -0
  41. package/dist/core/settings-injector.js +4 -2
  42. package/dist/core/spawn.d.ts +1 -1
  43. package/dist/core/spawn.js +4 -11
  44. package/dist/core/stats-cli.js +12 -0
  45. package/dist/core/trust-layer-intent.d.ts +35 -0
  46. package/dist/core/trust-layer-intent.js +30 -0
  47. package/dist/core/types.d.ts +1 -1
  48. package/dist/engine/compound-extractor.js +7 -9
  49. package/dist/engine/learn-cli.js +4 -2
  50. package/dist/engine/lifecycle/bypass-detector.d.ts +6 -1
  51. package/dist/engine/lifecycle/bypass-detector.js +57 -5
  52. package/dist/fgx.js +2 -1
  53. package/dist/forge/evidence-processor.js +12 -0
  54. package/dist/forge/onboarding.d.ts +3 -2
  55. package/dist/forge/onboarding.js +3 -2
  56. package/dist/hooks/db-guard.js +3 -3
  57. package/dist/hooks/forge-loop-progress.d.ts +9 -0
  58. package/dist/hooks/forge-loop-progress.js +38 -0
  59. package/dist/hooks/hook-registry.js +1 -1
  60. package/dist/hooks/hooks-generator.d.ts +15 -1
  61. package/dist/hooks/hooks-generator.js +18 -16
  62. package/dist/hooks/keyword-detector.js +1 -1
  63. package/dist/hooks/post-tool-use.d.ts +1 -1
  64. package/dist/hooks/post-tool-use.js +13 -4
  65. package/dist/hooks/pre-compact.js +1 -1
  66. package/dist/hooks/pre-tool-use.js +4 -4
  67. package/dist/hooks/rate-limiter.js +2 -2
  68. package/dist/hooks/session-recovery.js +11 -0
  69. package/dist/hooks/shared/blocking-allowlist.d.ts +28 -0
  70. package/dist/hooks/shared/blocking-allowlist.js +38 -0
  71. package/dist/hooks/shared/forge-loop-state.d.ts +36 -0
  72. package/dist/hooks/shared/forge-loop-state.js +116 -0
  73. package/dist/hooks/shared/hook-response.d.ts +18 -0
  74. package/dist/hooks/shared/hook-response.js +31 -0
  75. package/dist/hooks/skill-injector.js +1 -1
  76. package/dist/hooks/stop-guard.js +15 -0
  77. package/dist/host/capabilities-claude.d.ts +8 -0
  78. package/dist/host/capabilities-claude.js +46 -0
  79. package/dist/host/capabilities-codex.d.ts +11 -0
  80. package/dist/host/capabilities-codex.js +50 -0
  81. package/dist/host/capabilities-registry.d.ts +11 -0
  82. package/dist/host/capabilities-registry.js +30 -0
  83. package/dist/host/codex-adapter.d.ts +8 -5
  84. package/dist/host/codex-adapter.js +10 -82
  85. package/dist/host/codex-output-parser.d.ts +39 -0
  86. package/dist/host/codex-output-parser.js +75 -0
  87. package/dist/host/exec-host.d.ts +54 -0
  88. package/dist/host/exec-host.js +92 -0
  89. package/dist/host/host-runtime.d.ts +37 -0
  90. package/dist/host/host-runtime.js +51 -0
  91. package/dist/host/install-claude.d.ts +35 -0
  92. package/dist/host/install-claude.js +238 -0
  93. package/dist/host/install-codex.d.ts +44 -0
  94. package/dist/host/install-codex.js +276 -0
  95. package/dist/host/install-orchestrator.d.ts +34 -0
  96. package/dist/host/install-orchestrator.js +126 -0
  97. package/dist/host/invoke-agent.d.ts +27 -0
  98. package/dist/host/invoke-agent.js +115 -0
  99. package/dist/host/parity-harness.d.ts +62 -0
  100. package/dist/host/parity-harness.js +283 -0
  101. package/dist/host/projection.d.ts +35 -0
  102. package/dist/host/projection.js +126 -0
  103. package/dist/mcp/server.js +11 -0
  104. package/dist/mcp/tools.js +47 -0
  105. package/dist/services/session.d.ts +6 -3
  106. package/dist/services/session.js +33 -4
  107. package/dist/store/evidence-store.d.ts +1 -0
  108. package/dist/store/evidence-store.js +34 -3
  109. package/dist/store/host-mismatch.d.ts +42 -0
  110. package/dist/store/host-mismatch.js +65 -0
  111. package/dist/store/profile-store.d.ts +29 -0
  112. package/dist/store/profile-store.js +53 -0
  113. package/dist/store/types.d.ts +13 -0
  114. package/hooks/hooks.json +6 -1
  115. package/package.json +6 -4
  116. package/plugin.json +4 -4
  117. package/scripts/postinstall.js +100 -25
  118. /package/{agents → assets/claude/agents}/analyst.md +0 -0
  119. /package/{agents → assets/claude/agents}/architect.md +0 -0
  120. /package/{agents → assets/claude/agents}/code-reviewer.md +0 -0
  121. /package/{agents → assets/claude/agents}/critic.md +0 -0
  122. /package/{agents → assets/claude/agents}/debugger.md +0 -0
  123. /package/{agents → assets/claude/agents}/designer.md +0 -0
  124. /package/{agents → assets/claude/agents}/executor.md +0 -0
  125. /package/{agents → assets/claude/agents}/explore.md +0 -0
  126. /package/{agents → assets/claude/agents}/git-master.md +0 -0
  127. /package/{agents → assets/claude/agents}/planner.md +0 -0
  128. /package/{agents → assets/claude/agents}/solution-evolver.md +0 -0
  129. /package/{agents → assets/claude/agents}/test-engineer.md +0 -0
  130. /package/{agents → assets/claude/agents}/verifier.md +0 -0
  131. /package/{commands → assets/claude/commands}/architecture-decision.md +0 -0
  132. /package/{commands → assets/claude/commands}/calibrate.md +0 -0
  133. /package/{commands → assets/claude/commands}/code-review.md +0 -0
  134. /package/{commands → assets/claude/commands}/compound.md +0 -0
  135. /package/{commands → assets/claude/commands}/deep-interview.md +0 -0
  136. /package/{commands → assets/claude/commands}/docker.md +0 -0
  137. /package/{commands → assets/claude/commands}/forge-loop.md +0 -0
  138. /package/{commands → assets/claude/commands}/learn.md +0 -0
  139. /package/{commands → assets/claude/commands}/retro.md +0 -0
  140. /package/{commands → assets/claude/commands}/ship.md +0 -0
package/dist/cli.js CHANGED
@@ -112,8 +112,31 @@ const commands = [
112
112
  await displayHookStatus(process.cwd());
113
113
  }
114
114
  }
115
+ else if (sub === 'default-host') {
116
+ const value = args[1];
117
+ const valid = new Set(['claude', 'codex', 'ask']);
118
+ if (value === undefined) {
119
+ const { getDefaultHost } = await import('./store/profile-store.js');
120
+ const current = getDefaultHost();
121
+ console.log(` current default_host: ${current ?? '(unset → claude fallback)'}`);
122
+ console.log(' Usage: forgen config default-host {claude|codex|ask}');
123
+ }
124
+ else if (!valid.has(value)) {
125
+ console.log(` Invalid value: ${value}. Use one of: claude, codex, ask`);
126
+ process.exit(1);
127
+ }
128
+ else {
129
+ const { setDefaultHost } = await import('./store/profile-store.js');
130
+ const ok = setDefaultHost(value);
131
+ if (!ok) {
132
+ console.log(' ✗ Profile not found. Run `forgen onboarding` first.');
133
+ process.exit(1);
134
+ }
135
+ console.log(` ✓ default_host set to: ${value}`);
136
+ }
137
+ }
115
138
  else {
116
- console.log('Usage: forgen config hooks [--regenerate]');
139
+ console.log('Usage:\n forgen config hooks [--regenerate]\n forgen config default-host [claude|codex|ask]');
117
140
  }
118
141
  },
119
142
  },
@@ -133,6 +156,53 @@ const commands = [
133
156
  await handleInit(args);
134
157
  },
135
158
  },
159
+ {
160
+ name: 'install',
161
+ description: 'Install forgen into a host. Usage: forgen install [claude|codex|both] [--dry-run] [--no-mcp]',
162
+ handler: async (args) => {
163
+ const knownSubs = new Set(['claude', 'codex', 'both']);
164
+ const target = args[0] && knownSubs.has(args[0]) ? args[0] : args[0]?.startsWith('--') ? undefined : args[0];
165
+ if (target !== undefined && !knownSubs.has(target)) {
166
+ console.log('Usage:\n forgen install [claude|codex|both] [--dry-run] [--no-mcp]\n\n No arg → interactive 3-choice (Claude/Codex/Both).');
167
+ return;
168
+ }
169
+ const dryRun = args.includes('--dry-run');
170
+ const registerMcp = !args.includes('--no-mcp');
171
+ const { runInstall, renderResult, resolvePkgRootFromBinary } = await import('./host/install-orchestrator.js');
172
+ const pkgRoot = resolvePkgRootFromBinary(import.meta.url);
173
+ const result = await runInstall({ target, pkgRoot, dryRun, registerMcp });
174
+ if (result === null) {
175
+ console.log('\n [forgen] Install skipped.');
176
+ return;
177
+ }
178
+ console.log(renderResult(result, dryRun));
179
+ },
180
+ },
181
+ {
182
+ name: 'parity',
183
+ description: 'Run host parity checks. Usage: forgen parity codex [--dry-run]',
184
+ handler: async (args) => {
185
+ const sub = args[0];
186
+ if (sub !== 'codex') {
187
+ console.log('Usage:\n forgen parity codex [--dry-run]\n\nNotes:\n - source 체크아웃에서만 작동합니다 (tests/ 디렉토리 필요).\n - npm install 로 설치된 패키지에서는 run-parity.sh 가 없습니다.');
188
+ return;
189
+ }
190
+ const here = path.dirname(new URL(import.meta.url).pathname);
191
+ const scriptPath = path.resolve(here, '..', 'tests', 'e2e', 'codex', 'run-parity.sh');
192
+ if (!fs.existsSync(scriptPath)) {
193
+ console.error('[forgen] run-parity.sh 는 source 체크아웃에서만 작동. 직접 git clone 후 실행하세요.');
194
+ console.error(` expected: ${scriptPath}`);
195
+ process.exit(1);
196
+ }
197
+ const { spawnSync } = await import('node:child_process');
198
+ const dryRun = args.includes('--dry-run');
199
+ const spawnArgs = dryRun ? ['--dry-run'] : [];
200
+ const result = spawnSync('bash', [scriptPath, ...spawnArgs], { stdio: 'inherit' });
201
+ if (result.status !== 0) {
202
+ process.exit(result.status ?? 1);
203
+ }
204
+ },
205
+ },
136
206
  {
137
207
  name: 'notepad',
138
208
  description: 'Notepad (show|add|clear)',
@@ -151,7 +221,7 @@ const commands = [
151
221
  },
152
222
  {
153
223
  name: 'onboarding',
154
- description: 'v1 2-question onboarding flow',
224
+ description: 'v1 4-question onboarding flow',
155
225
  handler: async (_args) => {
156
226
  const { runOnboarding } = await import('./forge/onboarding-cli.js');
157
227
  await runOnboarding();
@@ -407,7 +477,8 @@ async function main() {
407
477
  ${dim}Code, forged for you.${reset}
408
478
  ${dim}Scope: v1(${context.v1.session?.quality_pack ?? 'onboarding needed'})${reset}
409
479
  `);
410
- const runtimeLabel = runtime === 'codex' ? 'Codex' : 'Claude';
480
+ const { getHostRuntime } = await import('./host/host-runtime.js');
481
+ const runtimeLabel = getHostRuntime(runtime).displayName;
411
482
  console.log(`[forgen] Starting ${runtimeLabel}...\n`);
412
483
  await spawnClaudeWithResume(args, context, () => prepareHarness(process.cwd(), { runtime }), runtime);
413
484
  }
@@ -441,7 +512,7 @@ function printHelp() {
441
512
 
442
513
  Commands:
443
514
  forgen forge Personalize your coding profile
444
- forgen onboarding Run 2-question onboarding
515
+ forgen onboarding Run 4-question onboarding
445
516
  forgen inspect [profile|rules|corrections|session]
446
517
  Inspect v1 state (alias: evidence → corrections)
447
518
  forgen rule <list|suppress|activate|scan|health-scan|classify>
@@ -450,8 +521,9 @@ function printHelp() {
450
521
  forgen last-block Show the most recent block event
451
522
  forgen recall [--limit N] [--show]
452
523
  최근 compound 주입 이력 (solution body preview)
453
- forgen migrate [implicit-feedback|all]
454
- One-shot schema migration (category backfill)
524
+ forgen migrate [implicit-feedback|evidence-host|all]
525
+ One-shot schema migration (category backfill / host backfill)
526
+ forgen parity codex [--dry-run] Run codex parity checks (source checkout only)
455
527
  forgen compound Manage accumulated knowledge
456
528
  forgen dashboard Compound system dashboard
457
529
  forgen me Personal dashboard
@@ -12,38 +12,66 @@
12
12
  import * as fs from 'node:fs';
13
13
  import * as path from 'node:path';
14
14
  import { execFileSync } from 'node:child_process';
15
+ import { createRequire } from 'node:module';
15
16
  import { containsPromptInjection, filterSolutionContent } from '../hooks/prompt-injection-filter.js';
16
17
  import { redactSecrets } from '../hooks/secret-filter.js';
17
18
  import { createEvidence, saveEvidence, promoteSessionCandidates } from '../store/evidence-store.js';
18
19
  import { loadProfile } from '../store/profile-store.js';
19
20
  import { FORGEN_HOME, ME_DIR } from './paths.js';
21
+ import { classifyBehaviorKind, mapKindToAxisRefs } from './behavior-classifier.js';
20
22
  /** Auto-compound에 사용할 모델 — background 추출이므로 haiku로 충분 */
21
23
  const COMPOUND_MODEL = 'haiku';
22
- /** execFileSync wrapper: transient 에러(ETIMEDOUT 등) 시 1회 재시도 */
24
+ /**
25
+ * Host-aware exec retry — feat/codex-support P2-3 (Phase 2 critic fix).
26
+ *
27
+ * 보안 회귀 방지: Claude 분기는 *args 그대로* execFileSync 호출 → P1-S1 의
28
+ * `--allowedTools Bash(forgen compound:*)` sandbox hardening 보존.
29
+ * Codex 분기에서만 -p prompt 추출 → execHost (codex 는 --allowedTools 모름).
30
+ *
31
+ * Codex retry 정책 fix: ETIMEDOUT 시 sleep 후 retry 는 *Claude only*. Codex 는
32
+ * 60-90s response 가 정상이라 timeout 누적 retry 가 무의미 (즉시 fail).
33
+ */
23
34
  function execClaudeRetry(args, opts) {
24
- const TRANSIENT = /ETIMEDOUT|ECONNRESET|ECONNREFUSED|EPIPE/;
25
- for (let attempt = 0; attempt < 2; attempt++) {
26
- try {
27
- return execFileSync('claude', args, opts);
28
- }
29
- catch (e) {
30
- const msg = e instanceof Error ? e.message : String(e);
31
- if (attempt === 0 && TRANSIENT.test(msg)) {
32
- process.stderr.write(`[forgen-auto-compound] transient error, retrying in 3s...\n`);
33
- // Blocking synchronous sleep: Atomics.wait on a zero-initialized
34
- // SharedArrayBuffer is the Node.js idiom for blocking the event
35
- // loop without spawning child processes. This file runs as a
36
- // detached subprocess (`auto-compound-runner`) so blocking is
37
- // both safe and intentional. The 3000ms matches the backoff
38
- // before retry. Alternative setTimeout would require making this
39
- // function async, which would ripple through the entire runner.
40
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 3000);
41
- continue;
35
+ const mod = createRequire(import.meta.url)('../host/exec-host.js');
36
+ // profile.default_host host 결정 (lazy load)
37
+ const profileMod = createRequire(import.meta.url)('../store/profile-store.js');
38
+ const resolved = profileMod.resolveDefaultHost();
39
+ const host = resolved === 'codex' ? 'codex' : 'claude';
40
+ if (host === 'claude') {
41
+ // Claude 측은 기존 보안 hardening 보존: --allowedTools args 그대로 전달.
42
+ const TRANSIENT = /ETIMEDOUT|ECONNRESET|ECONNREFUSED|EPIPE/;
43
+ for (let attempt = 0; attempt < 2; attempt++) {
44
+ try {
45
+ return execFileSync('claude', args, opts);
46
+ }
47
+ catch (e) {
48
+ const msg = e instanceof Error ? e.message : String(e);
49
+ if (attempt === 0 && TRANSIENT.test(msg)) {
50
+ process.stderr.write(`[forgen-auto-compound] transient error, retrying in 3s...\n`);
51
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 3000);
52
+ continue;
53
+ }
54
+ throw e;
42
55
  }
43
- throw e;
44
56
  }
57
+ throw new Error('unreachable');
45
58
  }
46
- throw new Error('unreachable');
59
+ // host === 'codex' — prompt 만 추출 (codex 는 --allowedTools 등 미인식).
60
+ const pIdx = args.indexOf('-p');
61
+ if (pIdx === -1 || !args[pIdx + 1]) {
62
+ throw new Error('execClaudeRetry: codex host requires -p prompt argument');
63
+ }
64
+ const prompt = args[pIdx + 1];
65
+ const modelIdx = args.indexOf('--model');
66
+ const model = modelIdx !== -1 ? args[modelIdx + 1] : undefined;
67
+ const r = mod.execHost({
68
+ prompt,
69
+ model,
70
+ host: 'codex',
71
+ timeout: typeof opts.timeout === 'number' ? opts.timeout : 60000,
72
+ cwd: typeof opts.cwd === 'string' ? opts.cwd : undefined,
73
+ });
74
+ return r.message;
47
75
  }
48
76
  const [, , cwd, transcriptPath, sessionId] = process.argv;
49
77
  if (!cwd || !transcriptPath || !sessionId) {
@@ -205,9 +233,7 @@ function mergeOrCreateBehavior(dir, newContent, kind, today) {
205
233
  fs.writeFileSync(filePath, updated);
206
234
  return true;
207
235
  }
208
- catch {
209
- continue;
210
- }
236
+ catch { }
211
237
  }
212
238
  return false;
213
239
  }
@@ -308,14 +334,15 @@ ${sanitizedSummary.slice(0, 6000)}
308
334
  관찰된 패턴을 다음 형식으로 1~3개만 출력해주세요 (없으면 "관찰된 패턴 없음"):
309
335
  - [카테고리] 패턴 설명 (관찰 근거)
310
336
 
311
- 카테고리: 커뮤니케이션/작업습관/기술선호/의사결정/워크플로우
337
+ 카테고리: 커뮤니케이션/작업습관/기술선호/의사결정/워크플로우/품질안전/자율성
312
338
 
313
- 특히 "워크플로우" 카테고리에 주목하세요:
314
- - 사용자가 반복하는 작업 순서 패턴 (예: "항상 테스트 먼저 작성 → 구현 → 리팩토링 순서로 진행")
315
- - 특정 상황에서의 판단 규칙 (예: "PR 리뷰 보안 테스트 코드 품질 순서로 확인")
316
- - 조건부 접근법 (예: "버그 수정 재현 테스트부터 작성, 성능 이슈면 프로파일링부터")
339
+ 카테고리 가이드:
340
+ - "워크플로우": 반복하는 작업 순서, 판단 규칙, 조건부 접근법 (예: "테스트 먼저 → 구현 → 리팩토링 순서")
341
+ - "품질안전": 검증/테스트/안전성 관련 강한 선호 (예: "프로덕션 배포 Docker e2e 의무", "mock-only 검증 거부")
342
+ - "자율성": 확인/독립 결정 관련 선호 (예: "사소한 변경은 묻지 않고 진행", "큰 결정은 반드시 확인")
317
343
 
318
344
  워크플로우 패턴이 감지되면 반드시 구체적인 순서를 포함하세요.
345
+ 품질안전/자율성 패턴은 4축 개인화의 입력이므로 quality/autonomy 신호가 명확하면 반드시 해당 라벨을 사용하세요 (커뮤니케이션/작업습관 으로 흡수 금지).
319
346
 
320
347
  기존 패턴과 중복이면 건너뛰세요.${existingBehaviorPatterns}
321
348
 
@@ -346,11 +373,11 @@ ${sanitizedSummary.slice(0, 4000)}
346
373
  fs.mkdirSync(BEHAVIOR_DIR, { recursive: true });
347
374
  const today = new Date().toISOString().split('T')[0];
348
375
  const trimmed = userResult.trim();
349
- // 카테고리에 따라 kind 분류
350
- const kind = trimmed.includes('[워크플로우]') || trimmed.includes('순서') || trimmed.includes('→')
351
- ? 'workflow'
352
- : trimmed.includes('[의사결정]') ? 'thinking'
353
- : 'preference';
376
+ // 카테고리에 따라 kind 분류 — D1'' (2026-04-27): quality/autonomy 라벨 추가.
377
+ // 이전 3분기(workflow/thinking/preference) quality_safety/autonomy 축으로
378
+ // 가는 자동 신호를 communication_style 로 흡수해 626건 중 자동 추출 0건이
379
+ // 이 두 축에 닿지 못했음. 5분기로 확장. (분류 로직은 behavior-classifier.ts)
380
+ const kind = classifyBehaviorKind(trimmed);
354
381
  // 기존 유사 패턴이 있으면 observedCount 누적
355
382
  const merged = mergeOrCreateBehavior(BEHAVIOR_DIR, trimmed, kind, today);
356
383
  if (!merged) {
@@ -367,10 +394,7 @@ ${sanitizedSummary.slice(0, 4000)}
367
394
  session_id: sessionId,
368
395
  source_component: 'auto-compound-runner',
369
396
  summary: trimmed.slice(0, 200),
370
- axis_refs: kind === 'workflow' ? ['judgment_philosophy']
371
- : kind === 'preference' ? ['communication_style']
372
- : kind === 'thinking' ? ['judgment_philosophy']
373
- : [],
397
+ axis_refs: mapKindToAxisRefs(kind),
374
398
  confidence: 0.6,
375
399
  raw_payload: { kind, observedCount: 1 },
376
400
  });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Behavior Classifier — D1'' (2026-04-27)
3
+ *
4
+ * LLM 이 추출한 사용자 패턴을 5개 kind 로 분류하고 4축 axis_refs 로 매핑한다.
5
+ *
6
+ * 결함 history:
7
+ * v0.4.1 까지: kind 3분기(workflow/thinking/preference) → axis 2축
8
+ * (judgment_philosophy / communication_style) 만 자동 추출 가능.
9
+ * quality_safety / autonomy 축은 explicit_correction 16건 (Hooks 경로) 으로만
10
+ * 자라고, 자동 학습 600+ 건은 이 두 축에 0% 기여 — 측정 자기증거.
11
+ *
12
+ * v0.4.2: 5분기 [품질안전] / [자율성] 추가 → 4축 모두 cover.
13
+ * LLM prompt (auto-compound-runner) 에도 같은 라벨 가이드를 명시하여
14
+ * 형식 강제. 새 라벨이 안 나오면 기존 5분기로 fallback (호환).
15
+ */
16
+ export type BehaviorKind = 'safety' | 'autonomy' | 'workflow' | 'thinking' | 'preference';
17
+ /**
18
+ * LLM 출력 텍스트(`[카테고리] 설명` 형식)를 5개 kind 로 분류.
19
+ *
20
+ * 라벨 우선순위 (위에서 아래):
21
+ * 1. [품질안전] → safety
22
+ * 2. [자율성] → autonomy
23
+ * 3. [워크플로우] OR "순서"/"→" 토큰 → workflow
24
+ * 4. [의사결정] → thinking
25
+ * 5. 그 외 → preference (default)
26
+ */
27
+ export declare function classifyBehaviorKind(text: string): BehaviorKind;
28
+ export declare function mapKindToAxisRefs(kind: BehaviorKind): string[];
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Behavior Classifier — D1'' (2026-04-27)
3
+ *
4
+ * LLM 이 추출한 사용자 패턴을 5개 kind 로 분류하고 4축 axis_refs 로 매핑한다.
5
+ *
6
+ * 결함 history:
7
+ * v0.4.1 까지: kind 3분기(workflow/thinking/preference) → axis 2축
8
+ * (judgment_philosophy / communication_style) 만 자동 추출 가능.
9
+ * quality_safety / autonomy 축은 explicit_correction 16건 (Hooks 경로) 으로만
10
+ * 자라고, 자동 학습 600+ 건은 이 두 축에 0% 기여 — 측정 자기증거.
11
+ *
12
+ * v0.4.2: 5분기 [품질안전] / [자율성] 추가 → 4축 모두 cover.
13
+ * LLM prompt (auto-compound-runner) 에도 같은 라벨 가이드를 명시하여
14
+ * 형식 강제. 새 라벨이 안 나오면 기존 5분기로 fallback (호환).
15
+ */
16
+ const AXIS_REFS_BY_KIND = {
17
+ safety: ['quality_safety'],
18
+ autonomy: ['autonomy'],
19
+ workflow: ['judgment_philosophy'],
20
+ thinking: ['judgment_philosophy'],
21
+ preference: ['communication_style'],
22
+ };
23
+ /**
24
+ * LLM 출력 텍스트(`[카테고리] 설명` 형식)를 5개 kind 로 분류.
25
+ *
26
+ * 라벨 우선순위 (위에서 아래):
27
+ * 1. [품질안전] → safety
28
+ * 2. [자율성] → autonomy
29
+ * 3. [워크플로우] OR "순서"/"→" 토큰 → workflow
30
+ * 4. [의사결정] → thinking
31
+ * 5. 그 외 → preference (default)
32
+ */
33
+ export function classifyBehaviorKind(text) {
34
+ if (text.includes('[품질안전]'))
35
+ return 'safety';
36
+ if (text.includes('[자율성]'))
37
+ return 'autonomy';
38
+ if (text.includes('[워크플로우]') || text.includes('순서') || text.includes('→'))
39
+ return 'workflow';
40
+ if (text.includes('[의사결정]'))
41
+ return 'thinking';
42
+ return 'preference';
43
+ }
44
+ export function mapKindToAxisRefs(kind) {
45
+ return [...AXIS_REFS_BY_KIND[kind]];
46
+ }
@@ -86,6 +86,13 @@ export declare function collectLifecycleActivity(): LifecycleActivity;
86
86
  export declare function collectSessionHistory(): SessionHistory;
87
87
  /** Collect hook error data. */
88
88
  export declare function collectHookHealth(): HookHealth;
89
+ export interface MultiHostData {
90
+ claude: number;
91
+ codex: number;
92
+ total: number;
93
+ }
94
+ /** Collect multi-host evidence distribution from host-mismatch store. */
95
+ export declare function collectMultiHostData(): MultiHostData;
89
96
  export interface LearningCurve {
90
97
  correctionsLast7d: number;
91
98
  correctionsPrev7d: number;
@@ -23,6 +23,7 @@ const require = createRequire(import.meta.url);
23
23
  import { ME_SOLUTIONS, ME_RULES, ME_BEHAVIOR, STATE_DIR, } from './paths.js';
24
24
  import { parseFrontmatterOnly } from '../engine/solution-format.js';
25
25
  import { readMatchEvalLog } from '../engine/match-eval-log.js';
26
+ import { summarizeAllByHost } from '../store/host-mismatch.js';
26
27
  // ── ANSI color helpers ──
27
28
  const BOLD = '\x1b[1m';
28
29
  const DIM = '\x1b[2m';
@@ -356,6 +357,34 @@ function renderHookHealth(data) {
356
357
  lines.push(tableSep(widths, false, true));
357
358
  return lines.join('\n');
358
359
  }
360
+ /** Collect multi-host evidence distribution from host-mismatch store. */
361
+ export function collectMultiHostData() {
362
+ try {
363
+ return summarizeAllByHost();
364
+ }
365
+ catch {
366
+ return { claude: 0, codex: 0, total: 0 };
367
+ }
368
+ }
369
+ function renderMultiHost(data) {
370
+ const lines = [];
371
+ lines.push(` ${bold(cyan('Multi-Host Evidence'))}`);
372
+ lines.push('');
373
+ if (data.total === 0) {
374
+ lines.push(` ${dim('No evidence recorded yet.')}`);
375
+ return lines.join('\n');
376
+ }
377
+ const claudePct = Math.round((data.claude / data.total) * 100);
378
+ const codexPct = Math.round((data.codex / data.total) * 100);
379
+ lines.push(` Hosts claude:${data.claude} (${claudePct}%) codex:${data.codex} (${codexPct}%) total:${data.total}`);
380
+ // skew 경고: 80%+ 집중
381
+ const maxShare = Math.max(claudePct, codexPct);
382
+ if (data.total >= 5 && maxShare >= 80) {
383
+ const dominant = data.claude >= data.codex ? 'claude' : 'codex';
384
+ lines.push(` ${yellow(`⚠ ${dominant} 에 ${maxShare}% 집중 — 다른 host 데이터 부족`)}`);
385
+ }
386
+ return lines.join('\n');
387
+ }
359
388
  /**
360
389
  * Learning Curve 수집.
361
390
  * evidence 파일(교정 기록)과 compound 활용률을 교차 분석하여 "쓸수록 나아진다"를 정량화.
@@ -523,6 +552,7 @@ export function renderDashboard() {
523
552
  const session = collectSessionHistory();
524
553
  const hookHealth = collectHookHealth();
525
554
  const learning = collectLearningCurve();
555
+ const multiHost = collectMultiHostData();
526
556
  const divider = ` ${dim('─'.repeat(50))}`;
527
557
  const sections = [
528
558
  '',
@@ -538,6 +568,8 @@ export function renderDashboard() {
538
568
  divider,
539
569
  renderInjectionActivity(injection),
540
570
  divider,
571
+ renderMultiHost(multiHost),
572
+ divider,
541
573
  renderReflectionData(reflection),
542
574
  divider,
543
575
  renderLifecycleActivity(lifecycle),
@@ -5,6 +5,7 @@ import { execFileSync } from 'node:child_process';
5
5
  import { FORGEN_HOME, LAB_DIR, ME_BEHAVIOR, ME_DIR, ME_SOLUTIONS, ME_RULES, ME_SKILLS, PACKS_DIR, SESSIONS_DIR, STATE_DIR } from './paths.js';
6
6
  import { getTimingStats } from '../hooks/shared/hook-timing.js';
7
7
  import { countSessionScopedFiles, pruneState } from './state-gc.js';
8
+ import { summarizeAllByHost } from '../store/host-mismatch.js';
8
9
  /** ~/.claude/projects/ — Claude Code 세션 저장 경로 */
9
10
  const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
10
11
  let currentSection = '';
@@ -34,6 +35,58 @@ function commandExists(cmd) {
34
35
  return false;
35
36
  }
36
37
  }
38
+ /** parity-result.json 내용에서 경과 시간을 사람이 읽기 좋은 문자열로 변환 */
39
+ function relativeTime(isoString) {
40
+ const diffMs = Date.now() - new Date(isoString).getTime();
41
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
42
+ if (diffDays === 0) {
43
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
44
+ if (diffHours === 0) {
45
+ const diffMins = Math.floor(diffMs / (1000 * 60));
46
+ return `${diffMins}m ago`;
47
+ }
48
+ return `${diffHours}h ago`;
49
+ }
50
+ return `${diffDays}d ago`;
51
+ }
52
+ /** [Codex Parity] 섹션 렌더링 — ~/.forgen/state/parity-result.json 신선도 검사 */
53
+ function renderCodexParity() {
54
+ console.log(' [Codex Parity]');
55
+ const parityPath = path.join(STATE_DIR, 'parity-result.json');
56
+ if (!fs.existsSync(parityPath)) {
57
+ console.log(' △ Codex parity 미실행 — tests/e2e/codex/run-parity.sh 또는 forgen parity codex');
58
+ return;
59
+ }
60
+ let data;
61
+ try {
62
+ data = JSON.parse(fs.readFileSync(parityPath, 'utf-8'));
63
+ }
64
+ catch {
65
+ console.log(' ✗ Codex parity — parity-result.json 파싱 실패');
66
+ return;
67
+ }
68
+ if (data.passed === null || data.passed === undefined) {
69
+ console.log(' △ Codex parity dry-run only — 실 실행 필요');
70
+ return;
71
+ }
72
+ if (!data.passed) {
73
+ const timeStr = data.at ? relativeTime(data.at) : 'unknown';
74
+ const detail = data.result ?? data.note ?? 'no detail';
75
+ console.log(` ✗ Codex parity FAILED (at: ${timeStr}, detail: ${detail})`);
76
+ return;
77
+ }
78
+ // passed === true
79
+ const timeStr = data.at ? relativeTime(data.at) : 'unknown';
80
+ const version = data.version ? ` version ${data.version}` : '';
81
+ const diffMs = data.at ? Date.now() - new Date(data.at).getTime() : Infinity;
82
+ const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
83
+ if (diffMs > sevenDaysMs) {
84
+ console.log(` △ Codex parity green but stale (last run: ${timeStr}) — 재실행 권장`);
85
+ }
86
+ else {
87
+ console.log(` ✓ Codex parity green (last run: ${timeStr},${version})`);
88
+ }
89
+ }
37
90
  export async function runDoctor(opts = {}) {
38
91
  failedChecks = [];
39
92
  console.log('\n Forgen — Diagnostics\n');
@@ -386,6 +439,45 @@ export async function runDoctor(opts = {}) {
386
439
  // git 저장소가 아니거나 origin이 없으면 표시하지 않음
387
440
  console.log(' git remote: (none)');
388
441
  }
442
+ // P4 셀프 가드: fix:feat 비율 30% 초과 시 회귀 패턴 의심 경고.
443
+ try {
444
+ const { computeFixFeatRatio, formatFixRatio } = await import('./git-stats.js');
445
+ const ratio = computeFixFeatRatio();
446
+ if (ratio.available) {
447
+ console.log(` ${formatFixRatio(ratio)}`);
448
+ if (ratio.exceedsThreshold) {
449
+ console.log(' ⚠ fix:feat 비율이 임계값을 초과했습니다. "이거 고치면 저거 버그난다" 패턴 의심 — 검증 레이어 invariant 점검 권장.');
450
+ }
451
+ }
452
+ }
453
+ catch { /* fail-open */ }
454
+ console.log();
455
+ // [Multi-Host] — host 별 evidence 분포
456
+ console.log(' [Multi-Host]');
457
+ try {
458
+ const hostStats = summarizeAllByHost();
459
+ if (hostStats.total === 0) {
460
+ console.log(' No evidence recorded yet.');
461
+ }
462
+ else {
463
+ const claudePct = hostStats.total > 0 ? Math.round((hostStats.claude / hostStats.total) * 100) : 0;
464
+ const codexPct = hostStats.total > 0 ? Math.round((hostStats.codex / hostStats.total) * 100) : 0;
465
+ console.log(` Registered hosts: claude, codex`);
466
+ console.log(` Evidence by host: claude:${hostStats.claude} (${claudePct}%) codex:${hostStats.codex} (${codexPct}%) total:${hostStats.total}`);
467
+ // 한 host 가 80% 이상이면 skew 경고
468
+ const maxShare = Math.max(claudePct, codexPct);
469
+ if (hostStats.total >= 5 && maxShare >= 80) {
470
+ const dominant = claudePct >= codexPct ? 'claude' : 'codex';
471
+ console.log(` ⚠ evidence 가 ${dominant} 에 ${maxShare}% 집중됨 — 다른 host 에서 학습 데이터 부족 가능`);
472
+ }
473
+ }
474
+ }
475
+ catch {
476
+ console.log(' Unable to read host evidence data.');
477
+ }
478
+ console.log();
479
+ // [Codex Parity] — parity-result.json 신선도 검사 (v0.4.2 패턴 확장)
480
+ renderCodexParity();
389
481
  console.log();
390
482
  // [Summary] — 최종 상태 요약과 복구 액션을 한눈에 보이게
391
483
  console.log(' [Summary]');
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Git Stats — P4 셀프 가드 (2026-04-27)
3
+ *
4
+ * 최근 N커밋의 conventional commit 분포를 측정해 fix:feat 비율을 계산.
5
+ * 정상 OSS 권장은 fix < 20%. 36% 초과 시 회귀 패턴 의심 — forgen 의 자기 메타 가드.
6
+ *
7
+ * 이번 세션 측정값: v0.4.1 시점 fix 비율 36% (정상의 약 2배). 이 코드가 다음 릴리즈
8
+ * 시 같은 비율을 자동 노출하여 사용자가 회귀 패턴을 빠르게 인지하게 한다.
9
+ */
10
+ export interface FixRatioStats {
11
+ windowSize: number;
12
+ fixCount: number;
13
+ featCount: number;
14
+ /** fix / (fix + feat), 0~1. fix+feat=0 이면 0. */
15
+ ratio: number;
16
+ threshold: number;
17
+ exceedsThreshold: boolean;
18
+ /** git 명령이 성공했는지 (저장소 외부 또는 git 미설치 시 false). */
19
+ available: boolean;
20
+ }
21
+ /**
22
+ * git log --no-merges -N 결과에서 conventional commit 형식의 fix/feat 만 카운트.
23
+ *
24
+ * 분류:
25
+ * - `feat: ...` / `feat(scope): ...` → feat
26
+ * - `fix: ...` / `fix(scope): ...` → fix (단, scope ∈ {test, tests, docs, doc} 제외)
27
+ * - 그 외 (chore, refactor, docs, style, test, hash 없는 라인) → 무시
28
+ *
29
+ * fix(test):, fix(docs): 가 제외되는 이유: 사소한 노이즈 fix 가 회귀 신호를
30
+ * 흐리지 않도록. 진짜 위험은 fix(core), fix(hook), fix(api) 같은 logic fix.
31
+ */
32
+ export declare function computeFixFeatRatio(cwd?: string, windowSize?: number, threshold?: number): FixRatioStats;
33
+ /** 테스트용 — git log 출력 텍스트를 직접 파싱. */
34
+ export declare function parseGitLog(rawLog: string, windowSize?: number, threshold?: number): FixRatioStats;
35
+ /** 사람용 한 줄 라벨. */
36
+ export declare function formatFixRatio(s: FixRatioStats): string;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Git Stats — P4 셀프 가드 (2026-04-27)
3
+ *
4
+ * 최근 N커밋의 conventional commit 분포를 측정해 fix:feat 비율을 계산.
5
+ * 정상 OSS 권장은 fix < 20%. 36% 초과 시 회귀 패턴 의심 — forgen 의 자기 메타 가드.
6
+ *
7
+ * 이번 세션 측정값: v0.4.1 시점 fix 비율 36% (정상의 약 2배). 이 코드가 다음 릴리즈
8
+ * 시 같은 비율을 자동 노출하여 사용자가 회귀 패턴을 빠르게 인지하게 한다.
9
+ */
10
+ import { execFileSync } from 'node:child_process';
11
+ const DEFAULT_THRESHOLD = 0.30;
12
+ const DEFAULT_WINDOW = 30;
13
+ const SCOPE_EXCLUSIONS = new Set(['test', 'tests', 'docs', 'doc']);
14
+ /**
15
+ * git log --no-merges -N 결과에서 conventional commit 형식의 fix/feat 만 카운트.
16
+ *
17
+ * 분류:
18
+ * - `feat: ...` / `feat(scope): ...` → feat
19
+ * - `fix: ...` / `fix(scope): ...` → fix (단, scope ∈ {test, tests, docs, doc} 제외)
20
+ * - 그 외 (chore, refactor, docs, style, test, hash 없는 라인) → 무시
21
+ *
22
+ * fix(test):, fix(docs): 가 제외되는 이유: 사소한 노이즈 fix 가 회귀 신호를
23
+ * 흐리지 않도록. 진짜 위험은 fix(core), fix(hook), fix(api) 같은 logic fix.
24
+ */
25
+ export function computeFixFeatRatio(cwd = process.cwd(), windowSize = DEFAULT_WINDOW, threshold = DEFAULT_THRESHOLD) {
26
+ try {
27
+ const out = execFileSync('git', ['log', '--no-merges', '--oneline', `-${windowSize}`], { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
28
+ return parseGitLog(out, windowSize, threshold);
29
+ }
30
+ catch {
31
+ return makeUnavailable(windowSize, threshold);
32
+ }
33
+ }
34
+ /** 테스트용 — git log 출력 텍스트를 직접 파싱. */
35
+ export function parseGitLog(rawLog, windowSize = DEFAULT_WINDOW, threshold = DEFAULT_THRESHOLD) {
36
+ const lines = rawLog.trim().split('\n').filter(Boolean);
37
+ let fix = 0;
38
+ let feat = 0;
39
+ for (const line of lines) {
40
+ const msg = line.replace(/^[a-f0-9]{4,40}\s+/, '');
41
+ const m = msg.match(/^(fix|feat)(?:\(([^)]+)\))?:/);
42
+ if (!m)
43
+ continue;
44
+ const type = m[1];
45
+ const scope = (m[2] ?? '').toLowerCase().trim();
46
+ if (type === 'fix' && SCOPE_EXCLUSIONS.has(scope))
47
+ continue;
48
+ if (type === 'fix')
49
+ fix++;
50
+ else
51
+ feat++;
52
+ }
53
+ const total = fix + feat;
54
+ const ratio = total === 0 ? 0 : fix / total;
55
+ return {
56
+ windowSize,
57
+ fixCount: fix,
58
+ featCount: feat,
59
+ ratio,
60
+ threshold,
61
+ exceedsThreshold: ratio > threshold,
62
+ available: true,
63
+ };
64
+ }
65
+ function makeUnavailable(windowSize, threshold) {
66
+ return {
67
+ windowSize, fixCount: 0, featCount: 0, ratio: 0,
68
+ threshold, exceedsThreshold: false, available: false,
69
+ };
70
+ }
71
+ /** 사람용 한 줄 라벨. */
72
+ export function formatFixRatio(s) {
73
+ if (!s.available)
74
+ return 'fix:feat ratio n/a (git unavailable)';
75
+ const pct = (s.ratio * 100).toFixed(0);
76
+ const thresholdPct = (s.threshold * 100).toFixed(0);
77
+ const flag = s.exceedsThreshold ? ` ⚠ over ${thresholdPct}%` : '';
78
+ return `fix:feat ratio ${pct}% (${s.fixCount}/${s.fixCount + s.featCount} in last ${s.windowSize})${flag}`;
79
+ }