@wooojin/forgen 0.4.0 → 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 (187) hide show
  1. package/.claude-plugin/plugin.json +5 -5
  2. package/CHANGELOG.md +194 -15
  3. package/CONTRIBUTING.md +2 -2
  4. package/README.ja.md +74 -9
  5. package/README.ko.md +77 -12
  6. package/README.md +127 -25
  7. package/README.zh.md +43 -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/checks/conclusion-verification-ratio.d.ts +37 -0
  24. package/dist/checks/conclusion-verification-ratio.js +86 -0
  25. package/dist/checks/fact-vs-agreement.d.ts +47 -0
  26. package/dist/checks/fact-vs-agreement.js +92 -0
  27. package/dist/checks/self-score-deflation.d.ts +38 -0
  28. package/dist/checks/self-score-deflation.js +108 -0
  29. package/dist/cli.js +98 -6
  30. package/dist/core/auto-compound-runner.js +137 -49
  31. package/dist/core/behavior-classifier.d.ts +28 -0
  32. package/dist/core/behavior-classifier.js +46 -0
  33. package/dist/core/dashboard.d.ts +7 -0
  34. package/dist/core/dashboard.js +41 -2
  35. package/dist/core/doctor.js +118 -5
  36. package/dist/core/extraction-notice.d.ts +18 -0
  37. package/dist/core/extraction-notice.js +64 -0
  38. package/dist/core/git-stats.d.ts +36 -0
  39. package/dist/core/git-stats.js +79 -0
  40. package/dist/core/harness.d.ts +1 -1
  41. package/dist/core/harness.js +27 -20
  42. package/dist/core/host-detect.d.ts +42 -0
  43. package/dist/core/host-detect.js +68 -0
  44. package/dist/core/init-cli.d.ts +26 -0
  45. package/dist/core/init-cli.js +104 -0
  46. package/dist/core/init.js +17 -0
  47. package/dist/core/inspect-cli.js +1 -2
  48. package/dist/core/installer.js +2 -2
  49. package/dist/core/migrate-cli.d.ts +11 -0
  50. package/dist/core/migrate-cli.js +53 -0
  51. package/dist/core/migrate-evidence-host.d.ts +36 -0
  52. package/dist/core/migrate-evidence-host.js +49 -0
  53. package/dist/core/paths.d.ts +8 -1
  54. package/dist/core/paths.js +11 -2
  55. package/dist/core/recall-cli.d.ts +26 -0
  56. package/dist/core/recall-cli.js +125 -0
  57. package/dist/core/recall-reference-detector.d.ts +43 -0
  58. package/dist/core/recall-reference-detector.js +65 -0
  59. package/dist/core/settings-injector.js +4 -2
  60. package/dist/core/spawn.d.ts +1 -1
  61. package/dist/core/spawn.js +4 -11
  62. package/dist/core/stats-cli.d.ts +21 -0
  63. package/dist/core/stats-cli.js +133 -10
  64. package/dist/core/trust-layer-intent.d.ts +35 -0
  65. package/dist/core/trust-layer-intent.js +30 -0
  66. package/dist/core/types.d.ts +1 -1
  67. package/dist/core/uninstall.js +2 -1
  68. package/dist/engine/compound-cli.js +1 -0
  69. package/dist/engine/compound-export.js +8 -3
  70. package/dist/engine/compound-extractor.js +7 -9
  71. package/dist/engine/learn-cli.js +5 -6
  72. package/dist/engine/lifecycle/bypass-detector.d.ts +6 -1
  73. package/dist/engine/lifecycle/bypass-detector.js +57 -5
  74. package/dist/engine/lifecycle/lifecycle-cli.js +4 -4
  75. package/dist/engine/lifecycle/meta-reclassifier.js +3 -3
  76. package/dist/engine/lifecycle/orchestrator.js +2 -2
  77. package/dist/engine/lifecycle/signals.js +6 -6
  78. package/dist/engine/meta-learning/session-quality-scorer.d.ts +1 -6
  79. package/dist/engine/meta-learning/session-quality-scorer.js +2 -21
  80. package/dist/engine/skill-promoter.js +3 -6
  81. package/dist/fgx.js +2 -1
  82. package/dist/forge/evidence-processor.js +12 -0
  83. package/dist/forge/onboarding.d.ts +3 -2
  84. package/dist/forge/onboarding.js +3 -2
  85. package/dist/hooks/context-guard.js +1 -1
  86. package/dist/hooks/dangerous-patterns.json +3 -3
  87. package/dist/hooks/db-guard.js +21 -5
  88. package/dist/hooks/forge-loop-progress.d.ts +9 -0
  89. package/dist/hooks/forge-loop-progress.js +38 -0
  90. package/dist/hooks/hook-registry.js +1 -1
  91. package/dist/hooks/hooks-generator.d.ts +15 -1
  92. package/dist/hooks/hooks-generator.js +18 -16
  93. package/dist/hooks/intent-classifier.js +1 -1
  94. package/dist/hooks/keyword-detector.js +2 -2
  95. package/dist/hooks/notepad-injector.js +1 -1
  96. package/dist/hooks/permission-handler.js +1 -1
  97. package/dist/hooks/post-tool-failure.js +1 -1
  98. package/dist/hooks/post-tool-use.d.ts +7 -1
  99. package/dist/hooks/post-tool-use.js +50 -23
  100. package/dist/hooks/pre-compact.js +2 -2
  101. package/dist/hooks/pre-tool-use.d.ts +7 -0
  102. package/dist/hooks/pre-tool-use.js +28 -10
  103. package/dist/hooks/rate-limiter.js +3 -3
  104. package/dist/hooks/secret-filter.js +1 -1
  105. package/dist/hooks/session-recovery.js +12 -1
  106. package/dist/hooks/shared/blocking-allowlist.d.ts +28 -0
  107. package/dist/hooks/shared/blocking-allowlist.js +38 -0
  108. package/dist/hooks/shared/command-parser.d.ts +44 -0
  109. package/dist/hooks/shared/command-parser.js +50 -0
  110. package/dist/hooks/shared/forge-loop-state.d.ts +36 -0
  111. package/dist/hooks/shared/forge-loop-state.js +116 -0
  112. package/dist/hooks/shared/hook-response.d.ts +30 -2
  113. package/dist/hooks/shared/hook-response.js +61 -3
  114. package/dist/hooks/skill-injector.js +2 -2
  115. package/dist/hooks/slop-detector.js +2 -2
  116. package/dist/hooks/solution-injector.d.ts +9 -0
  117. package/dist/hooks/solution-injector.js +48 -5
  118. package/dist/hooks/stop-guard.js +152 -13
  119. package/dist/hooks/subagent-tracker.js +1 -1
  120. package/dist/host/capabilities-claude.d.ts +8 -0
  121. package/dist/host/capabilities-claude.js +46 -0
  122. package/dist/host/capabilities-codex.d.ts +11 -0
  123. package/dist/host/capabilities-codex.js +50 -0
  124. package/dist/host/capabilities-registry.d.ts +11 -0
  125. package/dist/host/capabilities-registry.js +30 -0
  126. package/dist/host/codex-adapter.d.ts +8 -5
  127. package/dist/host/codex-adapter.js +10 -82
  128. package/dist/host/codex-output-parser.d.ts +39 -0
  129. package/dist/host/codex-output-parser.js +75 -0
  130. package/dist/host/exec-host.d.ts +54 -0
  131. package/dist/host/exec-host.js +92 -0
  132. package/dist/host/host-runtime.d.ts +37 -0
  133. package/dist/host/host-runtime.js +51 -0
  134. package/dist/host/install-claude.d.ts +35 -0
  135. package/dist/host/install-claude.js +238 -0
  136. package/dist/host/install-codex.d.ts +44 -0
  137. package/dist/host/install-codex.js +276 -0
  138. package/dist/host/install-orchestrator.d.ts +34 -0
  139. package/dist/host/install-orchestrator.js +126 -0
  140. package/dist/host/invoke-agent.d.ts +27 -0
  141. package/dist/host/invoke-agent.js +115 -0
  142. package/dist/host/parity-harness.d.ts +62 -0
  143. package/dist/host/parity-harness.js +283 -0
  144. package/dist/host/projection.d.ts +35 -0
  145. package/dist/host/projection.js +126 -0
  146. package/dist/i18n/index.js +3 -5
  147. package/dist/mcp/server.js +11 -0
  148. package/dist/mcp/tools.js +47 -0
  149. package/dist/services/session.d.ts +6 -3
  150. package/dist/services/session.js +33 -4
  151. package/dist/store/evidence-store.d.ts +1 -0
  152. package/dist/store/evidence-store.js +45 -3
  153. package/dist/store/host-mismatch.d.ts +42 -0
  154. package/dist/store/host-mismatch.js +65 -0
  155. package/dist/store/implicit-feedback-store.d.ts +59 -0
  156. package/dist/store/implicit-feedback-store.js +153 -0
  157. package/dist/store/profile-store.d.ts +29 -0
  158. package/dist/store/profile-store.js +53 -0
  159. package/dist/store/rule-store.js +8 -0
  160. package/dist/store/types.d.ts +13 -0
  161. package/hooks/hooks.json +6 -1
  162. package/package.json +7 -5
  163. package/plugin.json +4 -4
  164. package/scripts/postinstall.js +100 -25
  165. /package/{agents → assets/claude/agents}/analyst.md +0 -0
  166. /package/{agents → assets/claude/agents}/architect.md +0 -0
  167. /package/{agents → assets/claude/agents}/code-reviewer.md +0 -0
  168. /package/{agents → assets/claude/agents}/critic.md +0 -0
  169. /package/{agents → assets/claude/agents}/debugger.md +0 -0
  170. /package/{agents → assets/claude/agents}/designer.md +0 -0
  171. /package/{agents → assets/claude/agents}/executor.md +0 -0
  172. /package/{agents → assets/claude/agents}/explore.md +0 -0
  173. /package/{agents → assets/claude/agents}/git-master.md +0 -0
  174. /package/{agents → assets/claude/agents}/planner.md +0 -0
  175. /package/{agents → assets/claude/agents}/solution-evolver.md +0 -0
  176. /package/{agents → assets/claude/agents}/test-engineer.md +0 -0
  177. /package/{agents → assets/claude/agents}/verifier.md +0 -0
  178. /package/{commands → assets/claude/commands}/architecture-decision.md +0 -0
  179. /package/{commands → assets/claude/commands}/calibrate.md +0 -0
  180. /package/{commands → assets/claude/commands}/code-review.md +0 -0
  181. /package/{commands → assets/claude/commands}/compound.md +0 -0
  182. /package/{commands → assets/claude/commands}/deep-interview.md +0 -0
  183. /package/{commands → assets/claude/commands}/docker.md +0 -0
  184. /package/{commands → assets/claude/commands}/forge-loop.md +0 -0
  185. /package/{commands → assets/claude/commands}/learn.md +0 -0
  186. /package/{commands → assets/claude/commands}/retro.md +0 -0
  187. /package/{commands → assets/claude/commands}/ship.md +0 -0
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Claude InstallPlan — feat/codex-support Phase 1 (P1-2)
3
+ *
4
+ * `npm install` postinstall.js 의 *Claude 측* 4 작업을 module 로 분리.
5
+ * `forgen install claude` CLI 가 호출 + (P1-6 에서) postinstall.js 도 위임.
6
+ *
7
+ * 4 작업:
8
+ * 1. Plugin cache: ~/.claude/plugins/cache/forgen-local/forgen/<ver>/ 작성 + installed_plugins.json 등록
9
+ * 2. Slash commands: ~/.claude/commands/forgen/*.md 생성 (forgen-managed marker)
10
+ * 3. Settings hooks injection: ~/.claude/settings.json 의 hooks 머지 (forgen entry idempotent)
11
+ * 4. MCP register: ~/.claude.json 에 mcpServers.forgen-compound 추가
12
+ *
13
+ * 사용자 비-forgen 자산 보존 + 재실행 idempotent.
14
+ */
15
+ export interface ClaudeInstallOptions {
16
+ pkgRoot: string;
17
+ /** Override home dir (default: os.homedir()). 격리 테스트용. */
18
+ homeDir?: string;
19
+ /** Dry-run: 파일 미작성, 결과만 반환. */
20
+ dryRun?: boolean;
21
+ /** MCP forgen-compound 등록 여부 (default true). */
22
+ registerMcp?: boolean;
23
+ }
24
+ export interface ClaudeInstallResult {
25
+ homeDir: string;
26
+ pluginCachePath: string;
27
+ pluginCacheWritten: boolean;
28
+ slashCommandsPath: string;
29
+ slashCommandsCount: number;
30
+ settingsPath: string;
31
+ hooksInjected: number;
32
+ mcpRegistered: boolean;
33
+ mcpAlreadyPresent: boolean;
34
+ }
35
+ export declare function planClaudeInstall(opts: ClaudeInstallOptions): ClaudeInstallResult;
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Claude InstallPlan — feat/codex-support Phase 1 (P1-2)
3
+ *
4
+ * `npm install` postinstall.js 의 *Claude 측* 4 작업을 module 로 분리.
5
+ * `forgen install claude` CLI 가 호출 + (P1-6 에서) postinstall.js 도 위임.
6
+ *
7
+ * 4 작업:
8
+ * 1. Plugin cache: ~/.claude/plugins/cache/forgen-local/forgen/<ver>/ 작성 + installed_plugins.json 등록
9
+ * 2. Slash commands: ~/.claude/commands/forgen/*.md 생성 (forgen-managed marker)
10
+ * 3. Settings hooks injection: ~/.claude/settings.json 의 hooks 머지 (forgen entry idempotent)
11
+ * 4. MCP register: ~/.claude.json 에 mcpServers.forgen-compound 추가
12
+ *
13
+ * 사용자 비-forgen 자산 보존 + 재실행 idempotent.
14
+ */
15
+ import * as fs from 'node:fs';
16
+ import * as os from 'node:os';
17
+ import * as path from 'node:path';
18
+ import { generateHooksJson } from '../hooks/hooks-generator.js';
19
+ const PLUGIN_KEY = 'forgen@forgen-local';
20
+ const FORGEN_MANAGED_MARKER = '<!-- forgen-managed -->';
21
+ function readPkgVersion(pkgRoot) {
22
+ try {
23
+ const pkg = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf-8'));
24
+ return pkg.version ?? '0.0.0';
25
+ }
26
+ catch {
27
+ return '0.0.0';
28
+ }
29
+ }
30
+ // ── 1. Plugin cache ────────────────────────────────────────────────────
31
+ function writePluginCache(opts) {
32
+ const { pkgRoot, cacheDir, pluginsDir, version, dryRun } = opts;
33
+ if (dryRun)
34
+ return false;
35
+ const cacheParent = path.dirname(cacheDir);
36
+ // 이전 잔재 제거 + 디렉토리 작성
37
+ try {
38
+ fs.rmSync(cacheParent, { recursive: true, force: true });
39
+ }
40
+ catch { /* ignore */ }
41
+ fs.mkdirSync(cacheParent, { recursive: true });
42
+ // 1차: symlink 시도 (개발 환경)
43
+ let linked = false;
44
+ try {
45
+ fs.symlinkSync(pkgRoot, cacheDir, 'dir');
46
+ linked = true;
47
+ }
48
+ catch {
49
+ // symlink 실패 → cp fallback
50
+ }
51
+ if (!linked) {
52
+ fs.mkdirSync(cacheDir, { recursive: true });
53
+ const copyDirs = ['.claude-plugin', 'hooks', 'skills', 'assets'];
54
+ for (const dir of copyDirs) {
55
+ const src = path.join(pkgRoot, dir);
56
+ if (fs.existsSync(src))
57
+ fs.cpSync(src, path.join(cacheDir, dir), { recursive: true });
58
+ }
59
+ if (fs.existsSync(path.join(pkgRoot, 'dist'))) {
60
+ fs.cpSync(path.join(pkgRoot, 'dist'), path.join(cacheDir, 'dist'), { recursive: true });
61
+ }
62
+ // core deps
63
+ const coreDeps = ['js-yaml', '@modelcontextprotocol', 'zod'];
64
+ fs.mkdirSync(path.join(cacheDir, 'node_modules'), { recursive: true });
65
+ for (const dep of coreDeps) {
66
+ const depSrc = path.join(pkgRoot, 'node_modules', dep);
67
+ if (fs.existsSync(depSrc)) {
68
+ fs.cpSync(depSrc, path.join(cacheDir, 'node_modules', dep), { recursive: true });
69
+ }
70
+ }
71
+ }
72
+ // installed_plugins.json 등록
73
+ const installedPath = path.join(pluginsDir, 'installed_plugins.json');
74
+ let installed = { version: 2, plugins: {} };
75
+ if (fs.existsSync(installedPath)) {
76
+ try {
77
+ installed = JSON.parse(fs.readFileSync(installedPath, 'utf-8'));
78
+ }
79
+ catch { /* ignore */ }
80
+ }
81
+ installed.plugins = installed.plugins ?? {};
82
+ installed.plugins[PLUGIN_KEY] = [{
83
+ scope: 'user',
84
+ installPath: cacheDir,
85
+ version,
86
+ installedAt: new Date().toISOString(),
87
+ lastUpdated: new Date().toISOString(),
88
+ }];
89
+ fs.mkdirSync(pluginsDir, { recursive: true });
90
+ fs.writeFileSync(installedPath, `${JSON.stringify(installed, null, 2)}\n`);
91
+ return true;
92
+ }
93
+ // ── 2. Slash commands ──────────────────────────────────────────────────
94
+ function writeSlashCommands(opts) {
95
+ const { pkgRoot, targetDir, dryRun } = opts;
96
+ const sourceDir = path.join(pkgRoot, 'assets', 'claude', 'commands');
97
+ if (!fs.existsSync(sourceDir))
98
+ return 0;
99
+ if (dryRun) {
100
+ return fs.readdirSync(sourceDir).filter((f) => f.endsWith('.md')).length;
101
+ }
102
+ fs.mkdirSync(targetDir, { recursive: true });
103
+ let count = 0;
104
+ for (const file of fs.readdirSync(sourceDir).filter((f) => f.endsWith('.md'))) {
105
+ const skillContent = fs.readFileSync(path.join(sourceDir, file), 'utf-8');
106
+ const descMatch = skillContent.match(/description:\s*(.+)/);
107
+ const desc = descMatch?.[1]?.trim() ?? file.replace(/\.md$/, '');
108
+ const skillName = file.replace(/\.md$/, '');
109
+ const out = `# ${desc}\n\n${FORGEN_MANAGED_MARKER}\n\nActivate Forgen "${skillName}" mode for the task: $ARGUMENTS\n\n${skillContent}`;
110
+ const target = path.join(targetDir, file);
111
+ if (fs.existsSync(target)) {
112
+ const existing = fs.readFileSync(target, 'utf-8');
113
+ if (!existing.includes(FORGEN_MANAGED_MARKER))
114
+ continue; // 사용자 작성 — skip
115
+ }
116
+ fs.writeFileSync(target, out);
117
+ count += 1;
118
+ }
119
+ return count;
120
+ }
121
+ // ── 3. Settings hooks injection ────────────────────────────────────────
122
+ function injectHooksIntoSettings(opts) {
123
+ const { pkgRoot, settingsPath, dryRun } = opts;
124
+ // settings.json 컨텍스트는 ${CLAUDE_PLUGIN_ROOT} 미해석 — 절대 경로 박제 (postinstall.js 와 동일 노하우)
125
+ const generated = generateHooksJson({
126
+ pluginRoot: path.join(pkgRoot, 'dist'),
127
+ runtime: 'claude',
128
+ releaseMode: true,
129
+ });
130
+ let count = 0;
131
+ for (const events of Object.values(generated.hooks)) {
132
+ for (const group of events) {
133
+ const g = group;
134
+ if (Array.isArray(g.hooks))
135
+ count += g.hooks.length;
136
+ }
137
+ }
138
+ if (dryRun)
139
+ return count;
140
+ let settings = {};
141
+ if (fs.existsSync(settingsPath)) {
142
+ try {
143
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
144
+ }
145
+ catch { /* fallthrough */ }
146
+ }
147
+ const hooksConfig = settings.hooks ?? {};
148
+ // 기존 forgen hook 제거 (path 에 pkgRoot 또는 CLAUDE_PLUGIN_ROOT 포함된 entry)
149
+ for (const [event, entries] of Object.entries(hooksConfig)) {
150
+ if (!Array.isArray(entries))
151
+ continue;
152
+ const filtered = entries.filter((entry) => {
153
+ const e = entry;
154
+ if (!Array.isArray(e.hooks))
155
+ return true;
156
+ // forgen-managed entry 식별: pkgRoot 또는 CLAUDE_PLUGIN_ROOT 또는 'forgen' 포함
157
+ return !e.hooks.some((h) => typeof h.command === 'string' &&
158
+ (h.command.includes(pkgRoot) || h.command.includes('CLAUDE_PLUGIN_ROOT') || h.command.includes('/forgen-local/forgen/')));
159
+ });
160
+ if (filtered.length === 0)
161
+ delete hooksConfig[event];
162
+ else
163
+ hooksConfig[event] = filtered;
164
+ }
165
+ // forgen 측 entry 추가
166
+ for (const [event, entries] of Object.entries(generated.hooks)) {
167
+ if (!hooksConfig[event])
168
+ hooksConfig[event] = [];
169
+ hooksConfig[event].push(...entries);
170
+ }
171
+ settings.hooks = hooksConfig;
172
+ // enabledPlugins 등록
173
+ const enabled = settings.enabledPlugins ?? {};
174
+ enabled[PLUGIN_KEY] = true;
175
+ settings.enabledPlugins = enabled;
176
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
177
+ fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
178
+ return count;
179
+ }
180
+ function registerMcpInClaudeJson(opts) {
181
+ const { pkgRoot, claudeJsonPath, dryRun } = opts;
182
+ const serverPath = path.join(pkgRoot, 'dist', 'mcp', 'server.js');
183
+ const desired = { command: 'node', args: [serverPath] };
184
+ let claudeJson = {};
185
+ if (fs.existsSync(claudeJsonPath)) {
186
+ try {
187
+ claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf-8'));
188
+ }
189
+ catch { /* ignore */ }
190
+ }
191
+ const mcpServers = claudeJson.mcpServers ?? {};
192
+ const existing = mcpServers['forgen-compound'];
193
+ const alreadyPresent = existing !== undefined &&
194
+ existing.command === 'node' &&
195
+ Array.isArray(existing.args) &&
196
+ JSON.stringify(existing.args) === JSON.stringify(desired.args);
197
+ if (dryRun) {
198
+ return { registered: !alreadyPresent, alreadyPresent };
199
+ }
200
+ mcpServers['forgen-compound'] = desired;
201
+ claudeJson.mcpServers = mcpServers;
202
+ fs.mkdirSync(path.dirname(claudeJsonPath), { recursive: true });
203
+ fs.writeFileSync(claudeJsonPath, `${JSON.stringify(claudeJson, null, 2)}\n`);
204
+ return { registered: !alreadyPresent, alreadyPresent };
205
+ }
206
+ // ── public ─────────────────────────────────────────────────────────────
207
+ export function planClaudeInstall(opts) {
208
+ if (!opts.pkgRoot || !fs.existsSync(opts.pkgRoot)) {
209
+ throw new Error(`planClaudeInstall: invalid pkgRoot ${opts.pkgRoot}`);
210
+ }
211
+ const homeDir = opts.homeDir ?? os.homedir();
212
+ const dryRun = opts.dryRun ?? false;
213
+ const registerMcp = opts.registerMcp ?? true;
214
+ const version = readPkgVersion(opts.pkgRoot);
215
+ const claudeDir = path.join(homeDir, '.claude');
216
+ const pluginsDir = path.join(claudeDir, 'plugins');
217
+ const cacheDir = path.join(pluginsDir, 'cache', 'forgen-local', 'forgen', version);
218
+ const slashCommandsDir = path.join(claudeDir, 'commands', 'forgen');
219
+ const settingsPath = path.join(claudeDir, 'settings.json');
220
+ const claudeJsonPath = path.join(homeDir, '.claude.json');
221
+ const pluginCacheWritten = writePluginCache({ pkgRoot: opts.pkgRoot, cacheDir, pluginsDir, version, dryRun });
222
+ const slashCommandsCount = writeSlashCommands({ pkgRoot: opts.pkgRoot, targetDir: slashCommandsDir, dryRun });
223
+ const hooksInjected = injectHooksIntoSettings({ pkgRoot: opts.pkgRoot, settingsPath, dryRun });
224
+ const mcp = registerMcp
225
+ ? registerMcpInClaudeJson({ pkgRoot: opts.pkgRoot, claudeJsonPath, dryRun })
226
+ : { registered: false, alreadyPresent: false };
227
+ return {
228
+ homeDir,
229
+ pluginCachePath: cacheDir,
230
+ pluginCacheWritten,
231
+ slashCommandsPath: slashCommandsDir,
232
+ slashCommandsCount,
233
+ settingsPath,
234
+ hooksInjected,
235
+ mcpRegistered: mcp.registered,
236
+ mcpAlreadyPresent: mcp.alreadyPresent,
237
+ };
238
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Codex InstallPlan — Multi-Host Core Design §10 우선순위 3
3
+ *
4
+ * `~/.codex/hooks.json` 에 forgen hook 등록(절대경로, idempotent), `~/.codex/config.toml`
5
+ * 에 forgen-compound MCP 등록(managed marker block). $CODEX_HOME 환경변수 존중.
6
+ *
7
+ * 동작 원칙:
8
+ * - hook 등록은 generateHooksJson({runtime:'codex', pluginRoot, releaseMode}) 결과를 그대로 사용
9
+ * — 이미 codex-adapter wrapper + 절대경로 적용됨 (spec §18.5 결정 옵션 1).
10
+ * - 사용자가 직접 작성한 비-forgen hook 은 보존 (`isForgenHookEntry` pattern).
11
+ * - MCP 등록은 TOML 라이브러리 없이 marker block 으로 idempotent 관리.
12
+ * - dryRun 시 파일을 쓰지 않고 결과만 반환 (테스트 + preview 용).
13
+ */
14
+ export interface CodexInstallOptions {
15
+ /** forgen package root (build 산출물 dist/ 의 부모). 기본: 호출 시 process.cwd(). */
16
+ pkgRoot: string;
17
+ /** codex home (default: $CODEX_HOME ?? ~/.codex). */
18
+ codexHome?: string;
19
+ /** dry-run: 파일 미작성, 결과만 반환. */
20
+ dryRun?: boolean;
21
+ /** MCP 서버 등록 여부 (default true). */
22
+ registerMcp?: boolean;
23
+ /** hooks-generator releaseMode (default true: 환경 독립). */
24
+ releaseMode?: boolean;
25
+ /** AGENTS.md 위치 override (default: pkgRoot 기준 자동 resolve). 격리 테스트용. */
26
+ agentsMdPath?: string;
27
+ }
28
+ export interface CodexInstallResult {
29
+ codexHome: string;
30
+ hooksPath: string;
31
+ hooksWritten: boolean;
32
+ hooksCount: number;
33
+ preservedUserHookCount: number;
34
+ configTomlPath: string;
35
+ mcpRegistered: boolean;
36
+ mcpAlreadyPresent: boolean;
37
+ /** P3-3 (US-013): Codex skills/ 에 install 된 forgen 명령 수 */
38
+ skillsInstalled: number;
39
+ skillsPath: string;
40
+ /** P3-3: AGENTS.md (cwd) 에 forgen rule block 인젝션 여부 */
41
+ agentsMdPath: string;
42
+ agentsMdInjected: boolean;
43
+ }
44
+ export declare function planCodexInstall(opts: CodexInstallOptions): CodexInstallResult;
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Codex InstallPlan — Multi-Host Core Design §10 우선순위 3
3
+ *
4
+ * `~/.codex/hooks.json` 에 forgen hook 등록(절대경로, idempotent), `~/.codex/config.toml`
5
+ * 에 forgen-compound MCP 등록(managed marker block). $CODEX_HOME 환경변수 존중.
6
+ *
7
+ * 동작 원칙:
8
+ * - hook 등록은 generateHooksJson({runtime:'codex', pluginRoot, releaseMode}) 결과를 그대로 사용
9
+ * — 이미 codex-adapter wrapper + 절대경로 적용됨 (spec §18.5 결정 옵션 1).
10
+ * - 사용자가 직접 작성한 비-forgen hook 은 보존 (`isForgenHookEntry` pattern).
11
+ * - MCP 등록은 TOML 라이브러리 없이 marker block 으로 idempotent 관리.
12
+ * - dryRun 시 파일을 쓰지 않고 결과만 반환 (테스트 + preview 용).
13
+ */
14
+ import * as fs from 'node:fs';
15
+ import * as os from 'node:os';
16
+ import * as path from 'node:path';
17
+ import { generateHooksJson } from '../hooks/hooks-generator.js';
18
+ const MCP_MARKER_BEGIN = '# >>> forgen-managed-mcp';
19
+ const MCP_MARKER_END = '# <<< forgen-managed-mcp';
20
+ const FORGEN_SKILL_MARKER = '<!-- forgen-managed -->';
21
+ const AGENTS_MD_BEGIN = '<!-- >>> forgen-managed-rules -->';
22
+ const AGENTS_MD_END = '<!-- <<< forgen-managed-rules -->';
23
+ function resolveCodexHome(opts) {
24
+ return opts.codexHome ?? process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex');
25
+ }
26
+ function isForgenManagedHook(entry, pkgRoot) {
27
+ if (!entry || typeof entry !== 'object')
28
+ return false;
29
+ const e = entry;
30
+ if (!Array.isArray(e.hooks))
31
+ return false;
32
+ return e.hooks.some((h) => typeof h.command === 'string' && h.command.includes(pkgRoot));
33
+ }
34
+ function readJsonFile(p) {
35
+ try {
36
+ if (!fs.existsSync(p))
37
+ return null;
38
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ function buildMcpBlock(pkgRoot) {
45
+ // forgen-mcp 는 dist/mcp/server.js. node 경로는 PATH 기반.
46
+ // `--host=codex` 인자는 server.ts 가 process.env.FORGEN_HOST 로 set 하여
47
+ // correction-record evidence 박제 시 host:"codex" 로 정확히 태깅되게 한다 (spec §10-5).
48
+ const serverPath = path.join(pkgRoot, 'dist', 'mcp', 'server.js');
49
+ return [
50
+ MCP_MARKER_BEGIN,
51
+ '[mcp_servers.forgen-compound]',
52
+ 'command = "node"',
53
+ `args = [${JSON.stringify(serverPath)}, "--host=codex"]`,
54
+ MCP_MARKER_END,
55
+ ].join('\n');
56
+ }
57
+ function upsertMcpBlock(currentToml, pkgRoot) {
58
+ const block = buildMcpBlock(pkgRoot);
59
+ // marker block 이 있으면 그 사이를 새 block 으로 교체
60
+ const reMarker = new RegExp(`${MCP_MARKER_BEGIN}[\\s\\S]*?${MCP_MARKER_END}`, 'g');
61
+ if (reMarker.test(currentToml)) {
62
+ const replaced = currentToml.replace(reMarker, block);
63
+ return { content: replaced, alreadyPresent: replaced === currentToml };
64
+ }
65
+ // 없으면 끝에 append
66
+ const trimmed = currentToml.replace(/\s+$/, '');
67
+ const sep = trimmed.length > 0 ? '\n\n' : '';
68
+ return { content: `${trimmed}${sep}${block}\n`, alreadyPresent: false };
69
+ }
70
+ export function planCodexInstall(opts) {
71
+ const codexHome = resolveCodexHome(opts);
72
+ const hooksPath = path.join(codexHome, 'hooks.json');
73
+ const configTomlPath = path.join(codexHome, 'config.toml');
74
+ const releaseMode = opts.releaseMode ?? true;
75
+ // 1) forgen 측 hook (codex-adapter wrap + 절대경로) 생성
76
+ const generated = generateHooksJson({
77
+ pluginRoot: path.join(opts.pkgRoot, 'dist'),
78
+ runtime: 'codex',
79
+ releaseMode,
80
+ });
81
+ const generatedHooks = generated.hooks;
82
+ // 2) 기존 hooks.json 읽기 + forgen entry 제거 후 보존
83
+ const existing = readJsonFile(hooksPath);
84
+ const existingHooksByEvent = (existing?.hooks ?? {});
85
+ const preserved = {};
86
+ let preservedCount = 0;
87
+ for (const [event, entries] of Object.entries(existingHooksByEvent)) {
88
+ if (!Array.isArray(entries))
89
+ continue;
90
+ const userEntries = entries.filter((e) => !isForgenManagedHook(e, opts.pkgRoot));
91
+ if (userEntries.length > 0) {
92
+ preserved[event] = userEntries;
93
+ preservedCount += userEntries.length;
94
+ }
95
+ }
96
+ // 3) merge: user 보존 + forgen fresh.
97
+ // `forgenCount` 는 실제 hook 명령 개수 (matcher group 내부 hooks[] 길이의 합) 로 집계한다.
98
+ const merged = { ...preserved };
99
+ let forgenCount = 0;
100
+ for (const [event, entries] of Object.entries(generatedHooks)) {
101
+ const list = merged[event] ?? [];
102
+ list.push(...entries);
103
+ merged[event] = list;
104
+ for (const group of entries) {
105
+ const g = group;
106
+ if (Array.isArray(g.hooks))
107
+ forgenCount += g.hooks.length;
108
+ }
109
+ }
110
+ const finalHooksFile = {
111
+ description: 'forgen Codex hooks (managed; user-authored entries preserved)',
112
+ hooks: merged,
113
+ };
114
+ // 4) MCP 등록
115
+ const registerMcp = opts.registerMcp ?? true;
116
+ let mcpAlreadyPresent = false;
117
+ let mcpRegistered = false;
118
+ let mcpContentToWrite = null;
119
+ if (registerMcp) {
120
+ const currentToml = fs.existsSync(configTomlPath)
121
+ ? fs.readFileSync(configTomlPath, 'utf-8')
122
+ : '';
123
+ const { content, alreadyPresent } = upsertMcpBlock(currentToml, opts.pkgRoot);
124
+ mcpAlreadyPresent = alreadyPresent;
125
+ mcpRegistered = !alreadyPresent;
126
+ mcpContentToWrite = content;
127
+ }
128
+ // 5) 실제 쓰기 (dryRun 이면 skip) — hooks.json + config.toml
129
+ if (!opts.dryRun) {
130
+ fs.mkdirSync(codexHome, { recursive: true });
131
+ fs.writeFileSync(hooksPath, `${JSON.stringify(finalHooksFile, null, 2)}\n`, 'utf-8');
132
+ if (mcpContentToWrite !== null) {
133
+ fs.writeFileSync(configTomlPath, mcpContentToWrite, 'utf-8');
134
+ }
135
+ }
136
+ // 6) P3-3 (US-013): Codex skills/ 에 forgen 10 commands install
137
+ // Codex 의 skills 메커니즘 (codex-rs/core-skills) 구조: <skill-name>/SKILL.md
138
+ // + frontmatter (name + description). forgen-managed marker 로 idempotent.
139
+ const skillsPath = path.join(codexHome, 'skills');
140
+ const sourceCommandsDir = path.join(opts.pkgRoot, 'assets', 'claude', 'commands');
141
+ const skillsResult = installCodexSkills({ sourceDir: sourceCommandsDir, targetDir: skillsPath, dryRun: opts.dryRun ?? false });
142
+ // 7) P3-3 (US-013): cwd/AGENTS.md 에 forgen rules block 인젝션 (managed marker)
143
+ // Codex 가 AGENTS.md 를 자동 read (codex-rs/core/src/agents_md.rs 검증).
144
+ // pkgRoot 의 git repo root 의 AGENTS.md, 또는 explicit override.
145
+ const agentsMdPath = opts.agentsMdPath ?? resolveAgentsMdPath(opts.pkgRoot);
146
+ const agentsResult = upsertForgenRulesInAgentsMd({ agentsMdPath, pkgRoot: opts.pkgRoot, dryRun: opts.dryRun ?? false });
147
+ return {
148
+ codexHome,
149
+ hooksPath,
150
+ hooksWritten: !opts.dryRun,
151
+ hooksCount: forgenCount,
152
+ preservedUserHookCount: preservedCount,
153
+ configTomlPath,
154
+ mcpRegistered,
155
+ mcpAlreadyPresent,
156
+ skillsInstalled: skillsResult.installed,
157
+ skillsPath,
158
+ agentsMdPath,
159
+ agentsMdInjected: agentsResult.injected,
160
+ };
161
+ }
162
+ // ── P3-3: Codex skills install ────────────────────────────────────────
163
+ function installCodexSkills(opts) {
164
+ const { sourceDir, targetDir, dryRun } = opts;
165
+ if (!fs.existsSync(sourceDir))
166
+ return { installed: 0 };
167
+ const files = fs.readdirSync(sourceDir).filter((f) => f.endsWith('.md'));
168
+ if (dryRun)
169
+ return { installed: files.length };
170
+ fs.mkdirSync(targetDir, { recursive: true });
171
+ let count = 0;
172
+ for (const file of files) {
173
+ const skillName = file.replace(/\.md$/, '');
174
+ const skillDir = path.join(targetDir, skillName);
175
+ const skillFile = path.join(skillDir, 'SKILL.md');
176
+ if (fs.existsSync(skillFile)) {
177
+ const existing = fs.readFileSync(skillFile, 'utf-8');
178
+ // Phase 3 critic fix: marker 가 *frontmatter 직후* 위치에 있는지 검증.
179
+ // 사용자가 forgen 문서를 인용해 본문 안에 marker 가 우연히 포함될 수 있어
180
+ // includes() 만으론 안전 X. 정규식으로 frontmatter 종결(`---\n`) 다음 빈 줄 다음
181
+ // 첫 non-blank 줄에 marker 가 있는지 확인.
182
+ const fmMarkerRe = /^---\n[\s\S]*?\n---\n\s*<!-- forgen-managed -->/;
183
+ if (!fmMarkerRe.test(existing))
184
+ continue; // 사용자 작성 또는 손상 — skip
185
+ }
186
+ const raw = fs.readFileSync(path.join(sourceDir, file), 'utf-8');
187
+ const descMatch = raw.match(/description:\s*(.+)/);
188
+ const desc = descMatch?.[1]?.trim() ?? skillName;
189
+ const bodyMatch = raw.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/);
190
+ const body = bodyMatch?.[1]?.trim() ?? raw;
191
+ const out = `---\nname: ${skillName}\ndescription: ${desc}\n---\n\n${FORGEN_SKILL_MARKER}\n\n${body}\n`;
192
+ fs.mkdirSync(skillDir, { recursive: true });
193
+ fs.writeFileSync(skillFile, out);
194
+ count += 1;
195
+ }
196
+ return { installed: count };
197
+ }
198
+ // ── P3-3: AGENTS.md inject ────────────────────────────────────────────
199
+ function resolveAgentsMdPath(pkgRoot) {
200
+ // Phase 3 critic fix: pkgRoot 기반 walk-up 은 `npm install -g` 시 시스템 디렉토리
201
+ // (예: /usr/local/lib/node_modules/forgen) 에 fallback AGENTS.md 작성 위험.
202
+ // *cwd 기반* 으로 변경 — 사용자 작업 디렉토리의 git root, 없으면 cwd 자체.
203
+ // (사용자가 forgen install codex 를 실행하는 위치가 install target 이라는 자연 가정.)
204
+ // pkgRoot 는 fallback 으로 유지 (cwd 가 git root 를 못 찾고 / 등 시스템 dir 일 때).
205
+ const cwd = process.cwd();
206
+ let dir = cwd;
207
+ for (let depth = 0; depth < 8; depth += 1) {
208
+ if (fs.existsSync(path.join(dir, '.git')))
209
+ return path.join(dir, 'AGENTS.md');
210
+ const parent = path.dirname(dir);
211
+ if (parent === dir)
212
+ break;
213
+ dir = parent;
214
+ }
215
+ // cwd 에서 .git 못 찾음 — cwd 직접 사용 (시스템 dir 가 아닌 한 안전).
216
+ // 시스템 dir (예: /, /usr) 인 경우 ~/AGENTS.md fallback (사용자 home 안전).
217
+ if (cwd === '/' || cwd.startsWith('/usr/') || cwd.startsWith('/opt/')) {
218
+ return path.join(os.homedir(), 'AGENTS.md');
219
+ }
220
+ return path.join(cwd, 'AGENTS.md');
221
+ }
222
+ function buildForgenRulesBlock(pkgRoot) {
223
+ // forgen 의 핵심 규칙 + 사용자 profile 안내 (가벼운 헤더만 — 실 rule 은 hook chain 이 inject)
224
+ const lines = [
225
+ AGENTS_MD_BEGIN,
226
+ '## forgen managed rules',
227
+ '',
228
+ '본 블록은 `forgen install codex` 가 자동 관리. 직접 편집 금지 — 다음 install 시 덮어쓰임.',
229
+ '',
230
+ '- forgen-compound MCP 가 ~/.codex/config.toml 에 등록됨. 학습된 솔루션을 `compound-search` 로 조회 가능.',
231
+ '- 사용자 교정은 `correction-record` MCP 도구로 즉시 박제 (kind: fix-now / prefer-from-now / avoid-this).',
232
+ '- forgen 의 4축 profile (quality_safety / autonomy / judgment_philosophy / communication_style) 이 응답 톤 + 검증 깊이를 가이드.',
233
+ '- 본 rule 은 cwd 의 AGENTS.md 가 자동 read 되는 Codex 의 user_instructions 경로로 흘러들어감.',
234
+ `- pkgRoot: ${pkgRoot}`,
235
+ AGENTS_MD_END,
236
+ ];
237
+ return lines.join('\n');
238
+ }
239
+ function upsertForgenRulesInAgentsMd(opts) {
240
+ const { agentsMdPath, pkgRoot, dryRun } = opts;
241
+ const block = buildForgenRulesBlock(pkgRoot);
242
+ let current = '';
243
+ if (fs.existsSync(agentsMdPath)) {
244
+ current = fs.readFileSync(agentsMdPath, 'utf-8');
245
+ }
246
+ // Phase 3 critic fix #1: RegExp lastIndex 위험 회피 — g flag 제거 + 매번 새 RegExp.
247
+ const reMarker = new RegExp(`${escapeRegex(AGENTS_MD_BEGIN)}[\\s\\S]*?${escapeRegex(AGENTS_MD_END)}`);
248
+ const hasBlock = reMarker.test(current);
249
+ // Phase 3 critic fix #2: AGENTS.md self-heal — begin marker 만 있고 end 손상 시
250
+ // 누적 방지. begin 부터 파일 끝까지 + AGENTS_MD_END 미존재 = 손상으로 판단,
251
+ // begin 부터 파일 끝까지를 *전부* 새 block 으로 교체.
252
+ let newContent;
253
+ if (hasBlock) {
254
+ newContent = current.replace(reMarker, block);
255
+ }
256
+ else {
257
+ const beginIdx = current.indexOf(AGENTS_MD_BEGIN);
258
+ const endIdx = current.indexOf(AGENTS_MD_END);
259
+ if (beginIdx !== -1 && endIdx === -1) {
260
+ // 손상: begin 만 있음 → begin 부터 끝까지 교체 (self-heal)
261
+ newContent = `${current.slice(0, beginIdx).replace(/\s+$/, '')}\n\n${block}\n`;
262
+ }
263
+ else {
264
+ // 깨끗한 신규 또는 둘 다 없음 → 끝에 append
265
+ newContent = `${current.replace(/\s+$/, '')}${current.length > 0 ? '\n\n' : ''}${block}\n`;
266
+ }
267
+ }
268
+ if (dryRun)
269
+ return { injected: newContent !== current };
270
+ fs.mkdirSync(path.dirname(agentsMdPath), { recursive: true });
271
+ fs.writeFileSync(agentsMdPath, newContent, 'utf-8');
272
+ return { injected: newContent !== current };
273
+ }
274
+ function escapeRegex(s) {
275
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
276
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Install orchestrator — feat/codex-support P1-3
3
+ *
4
+ * `forgen install` CLI 의 분기 처리:
5
+ * - 인자 없음 → interactive 3-choice (claude/codex/both/quit)
6
+ * - 'claude' → planClaudeInstall()
7
+ * - 'codex' → planCodexInstall()
8
+ * - 'both' → 둘 다 실행
9
+ *
10
+ * 사용자 host 선택 권한이 forgen 측에 위임 (1원칙: Claude default 강요 금지).
11
+ * Phase 1 Round 2 의 *마이그레이션 정책 C* (기존 entry 보존) 와 함께 동작.
12
+ */
13
+ import { detectAvailableHosts } from '../core/host-detect.js';
14
+ import { type ClaudeInstallResult } from './install-claude.js';
15
+ import { type CodexInstallResult } from './install-codex.js';
16
+ export type InstallTarget = 'claude' | 'codex' | 'both';
17
+ export interface OrchestratorOptions {
18
+ /** Sub-command 인자: 'claude'|'codex'|'both' 또는 undefined (interactive). */
19
+ target?: string;
20
+ pkgRoot: string;
21
+ dryRun?: boolean;
22
+ registerMcp?: boolean;
23
+ }
24
+ export interface OrchestratorResult {
25
+ target: InstallTarget;
26
+ claude?: ClaudeInstallResult;
27
+ codex?: CodexInstallResult;
28
+ detection: ReturnType<typeof detectAvailableHosts>;
29
+ }
30
+ export declare function runInstall(opts: OrchestratorOptions): Promise<OrchestratorResult | null>;
31
+ /** CLI 출력 포맷터 — orchestrator 결과를 사용자에게 표시. */
32
+ export declare function renderResult(result: OrchestratorResult, dryRun: boolean): string;
33
+ /** pkgRoot resolve from binary location (dist/cli.js → pkgRoot). */
34
+ export declare function resolvePkgRootFromBinary(metaUrl: string): string;