@wooojin/forgen 0.4.1 → 0.4.4

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 (151) hide show
  1. package/.claude-plugin/plugin.json +5 -5
  2. package/CHANGELOG.md +267 -15
  3. package/CONTRIBUTING.md +2 -2
  4. package/README.ja.md +17 -9
  5. package/README.ko.md +34 -12
  6. package/README.md +65 -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/{commands → assets/claude/commands}/calibrate.md +4 -3
  13. package/{commands → assets/claude/commands}/retro.md +2 -2
  14. package/assets/demo/01-install.gif +0 -0
  15. package/assets/demo/01-install.tape +54 -0
  16. package/assets/demo/02-compound-learning.gif +0 -0
  17. package/assets/demo/02-compound-learning.tape +50 -0
  18. package/assets/demo/03-forge-personalization.gif +0 -0
  19. package/assets/demo/03-forge-personalization.tape +64 -0
  20. package/assets/demo/before-after.gif +0 -0
  21. package/assets/demo/before-after.tape +98 -0
  22. package/assets/demo-preview.svg +96 -0
  23. package/assets/icon.png +0 -0
  24. package/{hooks → assets/shared}/hook-registry.json +2 -1
  25. package/dist/checks/_shared/text-sanitizer.d.ts +21 -0
  26. package/dist/checks/_shared/text-sanitizer.js +60 -0
  27. package/dist/checks/dangerous-response-pattern.d.ts +32 -0
  28. package/dist/checks/dangerous-response-pattern.js +65 -0
  29. package/dist/checks/fact-vs-agreement.js +25 -1
  30. package/dist/cli.js +78 -6
  31. package/dist/core/auto-compound-runner.js +90 -39
  32. package/dist/core/behavior-classifier.d.ts +28 -0
  33. package/dist/core/behavior-classifier.js +46 -0
  34. package/dist/core/dashboard.d.ts +7 -0
  35. package/dist/core/dashboard.js +32 -0
  36. package/dist/core/doctor.js +92 -0
  37. package/dist/core/git-stats.d.ts +36 -0
  38. package/dist/core/git-stats.js +79 -0
  39. package/dist/core/harness.d.ts +1 -1
  40. package/dist/core/harness.js +27 -20
  41. package/dist/core/host-detect.d.ts +42 -0
  42. package/dist/core/host-detect.js +68 -0
  43. package/dist/core/installer.js +2 -2
  44. package/dist/core/migrate-cli.d.ts +1 -0
  45. package/dist/core/migrate-cli.js +19 -0
  46. package/dist/core/migrate-evidence-host.d.ts +36 -0
  47. package/dist/core/migrate-evidence-host.js +49 -0
  48. package/dist/core/settings-injector.js +4 -2
  49. package/dist/core/spawn.d.ts +1 -1
  50. package/dist/core/spawn.js +4 -11
  51. package/dist/core/stats-cli.js +12 -0
  52. package/dist/core/trust-layer-intent.d.ts +35 -0
  53. package/dist/core/trust-layer-intent.js +30 -0
  54. package/dist/core/types.d.ts +1 -1
  55. package/dist/engine/compound-extractor.js +7 -9
  56. package/dist/engine/learn-cli.js +4 -2
  57. package/dist/engine/lifecycle/bypass-detector.d.ts +6 -1
  58. package/dist/engine/lifecycle/bypass-detector.js +57 -5
  59. package/dist/fgx.js +2 -1
  60. package/dist/forge/evidence-processor.js +12 -0
  61. package/dist/forge/onboarding.d.ts +3 -2
  62. package/dist/forge/onboarding.js +3 -2
  63. package/dist/hooks/db-guard.js +3 -3
  64. package/dist/hooks/forge-loop-progress.d.ts +9 -0
  65. package/dist/hooks/forge-loop-progress.js +38 -0
  66. package/dist/hooks/hook-registry.js +1 -1
  67. package/dist/hooks/hooks-generator.d.ts +15 -1
  68. package/dist/hooks/hooks-generator.js +18 -16
  69. package/dist/hooks/keyword-detector.js +1 -1
  70. package/dist/hooks/post-tool-use.d.ts +1 -1
  71. package/dist/hooks/post-tool-use.js +13 -4
  72. package/dist/hooks/pre-compact.js +1 -1
  73. package/dist/hooks/pre-tool-use.js +4 -4
  74. package/dist/hooks/rate-limiter.js +2 -2
  75. package/dist/hooks/session-recovery.js +11 -0
  76. package/dist/hooks/shared/blocking-allowlist.d.ts +28 -0
  77. package/dist/hooks/shared/blocking-allowlist.js +38 -0
  78. package/dist/hooks/shared/forge-loop-state.d.ts +36 -0
  79. package/dist/hooks/shared/forge-loop-state.js +116 -0
  80. package/dist/hooks/shared/hook-response.d.ts +18 -0
  81. package/dist/hooks/shared/hook-response.js +31 -0
  82. package/dist/hooks/skill-injector.js +1 -1
  83. package/dist/hooks/stop-guard.js +57 -25
  84. package/dist/host/capabilities-claude.d.ts +8 -0
  85. package/dist/host/capabilities-claude.js +46 -0
  86. package/dist/host/capabilities-codex.d.ts +11 -0
  87. package/dist/host/capabilities-codex.js +50 -0
  88. package/dist/host/capabilities-registry.d.ts +11 -0
  89. package/dist/host/capabilities-registry.js +30 -0
  90. package/dist/host/codex-adapter.d.ts +8 -5
  91. package/dist/host/codex-adapter.js +10 -82
  92. package/dist/host/codex-output-parser.d.ts +39 -0
  93. package/dist/host/codex-output-parser.js +75 -0
  94. package/dist/host/exec-host.d.ts +54 -0
  95. package/dist/host/exec-host.js +92 -0
  96. package/dist/host/host-runtime.d.ts +37 -0
  97. package/dist/host/host-runtime.js +51 -0
  98. package/dist/host/install-claude.d.ts +35 -0
  99. package/dist/host/install-claude.js +238 -0
  100. package/dist/host/install-codex.d.ts +44 -0
  101. package/dist/host/install-codex.js +276 -0
  102. package/dist/host/install-orchestrator.d.ts +34 -0
  103. package/dist/host/install-orchestrator.js +126 -0
  104. package/dist/host/invoke-agent.d.ts +27 -0
  105. package/dist/host/invoke-agent.js +115 -0
  106. package/dist/host/parity-harness.d.ts +62 -0
  107. package/dist/host/parity-harness.js +283 -0
  108. package/dist/host/projection.d.ts +35 -0
  109. package/dist/host/projection.js +126 -0
  110. package/dist/mcp/server.js +11 -0
  111. package/dist/mcp/tools.js +51 -0
  112. package/dist/renderer/rule-renderer.d.ts +1 -1
  113. package/dist/renderer/rule-renderer.js +73 -1
  114. package/dist/services/session.d.ts +6 -3
  115. package/dist/services/session.js +33 -4
  116. package/dist/store/compound-usage-store.d.ts +28 -0
  117. package/dist/store/compound-usage-store.js +59 -0
  118. package/dist/store/evidence-store.d.ts +1 -0
  119. package/dist/store/evidence-store.js +34 -3
  120. package/dist/store/host-mismatch.d.ts +42 -0
  121. package/dist/store/host-mismatch.js +65 -0
  122. package/dist/store/profile-store.d.ts +29 -0
  123. package/dist/store/profile-store.js +53 -0
  124. package/dist/store/types.d.ts +13 -0
  125. package/hooks/hooks.json +6 -1
  126. package/package.json +6 -4
  127. package/plugin.json +4 -4
  128. package/scripts/postinstall.js +100 -25
  129. package/skills/calibrate/SKILL.md +4 -3
  130. package/skills/retro/SKILL.md +2 -2
  131. /package/{agents → assets/claude/agents}/analyst.md +0 -0
  132. /package/{agents → assets/claude/agents}/architect.md +0 -0
  133. /package/{agents → assets/claude/agents}/code-reviewer.md +0 -0
  134. /package/{agents → assets/claude/agents}/critic.md +0 -0
  135. /package/{agents → assets/claude/agents}/debugger.md +0 -0
  136. /package/{agents → assets/claude/agents}/designer.md +0 -0
  137. /package/{agents → assets/claude/agents}/executor.md +0 -0
  138. /package/{agents → assets/claude/agents}/explore.md +0 -0
  139. /package/{agents → assets/claude/agents}/git-master.md +0 -0
  140. /package/{agents → assets/claude/agents}/planner.md +0 -0
  141. /package/{agents → assets/claude/agents}/solution-evolver.md +0 -0
  142. /package/{agents → assets/claude/agents}/test-engineer.md +0 -0
  143. /package/{agents → assets/claude/agents}/verifier.md +0 -0
  144. /package/{commands → assets/claude/commands}/architecture-decision.md +0 -0
  145. /package/{commands → assets/claude/commands}/code-review.md +0 -0
  146. /package/{commands → assets/claude/commands}/compound.md +0 -0
  147. /package/{commands → assets/claude/commands}/deep-interview.md +0 -0
  148. /package/{commands → assets/claude/commands}/docker.md +0 -0
  149. /package/{commands → assets/claude/commands}/forge-loop.md +0 -0
  150. /package/{commands → assets/claude/commands}/learn.md +0 -0
  151. /package/{commands → assets/claude/commands}/ship.md +0 -0
@@ -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;
@@ -0,0 +1,126 @@
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 * as path from 'node:path';
14
+ import * as readline from 'node:readline';
15
+ import { detectAvailableHosts } from '../core/host-detect.js';
16
+ import { planClaudeInstall } from './install-claude.js';
17
+ import { planCodexInstall } from './install-codex.js';
18
+ function askChoice(rl, question, validChoices) {
19
+ return new Promise((resolve) => {
20
+ const ask = () => {
21
+ rl.question(question, (answer) => {
22
+ const trimmed = answer.trim().toLowerCase();
23
+ if (validChoices.includes(trimmed))
24
+ resolve(trimmed);
25
+ else {
26
+ console.log(` Please enter one of: ${validChoices.join(', ')}`);
27
+ ask();
28
+ }
29
+ });
30
+ };
31
+ ask();
32
+ });
33
+ }
34
+ function renderHostStatus(host) {
35
+ if (!host.available)
36
+ return ` ✗ ${host.host} (not detected — binary 미설치 + ~/.${host.host}/ 부재)`;
37
+ const bits = [];
38
+ if (host.binaryFound)
39
+ bits.push(`binary: ${host.binaryPath}`);
40
+ if (host.homeExists)
41
+ bits.push(`home: ${host.homePath}`);
42
+ if (host.host === 'codex' && host.authPresent)
43
+ bits.push('auth: present');
44
+ return ` ✓ ${host.host} (${bits.join(', ')})`;
45
+ }
46
+ async function chooseTargetInteractively(detection) {
47
+ console.log('\n [forgen] Setup wizard\n');
48
+ console.log(' Detected hosts:');
49
+ console.log(renderHostStatus(detection.claude));
50
+ console.log(renderHostStatus(detection.codex));
51
+ console.log('');
52
+ if (detection.noneAvailable) {
53
+ console.log(' ⚠ Neither Claude nor Codex detected. Install one of:');
54
+ console.log(' - Claude Code: npm install -g @anthropic-ai/claude-code');
55
+ console.log(' - Codex CLI: npm install -g @openai/codex');
56
+ return null;
57
+ }
58
+ console.log(' Where to register forgen?');
59
+ console.log(' [1] Claude only');
60
+ console.log(' [2] Codex only');
61
+ console.log(' [3] Both');
62
+ console.log(' [q] Quit');
63
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
64
+ try {
65
+ const choice = await askChoice(rl, ' Choice: ', ['1', '2', '3', 'q']);
66
+ if (choice === 'q')
67
+ return null;
68
+ return choice === '1' ? 'claude' : choice === '2' ? 'codex' : 'both';
69
+ }
70
+ finally {
71
+ rl.close();
72
+ }
73
+ }
74
+ export async function runInstall(opts) {
75
+ const detection = detectAvailableHosts();
76
+ let target;
77
+ if (opts.target === 'claude' || opts.target === 'codex' || opts.target === 'both') {
78
+ target = opts.target;
79
+ }
80
+ else if (opts.target === undefined) {
81
+ const interactive = await chooseTargetInteractively(detection);
82
+ if (interactive === null)
83
+ return null;
84
+ target = interactive;
85
+ }
86
+ else {
87
+ throw new Error(`Unknown install target: ${opts.target}. Use claude|codex|both or omit for interactive.`);
88
+ }
89
+ const result = { target, detection };
90
+ const dryRun = opts.dryRun ?? false;
91
+ const registerMcp = opts.registerMcp ?? true;
92
+ if (target === 'claude' || target === 'both') {
93
+ result.claude = planClaudeInstall({ pkgRoot: opts.pkgRoot, dryRun, registerMcp });
94
+ }
95
+ if (target === 'codex' || target === 'both') {
96
+ result.codex = planCodexInstall({ pkgRoot: opts.pkgRoot, dryRun, registerMcp });
97
+ }
98
+ return result;
99
+ }
100
+ /** CLI 출력 포맷터 — orchestrator 결과를 사용자에게 표시. */
101
+ export function renderResult(result, dryRun) {
102
+ const lines = [];
103
+ lines.push(`\n [forgen] Install ${dryRun ? '(dry-run)' : 'completed'} — target: ${result.target}`);
104
+ if (result.claude) {
105
+ lines.push('');
106
+ lines.push(' Claude:');
107
+ lines.push(` plugin cache: ${result.claude.pluginCachePath}`);
108
+ lines.push(` slash commands: ${result.claude.slashCommandsCount} → ${result.claude.slashCommandsPath}`);
109
+ lines.push(` settings.json hooks: ${result.claude.hooksInjected}`);
110
+ lines.push(` MCP: ${result.claude.mcpAlreadyPresent ? 'already present' : (result.claude.mcpRegistered ? 'registered' : 'skipped')}`);
111
+ }
112
+ if (result.codex) {
113
+ lines.push('');
114
+ lines.push(' Codex:');
115
+ lines.push(` CODEX_HOME: ${result.codex.codexHome}`);
116
+ lines.push(` hooks.json: ${result.codex.hooksCount} forgen hooks (preserved user: ${result.codex.preservedUserHookCount})`);
117
+ lines.push(` MCP: ${result.codex.mcpAlreadyPresent ? 'already present' : (result.codex.mcpRegistered ? 'registered' : 'skipped')}`);
118
+ }
119
+ lines.push('');
120
+ return lines.join('\n');
121
+ }
122
+ /** pkgRoot resolve from binary location (dist/cli.js → pkgRoot). */
123
+ export function resolvePkgRootFromBinary(metaUrl) {
124
+ const here = path.dirname(new URL(metaUrl).pathname);
125
+ return path.resolve(here, '..');
126
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * invoke-agent — feat/codex-support P3-4/P3-5
3
+ *
4
+ * forgen 의 sub-agent (assets/claude/agents/<name>.md) 를 host-aware 로 호출.
5
+ * Claude 의 Task tool 동치 — 별도 child process 에서 sub-agent 의 system prompt 를
6
+ * prefix 로 사용자 task 실행 후 결과 반환.
7
+ *
8
+ * Recursion guard: FORGEN_INVOKE_DEPTH env var 로 depth 추적, max 2 (sub-agent 가
9
+ * 또 sub-agent 호출 시도 → 차단).
10
+ */
11
+ import { type ExecHostResult } from './exec-host.js';
12
+ export interface InvokeAgentOptions {
13
+ agentName: string;
14
+ task: string;
15
+ /** Child process timeout (ms). Default 60s. */
16
+ timeoutMs?: number;
17
+ /** Override host (default: profile.default_host). */
18
+ host?: 'claude' | 'codex';
19
+ }
20
+ export interface InvokeAgentResult {
21
+ agentName: string;
22
+ host: 'claude' | 'codex';
23
+ summary: string;
24
+ durationMs: number;
25
+ usage: ExecHostResult['usage'];
26
+ }
27
+ export declare function invokeAgent(opts: InvokeAgentOptions): Promise<InvokeAgentResult>;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * invoke-agent — feat/codex-support P3-4/P3-5
3
+ *
4
+ * forgen 의 sub-agent (assets/claude/agents/<name>.md) 를 host-aware 로 호출.
5
+ * Claude 의 Task tool 동치 — 별도 child process 에서 sub-agent 의 system prompt 를
6
+ * prefix 로 사용자 task 실행 후 결과 반환.
7
+ *
8
+ * Recursion guard: FORGEN_INVOKE_DEPTH env var 로 depth 추적, max 2 (sub-agent 가
9
+ * 또 sub-agent 호출 시도 → 차단).
10
+ */
11
+ import * as fs from 'node:fs';
12
+ import * as path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { execHost } from './exec-host.js';
15
+ const MAX_DEPTH = 2;
16
+ const MAX_CONCURRENT = 3;
17
+ let activeInvocations = 0;
18
+ function findAgentsRoot() {
19
+ // Phase 3 critic fix: 단순 디렉토리 매치 시 모노레포의 동명 디렉토리 위험.
20
+ // package.json 의 name === '@wooojin/forgen' 검증으로 *정확한 forgen pkg root* 확정.
21
+ let dir = path.dirname(fileURLToPath(import.meta.url));
22
+ for (let depth = 0; depth < 8; depth += 1) {
23
+ const pkgJson = path.join(dir, 'package.json');
24
+ if (fs.existsSync(pkgJson)) {
25
+ try {
26
+ const pkg = JSON.parse(fs.readFileSync(pkgJson, 'utf-8'));
27
+ if (pkg.name === '@wooojin/forgen') {
28
+ const candidate = path.join(dir, 'assets', 'claude', 'agents');
29
+ if (fs.existsSync(candidate))
30
+ return candidate;
31
+ }
32
+ }
33
+ catch { /* fallthrough — 다음 walk-up */ }
34
+ }
35
+ const parent = path.dirname(dir);
36
+ if (parent === dir)
37
+ break;
38
+ dir = parent;
39
+ }
40
+ throw new Error('invoke-agent: forgen pkg root + assets/claude/agents/ not found');
41
+ }
42
+ function loadAgentDefinition(agentName) {
43
+ const safeName = agentName.replace(/[^a-zA-Z0-9_-]/g, '');
44
+ if (safeName !== agentName || safeName.length === 0) {
45
+ throw new Error(`invoke-agent: invalid agent_name "${agentName}" — use only [a-zA-Z0-9_-]`);
46
+ }
47
+ const root = findAgentsRoot();
48
+ const filePath = path.join(root, `${safeName}.md`);
49
+ if (!fs.existsSync(filePath)) {
50
+ const available = fs.readdirSync(root)
51
+ .filter((f) => f.endsWith('.md'))
52
+ .map((f) => f.replace(/\.md$/, ''))
53
+ .sort();
54
+ throw new Error(`invoke-agent: agent "${agentName}" not found. Available: ${available.join(', ')}`);
55
+ }
56
+ const raw = fs.readFileSync(filePath, 'utf-8');
57
+ // Phase 3 critic fix: BOM + CRLF 정규화 (Windows / Notion 파일 호환)
58
+ const normalized = raw.replace(/^/, '').replace(/\r\n/g, '\n');
59
+ const fmMatch = normalized.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
60
+ const description = fmMatch?.[1].match(/description:\s*(.+)/)?.[1].trim() ?? safeName;
61
+ const body = fmMatch?.[2]?.trim() ?? normalized;
62
+ return { systemPrompt: body, description };
63
+ }
64
+ function buildAgentPrompt(opts) {
65
+ return [
66
+ `You are the "${opts.agentName}" sub-agent. ${opts.description}`,
67
+ '',
68
+ '<system-prompt>',
69
+ opts.systemPrompt,
70
+ '</system-prompt>',
71
+ '',
72
+ 'TASK:',
73
+ opts.task,
74
+ '',
75
+ 'Respond with the deliverable — concise, focused on the task. No preamble.',
76
+ ].join('\n');
77
+ }
78
+ export async function invokeAgent(opts) {
79
+ // Phase 3 critic fix: depth 외에 fan-out 도 제한.
80
+ // depth 2 에서 N 개 sibling invoke 가 동시 시작되면 N² child spawn 가능 →
81
+ // 비용/timeout cascading. process-level concurrency limit MAX_CONCURRENT 로 제한.
82
+ const currentDepth = parseInt(process.env.FORGEN_INVOKE_DEPTH ?? '0', 10);
83
+ if (currentDepth >= MAX_DEPTH) {
84
+ throw new Error(`invoke-agent: max recursion depth ${MAX_DEPTH} exceeded (current=${currentDepth})`);
85
+ }
86
+ if (activeInvocations >= MAX_CONCURRENT) {
87
+ throw new Error(`invoke-agent: max concurrent invocations ${MAX_CONCURRENT} reached (active=${activeInvocations}). ` +
88
+ 'Sibling sub-agents must run sequentially.');
89
+ }
90
+ const { systemPrompt, description } = loadAgentDefinition(opts.agentName);
91
+ const prompt = buildAgentPrompt({ agentName: opts.agentName, description, systemPrompt, task: opts.task });
92
+ const startedAt = Date.now();
93
+ activeInvocations += 1;
94
+ try {
95
+ // Phase 3 critic fix: default timeout 60s → 90s (codex sandbox startup +
96
+ // 인증 + LLM 응답까지 60s 부족할 수 있음. tail latency 안전마진).
97
+ const result = execHost({
98
+ prompt,
99
+ timeout: opts.timeoutMs ?? 90000,
100
+ host: opts.host,
101
+ env: { FORGEN_INVOKE_DEPTH: String(currentDepth + 1) },
102
+ });
103
+ const durationMs = Date.now() - startedAt;
104
+ return {
105
+ agentName: opts.agentName,
106
+ host: result.host,
107
+ summary: result.message,
108
+ durationMs,
109
+ usage: result.usage,
110
+ };
111
+ }
112
+ finally {
113
+ activeInvocations -= 1;
114
+ }
115
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * BehavioralParityScenario harness — Multi-Host Core Design §10 우선순위 4
3
+ *
4
+ * "Claude 와 Codex 양쪽에서 같은 입력을 흘려보냈을 때 evidence 가 의미적으로 같다" 를
5
+ * 검증하는 골격. P4 단계에서는 *projection 사영 후 등가성* 만 검증한다 — 실 모델 호출은
6
+ * P6 (실 Codex CLI) 트랙.
7
+ *
8
+ * 본 harness 가 verify 하는 것:
9
+ * 1. 같은 forgen hook 입력에 대해 양쪽 host 의 raw 출력을 사영하면 의미 동치한 객체가 된다.
10
+ * 2. 사영 결과가 1원칙 (Claude reference) 의 행동 의도와 일치한다.
11
+ *
12
+ * verify 하지 않는 것 (P6 별도 트랙):
13
+ * - 실제 Codex 모델이 같은 prompt 에 같은 행동을 보이는지
14
+ * - 실제 Claude 모델과의 동작 동등성
15
+ */
16
+ import type { HookEventInput, HookEventOutput } from '../core/types.js';
17
+ import type { HostId, TrustLayerIntent } from '../core/trust-layer-intent.js';
18
+ export interface BehavioralParityScenario {
19
+ readonly id: string;
20
+ /** 검증하려는 Trust Layer 의도. */
21
+ readonly intent: TrustLayerIntent;
22
+ readonly description: string;
23
+ /** hook 입력 (HookEventInput 동치). */
24
+ readonly input: HookEventInput;
25
+ /**
26
+ * 각 host 가 *내보낼 것으로 가정* 하는 raw 출력. P4 단계에서는 spec §18 source schema
27
+ * 기반 직접 작성. P6 단계에서는 실 Codex CLI 출력으로 대체.
28
+ */
29
+ readonly hostRaw: Record<HostId, unknown>;
30
+ /**
31
+ * 사영 후 의미 동치성을 검증할 키들.
32
+ * 예: ['continue', 'hookSpecificOutput.permissionDecision'].
33
+ * 각 key 는 . 으로 nested path 표현.
34
+ */
35
+ readonly compareKeys: ReadonlyArray<string>;
36
+ }
37
+ export interface ParityCheckResult {
38
+ readonly scenarioId: string;
39
+ readonly intent: TrustLayerIntent;
40
+ readonly passed: boolean;
41
+ readonly diffs: ReadonlyArray<{
42
+ key: string;
43
+ claude: unknown;
44
+ codex: unknown;
45
+ }>;
46
+ /** 사영 결과 자체 (디버깅용). */
47
+ readonly projected: Readonly<Record<HostId, HookEventOutput>>;
48
+ }
49
+ export declare function runScenario(scenario: BehavioralParityScenario): ParityCheckResult;
50
+ /**
51
+ * P4 1차 시나리오 corpus — Trust Layer 7 의도 중 hook 출력으로 직접 관측 가능한 5종.
52
+ * (`forge-loop-state-inject` 는 inject-context 의 특수 케이스, `self-evidence-record` 는
53
+ * 파일 시스템 사이드이펙트라 본 corpus 가 아닌 별도 e2e 트랙.)
54
+ *
55
+ * P4 2차 추가 시나리오 (spec §10 우선순위 4 산출물, 2026-04-27):
56
+ * - forge-loop-m1-inject-stale : §17.1 fact 1 / M1 hook 1KB cap + stale tag
57
+ * - block-tool-use-pretool-ask : §9.0 block-tool-use row — Codex ask 값 동치
58
+ * - stop-hook-reentry-guard : §15 stop_hook_active=true 즉시 approve 경로
59
+ * - posttooluse-general-block-only : §9.0 secret-filter row partial — block decision 만 등가
60
+ * - suppress-output-equivalence : suppressOutput 동치성
61
+ */
62
+ export declare const SCENARIO_CORPUS: ReadonlyArray<BehavioralParityScenario>;