@wooojin/forgen 0.5.0 → 0.5.5

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 (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +167 -0
  3. package/README.ja.md +12 -8
  4. package/README.ko.md +12 -8
  5. package/README.md +1 -0
  6. package/README.zh.md +12 -8
  7. package/assets/shared/hook-registry.json +210 -22
  8. package/dist/core/auto-compound-runner.js +73 -14
  9. package/dist/core/changelog-cli.d.ts +4 -1
  10. package/dist/core/changelog-cli.js +8 -7
  11. package/dist/core/compound-sweep-cli.d.ts +4 -1
  12. package/dist/core/compound-sweep-cli.js +25 -5
  13. package/dist/core/doctor.js +29 -0
  14. package/dist/core/spawn.d.ts +26 -0
  15. package/dist/core/spawn.js +38 -3
  16. package/dist/engine/compound-loop.js +20 -1
  17. package/dist/hooks/context-guard.js +7 -3
  18. package/dist/hooks/hook-config.js +5 -0
  19. package/dist/hooks/hook-registry.d.ts +6 -1
  20. package/dist/hooks/hooks-generator.js +3 -0
  21. package/dist/hooks/session-end.d.ts +34 -0
  22. package/dist/hooks/session-end.js +100 -0
  23. package/dist/hooks/session-recovery.js +18 -1
  24. package/dist/hooks/shared/hook-timing.js +6 -1
  25. package/dist/host/codex-rules-context.d.ts +25 -0
  26. package/dist/host/codex-rules-context.js +62 -0
  27. package/dist/host/exec-host.d.ts +10 -0
  28. package/dist/host/exec-host.js +13 -2
  29. package/dist/host/install-codex.d.ts +58 -0
  30. package/dist/host/install-codex.js +291 -23
  31. package/dist/host/install-orchestrator.js +10 -0
  32. package/dist/host/invoke-agent.js +1 -0
  33. package/dist/host/parity-harness.js +7 -3
  34. package/dist/host/projection.d.ts +37 -3
  35. package/dist/host/projection.js +93 -42
  36. package/dist/store/evidence-store.js +121 -57
  37. package/dist/store/rule-store.d.ts +41 -0
  38. package/dist/store/rule-store.js +77 -2
  39. package/hooks/hooks.json +13 -1
  40. package/package.json +1 -1
  41. package/plugin.json +1 -1
  42. package/skills/architecture-decision/SKILL.md +18 -0
  43. package/skills/calibrate/SKILL.md +18 -0
  44. package/skills/code-review/SKILL.md +17 -0
  45. package/skills/compound/SKILL.md +17 -0
  46. package/skills/deep-interview/SKILL.md +7 -0
  47. package/skills/docker/SKILL.md +18 -0
  48. package/skills/forge-loop/SKILL.md +23 -0
  49. package/skills/learn/SKILL.md +15 -0
  50. package/skills/retro/SKILL.md +16 -0
  51. package/skills/ship/SKILL.md +18 -0
@@ -153,13 +153,28 @@ async function main() {
153
153
  // 이전엔 prepareHarness (fgx/forgen wrapper) 만 호출 → 직접 claude/codex 호출 시
154
154
  // ~/.forgen/state/sessions/<id>.json 미생성. SessionStart hook 에서도 호출하여
155
155
  // 양쪽 진입 경로 모두에서 session state 박제.
156
+ let v1RenderedRules = null;
156
157
  try {
157
158
  const { bootstrapV1Session } = await import('../core/v1-bootstrap.js');
158
- bootstrapV1Session();
159
+ v1RenderedRules = bootstrapV1Session().renderedRules;
159
160
  }
160
161
  catch (e) {
161
162
  log.debug('v1-bootstrap SessionStart 호출 실패 (fail-open)', e);
162
163
  }
164
+ // ADR-014 D1 — Codex 에는 .claude/rules/ 로드 표면이 없으므로 같은 룰을 SessionStart
165
+ // additionalContext 로 주입한다 (Claude 세션은 파일 경로로 이미 로드되므로 skip).
166
+ const codexRulesBlock = await (async () => {
167
+ try {
168
+ const { isCodexRuntime, buildCodexRulesContext } = await import('../host/codex-rules-context.js');
169
+ if (!isCodexRuntime())
170
+ return null;
171
+ return await buildCodexRulesContext(sessionContext.cwd, v1RenderedRules);
172
+ }
173
+ catch (e) {
174
+ log.debug('codex rules inject 실패 (fail-open)', e);
175
+ return null;
176
+ }
177
+ })();
163
178
  if (!fs.existsSync(STATE_DIR)) {
164
179
  console.log(approve());
165
180
  return;
@@ -435,6 +450,8 @@ async function main() {
435
450
  catch (e) {
436
451
  log.debug('lifecycle check 실패', e);
437
452
  }
453
+ if (codexRulesBlock)
454
+ recoveryMessages.unshift(codexRulesBlock);
438
455
  if (recoveryMessages.length > 0) {
439
456
  console.log(approveWithContext(recoveryMessages.join('\n\n'), 'SessionStart'));
440
457
  }
@@ -16,9 +16,14 @@ const MAX_LINES = 500;
16
16
  // MAX_LINES × 1.5 여유를 둠.
17
17
  const ROTATE_SIZE_BYTES = MAX_LINES * 80 * 2; // ~80KB
18
18
  export function recordHookTiming(hookName, durationMs, event) {
19
+ // ADR-015 C-G1 (critic #2): 중첩 추출 run 의 빠른 early-approve 타이밍이 p95 를 왜곡하지 않도록 기록 생략.
20
+ if (process.env.FORGEN_NESTED_RUN === '1')
21
+ return;
19
22
  try {
20
23
  fs.mkdirSync(STATE_DIR, { recursive: true });
21
- const entry = JSON.stringify({ hook: hookName, ms: durationMs, event, at: Date.now() });
24
+ // ADR-014 D5 — host 별 발화 구분 (codex-adapter 가 FORGEN_RUNTIME=codex 주입).
25
+ const rt = process.env.FORGEN_RUNTIME === 'codex' ? 'codex' : 'claude';
26
+ const entry = JSON.stringify({ hook: hookName, ms: durationMs, event, at: Date.now(), rt });
22
27
  fs.appendFileSync(TIMING_LOG, `${entry}\n`);
23
28
  // Rotate if too large — size-gated (statSync only, skip read/write 대부분의 호출)
24
29
  try {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Codex 개인화 룰 주입 — ADR-014 D1
3
+ *
4
+ * Claude 는 `.claude/rules/*.md` 파일을 Claude Code 가 매 턴 로드하지만 Codex 에는 그 표면이 없다.
5
+ * 본 모듈은 *같은 소스* (generateClaudeRuleFiles) 를 *같은 캡* (RULE_FILE_CAPS) 으로 렌더해
6
+ * SessionStart additionalContext 로 넣을 블록을 만든다. hooks.json 을 바꾸지 않기 위해 새 훅이
7
+ * 아니라 session-recovery 내부의 codex 분기에서 호출된다.
8
+ *
9
+ * 컴팩션 후 재주입은 별도 경로가 없다: Claude Code 와 Codex 모두 compaction 시 SessionStart 를
10
+ * source="compact" 로 다시 발화하므로 (Codex 공식 hooks 문서), 같은 경로가 한 번 더 돈다.
11
+ * (critic 리뷰 2026-10-01: PreCompact 플래그 경로는 2중 주입이라 제거.)
12
+ */
13
+ export declare const FORGEN_RULES_TAG = "forgen-rules";
14
+ /** codex-adapter 가 delegate hook 에 FORGEN_RUNTIME=codex 를 주입한다 (0.4.6+). */
15
+ export declare function isCodexRuntime(env?: NodeJS.ProcessEnv): boolean;
16
+ /**
17
+ * 룰 파일 맵 → 단일 additionalContext 블록. Claude 의 injectClaudeRuleFiles 와 동일 캡:
18
+ * 파일당 perRuleFile, 총량 totalRuleFiles. 캡 초과 파일은 잘리고, 총량 초과 시 이후 파일은 생략.
19
+ */
20
+ export declare function renderCodexRulesBlock(ruleFiles: Record<string, string>): string | null;
21
+ /**
22
+ * cwd + 렌더된 v1 룰 → Codex 주입 블록. config-injector 는 무거워서 lazy import.
23
+ * 실패 시 null (fail-open — 룰 주입 실패가 세션 시작을 막으면 안 된다).
24
+ */
25
+ export declare function buildCodexRulesContext(cwd: string, renderedRules: string | null): Promise<string | null>;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Codex 개인화 룰 주입 — ADR-014 D1
3
+ *
4
+ * Claude 는 `.claude/rules/*.md` 파일을 Claude Code 가 매 턴 로드하지만 Codex 에는 그 표면이 없다.
5
+ * 본 모듈은 *같은 소스* (generateClaudeRuleFiles) 를 *같은 캡* (RULE_FILE_CAPS) 으로 렌더해
6
+ * SessionStart additionalContext 로 넣을 블록을 만든다. hooks.json 을 바꾸지 않기 위해 새 훅이
7
+ * 아니라 session-recovery 내부의 codex 분기에서 호출된다.
8
+ *
9
+ * 컴팩션 후 재주입은 별도 경로가 없다: Claude Code 와 Codex 모두 compaction 시 SessionStart 를
10
+ * source="compact" 로 다시 발화하므로 (Codex 공식 hooks 문서), 같은 경로가 한 번 더 돈다.
11
+ * (critic 리뷰 2026-10-01: PreCompact 플래그 경로는 2중 주입이라 제거.)
12
+ */
13
+ import { RULE_FILE_CAPS } from '../hooks/shared/injection-caps.js';
14
+ export const FORGEN_RULES_TAG = 'forgen-rules';
15
+ /** codex-adapter 가 delegate hook 에 FORGEN_RUNTIME=codex 를 주입한다 (0.4.6+). */
16
+ export function isCodexRuntime(env = process.env) {
17
+ return env.FORGEN_RUNTIME === 'codex';
18
+ }
19
+ /**
20
+ * 룰 파일 맵 → 단일 additionalContext 블록. Claude 의 injectClaudeRuleFiles 와 동일 캡:
21
+ * 파일당 perRuleFile, 총량 totalRuleFiles. 캡 초과 파일은 잘리고, 총량 초과 시 이후 파일은 생략.
22
+ */
23
+ export function renderCodexRulesBlock(ruleFiles) {
24
+ const entries = Object.entries(ruleFiles).filter(([, c]) => typeof c === 'string' && c.trim().length > 0);
25
+ if (entries.length === 0)
26
+ return null;
27
+ const PER = RULE_FILE_CAPS.perRuleFile;
28
+ const TOTAL = RULE_FILE_CAPS.totalRuleFiles;
29
+ const sections = [];
30
+ let total = 0;
31
+ for (const [filename, content] of entries) {
32
+ const capped = content.length > PER
33
+ ? `${content.slice(0, PER)}\n... (capped at rule file limit)\n`
34
+ : content;
35
+ if (total + capped.length > TOTAL)
36
+ break;
37
+ total += capped.length;
38
+ sections.push(`<!-- ${filename} -->\n${capped.trim()}`);
39
+ }
40
+ if (sections.length === 0)
41
+ return null;
42
+ return [
43
+ `<${FORGEN_RULES_TAG} host="codex">`,
44
+ 'These are the same forgen rules Claude Code loads from .claude/rules/. They apply to this Codex session. Follow them.',
45
+ '',
46
+ sections.join('\n\n---\n\n'),
47
+ `</${FORGEN_RULES_TAG}>`,
48
+ ].join('\n');
49
+ }
50
+ /**
51
+ * cwd + 렌더된 v1 룰 → Codex 주입 블록. config-injector 는 무거워서 lazy import.
52
+ * 실패 시 null (fail-open — 룰 주입 실패가 세션 시작을 막으면 안 된다).
53
+ */
54
+ export async function buildCodexRulesContext(cwd, renderedRules) {
55
+ try {
56
+ const { generateClaudeRuleFiles } = await import('../core/config-injector.js');
57
+ return renderCodexRulesBlock(generateClaudeRuleFiles(cwd, renderedRules));
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
@@ -11,6 +11,11 @@
11
11
  */
12
12
  import type { HostId } from '../core/trust-layer-intent.js';
13
13
  export interface ExecHostOptions {
14
+ /**
15
+ * ADR-015 C-G1: forgen 훅을 끄고(FORGEN_NESTED_RUN=1) 세션을 남기지 않는 "추출용 중첩 실행" 모드.
16
+ * 기본 true. 위임 에이전트(invoke-agent)처럼 훅이 살아 있어야 하는 호출은 false.
17
+ */
18
+ nestedRun?: boolean;
14
19
  /** prompt — `-p`/`exec` 의 본문 */
15
20
  prompt: string;
16
21
  /** model 힌트 (claude: --model haiku, codex: 무시 — codex CLI 가 default 사용) */
@@ -50,6 +55,11 @@ export interface ExecHostResult {
50
55
  * 보장 (사용자 환경 미오염). compound-extractor / auto-compound-runner 같은
51
56
  * 백그라운드 학습 호출에 적합.
52
57
  */
58
+ /** ADR-015 C-G1 — forgen 이 띄우는 중첩 claude 실행의 공통 표식. hook-config.isHookEnabled 가 읽는다. */
59
+ export declare const NESTED_RUN_ENV: Readonly<Record<string, string>>;
60
+ /** `--no-session-persistence` 는 --print 전용 — 추출 run 의 transcript 를 디스크에 남기지 않는다. */
61
+ export declare const NESTED_RUN_CLAUDE_ARGS: ReadonlyArray<string>;
62
+ export declare function withNestedRunClaudeArgs(args: string[]): string[];
53
63
  export declare function execHost(opts: ExecHostOptions): ExecHostResult;
54
64
  /** 1회 retry — transient 에러(ETIMEDOUT 등) 대응. */
55
65
  export declare function execHostRetry(opts: ExecHostOptions): ExecHostResult;
@@ -34,6 +34,13 @@ export const DEFAULT_TIMEOUT_BY_HOST = {
34
34
  * 보장 (사용자 환경 미오염). compound-extractor / auto-compound-runner 같은
35
35
  * 백그라운드 학습 호출에 적합.
36
36
  */
37
+ /** ADR-015 C-G1 — forgen 이 띄우는 중첩 claude 실행의 공통 표식. hook-config.isHookEnabled 가 읽는다. */
38
+ export const NESTED_RUN_ENV = { FORGEN_NESTED_RUN: '1' };
39
+ /** `--no-session-persistence` 는 --print 전용 — 추출 run 의 transcript 를 디스크에 남기지 않는다. */
40
+ export const NESTED_RUN_CLAUDE_ARGS = ['--no-session-persistence'];
41
+ export function withNestedRunClaudeArgs(args) {
42
+ return args.includes('--no-session-persistence') ? args : [...args, ...NESTED_RUN_CLAUDE_ARGS];
43
+ }
37
44
  export function execHost(opts) {
38
45
  const resolved = resolveDefaultHost(opts.host);
39
46
  // 'ask' 는 자동 호출 컨텍스트라 명시 fallback. 그러나 Codex-only 사용자가 'ask'
@@ -53,10 +60,14 @@ export function execHost(opts) {
53
60
  env: { ...process.env, ...(opts.env ?? {}) },
54
61
  };
55
62
  if (host === 'claude') {
56
- const args = ['-p', opts.prompt];
63
+ // ADR-015 C-G1: 중첩 실행 표식 + 세션 비영속 (추출 run 의 transcript 가 다음 SessionStart 의
64
+ // "이전 세션 auto-compound" 후보로 잡히거나 forgen 훅이 재귀 발화하지 않도록).
65
+ const nested = opts.nestedRun ?? true;
66
+ const args = ['-p', opts.prompt, ...(nested ? NESTED_RUN_CLAUDE_ARGS : [])];
57
67
  if (opts.model)
58
68
  args.push('--model', opts.model);
59
- const stdout = execFileSync('claude', args, baseOpts);
69
+ const env = nested ? { ...(baseOpts.env ?? {}), ...NESTED_RUN_ENV } : baseOpts.env;
70
+ const stdout = execFileSync('claude', args, { ...baseOpts, env });
60
71
  return { message: stdout.toString().trim(), host: 'claude', usage: null };
61
72
  }
62
73
  // host === 'codex'
@@ -44,8 +44,65 @@ export interface CodexInstallResult {
44
44
  devGuideSkillsPath: string;
45
45
  devGuideSkillsInstalled: number;
46
46
  devGuideSkillsRemoved: number;
47
+ /** ADR-014 D2: ~/.codex/agents/ch-*.toml 커스텀 에이전트 설치 결과 */
48
+ agentsPath: string;
49
+ agentsInstalled: number;
50
+ agentsRemoved: number;
51
+ /** ADR-014 D4: hooks.json forgen 엔트리 중 config.toml hooks.state 에 신뢰 기록이 있는 수 */
52
+ hookTrust: CodexHookTrustAudit;
53
+ /** ADR-014 D2: config.toml `[features] multi_agent = true` 여부 (false 면 ch-* 에이전트 spawn 불가 → 안내) */
54
+ multiAgentEnabled: boolean;
55
+ }
56
+ export interface CodexHookTrustAudit {
57
+ /** hooks.json 의 forgen hook 명령 수 (Codex 가 지원하는 이벤트만) */
58
+ total: number;
59
+ /** Codex 가 모르는 이벤트라 조용히 무시되는 forgen 엔트리 (`<event>:<i>:<j>`) — trust 대상이 아님 */
60
+ ignoredByCodex: string[];
61
+ /** config.toml `[hooks.state."<hooks.json>:<event>:<i>:<j>"]` 에 trusted_hash 가 있는 수 */
62
+ trusted: number;
63
+ /** 신뢰 기록이 없는 hook 키 (`<event>:<i>:<j>`) */
64
+ untrusted: string[];
65
+ /** config.toml 자체가 없거나 hooks.state 가 전혀 없으면 true (Codex 가 아직 한 번도 훅을 review 안 함) */
66
+ noStateRecorded: boolean;
67
+ }
68
+ interface HooksFile {
69
+ description?: string;
70
+ hooks: Record<string, Array<unknown>>;
47
71
  }
48
72
  export declare function planCodexInstall(opts: CodexInstallOptions): CodexInstallResult;
73
+ /** `[features]` 섹션 안에 `multi_agent = true` 가 있는지 (TOML 라이브러리 없이 섹션 범위만 본다). */
74
+ export declare function isCodexMultiAgentEnabled(configToml: string): boolean;
75
+ /**
76
+ * Codex 0.153 hooks 공식 이벤트 12종 (learn.chatgpt.com/docs/hooks + binary 문자열). 이 밖의 이벤트
77
+ * (예: Claude 전용 PostToolUseFailure) 는 Codex 가 조용히 무시하므로 trust 대상이 아니다.
78
+ */
79
+ export declare const CODEX_SUPPORTED_HOOK_EVENTS: ReadonlySet<string>;
80
+ /** Codex 는 hooks.state 키에 이벤트명을 snake_case 로 쓴다 (PreToolUse → pre_tool_use). */
81
+ export declare function codexHookEventKey(event: string): string;
82
+ /**
83
+ * hooks.json 의 forgen 엔트리 각각에 대해 config.toml 의
84
+ * `[hooks.state."<hooksPath>:<event>:<groupIdx>:<hookIdx>"]` 섹션(trusted_hash) 존재를 대조한다.
85
+ * trusted_hash 자체는 검증하지 않는다 — Codex 가 변경된 훅을 review 전까지 skip 하는 정책을
86
+ * forgen 이 우회하지 않기 위해, *기록 유무* 만 가시화한다 (ADR-014 D4).
87
+ */
88
+ export declare function auditCodexHookTrust(opts: {
89
+ hooksPath: string;
90
+ configTomlPath: string;
91
+ pkgRoot: string;
92
+ hooksFile?: HooksFile | null;
93
+ configToml?: string;
94
+ }): CodexHookTrustAudit;
95
+ /**
96
+ * Claude agent .md → Codex agent role TOML.
97
+ * 공식 스키마(필수 name/description/developer_instructions, 선택 model_reasoning_effort/sandbox_mode)
98
+ * 외 필드는 쓰지 않는다 — Codex 가 unknown field 를 거부 (ADR-014 D2).
99
+ */
100
+ export declare function renderCodexAgentToml(file: string, raw: string): {
101
+ name: string;
102
+ toml: string;
103
+ } | null;
104
+ /** ADR-014 D3 — Codex 스킬에는 `$ARGUMENTS` 치환 변수가 없다. */
105
+ export declare function adaptSkillBodyForCodex(body: string): string;
49
106
  export declare function resolveAgentsMdPath(_pkgRoot: string): string;
50
107
  export declare function upsertForgenRulesInAgentsMd(opts: {
51
108
  agentsMdPath: string;
@@ -54,3 +111,4 @@ export declare function upsertForgenRulesInAgentsMd(opts: {
54
111
  }): {
55
112
  injected: boolean;
56
113
  };
114
+ export {};
@@ -85,29 +85,39 @@ export function planCodexInstall(opts) {
85
85
  releaseMode,
86
86
  });
87
87
  const generatedHooks = generated.hooks;
88
- // 2) 기존 hooks.json 읽기 + forgen entry 제거 후 보존
88
+ // 2) 기존 hooks.json 읽기 — forgen 그룹은 *제자리에서* 교체, 사용자 그룹은 위치 그대로 보존.
89
+ // (0.5.3 critic/실머신: 이전엔 사용자 그룹을 앞으로 모으고 forgen 을 뒤에 붙여 그룹 인덱스가
90
+ // 바뀌었고, Codex 의 trust 키 `<event>:<groupIdx>:<hookIdx>` 가 어긋나 20/21 → 12/21 로
91
+ // 훅 신뢰가 깨졌다. 바이트 동일성이 곧 신뢰 보존이다.)
89
92
  const existing = readJsonFile(hooksPath);
90
93
  const existingHooksByEvent = (existing?.hooks ?? {});
91
- const preserved = {};
94
+ const eventOrder = [...new Set([...Object.keys(existingHooksByEvent), ...Object.keys(generatedHooks)])];
95
+ const merged = {};
92
96
  let preservedCount = 0;
93
- for (const [event, entries] of Object.entries(existingHooksByEvent)) {
94
- if (!Array.isArray(entries))
95
- continue;
96
- const userEntries = entries.filter((e) => !isForgenManagedHook(e, opts.pkgRoot));
97
- if (userEntries.length > 0) {
98
- preserved[event] = userEntries;
99
- preservedCount += userEntries.length;
100
- }
101
- }
102
- // 3) merge: user 보존 + forgen fresh.
103
- // `forgenCount` 는 실제 hook 명령 개수 (matcher group 내부 hooks[] 길이의 합) 로 집계한다.
104
- const merged = { ...preserved };
105
97
  let forgenCount = 0;
106
- for (const [event, entries] of Object.entries(generatedHooks)) {
107
- const list = merged[event] ?? [];
108
- list.push(...entries);
109
- merged[event] = list;
110
- for (const group of entries) {
98
+ for (const event of eventOrder) {
99
+ const existingGroups = Array.isArray(existingHooksByEvent[event]) ? existingHooksByEvent[event] : [];
100
+ const generatedGroups = generatedHooks[event] ?? [];
101
+ const out = [];
102
+ let inserted = false;
103
+ for (const group of existingGroups) {
104
+ if (isForgenManagedHook(group, opts.pkgRoot)) {
105
+ if (!inserted) {
106
+ out.push(...generatedGroups);
107
+ inserted = true;
108
+ }
109
+ // 이후 중복 forgen 그룹은 드롭 (stale 누적 방지)
110
+ }
111
+ else {
112
+ out.push(group);
113
+ preservedCount += 1;
114
+ }
115
+ }
116
+ if (!inserted && generatedGroups.length > 0)
117
+ out.push(...generatedGroups);
118
+ if (out.length > 0)
119
+ merged[event] = out;
120
+ for (const group of generatedGroups) {
111
121
  const g = group;
112
122
  if (Array.isArray(g.hooks))
113
123
  forgenCount += g.hooks.length;
@@ -156,6 +166,21 @@ export function planCodexInstall(opts) {
156
166
  codexHome,
157
167
  dryRun: opts.dryRun ?? false,
158
168
  });
169
+ // 9) ADR-014 D2: assets/claude/agents/*.md → ~/.codex/agents/ch-<name>.toml
170
+ const codexAgents = installCodexAgents({
171
+ sourceDir: path.join(opts.pkgRoot, 'assets', 'claude', 'agents'),
172
+ targetDir: path.join(codexHome, 'agents'),
173
+ dryRun: opts.dryRun ?? false,
174
+ });
175
+ // 10) ADR-014 D4: 훅 신뢰 감사 (dryRun 이면 현재 디스크 상태 기준)
176
+ const hookTrust = auditCodexHookTrust({
177
+ hooksPath,
178
+ configTomlPath,
179
+ pkgRoot: opts.pkgRoot,
180
+ hooksFile: opts.dryRun ? (existing ?? finalHooksFile) : finalHooksFile,
181
+ configToml: mcpContentToWrite ?? (fs.existsSync(configTomlPath) ? fs.readFileSync(configTomlPath, 'utf-8') : ''),
182
+ });
183
+ const multiAgentEnabled = isCodexMultiAgentEnabled(mcpContentToWrite ?? (fs.existsSync(configTomlPath) ? fs.readFileSync(configTomlPath, 'utf-8') : ''));
159
184
  return {
160
185
  codexHome,
161
186
  hooksPath,
@@ -172,8 +197,230 @@ export function planCodexInstall(opts) {
172
197
  devGuideSkillsPath: devGuideResult.devGuideSkillsPath,
173
198
  devGuideSkillsInstalled: devGuideResult.devGuideSkillsInstalled,
174
199
  devGuideSkillsRemoved: devGuideResult.devGuideSkillsRemoved,
200
+ agentsPath: codexAgents.agentsPath,
201
+ agentsInstalled: codexAgents.installed,
202
+ agentsRemoved: codexAgents.removed,
203
+ hookTrust,
204
+ multiAgentEnabled,
175
205
  };
176
206
  }
207
+ /** `[features]` 섹션 안에 `multi_agent = true` 가 있는지 (TOML 라이브러리 없이 섹션 범위만 본다). */
208
+ export function isCodexMultiAgentEnabled(configToml) {
209
+ const m = configToml.match(/^\[features\]\s*$([\s\S]*?)(?=^\[|(?![\s\S]))/m);
210
+ if (!m)
211
+ return false;
212
+ return /^\s*multi_agent\s*=\s*true\s*$/m.test(m[1]);
213
+ }
214
+ // ── ADR-014 D4: Codex hook trust audit ────────────────────────────────
215
+ /**
216
+ * Codex 0.153 hooks 공식 이벤트 12종 (learn.chatgpt.com/docs/hooks + binary 문자열). 이 밖의 이벤트
217
+ * (예: Claude 전용 PostToolUseFailure) 는 Codex 가 조용히 무시하므로 trust 대상이 아니다.
218
+ */
219
+ export const CODEX_SUPPORTED_HOOK_EVENTS = new Set([
220
+ 'PreToolUse', 'PermissionRequest', 'PostToolUse', 'PreCompact', 'PostCompact', 'UserPromptSubmit',
221
+ 'SubagentStart', 'SubagentStop', 'Stop', 'Interrupt', 'SessionStart', 'SessionEnd',
222
+ ]);
223
+ /** Codex 는 hooks.state 키에 이벤트명을 snake_case 로 쓴다 (PreToolUse → pre_tool_use). */
224
+ export function codexHookEventKey(event) {
225
+ return event.replace(/(?<!^)([A-Z])/g, '_$1').toLowerCase();
226
+ }
227
+ /**
228
+ * hooks.json 의 forgen 엔트리 각각에 대해 config.toml 의
229
+ * `[hooks.state."<hooksPath>:<event>:<groupIdx>:<hookIdx>"]` 섹션(trusted_hash) 존재를 대조한다.
230
+ * trusted_hash 자체는 검증하지 않는다 — Codex 가 변경된 훅을 review 전까지 skip 하는 정책을
231
+ * forgen 이 우회하지 않기 위해, *기록 유무* 만 가시화한다 (ADR-014 D4).
232
+ */
233
+ export function auditCodexHookTrust(opts) {
234
+ const hooksFile = opts.hooksFile ?? readJsonFile(opts.hooksPath);
235
+ const toml = opts.configToml ?? (fs.existsSync(opts.configTomlPath) ? fs.readFileSync(opts.configTomlPath, 'utf-8') : '');
236
+ const stateKeys = new Set();
237
+ const re = /^\[hooks\.state\."([^"]+)"\]\s*$/gm;
238
+ let m = re.exec(toml);
239
+ while (m !== null) {
240
+ stateKeys.add(m[1]);
241
+ m = re.exec(toml);
242
+ }
243
+ let total = 0;
244
+ let trusted = 0;
245
+ const untrusted = [];
246
+ const ignoredByCodex = [];
247
+ const events = (hooksFile?.hooks ?? {});
248
+ for (const [event, groups] of Object.entries(events)) {
249
+ if (!Array.isArray(groups))
250
+ continue;
251
+ groups.forEach((group, gi) => {
252
+ const g = group;
253
+ if (!Array.isArray(g.hooks))
254
+ return;
255
+ g.hooks.forEach((h, hi) => {
256
+ const isForgen = typeof h.command === 'string' &&
257
+ (h.command.includes(opts.pkgRoot) || FORGEN_HOOK_SCRIPT_MARKER.test(h.command));
258
+ if (!isForgen)
259
+ return;
260
+ const key = `${codexHookEventKey(event)}:${gi}:${hi}`;
261
+ if (!CODEX_SUPPORTED_HOOK_EVENTS.has(event)) {
262
+ ignoredByCodex.push(key);
263
+ return;
264
+ }
265
+ total += 1;
266
+ if (stateKeys.has(`${opts.hooksPath}:${key}`))
267
+ trusted += 1;
268
+ else
269
+ untrusted.push(key);
270
+ });
271
+ });
272
+ }
273
+ return { total, trusted, untrusted, ignoredByCodex, noStateRecorded: stateKeys.size === 0 };
274
+ }
275
+ // ── ADR-014 D2: Codex custom agents (~/.codex/agents/ch-*.toml) ──────
276
+ const AGENT_TOML_MARKER = '# forgen-managed';
277
+ const AGENT_NAME_PREFIX = 'ch-';
278
+ function parseAgentMarkdown(raw) {
279
+ const fm = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
280
+ if (!fm)
281
+ return null;
282
+ const meta = {};
283
+ const tools = [];
284
+ const disallowedTools = [];
285
+ let currentList = null;
286
+ for (const line of fm[1].split('\n')) {
287
+ const listItem = line.match(/^\s+-\s+(.+)$/);
288
+ if (currentList && listItem) {
289
+ currentList.push(listItem[1].trim());
290
+ continue;
291
+ }
292
+ currentList = null;
293
+ const kv = line.match(/^([A-Za-z_]+):\s*(.*)$/);
294
+ if (!kv)
295
+ continue;
296
+ const [, k, v] = kv;
297
+ if (k === 'tools' || k === 'disallowedTools') {
298
+ const target = k === 'tools' ? tools : disallowedTools;
299
+ // 인라인 배열 `tools: [Read, Bash]` 도 허용
300
+ const inline = v.trim().match(/^\[(.*)\]$/);
301
+ if (inline)
302
+ target.push(...inline[1].split(',').map((t) => t.trim()).filter(Boolean));
303
+ else
304
+ currentList = target;
305
+ continue;
306
+ }
307
+ meta[k] = v.trim();
308
+ }
309
+ return { meta, tools, disallowedTools, body: fm[2].trim() };
310
+ }
311
+ /** TOML 문자열에 들어갈 수 없는 제어문자 제거 (탭/개행/CR 은 유지). */
312
+ function stripTomlControlChars(v) {
313
+ let out = '';
314
+ for (const ch of v) {
315
+ const code = ch.charCodeAt(0);
316
+ const isAllowed = (code >= 0x20 && code !== 0x7f) || code === 0x09 || code === 0x0a || code === 0x0d;
317
+ if (isAllowed)
318
+ out += ch;
319
+ }
320
+ return out;
321
+ }
322
+ /** TOML basic string (한 줄). JSON 문자열 문법은 TOML basic string 의 부분집합. */
323
+ function tomlString(v) {
324
+ return JSON.stringify(stripTomlControlChars(v));
325
+ }
326
+ /** TOML multi-line basic string. 백슬래시와 삼중따옴표만 이스케이프하면 된다. */
327
+ function tomlMultiline(v) {
328
+ const cleaned = stripTomlControlChars(v)
329
+ .split('\\').join('\\\\')
330
+ .split('"""').join('\\"\\"\\"');
331
+ return `"""\n${cleaned}\n"""`;
332
+ }
333
+ const REASONING_BY_CLAUDE_MODEL = { opus: 'high', sonnet: 'medium', haiku: 'low' };
334
+ /**
335
+ * Claude agent .md → Codex agent role TOML.
336
+ * 공식 스키마(필수 name/description/developer_instructions, 선택 model_reasoning_effort/sandbox_mode)
337
+ * 외 필드는 쓰지 않는다 — Codex 가 unknown field 를 거부 (ADR-014 D2).
338
+ */
339
+ export function renderCodexAgentToml(file, raw) {
340
+ const parsed = parseAgentMarkdown(raw);
341
+ if (!parsed)
342
+ return null;
343
+ const base = file.replace(/\.md$/, '');
344
+ const name = base.startsWith(AGENT_NAME_PREFIX) ? base : `${AGENT_NAME_PREFIX}${base}`;
345
+ if (!/^[A-Za-z0-9 _-]+$/.test(name))
346
+ return null;
347
+ const description = parsed.meta.description || name;
348
+ // critic 2026-10-01: 7/14 에이전트는 `tools:` 대신 `disallowedTools: [Write, Edit]` 로 읽기전용을 선언.
349
+ const WRITE_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
350
+ const canWrite = parsed.tools.length > 0
351
+ ? parsed.tools.some((t) => WRITE_TOOLS.has(t))
352
+ : !parsed.disallowedTools.some((t) => WRITE_TOOLS.has(t));
353
+ const sandbox = canWrite ? 'workspace-write' : 'read-only';
354
+ const effort = REASONING_BY_CLAUDE_MODEL[parsed.meta.model ?? ''] ?? 'medium';
355
+ const body = parsed.body.length > 0 ? parsed.body : description;
356
+ const toml = [
357
+ AGENT_TOML_MARKER,
358
+ `# generated by \`forgen install codex\` from assets/claude/agents/${file} — do not edit; re-generated on install`,
359
+ `name = ${tomlString(name)}`,
360
+ `description = ${tomlString(description)}`,
361
+ `model_reasoning_effort = ${tomlString(effort)}`,
362
+ `sandbox_mode = ${tomlString(sandbox)}`,
363
+ `developer_instructions = ${tomlMultiline(body)}`,
364
+ '',
365
+ ].join('\n');
366
+ return { name, toml };
367
+ }
368
+ function installCodexAgents(opts) {
369
+ const { sourceDir, targetDir, dryRun } = opts;
370
+ if (!fs.existsSync(sourceDir))
371
+ return { agentsPath: targetDir, installed: 0, removed: 0 };
372
+ const files = fs.readdirSync(sourceDir).filter((f) => f.endsWith('.md'));
373
+ const isUserOwned = (p) => {
374
+ try {
375
+ const st = fs.lstatSync(p);
376
+ if (st.isSymbolicLink())
377
+ return true; // 사용자 심링크 (dangling 포함) — 건드리지 않음
378
+ return !fs.readFileSync(p, 'utf-8').slice(0, 64).startsWith(AGENT_TOML_MARKER);
379
+ }
380
+ catch {
381
+ return false; // 없음
382
+ }
383
+ };
384
+ if (dryRun) {
385
+ const wouldInstall = files.filter((f) => {
386
+ const r = renderCodexAgentToml(f, fs.readFileSync(path.join(sourceDir, f), 'utf-8'));
387
+ return r !== null && !isUserOwned(path.join(targetDir, `${r.name}.toml`));
388
+ }).length;
389
+ return { agentsPath: targetDir, installed: wouldInstall, removed: 0 };
390
+ }
391
+ fs.mkdirSync(targetDir, { recursive: true });
392
+ // stale 정리: forgen-managed 마커가 있는 ch-*.toml 만. 사용자 파일은 보존.
393
+ let removed = 0;
394
+ for (const entry of fs.readdirSync(targetDir)) {
395
+ if (!entry.startsWith(AGENT_NAME_PREFIX) || !entry.endsWith('.toml'))
396
+ continue;
397
+ const p = path.join(targetDir, entry);
398
+ try {
399
+ if (fs.lstatSync(p).isSymbolicLink())
400
+ continue;
401
+ const head = fs.readFileSync(p, 'utf-8').slice(0, 64);
402
+ if (!head.startsWith(AGENT_TOML_MARKER))
403
+ continue;
404
+ fs.unlinkSync(p);
405
+ removed += 1;
406
+ }
407
+ catch { /* best-effort */ }
408
+ }
409
+ let installed = 0;
410
+ for (const file of files) {
411
+ const rendered = renderCodexAgentToml(file, fs.readFileSync(path.join(sourceDir, file), 'utf-8'));
412
+ if (!rendered)
413
+ continue;
414
+ const dst = path.join(targetDir, `${rendered.name}.toml`);
415
+ if (isUserOwned(dst)) {
416
+ // 방금 stale 정리에서 살아남은 = 사용자 작성 (마커 없음) 또는 심링크 → 보존
417
+ continue;
418
+ }
419
+ fs.writeFileSync(dst, rendered.toml, 'utf-8');
420
+ installed += 1;
421
+ }
422
+ return { agentsPath: targetDir, installed, removed };
423
+ }
177
424
  // ── v0.4.9: dev-guide skills → ~/.codex/skills ────────────────────────
178
425
  // dev-guide prefix pattern: forgen-<stack>-<skill> (e.g. forgen-react-fe-build)
179
426
  // 반드시 stack 이 react|vue|node|go 인 것만 매칭 — forgen 자체 commands 보존
@@ -237,6 +484,27 @@ function installDevGuideSkillsToCodex(opts) {
237
484
  return { devGuideSkillsPath: codexSkillsDir, devGuideSkillsInstalled: installed, devGuideSkillsRemoved: removed };
238
485
  }
239
486
  // ── P3-3: Codex skills install ────────────────────────────────────────
487
+ /** ADR-014 D3 — Codex 스킬에는 `$ARGUMENTS` 치환 변수가 없다. */
488
+ export function adaptSkillBodyForCodex(body) {
489
+ return body
490
+ .replace(/\{\$ARGUMENTS\}/g, '{the user\'s request text}')
491
+ .replace(/`\$ARGUMENTS`/g, 'the user\'s request text (what follows the skill name)')
492
+ .replace(/\$ARGUMENTS/g, 'the user\'s request text (what follows the skill name)');
493
+ }
494
+ /** ADR-014 D2/D3 — 스킬 본문의 ch-* 에이전트 참조가 Codex 에서 어떻게 해석되는지 명시. */
495
+ const CODEX_SKILL_HOST_NOTE = `
496
+ ---
497
+
498
+ ## Codex host note (forgen-managed)
499
+
500
+ - Sub-agents named \`ch-*\` (ch-planner, ch-executor, ch-verifier, ch-critic, ...) are installed as Codex
501
+ custom agents under \`$CODEX_HOME/agents/ch-*.toml\`. Spawn them with Codex's multi-agent tools when
502
+ available (\`[features] multi_agent = true\` in config.toml).
503
+ - If spawning is unavailable, call the \`invoke-agent\` tool on the \`forgen-compound\` MCP server
504
+ (agent_name + task) or perform that stage inline yourself. Do not skip the stage.
505
+ - The forgen personalized rules for this session arrive as a \`<forgen-rules host="codex">\` block at
506
+ session start. Treat them exactly like Claude Code's .claude/rules/.
507
+ `;
240
508
  function installCodexSkills(opts) {
241
509
  const { sourceDir, targetDir, dryRun } = opts;
242
510
  if (!fs.existsSync(sourceDir))
@@ -264,8 +532,8 @@ function installCodexSkills(opts) {
264
532
  const descMatch = raw.match(/description:\s*(.+)/);
265
533
  const desc = descMatch?.[1]?.trim() ?? skillName;
266
534
  const bodyMatch = raw.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/);
267
- const body = bodyMatch?.[1]?.trim() ?? raw;
268
- const out = `---\nname: ${skillName}\ndescription: ${desc}\n---\n\n${FORGEN_SKILL_MARKER}\n\n${body}\n`;
535
+ const body = adaptSkillBodyForCodex(bodyMatch?.[1]?.trim() ?? raw);
536
+ const out = `---\nname: ${skillName}\ndescription: ${desc}\n---\n\n${FORGEN_SKILL_MARKER}\n\n${body}\n${CODEX_SKILL_HOST_NOTE}`;
269
537
  fs.mkdirSync(skillDir, { recursive: true });
270
538
  fs.writeFileSync(skillFile, out);
271
539
  count += 1;
@@ -297,7 +565,7 @@ export function resolveAgentsMdPath(_pkgRoot) {
297
565
  return path.join(cwd, 'AGENTS.md');
298
566
  }
299
567
  function buildForgenRulesBlock(pkgRoot) {
300
- // forgen 의 핵심 규칙 + 사용자 profile 안내 (가벼운 헤더만 — 실 rule 은 hook chain 이 inject)
568
+ // forgen 의 핵심 규칙 + 사용자 profile 안내 (가벼운 헤더만 — 실 rule 본문은 ADR-014 D1 의 SessionStart hook 주입)
301
569
  const lines = [
302
570
  AGENTS_MD_BEGIN,
303
571
  '## forgen managed rules',
@@ -307,7 +575,7 @@ function buildForgenRulesBlock(pkgRoot) {
307
575
  '- forgen-compound MCP 가 ~/.codex/config.toml 에 등록됨. 학습된 솔루션을 `compound-search` 로 조회 가능.',
308
576
  '- 사용자 교정은 `correction-record` MCP 도구로 즉시 박제 (kind: fix-now / prefer-from-now / avoid-this).',
309
577
  '- forgen 의 4축 profile (quality_safety / autonomy / judgment_philosophy / communication_style) 이 응답 톤 + 검증 깊이를 가이드.',
310
- '- 본 rule 은 cwd 의 AGENTS.md 가 자동 read 되는 Codex 의 user_instructions 경로로 흘러들어감.',
578
+ '- 개인화 룰 본문은 세션 시작 시 SessionStart hook 이 `<forgen-rules host="codex">` 블록으로 주입 (ADR-014). 서브에이전트는 ~/.codex/agents/ch-*.toml.',
311
579
  `- pkgRoot: ${pkgRoot}`,
312
580
  AGENTS_MD_END,
313
581
  ];
@@ -127,6 +127,16 @@ export function renderResult(result, dryRun) {
127
127
  lines.push(` CODEX_HOME: ${result.codex.codexHome}`);
128
128
  lines.push(` hooks.json: ${result.codex.hooksCount} forgen hooks (preserved user: ${result.codex.preservedUserHookCount})`);
129
129
  lines.push(` MCP: ${result.codex.mcpAlreadyPresent ? 'already present' : (result.codex.mcpRegistered ? 'registered' : 'skipped')}`);
130
+ lines.push(` skills: ${result.codex.skillsInstalled} commands + ${result.codex.devGuideSkillsInstalled} dev-guide → ${result.codex.skillsPath}`);
131
+ lines.push(` agents: ${result.codex.agentsInstalled} ch-*.toml → ${result.codex.agentsPath}${result.codex.multiAgentEnabled ? '' : ' (⚠ config.toml 에 [features] multi_agent = true 가 없어 Codex 가 spawn 하지 못함 — 추가 권장)'}`);
132
+ lines.push(` rules: injected per session via SessionStart hook (<forgen-rules host="codex">)`);
133
+ const t = result.codex.hookTrust;
134
+ if (t.total > 0 && t.trusted === t.total) {
135
+ lines.push(` hook trust: ${t.trusted}/${t.total} trusted${t.ignoredByCodex.length ? ` (+${t.ignoredByCodex.length} Claude-only event ignored by Codex)` : ''}`);
136
+ }
137
+ else {
138
+ lines.push(` hook trust: ${t.trusted}/${t.total} trusted — ${t.untrusted.length} hook(s) need review. Codex skips untrusted hooks: run \`/hooks\` inside codex and trust the forgen entries.`);
139
+ }
130
140
  }
131
141
  if (result.opencode) {
132
142
  lines.push('');