@wooojin/forgen 0.5.0 → 0.5.6

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 (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +236 -0
  3. package/README.ja.md +16 -8
  4. package/README.ko.md +17 -9
  5. package/README.md +6 -1
  6. package/README.zh.md +16 -8
  7. package/assets/claude/skills/verify/SKILL.md +66 -0
  8. package/assets/shared/hook-registry.json +217 -22
  9. package/dist/cli.js +7 -3
  10. package/dist/core/auto-compound-runner.js +73 -14
  11. package/dist/core/changelog-cli.d.ts +4 -1
  12. package/dist/core/changelog-cli.js +8 -7
  13. package/dist/core/compound-sweep-cli.d.ts +4 -1
  14. package/dist/core/compound-sweep-cli.js +25 -5
  15. package/dist/core/doctor.js +37 -0
  16. package/dist/core/spawn.d.ts +26 -0
  17. package/dist/core/spawn.js +38 -3
  18. package/dist/core/uninstall.d.ts +7 -0
  19. package/dist/core/uninstall.js +46 -1
  20. package/dist/engine/compound-loop.js +20 -1
  21. package/dist/hooks/context-guard.d.ts +3 -0
  22. package/dist/hooks/context-guard.js +16 -8
  23. package/dist/hooks/hook-config.js +5 -0
  24. package/dist/hooks/hook-registry.d.ts +17 -1
  25. package/dist/hooks/hooks-generator.d.ts +2 -0
  26. package/dist/hooks/hooks-generator.js +8 -1
  27. package/dist/hooks/session-end.d.ts +41 -0
  28. package/dist/hooks/session-end.js +113 -0
  29. package/dist/hooks/session-recovery.js +18 -1
  30. package/dist/hooks/shared/hook-timing.js +6 -1
  31. package/dist/host/codex-adapter.js +5 -0
  32. package/dist/host/codex-hook-alive.d.ts +23 -0
  33. package/dist/host/codex-hook-alive.js +51 -0
  34. package/dist/host/codex-notify.d.ts +55 -0
  35. package/dist/host/codex-notify.js +153 -0
  36. package/dist/host/codex-rollout.d.ts +21 -0
  37. package/dist/host/codex-rollout.js +82 -0
  38. package/dist/host/codex-rules-context.d.ts +25 -0
  39. package/dist/host/codex-rules-context.js +62 -0
  40. package/dist/host/exec-host.d.ts +10 -0
  41. package/dist/host/exec-host.js +18 -3
  42. package/dist/host/install-claude.d.ts +14 -0
  43. package/dist/host/install-claude.js +53 -0
  44. package/dist/host/install-codex.d.ts +120 -0
  45. package/dist/host/install-codex.js +560 -52
  46. package/dist/host/install-orchestrator.d.ts +4 -0
  47. package/dist/host/install-orchestrator.js +32 -2
  48. package/dist/host/invoke-agent.js +1 -0
  49. package/dist/host/parity-harness.js +7 -3
  50. package/dist/host/projection.d.ts +37 -3
  51. package/dist/host/projection.js +93 -42
  52. package/dist/store/evidence-store.js +121 -57
  53. package/dist/store/rule-store.d.ts +41 -0
  54. package/dist/store/rule-store.js +77 -2
  55. package/hooks/hooks.json +13 -1
  56. package/package.json +1 -1
  57. package/plugin.json +1 -1
  58. package/skills/architecture-decision/SKILL.md +18 -0
  59. package/skills/calibrate/SKILL.md +18 -0
  60. package/skills/code-review/SKILL.md +17 -0
  61. package/skills/compound/SKILL.md +17 -0
  62. package/skills/deep-interview/SKILL.md +7 -0
  63. package/skills/docker/SKILL.md +18 -0
  64. package/skills/forge-loop/SKILL.md +23 -0
  65. package/skills/learn/SKILL.md +15 -0
  66. package/skills/retro/SKILL.md +16 -0
  67. package/skills/ship/SKILL.md +18 -0
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Codex 훅 생존 마커 — ADR-016 D1
3
+ *
4
+ * Codex 는 미승인(untrusted)/변경된(modified) 훅을 조용히 skip 한다. "forgen 훅이 실제로 실행되고 있는가"
5
+ * 를 관측하는 유일한 choke point 는 codex-adapter 다 (Codex 가 forgen 훅을 실행하면 반드시 거친다).
6
+ * 어댑터가 턴 경계 이벤트에서 전역 마커를 갱신하고, notify 폴백(codex-notify)이 그 신선도를 본다.
7
+ *
8
+ * 훅 신뢰 상태는 hooks.json 단위로 모든 세션이 공유하므로 마커는 세션별이 아니라 전역 1개다.
9
+ * 어댑터가 매 훅마다 import 하므로 의존성은 node 내장 + paths 만 둔다.
10
+ */
11
+ import * as fs from 'node:fs';
12
+ import * as path from 'node:path';
13
+ import { STATE_DIR } from '../core/paths.js';
14
+ export const CODEX_HOOK_ALIVE_PATH = path.join(STATE_DIR, 'codex-hook-alive.json');
15
+ /** notify 는 Stop 훅 직후 발화한다. Stop 훅 타임아웃(10s) 대비 넉넉한 창. */
16
+ export const CODEX_HOOK_ALIVE_WINDOW_MS = 120_000;
17
+ /** 턴 경계 이벤트만 기록 — PreToolUse/PostToolUse 마다 쓰지 않는다. */
18
+ const ALIVE_EVENTS = new Set(['Stop', 'SubagentStop', 'UserPromptSubmit']);
19
+ /** 어댑터 입력(stdin JSON)으로 마커 갱신. 대상 이벤트가 아니면 no-op. 실패는 삼킨다 (fail-open). */
20
+ export function markCodexHookAlive(input, now = Date.now()) {
21
+ try {
22
+ const i = (input ?? {});
23
+ const event = typeof i.hook_event_name === 'string' ? i.hook_event_name
24
+ : typeof i.hookEventName === 'string' ? i.hookEventName : '';
25
+ if (!ALIVE_EVENTS.has(event))
26
+ return false;
27
+ const marker = { at: now, event };
28
+ if (typeof i.session_id === 'string')
29
+ marker.sessionId = i.session_id;
30
+ fs.mkdirSync(STATE_DIR, { recursive: true });
31
+ fs.writeFileSync(CODEX_HOOK_ALIVE_PATH, JSON.stringify(marker));
32
+ return true;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ export function readCodexHookAlive() {
39
+ try {
40
+ const m = JSON.parse(fs.readFileSync(CODEX_HOOK_ALIVE_PATH, 'utf-8'));
41
+ return typeof m.at === 'number' && Number.isFinite(m.at) ? m : null;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ /** 최근 window 안에 forgen 훅이 Codex 에서 실행됐는가. */
48
+ export function isCodexHookAlive(now = Date.now(), windowMs = CODEX_HOOK_ALIVE_WINDOW_MS) {
49
+ const m = readCodexHookAlive();
50
+ return m !== null && now - m.at >= 0 && now - m.at < windowMs;
51
+ }
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Codex `notify` 폴백 — ADR-016 D1
4
+ *
5
+ * Codex 는 config.toml 의 top-level `notify = [argv…]` 프로그램을 **턴 완료마다**(Stop 훅 뒤) detached 로
6
+ * 실행하고, JSON 페이로드를 마지막 argv 로 넘긴다. 훅과 달리 **신뢰(trust) 승인과 무관**하게 돈다.
7
+ * forgen 은 이것을 "훅이 조용히 skip 되는 동안의 안전망" 으로만 쓴다:
8
+ *
9
+ * 1. forgen 훅이 살아 있으면(codex-adapter 가 방금 alive 마커를 갱신) 아무것도 하지 않는다 —
10
+ * auto-compound 는 Stop 훅(context-guard) 소관이다.
11
+ * 2. 훅이 돌지 않았으면 silent 플래그를 남기고(`forgen doctor` 가 노출), 세션이 충분히 길면 Stop 훅과
12
+ * 같은 디바운스 경로로 auto-compound 를 띄운다.
13
+ *
14
+ * 페이로드 (codex-rs/hooks/src/legacy_notify.rs, 0.153.4):
15
+ * { "type":"agent-turn-complete", "thread-id", "turn-id", "cwd", "client"?, "input-messages":[…],
16
+ * "last-assistant-message" } — transcript 경로는 없다. `thread-id` 로 rollout 파일을 찾는다.
17
+ *
18
+ * argv: `codex-notify.js [-- <chain program> <args…>] <payload JSON>`
19
+ * `--` 뒤는 사용자가 수동으로 체인한 자기 notifier (단일 argv 제약 우회). 페이로드를 붙여 먼저 전달한다.
20
+ *
21
+ * 실패 정책: 모든 단계 fail-open. stdout/stderr 는 Codex 가 /dev/null 로 버린다.
22
+ */
23
+ export declare const CODEX_HOOKS_SILENT_PATH: string;
24
+ export interface CodexNotifyPayload {
25
+ type?: string;
26
+ 'thread-id'?: string;
27
+ 'turn-id'?: string;
28
+ cwd?: string;
29
+ client?: string;
30
+ }
31
+ export interface CodexHooksSilent {
32
+ detectedAt: string;
33
+ sessionId: string;
34
+ cwd: string;
35
+ /** 연속으로 관측된 silent 턴 수 */
36
+ count: number;
37
+ }
38
+ export type NotifyOutcome = 'nested-run' | 'no-payload' | 'ignored-event' | 'hooks-alive' | 'silent-recorded' | 'silent-compound-spawned';
39
+ /** argv(스크립트 뒤) → 체인 프로그램 + 페이로드. 페이로드는 항상 마지막 argv. */
40
+ export declare function parseNotifyArgv(argv: string[]): {
41
+ chain: string[];
42
+ payloadRaw: string | null;
43
+ payload: CodexNotifyPayload | null;
44
+ };
45
+ /** doctor 용 — 유효(24h 이내)한 silent 관측만 반환. */
46
+ export declare function readCodexHooksSilent(now?: number): CodexHooksSilent | null;
47
+ export interface NotifyDeps {
48
+ now?: number;
49
+ env?: NodeJS.ProcessEnv;
50
+ /** 테스트 주입: auto-compound 디바운스 트리거 */
51
+ spawnCompound?: (sessionId: string, transcriptPath: string, promptCount: number, cwd: string) => Promise<boolean>;
52
+ /** 테스트 주입: forgen hook-config 조회 */
53
+ isHookEnabled?: (name: string) => boolean;
54
+ }
55
+ export declare function handleNotify(argv: string[], deps?: NotifyDeps): Promise<NotifyOutcome>;
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Codex `notify` 폴백 — ADR-016 D1
4
+ *
5
+ * Codex 는 config.toml 의 top-level `notify = [argv…]` 프로그램을 **턴 완료마다**(Stop 훅 뒤) detached 로
6
+ * 실행하고, JSON 페이로드를 마지막 argv 로 넘긴다. 훅과 달리 **신뢰(trust) 승인과 무관**하게 돈다.
7
+ * forgen 은 이것을 "훅이 조용히 skip 되는 동안의 안전망" 으로만 쓴다:
8
+ *
9
+ * 1. forgen 훅이 살아 있으면(codex-adapter 가 방금 alive 마커를 갱신) 아무것도 하지 않는다 —
10
+ * auto-compound 는 Stop 훅(context-guard) 소관이다.
11
+ * 2. 훅이 돌지 않았으면 silent 플래그를 남기고(`forgen doctor` 가 노출), 세션이 충분히 길면 Stop 훅과
12
+ * 같은 디바운스 경로로 auto-compound 를 띄운다.
13
+ *
14
+ * 페이로드 (codex-rs/hooks/src/legacy_notify.rs, 0.153.4):
15
+ * { "type":"agent-turn-complete", "thread-id", "turn-id", "cwd", "client"?, "input-messages":[…],
16
+ * "last-assistant-message" } — transcript 경로는 없다. `thread-id` 로 rollout 파일을 찾는다.
17
+ *
18
+ * argv: `codex-notify.js [-- <chain program> <args…>] <payload JSON>`
19
+ * `--` 뒤는 사용자가 수동으로 체인한 자기 notifier (단일 argv 제약 우회). 페이로드를 붙여 먼저 전달한다.
20
+ *
21
+ * 실패 정책: 모든 단계 fail-open. stdout/stderr 는 Codex 가 /dev/null 로 버린다.
22
+ */
23
+ import { spawn } from 'node:child_process';
24
+ import * as fs from 'node:fs';
25
+ import * as os from 'node:os';
26
+ import * as path from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { STATE_DIR } from '../core/paths.js';
29
+ import { isCodexHookAlive } from './codex-hook-alive.js';
30
+ import { countCodexUserPrompts, findCodexRollout } from './codex-rollout.js';
31
+ export const CODEX_HOOKS_SILENT_PATH = path.join(STATE_DIR, 'codex-hooks-silent.json');
32
+ /** silent 플래그 유효기간 — 이보다 오래된 관측은 doctor 가 무시한다. */
33
+ const SILENT_TTL_MS = 24 * 60 * 60 * 1000;
34
+ /** Stop 훅 경로와 같은 "의미 있는 세션" 임계. */
35
+ const MIN_USER_PROMPTS = 10;
36
+ /** argv(스크립트 뒤) → 체인 프로그램 + 페이로드. 페이로드는 항상 마지막 argv. */
37
+ export function parseNotifyArgv(argv) {
38
+ if (argv.length === 0)
39
+ return { chain: [], payloadRaw: null, payload: null };
40
+ const payloadRaw = argv[argv.length - 1];
41
+ const chain = argv[0] === '--' ? argv.slice(1, -1) : [];
42
+ let payload = null;
43
+ try {
44
+ const parsed = JSON.parse(payloadRaw);
45
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
46
+ payload = parsed;
47
+ }
48
+ catch { /* 페이로드 아님 */ }
49
+ return { chain, payloadRaw, payload };
50
+ }
51
+ /** doctor 용 — 유효(24h 이내)한 silent 관측만 반환. */
52
+ export function readCodexHooksSilent(now = Date.now()) {
53
+ try {
54
+ const s = JSON.parse(fs.readFileSync(CODEX_HOOKS_SILENT_PATH, 'utf-8'));
55
+ const at = Date.parse(s.detectedAt);
56
+ if (!Number.isFinite(at) || now - at > SILENT_TTL_MS)
57
+ return null;
58
+ if (typeof s.sessionId !== 'string' || typeof s.count !== 'number')
59
+ return null;
60
+ return s;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ function recordSilent(sessionId, cwd, now) {
67
+ const prev = readCodexHooksSilent(now);
68
+ const next = {
69
+ detectedAt: new Date(now).toISOString(),
70
+ sessionId,
71
+ cwd,
72
+ count: (prev?.count ?? 0) + 1,
73
+ };
74
+ fs.mkdirSync(STATE_DIR, { recursive: true });
75
+ fs.writeFileSync(CODEX_HOOKS_SILENT_PATH, JSON.stringify(next));
76
+ }
77
+ function clearSilent() {
78
+ try {
79
+ fs.rmSync(CODEX_HOOKS_SILENT_PATH, { force: true });
80
+ }
81
+ catch { /* noop */ }
82
+ }
83
+ function runChain(chain, payloadRaw) {
84
+ if (chain.length === 0)
85
+ return;
86
+ try {
87
+ const child = spawn(chain[0], [...chain.slice(1), payloadRaw], { detached: true, stdio: 'ignore' });
88
+ child.on('error', () => { });
89
+ child.unref();
90
+ }
91
+ catch { /* fail-open */ }
92
+ }
93
+ /** Stop 경로는 context-guard 훅이 auto-compound 를 띄운다 — 사용자가 그 훅을 껐으면 폴백도 띄우지 않는다. */
94
+ async function contextGuardEnabled(deps) {
95
+ try {
96
+ const isEnabled = deps.isHookEnabled ?? (await import('../hooks/hook-config.js')).isHookEnabled;
97
+ return isEnabled('context-guard');
98
+ }
99
+ catch {
100
+ return true; // 설정을 못 읽으면 기본값(활성)
101
+ }
102
+ }
103
+ export async function handleNotify(argv, deps = {}) {
104
+ const env = deps.env ?? process.env;
105
+ const now = deps.now ?? Date.now();
106
+ // forgen 자신의 추출용 `codex exec` — 재귀/오탐 방지 (env 는 Codex 세션 시작 시점 스냅샷으로 상속된다).
107
+ if (env.FORGEN_NESTED_RUN === '1')
108
+ return 'nested-run';
109
+ const { chain, payloadRaw, payload } = parseNotifyArgv(argv);
110
+ if (payloadRaw !== null)
111
+ runChain(chain, payloadRaw);
112
+ if (!payload)
113
+ return 'no-payload';
114
+ if (payload.type !== 'agent-turn-complete')
115
+ return 'ignored-event';
116
+ if (isCodexHookAlive(now)) {
117
+ clearSilent();
118
+ return 'hooks-alive';
119
+ }
120
+ const sessionId = typeof payload['thread-id'] === 'string' ? payload['thread-id'] : 'unknown';
121
+ const cwd = typeof payload.cwd === 'string' && payload.cwd.length > 0 ? payload.cwd : process.cwd();
122
+ try {
123
+ recordSilent(sessionId, cwd, now);
124
+ }
125
+ catch { /* fail-open */ }
126
+ // 훅이 돌지 않으므로 Stop 트리거 auto-compound 도 없다 → 같은 디바운스 경로로 대신 띄운다.
127
+ // (프롬프트 수는 rollout 전체 기준이라 resume 된 세션에서는 Stop 경로의 훅 카운터보다 클 수 있다.)
128
+ try {
129
+ if (!(await contextGuardEnabled(deps)))
130
+ return 'silent-recorded';
131
+ const codexHome = env.CODEX_HOME ?? path.join(os.homedir(), '.codex');
132
+ const rollout = findCodexRollout(codexHome, sessionId);
133
+ if (!rollout)
134
+ return 'silent-recorded';
135
+ const prompts = countCodexUserPrompts(rollout);
136
+ if (prompts < MIN_USER_PROMPTS)
137
+ return 'silent-recorded';
138
+ const spawnCompound = deps.spawnCompound ?? (async (sid, transcript, count, dir) => {
139
+ // 훅 경로에선 codex-adapter 가 주입하는 값 — 러너의 evidence/timing 이 rt:"codex" 로 태깅된다.
140
+ // (추출에 쓸 LLM host 는 이것과 무관하게 profile.default_host 로 정해진다 — Stop 경로와 동일.)
141
+ process.env.FORGEN_RUNTIME = 'codex';
142
+ const { maybeSpawnAutoCompound } = await import('../hooks/context-guard.js');
143
+ return maybeSpawnAutoCompound(sid, transcript, count, dir);
144
+ });
145
+ return (await spawnCompound(sessionId, rollout, prompts, cwd)) ? 'silent-compound-spawned' : 'silent-recorded';
146
+ }
147
+ catch {
148
+ return 'silent-recorded';
149
+ }
150
+ }
151
+ if (process.argv[1] && fs.realpathSync(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
152
+ handleNotify(process.argv.slice(2)).catch(() => { });
153
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Codex rollout 파일 헬퍼 — ADR-016 (SessionEnd 훅 + notify 폴백 공용)
3
+ *
4
+ * rollout: `$CODEX_HOME/sessions/YYYY/MM/DD/rollout-<local ts>-<thread id>.jsonl` (compact JSONL).
5
+ */
6
+ /**
7
+ * thread id(= 훅의 session_id = notify 의 `thread-id`) 로 rollout 파일을 찾는다 (최근 날짜부터).
8
+ * id 는 UUID 형태만 허용한다.
9
+ */
10
+ export declare function findCodexRollout(codexHome: string, threadId: string): string | null;
11
+ /** 스캔 상한. SessionEnd 훅은 3s 예산이라 전체 스트리밍(239MB ≈ 3s) 대신 raw 바이트 스캔 + 상한을 쓴다. */
12
+ export declare const CODEX_PROMPT_SCAN_MAX_BYTES: number;
13
+ /**
14
+ * Codex rollout 의 *실제 사용자 프롬프트* 수 — `event_msg` / `payload.type:"user_message"` 레코드.
15
+ *
16
+ * `response_item` role=user 는 AGENTS.md·환경 컨텍스트 주입까지 포함해 실측 ~1.8배 과대계수이고
17
+ * (51 vs 91), 줄당 수십 KB 인 tool 출력 때문에 "앞 200KB 만 JSON.parse" 로는 프롬프트 1~2개밖에 못 본다.
18
+ * 그래서 JSON 을 파싱하지 않고 구조 수준의 바이트 패턴을 센다 — 문자열 값 안의 같은 텍스트는 따옴표가
19
+ * `\"` 로 이스케이프되므로 매치되지 않는다. maxBytes 까지만 읽는다 (하한 추정).
20
+ */
21
+ export declare function countCodexUserPrompts(rolloutPath: string, maxBytes?: number): number;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Codex rollout 파일 헬퍼 — ADR-016 (SessionEnd 훅 + notify 폴백 공용)
3
+ *
4
+ * rollout: `$CODEX_HOME/sessions/YYYY/MM/DD/rollout-<local ts>-<thread id>.jsonl` (compact JSONL).
5
+ */
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+ /** rollout 탐색 시 최근 일자 디렉토리만 본다 (resume 된 오래된 세션 대비 여유). */
9
+ const MAX_DAY_DIRS = 45;
10
+ function sortedDesc(dir) {
11
+ try {
12
+ return fs.readdirSync(dir).sort().reverse();
13
+ }
14
+ catch {
15
+ return [];
16
+ }
17
+ }
18
+ /**
19
+ * thread id(= 훅의 session_id = notify 의 `thread-id`) 로 rollout 파일을 찾는다 (최근 날짜부터).
20
+ * id 는 UUID 형태만 허용한다.
21
+ */
22
+ export function findCodexRollout(codexHome, threadId) {
23
+ if (!/^[A-Za-z0-9-]{8,64}$/.test(threadId))
24
+ return null;
25
+ const root = path.join(codexHome, 'sessions');
26
+ let scanned = 0;
27
+ for (const y of sortedDesc(root)) {
28
+ for (const m of sortedDesc(path.join(root, y))) {
29
+ for (const d of sortedDesc(path.join(root, y, m))) {
30
+ if (scanned >= MAX_DAY_DIRS)
31
+ return null;
32
+ scanned += 1;
33
+ const dayDir = path.join(root, y, m, d);
34
+ const hit = sortedDesc(dayDir).find((f) => f.startsWith('rollout-') && f.endsWith('.jsonl') && f.includes(`-${threadId}`));
35
+ if (hit)
36
+ return path.join(dayDir, hit);
37
+ }
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+ const USER_PROMPT_NEEDLE = Buffer.from('"type":"user_message"');
43
+ const CHUNK_BYTES = 1024 * 1024;
44
+ /** 스캔 상한. SessionEnd 훅은 3s 예산이라 전체 스트리밍(239MB ≈ 3s) 대신 raw 바이트 스캔 + 상한을 쓴다. */
45
+ export const CODEX_PROMPT_SCAN_MAX_BYTES = 64 * 1024 * 1024;
46
+ /**
47
+ * Codex rollout 의 *실제 사용자 프롬프트* 수 — `event_msg` / `payload.type:"user_message"` 레코드.
48
+ *
49
+ * `response_item` role=user 는 AGENTS.md·환경 컨텍스트 주입까지 포함해 실측 ~1.8배 과대계수이고
50
+ * (51 vs 91), 줄당 수십 KB 인 tool 출력 때문에 "앞 200KB 만 JSON.parse" 로는 프롬프트 1~2개밖에 못 본다.
51
+ * 그래서 JSON 을 파싱하지 않고 구조 수준의 바이트 패턴을 센다 — 문자열 값 안의 같은 텍스트는 따옴표가
52
+ * `\"` 로 이스케이프되므로 매치되지 않는다. maxBytes 까지만 읽는다 (하한 추정).
53
+ */
54
+ export function countCodexUserPrompts(rolloutPath, maxBytes = CODEX_PROMPT_SCAN_MAX_BYTES) {
55
+ const fd = fs.openSync(rolloutPath, 'r');
56
+ try {
57
+ const overlap = USER_PROMPT_NEEDLE.length - 1;
58
+ const buf = Buffer.alloc(CHUNK_BYTES + overlap);
59
+ let carry = 0;
60
+ let pos = 0;
61
+ let count = 0;
62
+ while (pos < maxBytes) {
63
+ const read = fs.readSync(fd, buf, carry, Math.min(CHUNK_BYTES, maxBytes - pos), pos);
64
+ if (read <= 0)
65
+ break;
66
+ const view = buf.subarray(0, carry + read);
67
+ let idx = view.indexOf(USER_PROMPT_NEEDLE);
68
+ while (idx !== -1) {
69
+ count += 1;
70
+ idx = view.indexOf(USER_PROMPT_NEEDLE, idx + USER_PROMPT_NEEDLE.length);
71
+ }
72
+ // 청크 경계에 걸친 패턴 대비: 끝 overlap 바이트를 다음 청크 앞에 붙인다 (needle 보다 짧아 이중계수 없음).
73
+ carry = Math.min(overlap, view.length);
74
+ view.copy(buf, 0, view.length - carry, view.length);
75
+ pos += read;
76
+ }
77
+ return count;
78
+ }
79
+ finally {
80
+ fs.closeSync(fd);
81
+ }
82
+ }
@@ -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'
@@ -71,7 +82,11 @@ export function execHost(opts) {
71
82
  '--skip-git-repo-check',
72
83
  opts.prompt,
73
84
  ];
74
- const stdout = execFileSync('codex', args, baseOpts);
85
+ // ADR-016 (critic m2): Codex 추출 run 에도 중첩 표식을 준다. `--ephemeral` 은 rollout 만 안 남길 뿐
86
+ // 사용자 config(훅·notify)는 그대로 로드한다 — 표식이 없으면 forgen 훅이 추출 세션에서 발화하고
87
+ // notify 폴백이 그 세션을 "훅 미발화" 로 오기록한다.
88
+ const codexEnv = (opts.nestedRun ?? true) ? { ...(baseOpts.env ?? {}), ...NESTED_RUN_ENV } : baseOpts.env;
89
+ const stdout = execFileSync('codex', args, { ...baseOpts, env: codexEnv });
75
90
  const parsed = parseCodexJsonlOutput(stdout.toString());
76
91
  return {
77
92
  message: parsed.message,
@@ -10,6 +10,7 @@
10
10
  * 3. Settings hooks injection: ~/.claude/settings.json 의 hooks 머지 (forgen entry idempotent)
11
11
  * 4. MCP register: ~/.claude.json 에 mcpServers.forgen-compound 추가
12
12
  * 5. Dev-guide skills: ~/.claude/skills/forgen-<stack>-<skill>/ 설치 (forgen-managed only)
13
+ * 6. verify skill: ~/.claude/skills/verify/ 설치 (ADR-016 D3 — 사용자 소유면 보존)
13
14
  *
14
15
  * 사용자 비-forgen 자산 보존 + 재실행 idempotent.
15
16
  */
@@ -21,7 +22,17 @@ export interface ClaudeInstallOptions {
21
22
  dryRun?: boolean;
22
23
  /** MCP forgen-compound 등록 여부 (default true). */
23
24
  registerMcp?: boolean;
25
+ /** ADR-016 D3: user-level `verify` 스킬 설치 여부 (default true). */
26
+ installVerifySkill?: boolean;
24
27
  }
28
+ /** ADR-016 D3 — `~/.claude/skills/verify` 설치 결과. */
29
+ export type VerifySkillStatus =
30
+ /** forgen-managed 스킬을 새로 썼거나 갱신함 */
31
+ 'installed'
32
+ /** 사용자가 직접 만든 `verify` 스킬이 있어 건드리지 않음 */
33
+ | 'user-owned'
34
+ /** --no-verify-skill 또는 자산 부재 */
35
+ | 'skipped';
25
36
  export interface ClaudeInstallResult {
26
37
  homeDir: string;
27
38
  pluginCachePath: string;
@@ -35,5 +46,8 @@ export interface ClaudeInstallResult {
35
46
  skillsPath: string;
36
47
  skillsInstalled: number;
37
48
  skillsRemoved: number;
49
+ verifySkill: VerifySkillStatus;
38
50
  }
51
+ /** `<skillsDir>/verify` 가 사용자 소유인가 (심링크 / 마커 없는 SKILL.md / SKILL.md 없이 다른 파일만 있음). */
52
+ export declare function isUserOwnedVerifySkill(skillsDir: string): boolean;
39
53
  export declare function planClaudeInstall(opts: ClaudeInstallOptions): ClaudeInstallResult;
@@ -10,6 +10,7 @@
10
10
  * 3. Settings hooks injection: ~/.claude/settings.json 의 hooks 머지 (forgen entry idempotent)
11
11
  * 4. MCP register: ~/.claude.json 에 mcpServers.forgen-compound 추가
12
12
  * 5. Dev-guide skills: ~/.claude/skills/forgen-<stack>-<skill>/ 설치 (forgen-managed only)
13
+ * 6. verify skill: ~/.claude/skills/verify/ 설치 (ADR-016 D3 — 사용자 소유면 보존)
13
14
  *
14
15
  * 사용자 비-forgen 자산 보존 + 재실행 idempotent.
15
16
  */
@@ -301,6 +302,54 @@ function installDevGuideSkills(opts) {
301
302
  }
302
303
  return { skillsPath: skillsDir, skillsInstalled: installed, skillsRemoved: removed };
303
304
  }
305
+ // ── 6. verify skill (ADR-016 D3) ───────────────────────────────────────
306
+ /** frontmatter 직후에 forgen-managed 마커가 있는 SKILL.md 만 forgen 소유로 본다. */
307
+ const MANAGED_SKILL_RE = /^---\n[\s\S]*?\n---\n\s*<!-- forgen-managed -->/;
308
+ /** `<skillsDir>/verify` 가 사용자 소유인가 (심링크 / 마커 없는 SKILL.md / SKILL.md 없이 다른 파일만 있음). */
309
+ export function isUserOwnedVerifySkill(skillsDir) {
310
+ const dir = path.join(skillsDir, 'verify');
311
+ try {
312
+ if (fs.lstatSync(dir).isSymbolicLink())
313
+ return true;
314
+ }
315
+ catch {
316
+ return false; // 없음
317
+ }
318
+ const file = path.join(dir, 'SKILL.md');
319
+ try {
320
+ if (fs.lstatSync(file).isSymbolicLink())
321
+ return true;
322
+ return !MANAGED_SKILL_RE.test(fs.readFileSync(file, 'utf-8'));
323
+ }
324
+ catch {
325
+ // SKILL.md 없음 — 디렉토리에 다른 것이 있으면 사용자가 만들던 것
326
+ try {
327
+ return fs.readdirSync(dir).length > 0;
328
+ }
329
+ catch {
330
+ return true;
331
+ }
332
+ }
333
+ }
334
+ /**
335
+ * Claude Code 2.1.286+ 는 project/user 스킬에 `verify` 가 있으면 커밋 직전에 실행하라고 모델에 안내한다
336
+ * (플러그인 스킬 `forgen:verify` 는 해당 없음). 그래서 user 레벨에 un-namespaced 로 설치한다.
337
+ * 사용자 소유 스킬은 절대 덮어쓰지 않는다.
338
+ */
339
+ function installVerifySkill(opts) {
340
+ const src = path.join(opts.pkgRoot, 'assets', 'claude', 'skills', 'verify', 'SKILL.md');
341
+ if (!fs.existsSync(src))
342
+ return 'skipped';
343
+ if (isUserOwnedVerifySkill(opts.skillsDir))
344
+ return 'user-owned';
345
+ if (opts.dryRun)
346
+ return 'installed';
347
+ const dir = path.join(opts.skillsDir, 'verify');
348
+ fs.mkdirSync(dir, { recursive: true });
349
+ // 심링크가 아니라 복사 — 마커로 소유를 판정하고, npm 경로가 바뀌어도 깨지지 않게.
350
+ fs.copyFileSync(src, path.join(dir, 'SKILL.md'));
351
+ return 'installed';
352
+ }
304
353
  // ── public ─────────────────────────────────────────────────────────────
305
354
  export function planClaudeInstall(opts) {
306
355
  if (!opts.pkgRoot || !fs.existsSync(opts.pkgRoot)) {
@@ -324,6 +373,9 @@ export function planClaudeInstall(opts) {
324
373
  ? registerMcpInClaudeJson({ pkgRoot: opts.pkgRoot, claudeJsonPath, dryRun })
325
374
  : { registered: false, alreadyPresent: false };
326
375
  const skills = installDevGuideSkills({ pkgRoot: opts.pkgRoot, skillsDir, dryRun });
376
+ const verifySkill = (opts.installVerifySkill ?? true)
377
+ ? installVerifySkill({ pkgRoot: opts.pkgRoot, skillsDir, dryRun })
378
+ : 'skipped';
327
379
  return {
328
380
  homeDir,
329
381
  pluginCachePath: cacheDir,
@@ -337,5 +389,6 @@ export function planClaudeInstall(opts) {
337
389
  skillsPath: skills.skillsPath,
338
390
  skillsInstalled: skills.skillsInstalled,
339
391
  skillsRemoved: skills.skillsRemoved,
392
+ verifySkill,
340
393
  };
341
394
  }