@wooojin/forgen 0.4.5 → 0.4.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://claude.ai/schemas/claude-plugin.json",
3
3
  "name": "forgen",
4
- "version": "0.4.5",
4
+ "version": "0.4.6",
5
5
  "description": "Claude Code harness — the more you use Claude, the better it gets",
6
6
  "author": {
7
7
  "name": "jang-ujin",
package/CHANGELOG.md CHANGED
@@ -7,6 +7,238 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.6] — 2026-05-14 — Unattended Execution Resilience
11
+
12
+ 긴 무인 실행 (`forge-loop --goal-only` 새벽 실행, eval N=33+ sequential measurement)
13
+ 이 API rate-limit 을 만나도 자동 sleep + reset 후 재기동하도록 한 테마. 동시에
14
+ Codex hook side-effect 갭 (PermissionRequest dispatch 누락) 보완 + dead writer
15
+ 복구 + 성능 3종 단축.
16
+
17
+ ### Added
18
+
19
+ #### Theme F — Codex/Claude 동등 사용 (사용자 요청)
20
+
21
+ claude 와 codex 를 일상에서 동등하게 사용할 수 있게 forgen 측 갭 메움.
22
+
23
+ - **session-recovery hook 에서 v1-bootstrap 호출** (src/hooks/session-recovery.ts)
24
+ - 이전엔 prepareHarness (fgx/forgen wrapper) 만 bootstrapV1Session 호출 →
25
+ 직접 codex/claude 호출 시 `~/.forgen/state/sessions/<id>.json` 미생성.
26
+ SessionStart hook 에서도 호출하여 양쪽 진입 경로 모두에서 session state 박제
27
+ (단 profile 정상 시 — onboarding 안 한 사용자는 둘 다 동등하게 skip).
28
+
29
+ - **Codex transcript 위치 인식** (src/core/spawn.ts `transcriptProjectDir` 분기)
30
+ - claude: `~/.claude/projects/<sanitized-cwd>/<session>.jsonl`
31
+ - codex: `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<sid>.jsonl`
32
+ - 위치 인식 + snapshot-diff 기반 attribution 양쪽 작동.
33
+ - **auto-compound input parsing — codex JSONL schema 호환 추가**:
34
+ - `countUserMessages` (spawn.ts) + `extractSummary` (auto-compound-runner.ts)
35
+ 양쪽 모두 claude (`type='user'|'queue-operation'`) + codex
36
+ (`type='response_item'` with `payload.role='user'|'assistant'`,
37
+ `content[].text`) 자동 감지 처리
38
+ - codex transcript 도 user message ≥10 시 auto-compound 자동 트리거
39
+ - sessionId 는 rollout 파일명 패턴 (`rollout-<ts>-<sid>.jsonl`) 에서 추출
40
+ - FTS5 인덱싱은 claude 한정 (codex schema FTS 매핑 micro-issue, 추후)
41
+
42
+ - **동등 작동 항목** (라운드 25+ clean container 검증 합산):
43
+ - Hook 발화 (8 hook 종, codex 0.128.x): 동등
44
+ - prompt-history 기록: 동등 (secret redaction 포함)
45
+ - permissions-<id>.jsonl 기록: 동등 (codex 는 `source: 'pre-tool-use'` 경유)
46
+ - usage-telemetry `rt` 필드: 동등 (#17 fix 후)
47
+ - statusline usage 라인: 동등
48
+ - rate-limit detector + spawn loop: 코드 공유, runtime 분기 1줄
49
+ - `fgx --<runtime>` 단축 + auto-reconcile-on-launch: 동등
50
+
51
+ - **비동등 잔존 (codex CLI 측 구조적 한계)**:
52
+ - PermissionRequest 이벤트: codex `approval_policy=auto` 에서 미dispatch
53
+ (codex CLI 한계) — PreToolUse supplement 로 결과 데이터는 동등화
54
+ - Subagent dispatch: codex CLI 의 subagent 개념 미지원 — hooks 등록만 존재
55
+ - Codex 0.130.0 hooks dispatch 회귀: codex CLI 0.130 binary 안에 hook event
56
+ schema (HookSpecificOutputWire) 와 `hooks: stable: true` feature flag 모두
57
+ 있는데도 dispatch 안 됨. config.toml 의 hooks 경로 명시도 무효. codex CLI
58
+ 내부 변경으로 forgen 측에서 worktime 짧게 fix 불가. 0.128.x 권장 + 0.4.7 에
59
+ codex GitHub issue 제기 예정
60
+
61
+ #### Theme E — Codex auto-onboarding (사용자 요청)
62
+
63
+ - **`fgx --codex` / `fgx --claude` 단축 플래그** (src/services/session.ts)
64
+ - 기존 `--runtime codex` 의 짧은 alias. 일상 진입이 더 빠름.
65
+
66
+ - **Codex hooks 자동 reconcile-on-launch** (src/core/harness.ts `ensureCodexHooksFresh`)
67
+ - 매 codex runtime 진입 (fgx --codex / forgen --runtime codex) 시 ~/.codex/hooks.json
68
+ 의 forgen entry 가 현재 pkgRoot 와 일치하는지 fast staleness check.
69
+ - 부재/stale → planCodexInstall silent 재실행. 일치 → no-op (1ms).
70
+ - 사용자가 `forgen install codex` 를 명시 호출 안 해도 매번 정합성 보장.
71
+ Cross-machine sync, npm 글로벌 path 변경, forgen 버전 업그레이드 후
72
+ 자동 회복.
73
+ - **검증**: clean container 3 시나리오 — A (clean state → 자동 생성),
74
+ B (동일 상태 → no-op idempotent), C (stale `/FAKE/PATH` → 자동 정정).
75
+ 모두 통과.
76
+
77
+ #### Theme A — Unattended Execution Resilience
78
+
79
+ - **Rate-limit auto-resume** (ADR-008, `commit TBD`)
80
+ - `context-guard.ts` Stop hook 에 `RATE_LIMIT_REGEX` + 5 패턴 reset 시각 파서
81
+ 추가 (Resets at HH:MM, in Nh Nm, in N seconds, available again at ISO,
82
+ try again in N min). 22 unit tests.
83
+ - `pending-resume.json` schema 확장: `reason: 'rate-limit' | 'token-limit'`,
84
+ `resetAt?: ISO`, `runtime: 'claude' | 'codex'`. 기존 token-limit 호환 유지.
85
+ - `spawnClaudeWithResume` (src/core/spawn.ts) rate-limit 분기:
86
+ `resetAt` 정확하면 정밀 sleep + 60s 버퍼, 실패 시 exponential backoff
87
+ (1m → 5m → 15m → 30m → 1h → 2h cap). hard cap 6h. foreground countdown
88
+ (30s 갱신, Ctrl+C abort). `MAX_RESUMES`: token=3 (현행), rate-limit=10.
89
+ - **Fix-forward 정책**: detector regex 가 실 메시지와 어긋나면 `~/.forgen/state/
90
+ rate-limit-misses.jsonl` 에 raw 누적 → patch release 로 hotfix.
91
+ - **알려진 한계**: weekly limit (최대 7일) > 6h hard cap, abort + 명시 메시지.
92
+ "Resets at 14:30 PST" TZ 무시 (UTC 가정) — 첫 실 트리거 후 hotfix 예정.
93
+
94
+ - **Usage telemetry** (src/core/usage-telemetry.ts)
95
+ - 5h / weekly window sliding count. `recordToolCall` 가 PostToolUse 마다
96
+ append-only `~/.forgen/state/usage-telemetry.jsonl` 에 timestamp 기록.
97
+ - 10K 엔트리 누적 시 weekly cap 밖 자동 prune. claude/codex 별도 카운트.
98
+ - 의도적으로 "limit prediction" 제외 — Anthropic 의 실 limit 가 계정/플랜별
99
+ 가변. raw count 만 노출하고 사용자가 판단.
100
+
101
+ - **Statusline usage 라인** (src/core/statusline-cli.ts)
102
+ - 새 라인: `📊 87/5h · 412/wk · (claude)` — 5h/weekly 추세 노출.
103
+
104
+ - **Notification 모듈** (src/core/notify.ts)
105
+ - macOS: `osascript display notification`, Linux: `notify-send`,
106
+ Windows: 생략. webhook (Slack/Discord 호환) 지원: `~/.forgen/config.json`
107
+ 의 `notifyWebhookUrl`. rate-limit auto-resume 이 sleep 끝나고 재기동 시점에
108
+ 발송 — 노트북 닫고 잘 때 끝났는지 알 수 있음.
109
+
110
+ #### Theme B — Codex hook 갭 보완
111
+
112
+ - **Codex hooks dispatch 죽음 root-cause 박제** (docs/codex-integration.md)
113
+ - 사용자 보고된 "Codex hooks 죽음" 의 진짜 원인 3가지 분리:
114
+ 1. `prompt-history.jsonl` writer 부재 (dead code 잔재) — 0.4.6 writer 신설
115
+ 2. `context-signals.json` 정상 동작 (도구 실패 시에만 write — 의도)
116
+ 3. `permissions-<id>.jsonl` 미생성 — Codex `approval_policy=auto`/
117
+ `workspace-write` 에서 PermissionRequest hook 자체가 dispatch 안 됨
118
+ (forgen 측 버그 아님 — Codex CLI 정책)
119
+ - 기각: projection.ts side-effect 손실, STATE_DIR 미스매치 (모두 정상 검증)
120
+
121
+ - **PreToolUse permission supplement** (src/hooks/pre-tool-use.ts)
122
+ - Codex `auto` 환경에서도 권한 흐름 가시화 — 모든 PreToolUse 가
123
+ `permissions-<sessionId>.jsonl` 에 `source: 'pre-tool-use'` entry append.
124
+ Claude permission-handler 와 source 필드로 reader 측 dedup.
125
+
126
+ - **prompt-history writer 신설** (src/hooks/context-guard.ts)
127
+ - UserPromptSubmit 마다 truncated (1KB) prompt 를 `~/.forgen/state/
128
+ prompt-history.jsonl` append. **secret-filter 거쳐 redact** —
129
+ password/api_key/token/AWS/GitHub 등 평문 leak 차단. compound-extractor.ts
130
+ 의 dead read 코드가 의미를 가짐.
131
+
132
+ #### Theme C — Performance
133
+
134
+ - **Hook stdin idle-resolve + initial-wait fallback** (src/hooks/shared/read-stdin.ts) — perf #11
135
+ - hook-timing.jsonl 측정 결과 pre-tool-use **p95 = 2003ms** (정확히
136
+ timeout 값) — Claude/Codex CLI 가 stdin EOF 안 닫거나 stdin 자체 안 보내는
137
+ 케이스. 두 단계 fix:
138
+ 1. `IDLE_RESOLVE_MS=100`: 'data' 받은 후 추가 chunk 없으면 early resolve
139
+ 2. `INITIAL_WAIT_MS=300`: 'data' 자체가 안 오는 케이스 (codex 일부 hook
140
+ event) 대비 300ms 후 빈 데이터로 fallback resolve
141
+ - 합법적 데이터 손실 위험 없음 — payload 는 호출 즉시 (≤ 300ms) 첫 chunk 도착
142
+ - **검증**: 실 codex 2-라운드 e2e — 1라운드 (idle 만): pre-tool-use 4건 중
143
+ 1건이 ms=2004 잔존. 2라운드 (initial-wait 추가 후): 9 hook entry 모두 < 50ms,
144
+ 2003ms tail 완전 제거.
145
+
146
+ - **Auto-compound adaptive cooldown** (src/hooks/context-guard.ts) — perf #12
147
+ - Last run 이 0건 추출 (barren) 했으면 다음 cooldown 5min → 30min.
148
+ `extractedSolutions + promotedRules + userPatternFound` 합산 = 0 판정.
149
+ - Wasted background runs 6x 감소. 일반 case (추출 있음) 5분 유지.
150
+ - Full LLM 호출 parallelization 은 0.4.7 로 분리 — `execClaudeRetryAsync`
151
+ foundation 만 박제 (refactor surface 큼, sandbox + file-write order
152
+ invariant 검증 필요).
153
+
154
+ - **Statusline 5초 캐싱** (src/core/statusline-cli.ts) — perf #13
155
+ - `~/.forgen/state/statusline-cache.txt` mtime 기반. CACHE_TTL_MS=5_000.
156
+ - **결과**: 160ms → 101ms (37%). Node 시작 50ms 가 floor.
157
+
158
+ #### Theme D — Maintenance
159
+
160
+ - **Append-only jsonl 회전** (src/core/state-gc.ts `rotateAppendOnlyLogs`) — #14
161
+ - state-gc 의 SESSION_SCOPED_PREFIXES 가 단일 aggregate jsonl
162
+ (hook-timing, prompt-history, usage-telemetry 등) 무한 grow 미커버 갭 수정.
163
+ - 10MB cap 초과 시 `<name>.1` rotate, `<name>.2` 삭제 (한 단계 보존).
164
+ - `forgen doctor --prune-state` 와 함께 자동 실행. 5 unit tests.
165
+
166
+ ### Verification
167
+
168
+ - **vitest**: 2441/2441 PASS (225 files), 0 regression
169
+ - **Docker e2e (existing)**: 77/77 PASS, 6 warnings (pre-existing)
170
+ - **신규 unit tests**: rate-limit-detection (22) + rate-limit-backoff (9) +
171
+ log-rotation (5) = 36
172
+
173
+ #### Clean-container e2e (`tests/e2e/docker/Dockerfile.v046`)
174
+
175
+ 새 Docker e2e 가 host 권한 없이 깨끗한 container 안에서 실 claude + codex 호출
176
+ 하여 hook side-effect 를 검증. host UID/GID 매핑 + ~/.codex auth 마운트.
177
+
178
+ **최종 결과: 17/18 PASS** (1 fail = claude OAuth keychain 미전달 — 환경 한계)
179
+
180
+ - ✅ **Codex 측 100% 검증** (codex 0.128.0):
181
+ - hook-timing.jsonl +9 entries
182
+ - **permissions-<codex-id>.jsonl 생성** with `source: 'pre-tool-use'` —
183
+ 사용자 보고된 "Codex hooks 죽음" 갭 fix 가 진짜 e2e 환경에서 작동 확인
184
+ - usage-telemetry +1 (PostToolUse hook 발화)
185
+ - prompt-history +1 (UserPromptSubmit hook 발화)
186
+ - **pre-tool-use ms=10ms** (no 2003ms tail — #11 fix 실 codex 환경 검증)
187
+ - Secret redaction (clean container 에서 GitHub Token redact 확인)
188
+ - Rate-limit detector synthetic Stop event → marker 정상 작성
189
+
190
+ - ⚠️ **Claude 측 부분 검증**: hook 발화 +5 (UserPromptSubmit 만), 하지만
191
+ "Not logged in" 으로 도구 호출까지 안 감 → PostToolUse / PermissionRequest
192
+ 미발화. **원인은 환경 (macOS Keychain 의 OAuth 토큰을 container 로 전달
193
+ 불가)**, 코드 문제 아님.
194
+
195
+ ### Discovered + Fixed during e2e
196
+
197
+ - **#17 — codex-adapter.ts FORGEN_RUNTIME 미주입** (FIXED)
198
+ - 검증 라운드 20-2 에서 `usage-telemetry.jsonl` 직접 inspect 시 codex 호출
199
+ 인데 `{"rt":"claude"}` 발견. `recordToolCall` 가 `process.env.FORGEN_RUNTIME`
200
+ fall-through 로 'claude' 판정.
201
+ - Fix: codex-adapter.ts 의 `spawnSync` 에 `env: { ...process.env,
202
+ FORGEN_RUNTIME: 'codex' }` 명시 주입.
203
+ - 영향: statusline 의 "(codex)" 표시 + telemetry 분리 정상화.
204
+
205
+ - **#15 — install-codex.ts isForgenManagedHook 버그** (FIXED)
206
+ - Root cause: `command.includes(pkgRoot)` exact match 만 체크 → 다른 머신에서
207
+ install 한 hooks.json 이 마운트되면 stale path entry 가 "user entry" 로
208
+ 오분류 → 보존 + 새 entry 누적 → codex 가 stale path 로 dispatch 시도 →
209
+ silent fail
210
+ - Fix: `FORGEN_HOOK_SCRIPT_MARKER` regex (`/dist\/(host\/codex-adapter|hooks
211
+ \/[a-z][a-z0-9-]+)\.js/`) fallback. 사용자 보고된 "Codex hooks 죽음" 의
212
+ 더 깊은 원인이었음 — PermissionRequest skip 갭 (#9) 외에 path mismatch 도
213
+ 동시 작용하던 것
214
+ - **#16 — Codex 0.130.0 hooks dispatch 회귀** (별도 task)
215
+ - Bisect: 0.128.0 dispatch 정상 (9 entries), 0.130.0 silent fail
216
+ (~/.forgen/state/ 자체 미생성)
217
+ - forgen 측 코드 동일 — codex-cli 0.130.0 의 hooks API/schema 변경 의심
218
+ - 임시 권장: codex 0.128.x 사용. 0.4.7 에서 0.130.0 호환 조사
219
+
220
+ ### Known first-run UX gap (verified, not a code bug)
221
+
222
+ - `npm i -g forgen` 후 **한 번도** `fgx` 또는 `forgen install <claude|codex>` 을
223
+ 거치지 않고 `claude` / `codex` 를 직접 호출 → forgen hooks 비활성 (settings.json
224
+ / hooks.json 미등록).
225
+ - 첫 `fgx --claude` / `fgx --codex` 한 번 (또는 명시 install) 이면 settings.json /
226
+ hooks.json 이 영구 저장되어 이후 직접 호출도 hooks 발화 (clean container e2e
227
+ Scenario A/B/C 로 검증). v0.4.6 의 ensureCodexHooksFresh 가 fgx 진입을 더
228
+ 부드럽게 만듦.
229
+ - README / onboarding 에 "first command: `fgx`" 명시 권장. 자동화 fix (예: npm
230
+ postinstall 에서 install both) 는 사용자 권한 가정 위반이라 0.4.7 검토.
231
+
232
+ ### 미검증 항목 (실 트리거 자연 발생 대기)
233
+
234
+ - **Rate-limit auto-resume**: synthetic Stop event 로 marker 작성 검증.
235
+ 실 limit hit 은 자연 발생 시 fix-forward 정책 (rate-limit-misses.jsonl 누적
236
+ → patch hotfix) 으로 보강.
237
+ - **Notification 발송**: rate-limit 도달 의존. notify 모듈 syntax 검증 완료.
238
+ - **Claude full chain (PostToolUse 등)**: macOS Keychain 토큰 container 전달
239
+ 불가 — `claude /login` 별도 인증 필요한 환경 한계.
240
+
241
+
10
242
  ### Fixed — forgen-eval testbed 측정 결함 (ADR-007)
11
243
 
12
244
  `forgen-eval` ψ-stat 측정의 두 구조적 결함 식별 + 수정. 본 fix 이전 모든
@@ -9,4 +9,16 @@
9
9
  * 호출: session-recovery hook 또는 spawn.ts에서 detached spawn
10
10
  * 인자: [cwd] [transcriptPath] [sessionId]
11
11
  */
12
- export {};
12
+ import { type ExecFileOptions } from 'node:child_process';
13
+ /**
14
+ * 0.4.6 perf #12 — async 변형. 3 LLM 호출 (solution / user-pattern / learning) 을
15
+ * Promise.allSettled 로 병렬 실행하여 wall-clock 을 sum → max 로 단축 (~3x).
16
+ *
17
+ * 0.4.6 에서는 adaptive cooldown 으로 wasted runs 차단을 우선 적용했고, 호출부
18
+ * full parallelization 은 0.4.7 로 분리 (refactor surface 큼 — solution call 의
19
+ * `--allowedTools` sandbox 와 file-write 순서 invariant 검증 필요).
20
+ *
21
+ * 본 함수는 0.4.7 작업의 foundation 으로 박제 — export 하여 unused warning 회피.
22
+ * 동작은 sync 버전과 동일: Claude/Codex 분기 + retry on transient.
23
+ */
24
+ export declare function execClaudeRetryAsync(args: string[], opts: ExecFileOptions): Promise<string>;
@@ -11,7 +11,9 @@
11
11
  */
12
12
  import * as fs from 'node:fs';
13
13
  import * as path from 'node:path';
14
- import { execFileSync } from 'node:child_process';
14
+ import { execFileSync, execFile } from 'node:child_process';
15
+ import { promisify } from 'node:util';
16
+ const execFileAsync = promisify(execFile);
15
17
  import { createRequire } from 'node:module';
16
18
  import { containsPromptInjection, filterSolutionContent } from '../hooks/prompt-injection-filter.js';
17
19
  import { redactSecrets } from '../hooks/secret-filter.js';
@@ -78,6 +80,58 @@ function execClaudeRetry(args, opts) {
78
80
  });
79
81
  return r.message;
80
82
  }
83
+ /**
84
+ * 0.4.6 perf #12 — async 변형. 3 LLM 호출 (solution / user-pattern / learning) 을
85
+ * Promise.allSettled 로 병렬 실행하여 wall-clock 을 sum → max 로 단축 (~3x).
86
+ *
87
+ * 0.4.6 에서는 adaptive cooldown 으로 wasted runs 차단을 우선 적용했고, 호출부
88
+ * full parallelization 은 0.4.7 로 분리 (refactor surface 큼 — solution call 의
89
+ * `--allowedTools` sandbox 와 file-write 순서 invariant 검증 필요).
90
+ *
91
+ * 본 함수는 0.4.7 작업의 foundation 으로 박제 — export 하여 unused warning 회피.
92
+ * 동작은 sync 버전과 동일: Claude/Codex 분기 + retry on transient.
93
+ */
94
+ export async function execClaudeRetryAsync(args, opts) {
95
+ const mod = createRequire(import.meta.url)('../host/exec-host.js');
96
+ const profileMod = createRequire(import.meta.url)('../store/profile-store.js');
97
+ const resolved = profileMod.resolveDefaultHost();
98
+ const host = resolved === 'codex' ? 'codex' : 'claude';
99
+ if (host === 'claude') {
100
+ const TRANSIENT = /ETIMEDOUT|ECONNRESET|ECONNREFUSED|EPIPE/;
101
+ const MAX_ATTEMPTS = 2;
102
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
103
+ try {
104
+ const { stdout } = await execFileAsync('claude', args, opts);
105
+ return typeof stdout === 'string' ? stdout : stdout.toString();
106
+ }
107
+ catch (e) {
108
+ const msg = e instanceof Error ? e.message : String(e);
109
+ const match = msg.match(TRANSIENT);
110
+ if (attempt < MAX_ATTEMPTS && match) {
111
+ process.stderr.write(`[forgen-auto-compound] ${match[0]} on attempt ${attempt}/${MAX_ATTEMPTS}, retrying in 3s (auto-recovery)...\n`);
112
+ await new Promise(resolve => setTimeout(resolve, 3000));
113
+ continue;
114
+ }
115
+ throw e;
116
+ }
117
+ }
118
+ throw new Error('unreachable');
119
+ }
120
+ // codex 분기 (동기 execHost 라 그냥 호출 — 이미 빠름)
121
+ const pIdx = args.indexOf('-p');
122
+ if (pIdx === -1 || !args[pIdx + 1]) {
123
+ throw new Error('execClaudeRetryAsync: codex host requires -p prompt argument');
124
+ }
125
+ const prompt = args[pIdx + 1];
126
+ const modelIdx = args.indexOf('--model');
127
+ const model = modelIdx !== -1 ? args[modelIdx + 1] : undefined;
128
+ const r = mod.execHost({
129
+ prompt, model, host: 'codex',
130
+ timeout: typeof opts.timeout === 'number' ? opts.timeout : 60000,
131
+ cwd: typeof opts.cwd === 'string' ? opts.cwd : undefined,
132
+ });
133
+ return r.message;
134
+ }
81
135
  const [, , cwd, transcriptPath, sessionId] = process.argv;
82
136
  if (!cwd || !transcriptPath || !sessionId) {
83
137
  process.exit(1);
@@ -169,6 +223,12 @@ function extractText(c) {
169
223
  return c.filter((x) => x?.type === 'text').map((x) => x.text ?? '').join('\n');
170
224
  return '';
171
225
  }
226
+ /**
227
+ * 0.4.6 — claude/codex JSONL 양 schema 호환.
228
+ *
229
+ * Claude: {type: 'user'|'assistant', content: ...}
230
+ * Codex: {type: 'response_item', payload: {type: 'message', role: 'user'|'assistant', content: [{type: 'input_text', text: ...}]}}
231
+ */
172
232
  function extractSummary(filePath, maxChars = 8000) {
173
233
  const content = fs.readFileSync(filePath, 'utf-8');
174
234
  const lines = content.split('\n').filter(Boolean);
@@ -177,6 +237,7 @@ function extractSummary(filePath, maxChars = 8000) {
177
237
  for (const line of lines) {
178
238
  try {
179
239
  const entry = JSON.parse(line);
240
+ // Claude schema
180
241
  if (entry.type === 'user' || entry.type === 'queue-operation') {
181
242
  const text = extractText(entry.content);
182
243
  if (text) {
@@ -191,6 +252,21 @@ function extractSummary(filePath, maxChars = 8000) {
191
252
  totalChars += text.length;
192
253
  }
193
254
  }
255
+ // Codex schema
256
+ else if (entry.type === 'response_item' && entry.payload?.role === 'user') {
257
+ const text = extractCodexText(entry.payload.content);
258
+ if (text) {
259
+ messages.push(`[User] ${text.slice(0, 500)}`);
260
+ totalChars += text.length;
261
+ }
262
+ }
263
+ else if (entry.type === 'response_item' && entry.payload?.role === 'assistant') {
264
+ const text = extractCodexText(entry.payload.content);
265
+ if (text) {
266
+ messages.push(`[Assistant] ${text.slice(0, 500)}`);
267
+ totalChars += text.length;
268
+ }
269
+ }
194
270
  }
195
271
  catch { /* skip */ }
196
272
  if (totalChars > maxChars)
@@ -198,6 +274,18 @@ function extractSummary(filePath, maxChars = 8000) {
198
274
  }
199
275
  return messages.join('\n\n');
200
276
  }
277
+ /** Codex content array → flat string. content: [{type: 'input_text', text: ...}] */
278
+ function extractCodexText(content) {
279
+ if (!Array.isArray(content))
280
+ return '';
281
+ const parts = [];
282
+ for (const item of content) {
283
+ if (item && typeof item === 'object' && 'text' in item && typeof item.text === 'string') {
284
+ parts.push(item.text);
285
+ }
286
+ }
287
+ return parts.join('\n').trim();
288
+ }
201
289
  /**
202
290
  * 기존 behavior 파일에 유사 패턴이 있으면 observedCount를 +1 증가.
203
291
  * 유사도는 같은 kind + 내용 키워드 50%+ 겹침으로 판단.
@@ -242,6 +330,8 @@ function mergeOrCreateBehavior(dir, newContent, kind, today) {
242
330
  }
243
331
  return false;
244
332
  }
333
+ // 0.4.6 perf #12 — adaptive cooldown 시그널 (declared early for hoisting).
334
+ let userPatternFound = false;
245
335
  try {
246
336
  const rawSummary = extractSummary(transcriptPath);
247
337
  if (rawSummary.length < 200)
@@ -375,6 +465,7 @@ ${sanitizedSummary.slice(0, 4000)}
375
465
  process.stderr.write(`[forgen-auto-compound] behavior: injection detected in LLM output, skipping write\n`);
376
466
  }
377
467
  if (userResult && !isInjection && !userResult.includes('관찰된 패턴 없음') && userResult.trim().length > 10) {
468
+ userPatternFound = true; // 0.4.6 perf #12 — adaptive cooldown 시그널
378
469
  fs.mkdirSync(BEHAVIOR_DIR, { recursive: true });
379
470
  const today = new Date().toISOString().split('T')[0];
380
471
  const trimmed = userResult.trim();
@@ -625,6 +716,7 @@ ${sanitizedSummary.slice(0, 4000)}
625
716
  completedAt: new Date().toISOString(),
626
717
  extractedSolutions: extractedSolutionsCount,
627
718
  promotedRules: promotedCount,
719
+ userPatternFound, // 0.4.6 perf #12 — adaptive cooldown 시그널
628
720
  noticeShown: false,
629
721
  }));
630
722
  }
@@ -418,6 +418,15 @@ export async function runDoctor(opts = {}) {
418
418
  const report = pruneState({ dryRun: false });
419
419
  const mb = (report.bytesFreed / 1024 / 1024).toFixed(2);
420
420
  console.log(` → Pruned ${report.pruned}/${report.scanned} files (${mb} MB freed, >${report.retentionDays}d old)`);
421
+ // 0.4.6 #14 — append-only jsonl 회전 (10MB cap)
422
+ try {
423
+ const { rotateAppendOnlyLogs } = await import('./state-gc.js');
424
+ const rot = rotateAppendOnlyLogs();
425
+ if (rot.rotated > 0) {
426
+ console.log(` → Rotated ${rot.rotated}/${rot.scanned} append-only log(s): ${rot.sample.join(', ')}`);
427
+ }
428
+ }
429
+ catch { /* fail-open */ }
421
430
  // ADR-002 T4 — 90d 미주입 rule retire. pruneState 와 함께 "하루 한번 정돈" 의미 공유.
422
431
  try {
423
432
  const { runDailyT4Decay } = await import('./state-gc.js');
@@ -22,6 +22,52 @@ import { bootstrapV1Session, ensureV1Directories } from './v1-bootstrap.js';
22
22
  import { injectSettings } from './settings-injector.js';
23
23
  import { installAgents, installSlashCommands } from './installer.js';
24
24
  const log = createLogger('harness');
25
+ /**
26
+ * 0.4.6 — Codex hooks fast staleness check + auto-reconcile.
27
+ *
28
+ * 매 codex 진입 시 hooks.json 의 forgen entry 가 현재 pkgRoot 와 일치하는지
29
+ * 검사. 불일치 또는 부재 시 planCodexInstall 을 silently 재실행.
30
+ *
31
+ * Staleness 판정: hooks.json 안의 첫 codex-adapter.js 경로가 현재 pkgRoot 의
32
+ * dist/host/codex-adapter.js 와 일치 여부.
33
+ *
34
+ * Performance: hot-path 회피를 위해 lazy import + 단순 string match. 정상
35
+ * 케이스에서 1ms 이내. 불일치 시 planCodexInstall (~50ms).
36
+ */
37
+ async function ensureCodexHooksFresh(pkgRoot) {
38
+ const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex');
39
+ const hooksPath = path.join(codexHome, 'hooks.json');
40
+ const expectedAdapterPath = path.join(pkgRoot, 'dist', 'host', 'codex-adapter.js');
41
+ let needsReinstall = false;
42
+ if (!fs.existsSync(hooksPath)) {
43
+ needsReinstall = true;
44
+ log.debug('codex hooks.json 부재 — install 필요');
45
+ }
46
+ else {
47
+ try {
48
+ const raw = fs.readFileSync(hooksPath, 'utf-8');
49
+ // 가장 간단한 staleness signal: 현재 pkgRoot 의 adapter 경로가 hooks.json 에 나타나는가?
50
+ if (!raw.includes(expectedAdapterPath)) {
51
+ needsReinstall = true;
52
+ log.debug(`codex hooks.json stale (expected ${expectedAdapterPath} 미발견)`);
53
+ }
54
+ }
55
+ catch (e) {
56
+ needsReinstall = true;
57
+ log.debug('codex hooks.json 읽기 실패 — reinstall', e);
58
+ }
59
+ }
60
+ if (!needsReinstall)
61
+ return;
62
+ try {
63
+ const { planCodexInstall } = await import('../host/install-codex.js');
64
+ planCodexInstall({ pkgRoot, registerMcp: true });
65
+ log.debug('codex hooks 자동 reconcile 완료');
66
+ }
67
+ catch (e) {
68
+ log.debug('codex hooks reconcile 실패', e);
69
+ }
70
+ }
25
71
  /** forgen 패키지 루트 */
26
72
  function getPackageRoot() {
27
73
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
@@ -356,8 +402,21 @@ export async function prepareHarness(cwd, options = {}) {
356
402
  // 7. 슬래시 명령 설치
357
403
  installSlashCommands(cwd, pkgRoot);
358
404
  }
405
+ else if (runtime === 'codex') {
406
+ // 0.4.6 — Codex hooks 자동 reconcile-on-launch.
407
+ // 매 fgx --codex / forgen --runtime codex 호출 시 hooks.json 의 forgen entry 가
408
+ // 현재 pkgRoot 와 일치하는지 fast check. 불일치 (다른 머신/버전 install,
409
+ // pkg path 변경, 신규 설치) → silent reinstall.
410
+ // 사용자가 `forgen install codex` 를 명시 호출 안 해도 매번 정합성 보장.
411
+ try {
412
+ await ensureCodexHooksFresh(pkgRoot);
413
+ }
414
+ catch (e) {
415
+ log.debug('Codex hooks reconcile 실패 (fail-open)', e);
416
+ }
417
+ }
359
418
  else {
360
- log.debug(`prepareHarness: runtime=${runtime} — Claude artifact prep skipped (Phase 3 handles Codex prep)`);
419
+ log.debug(`prepareHarness: runtime=${runtime} — artifact prep skipped`);
361
420
  }
362
421
  // 8. tmux 바인딩 등록
363
422
  if (inTmux) {
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Forgen — Desktop / webhook notification helper (ADR-008 §"테마 A 알림").
3
+ *
4
+ * 0.4.6 신설 — rate-limit auto-resume 이 sleep 끝나고 재기동될 때 사용자에게
5
+ * 알림을 보냄 (노트북 닫고 잘 때 끝났는지 알 수 있어야 함).
6
+ *
7
+ * 정책:
8
+ * - macOS: osascript 'display notification'
9
+ * - Linux: notify-send (있으면)
10
+ * - Windows: 생략 (PowerShell BurntToast 의존성 회피)
11
+ * - webhook: ~/.forgen/config.json 의 notifyWebhookUrl 설정 시 POST
12
+ * - fail-open: 모든 실패는 silent log
13
+ */
14
+ /**
15
+ * 통합 알림 진입점. desktop + webhook 둘 다 시도.
16
+ * 모든 실패는 silent — 호출 측 차단 안 함.
17
+ */
18
+ export declare function sendNotification(title: string, body: string): void;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Forgen — Desktop / webhook notification helper (ADR-008 §"테마 A 알림").
3
+ *
4
+ * 0.4.6 신설 — rate-limit auto-resume 이 sleep 끝나고 재기동될 때 사용자에게
5
+ * 알림을 보냄 (노트북 닫고 잘 때 끝났는지 알 수 있어야 함).
6
+ *
7
+ * 정책:
8
+ * - macOS: osascript 'display notification'
9
+ * - Linux: notify-send (있으면)
10
+ * - Windows: 생략 (PowerShell BurntToast 의존성 회피)
11
+ * - webhook: ~/.forgen/config.json 의 notifyWebhookUrl 설정 시 POST
12
+ * - fail-open: 모든 실패는 silent log
13
+ */
14
+ import { spawn } from 'node:child_process';
15
+ import * as os from 'node:os';
16
+ import * as path from 'node:path';
17
+ import * as fs from 'node:fs';
18
+ import { FORGEN_HOME } from './paths.js';
19
+ import { createLogger } from './logger.js';
20
+ const log = createLogger('notify');
21
+ function loadConfig() {
22
+ try {
23
+ const cfgPath = path.join(FORGEN_HOME, 'config.json');
24
+ if (!fs.existsSync(cfgPath))
25
+ return {};
26
+ return JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
27
+ }
28
+ catch {
29
+ return {};
30
+ }
31
+ }
32
+ function escapeForOsascript(s) {
33
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
34
+ }
35
+ /** macOS native notification via osascript. silent fail. */
36
+ function notifyDarwin(title, body) {
37
+ try {
38
+ const script = `display notification "${escapeForOsascript(body)}" with title "${escapeForOsascript(title)}"`;
39
+ const child = spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' });
40
+ child.unref();
41
+ }
42
+ catch (e) {
43
+ log.debug('osascript notification 실패', e);
44
+ }
45
+ }
46
+ /** Linux notify-send. silent fail (notify-send 미설치 OK). */
47
+ function notifyLinux(title, body) {
48
+ try {
49
+ const child = spawn('notify-send', [title, body], { detached: true, stdio: 'ignore' });
50
+ child.unref();
51
+ }
52
+ catch (e) {
53
+ log.debug('notify-send 실패', e);
54
+ }
55
+ }
56
+ /** webhook POST (slack/discord 호환). silent fail. */
57
+ async function notifyWebhook(url, title, body) {
58
+ try {
59
+ // Slack / Discord 호환 — text 필드 우선, fallback 으로 content
60
+ const payload = JSON.stringify({
61
+ text: `*${title}*\n${body}`,
62
+ content: `**${title}**\n${body}`, // Discord
63
+ });
64
+ await fetch(url, {
65
+ method: 'POST',
66
+ headers: { 'Content-Type': 'application/json' },
67
+ body: payload,
68
+ });
69
+ }
70
+ catch (e) {
71
+ log.debug('webhook notify 실패', e);
72
+ }
73
+ }
74
+ /**
75
+ * 통합 알림 진입점. desktop + webhook 둘 다 시도.
76
+ * 모든 실패는 silent — 호출 측 차단 안 함.
77
+ */
78
+ export function sendNotification(title, body) {
79
+ const cfg = loadConfig();
80
+ const desktopEnabled = cfg.notifyDesktop !== false; // default true
81
+ if (desktopEnabled) {
82
+ const platform = os.platform();
83
+ if (platform === 'darwin')
84
+ notifyDarwin(title, body);
85
+ else if (platform === 'linux')
86
+ notifyLinux(title, body);
87
+ // win32: 생략
88
+ }
89
+ if (cfg.notifyWebhookUrl) {
90
+ // fire-and-forget — await 하지 않음 (caller 차단 방지)
91
+ void notifyWebhook(cfg.notifyWebhookUrl, title, body);
92
+ }
93
+ }
@@ -2,8 +2,19 @@ import type { V1HarnessContext } from './harness.js';
2
2
  import type { RuntimeHost } from './types.js';
3
3
  /** Claude Code를 하네스 환경으로 실행. exit code를 반환. */
4
4
  export declare function spawnClaude(args: string[], context: V1HarnessContext, runtime?: RuntimeHost): Promise<number>;
5
+ declare const MAX_RESUMES = 3;
5
6
  /**
6
- * 토큰 한도 도달 자동 재시작을 지원하는 claude 실행 래퍼.
7
- * context-guard가 pending-resume.json 마커를 생성하면 쿨다운 재시작.
7
+ * Exponential backoff for rate-limit when resetAt 파싱 실패.
8
+ * 1m 5m 15m 30m → 1h → 2h cap. 합 ≤ 6h.
9
+ */
10
+ export declare function rateLimitBackoffMs(attempt: number): number;
11
+ /**
12
+ * 토큰 한도 / API rate-limit 도달 시 자동 재시작을 지원하는 claude 실행 래퍼.
13
+ * context-guard가 pending-resume.json 마커를 생성하면 reason 별 정책으로 처리.
14
+ *
15
+ * - reason='token-limit': 30s 쿨다운, MAX_RESUMES_TOKEN=3
16
+ * - reason='rate-limit': resetAt 정밀 sleep (+60s 버퍼) 또는 exponential backoff,
17
+ * MAX_RESUMES_RATE=10, hard cap 6h
8
18
  */
9
19
  export declare function spawnClaudeWithResume(args: string[], context: V1HarnessContext, contextFactory: () => Promise<V1HarnessContext>, runtime?: RuntimeHost): Promise<void>;
20
+ export { MAX_RESUMES };