@wooojin/forgen 0.4.4 → 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.
Files changed (44) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +368 -4
  3. package/README.md +13 -0
  4. package/assets/claude/commands/forge-loop.md +62 -2
  5. package/dist/cli.js +8 -0
  6. package/dist/core/auto-compound-runner.d.ts +13 -1
  7. package/dist/core/auto-compound-runner.js +93 -1
  8. package/dist/core/doctor.js +9 -0
  9. package/dist/core/harness.js +60 -1
  10. package/dist/core/notify.d.ts +18 -0
  11. package/dist/core/notify.js +93 -0
  12. package/dist/core/settings-injector.js +8 -2
  13. package/dist/core/spawn.d.ts +13 -2
  14. package/dist/core/spawn.js +154 -32
  15. package/dist/core/state-gc.d.ts +10 -0
  16. package/dist/core/state-gc.js +71 -0
  17. package/dist/core/statusline-cli.d.ts +13 -0
  18. package/dist/core/statusline-cli.js +205 -0
  19. package/dist/core/usage-telemetry.d.ts +34 -0
  20. package/dist/core/usage-telemetry.js +101 -0
  21. package/dist/hooks/context-guard.d.ts +13 -0
  22. package/dist/hooks/context-guard.js +155 -4
  23. package/dist/hooks/hook-registry.js +9 -4
  24. package/dist/hooks/post-tool-use.js +3 -0
  25. package/dist/hooks/pre-tool-use.js +31 -0
  26. package/dist/hooks/session-recovery.js +11 -0
  27. package/dist/hooks/shared/read-stdin.js +44 -29
  28. package/dist/host/codex-adapter.js +5 -0
  29. package/dist/host/install-claude.js +34 -3
  30. package/dist/host/install-codex.js +7 -1
  31. package/dist/services/session.js +13 -0
  32. package/package.json +1 -1
  33. package/plugin.json +1 -1
  34. package/scripts/postinstall.js +61 -6
  35. package/skills/architecture-decision/SKILL.md +21 -0
  36. package/skills/calibrate/SKILL.md +21 -0
  37. package/skills/code-review/SKILL.md +21 -0
  38. package/skills/compound/SKILL.md +21 -0
  39. package/skills/deep-interview/SKILL.md +21 -0
  40. package/skills/docker/SKILL.md +21 -0
  41. package/skills/forge-loop/SKILL.md +76 -1
  42. package/skills/learn/SKILL.md +21 -0
  43. package/skills/retro/SKILL.md +21 -0
  44. package/skills/ship/SKILL.md +21 -0
@@ -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
+ }
@@ -43,12 +43,18 @@ function readSettingsWithBackup() {
43
43
  }
44
44
  return settings;
45
45
  }
46
- /** Apply forgen statusLine only if user hasn't set a custom one. */
46
+ /** Apply forgen statusLine only if user hasn't set a custom one.
47
+ * Migration: 'forgen me' → 'forgen statusline' (multi-line dump → compact HUD). */
47
48
  function applyStatusLine(settings) {
48
49
  const existing = settings.statusLine;
50
+ // 기존에 'forgen me'로 주입된 경우 → 'forgen statusline'으로 자동 마이그레이션
51
+ if (existing?.command === 'forgen me') {
52
+ settings.statusLine = { type: 'command', command: 'forgen statusline' };
53
+ return;
54
+ }
49
55
  const isForgenOwned = !existing || !existing.command || existing.command.startsWith('forgen');
50
56
  if (isForgenOwned) {
51
- settings.statusLine = { type: 'command', command: 'forgen me' };
57
+ settings.statusLine = { type: 'command', command: 'forgen statusline' };
52
58
  }
53
59
  }
54
60
  /** Check if a settings.json hook entry was installed by forgen. */
@@ -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 };