@wooojin/forgen 0.4.8 → 0.4.9

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 (122) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/assets/dev-guide/be/README.md +226 -0
  3. package/assets/dev-guide/be/adapters/build-agents-md.sh +63 -0
  4. package/assets/dev-guide/be/principles/common.md +433 -0
  5. package/assets/dev-guide/be/principles/go.md +469 -0
  6. package/assets/dev-guide/be/principles/node.md +388 -0
  7. package/assets/dev-guide/be/skills/go/be-build/SKILL.md +262 -0
  8. package/assets/dev-guide/be/skills/go/be-perf/SKILL.md +308 -0
  9. package/assets/dev-guide/be/skills/go/be-review/SKILL.md +119 -0
  10. package/assets/dev-guide/be/skills/go/be-security/SKILL.md +362 -0
  11. package/assets/dev-guide/be/skills/node/be-build/SKILL.md +239 -0
  12. package/assets/dev-guide/be/skills/node/be-perf/SKILL.md +272 -0
  13. package/assets/dev-guide/be/skills/node/be-review/SKILL.md +118 -0
  14. package/assets/dev-guide/be/skills/node/be-security/SKILL.md +355 -0
  15. package/assets/dev-guide/be/sources/12factor/INDEX.md +53 -0
  16. package/assets/dev-guide/be/sources/api-design/INDEX.md +56 -0
  17. package/assets/dev-guide/be/sources/ddia/INDEX.md +55 -0
  18. package/assets/dev-guide/be/sources/go-runtime/INDEX.md +62 -0
  19. package/assets/dev-guide/be/sources/node-runtime/INDEX.md +60 -0
  20. package/assets/dev-guide/be/sources/otel/INDEX.md +53 -0
  21. package/assets/dev-guide/be/sources/owasp-api/INDEX.md +52 -0
  22. package/assets/dev-guide/be/sources/postgres/INDEX.md +55 -0
  23. package/assets/dev-guide/be/sources/sre-book/INDEX.md +48 -0
  24. package/assets/dev-guide/fe/README.md +197 -0
  25. package/assets/dev-guide/fe/adapters/build-agents-md.sh +63 -0
  26. package/assets/dev-guide/fe/adapters/refresh.sh +68 -0
  27. package/assets/dev-guide/fe/principles/common.md +160 -0
  28. package/assets/dev-guide/fe/principles/react.md +183 -0
  29. package/assets/dev-guide/fe/principles/vue.md +196 -0
  30. package/assets/dev-guide/fe/skills/react/fe-build/SKILL.md +139 -0
  31. package/assets/dev-guide/fe/skills/react/fe-perf/SKILL.md +179 -0
  32. package/assets/dev-guide/fe/skills/react/fe-review/SKILL.md +141 -0
  33. package/assets/dev-guide/fe/skills/vue/fe-build/SKILL.md +148 -0
  34. package/assets/dev-guide/fe/skills/vue/fe-perf/SKILL.md +163 -0
  35. package/assets/dev-guide/fe/skills/vue/fe-review/SKILL.md +136 -0
  36. package/assets/dev-guide/fe/sources/a11y-dx/INDEX.md +41 -0
  37. package/assets/dev-guide/fe/sources/a11y-dx/chrome-devtools-memory.md +150 -0
  38. package/assets/dev-guide/fe/sources/a11y-dx/chrome-devtools-performance.md +99 -0
  39. package/assets/dev-guide/fe/sources/a11y-dx/lighthouse-audits.md +146 -0
  40. package/assets/dev-guide/fe/sources/a11y-dx/react-devtools-profiler.md +128 -0
  41. package/assets/dev-guide/fe/sources/a11y-dx/wcag22-new-criteria.md +174 -0
  42. package/assets/dev-guide/fe/sources/perf/01-core-web-vitals.md +58 -0
  43. package/assets/dev-guide/fe/sources/perf/02-inp.md +83 -0
  44. package/assets/dev-guide/fe/sources/perf/03-lcp-cls.md +130 -0
  45. package/assets/dev-guide/fe/sources/perf/04-speculation-rules.md +148 -0
  46. package/assets/dev-guide/fe/sources/perf/05-view-transitions.md +153 -0
  47. package/assets/dev-guide/fe/sources/perf/06-nextjs-caching.md +188 -0
  48. package/assets/dev-guide/fe/sources/perf/07-server-components.md +181 -0
  49. package/assets/dev-guide/fe/sources/perf/08-ppr.md +133 -0
  50. package/assets/dev-guide/fe/sources/perf/09-nextjs-image.md +200 -0
  51. package/assets/dev-guide/fe/sources/perf/10-optimize-lcp.md +201 -0
  52. package/assets/dev-guide/fe/sources/perf/INDEX.md +88 -0
  53. package/assets/dev-guide/fe/sources/react/INDEX.md +41 -0
  54. package/assets/dev-guide/fe/sources/react/keeping-components-pure.md +135 -0
  55. package/assets/dev-guide/fe/sources/react/no-effect-patterns.md +183 -0
  56. package/assets/dev-guide/fe/sources/react/react-compiler.md +182 -0
  57. package/assets/dev-guide/fe/sources/react/server-components.md +194 -0
  58. package/assets/dev-guide/fe/sources/react/server-functions.md +192 -0
  59. package/assets/dev-guide/fe/sources/react/suspense.md +218 -0
  60. package/assets/dev-guide/fe/sources/react/use-action-state.md +123 -0
  61. package/assets/dev-guide/fe/sources/react/use-form-status.md +158 -0
  62. package/assets/dev-guide/fe/sources/react/use-hook.md +153 -0
  63. package/assets/dev-guide/fe/sources/react/use-optimistic.md +194 -0
  64. package/assets/dev-guide/fe/sources/toss-ff/INDEX.md +58 -0
  65. package/assets/dev-guide/fe/sources/toss-ff/cohesion-code-directory.md +79 -0
  66. package/assets/dev-guide/fe/sources/toss-ff/cohesion-form-fields.md +110 -0
  67. package/assets/dev-guide/fe/sources/toss-ff/cohesion-magic-number.md +47 -0
  68. package/assets/dev-guide/fe/sources/toss-ff/coupling-item-edit-modal.md +124 -0
  69. package/assets/dev-guide/fe/sources/toss-ff/coupling-use-bottom-sheet.md +57 -0
  70. package/assets/dev-guide/fe/sources/toss-ff/coupling-use-page-state.md +71 -0
  71. package/assets/dev-guide/fe/sources/toss-ff/overview-4-principles.md +77 -0
  72. package/assets/dev-guide/fe/sources/toss-ff/predictability-hidden-logic.md +59 -0
  73. package/assets/dev-guide/fe/sources/toss-ff/predictability-http.md +77 -0
  74. package/assets/dev-guide/fe/sources/toss-ff/predictability-use-user.md +110 -0
  75. package/assets/dev-guide/fe/sources/toss-ff/readability-comparison-order.md +52 -0
  76. package/assets/dev-guide/fe/sources/toss-ff/readability-condition-name.md +64 -0
  77. package/assets/dev-guide/fe/sources/toss-ff/readability-login-start-page.md +183 -0
  78. package/assets/dev-guide/fe/sources/toss-ff/readability-magic-number.md +53 -0
  79. package/assets/dev-guide/fe/sources/toss-ff/readability-submit-button.md +73 -0
  80. package/assets/dev-guide/fe/sources/toss-ff/readability-ternary-operator.md +38 -0
  81. package/assets/dev-guide/fe/sources/toss-ff/readability-use-page-state.md +77 -0
  82. package/assets/dev-guide/fe/sources/toss-ff/readability-user-policy.md +98 -0
  83. package/assets/dev-guide/fe/sources/vue/INDEX.md +17 -0
  84. package/assets/dev-guide/fe/sources/vue/composition-api.md +251 -0
  85. package/assets/dev-guide/fe/sources/vue/nuxt-data-fetching.md +232 -0
  86. package/assets/dev-guide/fe/sources/vue/pinia-state-management.md +134 -0
  87. package/assets/dev-guide/fe/sources/vue/reactivity-pitfalls.md +261 -0
  88. package/assets/dev-guide/fe/sources/vue/style-guide-priority-a.md +117 -0
  89. package/assets/dev-guide/fe/sources/vue/style-guide-priority-b.md +231 -0
  90. package/assets/dev-guide/fe/sources/vue/style-guide-priority-c.md +86 -0
  91. package/assets/dev-guide/fe/sources/vue/style-guide-priority-d.md +72 -0
  92. package/dist/cli.js +42 -0
  93. package/dist/core/dashboard-cli.d.ts +12 -0
  94. package/dist/core/dashboard-cli.js +226 -0
  95. package/dist/core/dev-guide-injector.d.ts +26 -0
  96. package/dist/core/dev-guide-injector.js +137 -0
  97. package/dist/core/init.js +53 -0
  98. package/dist/core/lifecycle-classifier.d.ts +23 -0
  99. package/dist/core/lifecycle-classifier.js +104 -0
  100. package/dist/core/observability-backfill.d.ts +31 -0
  101. package/dist/core/observability-backfill.js +178 -0
  102. package/dist/core/observability-store.d.ts +58 -0
  103. package/dist/core/observability-store.js +195 -0
  104. package/dist/core/session-store.js +4 -0
  105. package/dist/core/spawn.d.ts +17 -0
  106. package/dist/core/spawn.js +179 -2
  107. package/dist/core/statusline-cli.js +34 -1
  108. package/dist/engine/compound-extractor.js +39 -0
  109. package/dist/engine/compound-loop.js +6 -0
  110. package/dist/engine/compound-retire.d.ts +20 -0
  111. package/dist/engine/compound-retire.js +85 -0
  112. package/dist/hooks/context-guard.js +25 -1
  113. package/dist/hooks/post-tool-use.js +48 -0
  114. package/dist/hooks/solution-injector.js +93 -0
  115. package/dist/host/install-claude.d.ts +6 -2
  116. package/dist/host/install-claude.js +74 -2
  117. package/dist/host/install-codex.d.ts +4 -0
  118. package/dist/host/install-codex.js +71 -0
  119. package/dist/host/install-orchestrator.js +1 -0
  120. package/package.json +6 -6
  121. package/plugin.json +1 -1
  122. package/scripts/postinstall.js +134 -0
@@ -2,13 +2,16 @@ import { spawn, execFileSync } from 'node:child_process';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import * as os from 'node:os';
5
+ import * as crypto from 'node:crypto';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { buildEnv } from './config-injector.js';
7
8
  import { loadGlobalConfig } from './global-config.js';
8
9
  import { createLogger } from './logger.js';
9
- import { STATE_DIR } from './paths.js';
10
+ import { STATE_DIR, ME_SOLUTIONS } from './paths.js';
10
11
  import { getHostRuntime } from '../host/host-runtime.js';
11
12
  import { sendNotification } from './notify.js';
13
+ import { querySurfacedWithin, emitSolutionEvent } from './observability-store.js';
14
+ import { parseSolutionV3 } from '../engine/solution-format.js';
12
15
  const log = createLogger('spawn');
13
16
  /** Phase 2: host-runtime 어댑터 위임. */
14
17
  function findRuntimeLauncher(runtime) {
@@ -139,6 +142,83 @@ async function countUserMessages(transcriptPath) {
139
142
  }
140
143
  return count;
141
144
  }
145
+ /**
146
+ * Observability P2: commit-diff acted_on signal.
147
+ * 세션 시작 이후 생성된 commit 들의 diff 를 scan 하여 surfaced 솔루션과 키워드 매칭.
148
+ * 프라이버시: commit sha 는 SHA1 해시 12char prefix 만 저장, diff/path 내용 미저장.
149
+ */
150
+ async function scanCommitDiffForActedOn(sessionId, cwd, sessionStartTime) {
151
+ try {
152
+ const surfaces = querySurfacedWithin(sessionId, 30); // 30분 window
153
+ if (surfaces.length === 0)
154
+ return;
155
+ const since = new Date(sessionStartTime).toISOString();
156
+ let logOutput;
157
+ try {
158
+ logOutput = execFileSync('git', ['log', '--since', since, '--pretty=format:%H'], {
159
+ cwd, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
160
+ });
161
+ }
162
+ catch {
163
+ return; // git not available or not a repo
164
+ }
165
+ const shas = logOutput.trim().split('\n').filter(Boolean);
166
+ if (shas.length === 0)
167
+ return;
168
+ const seen = new Set(); // (sha12+solutionId) dedup
169
+ for (const sha of shas) {
170
+ let diff;
171
+ try {
172
+ diff = execFileSync('git', ['show', '--unified=0', sha], {
173
+ cwd, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
174
+ });
175
+ }
176
+ catch {
177
+ continue;
178
+ }
179
+ const diffLower = diff.toLowerCase();
180
+ const shaHash = crypto.createHash('sha1').update(sha).digest('hex').slice(0, 12);
181
+ for (const surf of surfaces) {
182
+ const dedupKey = `${shaHash}:${surf.solutionId}`;
183
+ if (seen.has(dedupKey))
184
+ continue;
185
+ const filePath = path.join(ME_SOLUTIONS, `${surf.solutionId}.md`);
186
+ if (!fs.existsSync(filePath))
187
+ continue;
188
+ let raw;
189
+ try {
190
+ raw = fs.readFileSync(filePath, 'utf-8');
191
+ }
192
+ catch {
193
+ continue;
194
+ }
195
+ const sol = parseSolutionV3(raw);
196
+ if (!sol)
197
+ continue;
198
+ const tags = sol.frontmatter.tags ?? [];
199
+ const identifiers = sol.frontmatter.identifiers ?? [];
200
+ if (tags.length === 0 && identifiers.length === 0)
201
+ continue;
202
+ const hit = tags.some(t => diffLower.includes(t.toLowerCase()))
203
+ || identifiers.some(id => diffLower.includes(id.toLowerCase()));
204
+ if (!hit)
205
+ continue;
206
+ seen.add(dedupKey);
207
+ emitSolutionEvent({
208
+ sessionId,
209
+ solutionId: surf.solutionId,
210
+ eventType: 'acted_on',
211
+ signalSource: 'commit-diff',
212
+ signalScore: 0.30,
213
+ meta: { commit_sha_hash: shaHash, surface_ts: surf.ts },
214
+ });
215
+ }
216
+ }
217
+ }
218
+ catch (e) {
219
+ log.debug('scanCommitDiffForActedOn 실패', e);
220
+ }
221
+ }
142
222
  /**
143
223
  * 세션 종료 후 자동 compound 추출 + USER.md 업데이트.
144
224
  * auto-compound-runner.ts를 동기 실행하여 솔루션 추출 + 사용자 패턴 관찰.
@@ -178,6 +258,78 @@ async function indexTranscriptToFTS(cwd, transcriptPath, sessionId, runtime = 'c
178
258
  log.debug('FTS5 인덱싱 실패 (session-store 미구현 시 정상)', e);
179
259
  }
180
260
  }
261
+ /**
262
+ * Plan B-1: 세션 transcript 사후 스캔으로 rate-limit 감지.
263
+ *
264
+ * stdio='inherit' 라 Claude 출력을 직접 캡처할 수 없으므로,
265
+ * 세션 종료 후 transcript JSONL 의 마지막 N 라인을 읽어
266
+ * RATE_LIMIT_REGEX 매칭 여부를 확인한다.
267
+ *
268
+ * - transcript 없음 / 빈 파일 / parse 실패 → matched: false (fail-open)
269
+ * - 이미 pending-resume.json 존재 시 hook 이 먼저 잡은 것이므로 덮어쓰지 않음
270
+ *
271
+ * @param transcriptPath JSONL 파일 경로
272
+ * @param tailLines 검사할 마지막 라인 수 (기본 5)
273
+ */
274
+ export async function scanTranscriptForRateLimit(transcriptPath, tailLines = 5) {
275
+ const notFound = { matched: false, resetAt: null };
276
+ if (!transcriptPath)
277
+ return notFound;
278
+ try {
279
+ if (!fs.existsSync(transcriptPath))
280
+ return notFound;
281
+ const { RATE_LIMIT_REGEX, parseRateLimitResetAt } = await import('../hooks/context-guard.js');
282
+ // tail: 파일 끝에서 tailLines 개 라인만 읽음 (대용량 transcript 효율)
283
+ const { createInterface } = await import('node:readline');
284
+ const stream = fs.createReadStream(transcriptPath, { encoding: 'utf-8' });
285
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
286
+ const lines = [];
287
+ for await (const line of rl) {
288
+ if (line)
289
+ lines.push(line);
290
+ if (lines.length > tailLines)
291
+ lines.shift();
292
+ }
293
+ rl.close();
294
+ stream.close();
295
+ if (lines.length === 0)
296
+ return notFound;
297
+ // 각 라인의 content/text/message 필드를 합쳐 regex 적용
298
+ const combined = lines
299
+ .map((line) => {
300
+ try {
301
+ const obj = JSON.parse(line);
302
+ const parts = [];
303
+ if (typeof obj.content === 'string')
304
+ parts.push(obj.content);
305
+ if (typeof obj.text === 'string')
306
+ parts.push(obj.text);
307
+ if (typeof obj.message === 'string')
308
+ parts.push(obj.message);
309
+ // content 가 배열인 경우 (Claude JSONL block format)
310
+ if (Array.isArray(obj.content)) {
311
+ for (const block of obj.content) {
312
+ if (block && typeof block.text === 'string') {
313
+ parts.push(block.text);
314
+ }
315
+ }
316
+ }
317
+ return parts.join(' ');
318
+ }
319
+ catch {
320
+ return '';
321
+ }
322
+ })
323
+ .join(' ');
324
+ if (!RATE_LIMIT_REGEX.test(combined))
325
+ return notFound;
326
+ const resetAt = parseRateLimitResetAt(combined);
327
+ return { matched: true, resetAt };
328
+ }
329
+ catch {
330
+ return notFound;
331
+ }
332
+ }
181
333
  /** Claude Code를 하네스 환경으로 실행. exit code를 반환. */
182
334
  export async function spawnClaude(args, context, runtime = 'claude') {
183
335
  const launcher = findRuntimeLauncher(runtime);
@@ -235,7 +387,32 @@ export async function spawnClaude(args, context, runtime = 'claude') {
235
387
  }
236
388
  // 1. FTS5 인덱싱 — v0.4.8 (A1) 부터 Claude/Codex 모두 지원.
237
389
  await indexTranscriptToFTS(context.cwd, transcript, sessionId, runtime);
238
- // 2. 자동 compound (10+ user 메시지인 경우만) runtime 호환
390
+ // 2. Plan B-1: transcript 사후 스캔으로 rate-limit 감지 (hook 미감지 보완)
391
+ const resumePath = path.join(STATE_DIR, 'pending-resume.json');
392
+ if (!fs.existsSync(resumePath)) {
393
+ try {
394
+ const scanResult = await scanTranscriptForRateLimit(transcript);
395
+ if (scanResult.matched) {
396
+ const marker = {
397
+ reason: 'rate-limit',
398
+ sessionId,
399
+ runtime,
400
+ resetAt: scanResult.resetAt,
401
+ savedAt: new Date().toISOString(),
402
+ cwd: context.cwd,
403
+ source: 'spawn-transcript-scan',
404
+ };
405
+ fs.writeFileSync(resumePath, JSON.stringify(marker, null, 2));
406
+ log.debug(`transcript scan: rate-limit 감지 → pending-resume.json 작성`);
407
+ }
408
+ }
409
+ catch (e) {
410
+ log.debug('transcript rate-limit scan 실패 (fail-open)', e);
411
+ }
412
+ }
413
+ // 3. Observability P2: commit-diff acted_on scan
414
+ await scanCommitDiffForActedOn(sessionId, context.cwd, sessionStartTime);
415
+ // 4. 자동 compound (10+ user 메시지인 경우만) — 양 runtime 호환
239
416
  const userMsgCount = await countUserMessages(transcript);
240
417
  if (userMsgCount >= 10) {
241
418
  await runAutoCompound(context.cwd, transcript, sessionId);
@@ -17,6 +17,7 @@ import { execSync } from 'node:child_process';
17
17
  import { loadActiveRules } from '../store/rule-store.js';
18
18
  import { getUsageStats } from './usage-telemetry.js';
19
19
  import { STATE_DIR } from './paths.js';
20
+ import { classifySolutions } from './lifecycle-classifier.js';
20
21
  // 0.4.6 perf #13 — statusline 출력을 5초 캐싱.
21
22
  // claude statusLine 은 짧은 간격으로 재호출되는데 매번 git/find/rule-store 를
22
23
  // 실행하면 ~100ms 누적. CACHE_TTL_MS 동안 동일 출력 재사용.
@@ -123,6 +124,30 @@ function buildLine1(payload, cwd) {
123
124
  parts.push(`${GREEN}${gitBranch}${RESET}`);
124
125
  return parts.join(` ${DIM}|${RESET} `);
125
126
  }
127
+ /** Build lifecycle line: "🔥X 🟡X 🥶X 💀X 🌱X" — P3 신설. 0건이면 null */
128
+ function buildLifecycleLine() {
129
+ try {
130
+ const classified = classifySolutions();
131
+ if (classified.length === 0)
132
+ return null;
133
+ const counts = { hot: 0, warm: 0, cold: 0, dead: 0, new: 0 };
134
+ for (const c of classified)
135
+ counts[c.lifecycle]++;
136
+ const total = counts.hot + counts.warm + counts.cold + counts.dead + counts.new;
137
+ if (total === 0)
138
+ return null;
139
+ return [
140
+ `${YELLOW}🔥${counts.hot}${RESET}`,
141
+ `${YELLOW}🟡${counts.warm}${RESET}`,
142
+ `${DIM}🥶${counts.cold}${RESET}`,
143
+ `${DIM}💀${counts.dead}${RESET}`,
144
+ `${DIM}🌱${counts.new}${RESET}`,
145
+ ].join(` `);
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ }
126
151
  /** Build usage line: "📊 87/5h · 412/wk (claude)" — 0.4.6 신설 */
127
152
  function buildUsageLine() {
128
153
  try {
@@ -193,6 +218,7 @@ export async function handleStatusline() {
193
218
  const line1 = buildLine1(payload, cwd);
194
219
  const line3 = buildLine3(claudeDir, cwd);
195
220
  const usageLine = buildUsageLine();
221
+ const lifecycleLine = buildLifecycleLine();
196
222
  // Line 2 (context/usage): stdin JSON spec 미확인으로 생략 — TODO
197
223
  // Line 4 (tool counts): 추적 인프라 없음 — TODO
198
224
  // Line 5 (active task): 추적 인프라 없음 — TODO
@@ -200,6 +226,13 @@ export async function handleStatusline() {
200
226
  console.log(line3);
201
227
  if (usageLine)
202
228
  console.log(usageLine);
203
- const cacheBody = usageLine ? `${line1}\n${line3}\n${usageLine}\n` : `${line1}\n${line3}\n`;
229
+ if (lifecycleLine)
230
+ console.log(lifecycleLine);
231
+ const cacheLines = [line1, line3];
232
+ if (usageLine)
233
+ cacheLines.push(usageLine);
234
+ if (lifecycleLine)
235
+ cacheLines.push(lifecycleLine);
236
+ const cacheBody = cacheLines.join('\n') + '\n';
204
237
  writeCache(cacheBody);
205
238
  }
@@ -21,6 +21,7 @@ import { execFileSync } from 'node:child_process';
21
21
  import { execHost } from '../host/exec-host.js';
22
22
  import { serializeSolutionV3, DEFAULT_EVIDENCE, extractTags } from './solution-format.js';
23
23
  import { createLogger } from '../core/logger.js';
24
+ import { emitSolutionEvent } from '../core/observability-store.js';
24
25
  const log = createLogger('compound-extractor');
25
26
  import { CLAUDE_DIR, ME_SOLUTIONS, STATE_DIR } from '../core/paths.js';
26
27
  import { atomicWriteJSON, atomicWriteText } from '../hooks/shared/atomic-write.js';
@@ -845,6 +846,42 @@ export async function runExtraction(cwd, sessionId) {
845
846
  }
846
847
  return result;
847
848
  }
849
+ /**
850
+ * Observability P2: 새 솔루션 본문/supersedes 에서 기존 솔루션 참조 감지 → acted_on emit.
851
+ * fail-open.
852
+ */
853
+ function emitCompoundExtractActedOn(sessionId, newSolutionName, newContent, newSupersedes) {
854
+ try {
855
+ if (!fs.existsSync(ME_SOLUTIONS))
856
+ return;
857
+ const bodyLower = newContent.toLowerCase();
858
+ const supersedes = newSupersedes ?? '';
859
+ const files = fs.readdirSync(ME_SOLUTIONS).filter(f => f.endsWith('.md'));
860
+ for (const file of files) {
861
+ const existingName = path.basename(file, '.md');
862
+ if (existingName === newSolutionName)
863
+ continue;
864
+ const referenced = (supersedes && existingName === supersedes)
865
+ || bodyLower.includes(existingName.toLowerCase());
866
+ if (!referenced)
867
+ continue;
868
+ emitSolutionEvent({
869
+ sessionId,
870
+ solutionId: existingName,
871
+ eventType: 'acted_on',
872
+ signalSource: 'compound-extract',
873
+ signalScore: 0.20,
874
+ meta: {
875
+ new_solution: newSolutionName,
876
+ via: (supersedes && existingName === supersedes) ? 'supersedes' : 'body-mention',
877
+ },
878
+ });
879
+ }
880
+ }
881
+ catch (e) {
882
+ log.debug('emitCompoundExtractActedOn 실패', e);
883
+ }
884
+ }
848
885
  /** Process LLM extraction results (called after LLM returns) */
849
886
  export function processExtractionResults(rawJson, sessionId) {
850
887
  const saved = [];
@@ -882,6 +919,8 @@ export function processExtractionResults(rawJson, sessionId) {
882
919
  const savedName = saveExtractedSolution(sol, sessionId);
883
920
  if (savedName) {
884
921
  saved.push(savedName);
922
+ // Observability P2: compound-extract acted_on signal
923
+ emitCompoundExtractActedOn(sessionId, savedName, sol.content, null);
885
924
  }
886
925
  else {
887
926
  skipped.push(`${sol.name}: 파일 이미 존재`);
@@ -332,6 +332,12 @@ export async function handleCompound(args) {
332
332
  rollbackSolutions(since, { dryRun });
333
333
  return;
334
334
  }
335
+ // --- retire command (P3: dead 솔루션 archive) ---
336
+ if (args.includes('retire') || args.includes('--retire')) {
337
+ const { handleCompoundRetire } = await import('./compound-retire.js');
338
+ await handleCompoundRetire(args);
339
+ return;
340
+ }
335
341
  // --- explicit interactive command ---
336
342
  if (args.includes('interactive') || args.includes('--interactive')) {
337
343
  await interactiveCompound(cwd, scope);
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Forgen — Compound Retire (P3)
3
+ *
4
+ * `forgen compound retire [--dry-run] [--apply]`
5
+ *
6
+ * dead 분류 솔루션을 ~/ .forgen/lab/archived/<id>.md 로 이동.
7
+ * 기본은 dry-run (목록만 출력). --apply 시 사용자 확인 후 이동.
8
+ */
9
+ export interface RetireResult {
10
+ retired: string[];
11
+ skipped: string[];
12
+ dryRun: boolean;
13
+ }
14
+ /** dead 솔루션을 lab/archived/ 로 이동 */
15
+ export declare function retireDeadSolutions(opts: {
16
+ dryRun: boolean;
17
+ yes?: boolean;
18
+ }): Promise<RetireResult>;
19
+ /** CLI 핸들러: forgen compound retire */
20
+ export declare function handleCompoundRetire(args: string[]): Promise<void>;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Forgen — Compound Retire (P3)
3
+ *
4
+ * `forgen compound retire [--dry-run] [--apply]`
5
+ *
6
+ * dead 분류 솔루션을 ~/ .forgen/lab/archived/<id>.md 로 이동.
7
+ * 기본은 dry-run (목록만 출력). --apply 시 사용자 확인 후 이동.
8
+ */
9
+ import * as fs from 'node:fs';
10
+ import * as path from 'node:path';
11
+ import * as readline from 'node:readline';
12
+ import { ME_SOLUTIONS, ARCHIVED_DIR } from '../core/paths.js';
13
+ import { classifySolutions } from '../core/lifecycle-classifier.js';
14
+ function promptConfirm(question) {
15
+ return new Promise(resolve => {
16
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
17
+ rl.question(question, answer => {
18
+ rl.close();
19
+ resolve(answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes');
20
+ });
21
+ });
22
+ }
23
+ /** dead 솔루션을 lab/archived/ 로 이동 */
24
+ export async function retireDeadSolutions(opts) {
25
+ const classified = classifySolutions();
26
+ const dead = classified.filter(c => c.lifecycle === 'dead');
27
+ const retired = [];
28
+ const skipped = [];
29
+ if (dead.length === 0) {
30
+ return { retired: [], skipped: [], dryRun: opts.dryRun };
31
+ }
32
+ // dry-run or apply 출력
33
+ console.log(`\n Dead solutions (${dead.length}):\n`);
34
+ for (const d of dead) {
35
+ const dest = path.join(ARCHIVED_DIR, `${d.solutionId}.md`);
36
+ console.log(` ${d.solutionId}`);
37
+ console.log(` matched_180d=${d.matched_180d} age=${d.ageDays}d`);
38
+ console.log(` → ${dest}`);
39
+ }
40
+ console.log();
41
+ if (opts.dryRun) {
42
+ console.log(' [dry-run] 파일 이동 없음. --apply 로 실행하세요.\n');
43
+ return { retired: dead.map(d => d.solutionId), skipped: [], dryRun: true };
44
+ }
45
+ // apply — 확인 프롬프트
46
+ if (!opts.yes) {
47
+ const ok = await promptConfirm(` ${dead.length}개 솔루션을 archived 로 이동합니다. 계속하시겠습니까? (y/N) `);
48
+ if (!ok) {
49
+ console.log(' 취소되었습니다.\n');
50
+ return { retired: [], skipped: dead.map(d => d.solutionId), dryRun: false };
51
+ }
52
+ }
53
+ // mkdir + rename (fail-stop: 오류 시 즉시 throw)
54
+ fs.mkdirSync(ARCHIVED_DIR, { recursive: true });
55
+ for (const d of dead) {
56
+ const src = path.join(ME_SOLUTIONS, `${d.solutionId}.md`);
57
+ const dest = path.join(ARCHIVED_DIR, `${d.solutionId}.md`);
58
+ // 이미 archived 인 경우 skip
59
+ if (fs.existsSync(dest)) {
60
+ skipped.push(d.solutionId);
61
+ continue;
62
+ }
63
+ // src 없으면 skip
64
+ if (!fs.existsSync(src)) {
65
+ skipped.push(d.solutionId);
66
+ continue;
67
+ }
68
+ // fail-stop: rename 실패 시 throw (데이터 이동 정확성 우선)
69
+ fs.renameSync(src, dest);
70
+ retired.push(d.solutionId);
71
+ }
72
+ console.log(` ✓ ${retired.length}개 이동 완료`);
73
+ if (skipped.length > 0) {
74
+ console.log(` ○ ${skipped.length}개 skip (이미 archived 또는 파일 없음)`);
75
+ }
76
+ console.log();
77
+ return { retired, skipped, dryRun: false };
78
+ }
79
+ /** CLI 핸들러: forgen compound retire */
80
+ export async function handleCompoundRetire(args) {
81
+ const apply = args.includes('--apply');
82
+ const yes = args.includes('--yes');
83
+ const dryRun = !apply;
84
+ await retireDeadSolutions({ dryRun, yes });
85
+ }
@@ -29,7 +29,7 @@ const PROMPT_HISTORY_TRUNCATE = 1024; // ADR-008: 1KB cap per entry
29
29
  const RATE_LIMIT_MISSES_PATH = path.join(STATE_DIR, 'rate-limit-misses.jsonl');
30
30
  // ADR-008: detection regex 분리. token-limit 은 context window, rate-limit 은 API quota.
31
31
  export const TOKEN_LIMIT_REGEX = /context.*limit|token.*limit|conversation.*too.*long/i;
32
- export const RATE_LIMIT_REGEX = /rate.?limit|5.?hour.*limit|weekly.*limit|usage.*limit|quota.*exceeded/i;
32
+ export const RATE_LIMIT_REGEX = /rate.?limit|5.?hour.*limit|weekly.*limit|usage.*limit|quota.*exceeded|out of (?:extra |free )?usage|usage cap|monthly limit reached?/i;
33
33
  /**
34
34
  * Best-effort reset 시각 파서 (ADR-008 §2).
35
35
  *
@@ -64,6 +64,30 @@ export function parseRateLimitResetAt(msg, now = Date.now()) {
64
64
  if (sec > 0)
65
65
  return new Date(now + sec * 1000).toISOString();
66
66
  }
67
+ // Pattern 5: "resets <H>:<MM><am|pm>" (12h, optional "at", optional TZ label in parens)
68
+ // Pattern 1보다 앞에 위치: "resets at 4:20 pm" 에서 Pattern 1이 am/pm 없이 잡으면
69
+ // 24h 로 오변환되므로 am/pm 있는 경우를 먼저 처리.
70
+ // 예: "resets 4:20pm", "resets 4:20pm (Asia/Seoul)", "resets at 4:20 pm"
71
+ const ampm = msg.match(/resets?\s+(?:at\s+)?(\d{1,2}):(\d{2})\s*(am|pm)/i);
72
+ if (ampm) {
73
+ let h = parseInt(ampm[1], 10);
74
+ const m = parseInt(ampm[2], 10);
75
+ const meridiem = ampm[3].toLowerCase();
76
+ if (h >= 1 && h <= 12 && m >= 0 && m <= 59) {
77
+ // 12h → 24h 변환: 12am=0, 12pm=12, 1-11am=1-11, 1-11pm=13-23
78
+ if (meridiem === 'am') {
79
+ h = h === 12 ? 0 : h;
80
+ }
81
+ else {
82
+ h = h === 12 ? 12 : h + 12;
83
+ }
84
+ const d = new Date(now);
85
+ d.setUTCHours(h, m, 0, 0);
86
+ if (d.getTime() <= now)
87
+ d.setUTCDate(d.getUTCDate() + 1);
88
+ return d.toISOString();
89
+ }
90
+ }
67
91
  // Pattern 1: "Resets at HH:MM(:SS)? TZ" — TZ 미지원 (UTC 가정)
68
92
  const hhmm = msg.match(/(?:reset|retry|available)s?\s+at\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(UTC|GMT|PST|PDT|EST|EDT|KST|JST)?/i);
69
93
  if (hhmm) {
@@ -22,6 +22,9 @@ import { recordHookTiming } from './shared/hook-timing.js';
22
22
  import { createDriftState, evaluateDrift } from '../core/drift-score.js';
23
23
  import { appendImplicitFeedback } from '../store/implicit-feedback-store.js';
24
24
  import { recordToolCall } from '../core/usage-telemetry.js';
25
+ import { emitSolutionEvent, querySurfacedWithin } from '../core/observability-store.js';
26
+ import { parseSolutionV3 } from '../engine/solution-format.js';
27
+ import { ME_SOLUTIONS } from '../core/paths.js';
25
28
  const RECENT_TOOL_NAMES_WINDOW = 20;
26
29
  /** Lightweight hash for content comparison (not cryptographic) */
27
30
  function simpleHash(content) {
@@ -338,6 +341,51 @@ async function main() {
338
341
  catch (e) {
339
342
  log.debug('success hint generation 실패', e);
340
343
  }
344
+ // 8. Observability P2: tool-pattern acted_on signal
345
+ try {
346
+ const recentSurfaces = querySurfacedWithin(sessionId, 5);
347
+ if (recentSurfaces.length > 0 && toolName) {
348
+ const toolNameLower = toolName.toLowerCase();
349
+ const seen = new Set();
350
+ for (const surf of recentSurfaces) {
351
+ if (seen.has(surf.solutionId))
352
+ continue;
353
+ seen.add(surf.solutionId);
354
+ const filePath = path.join(ME_SOLUTIONS, `${surf.solutionId}.md`);
355
+ if (!fs.existsSync(filePath))
356
+ continue;
357
+ let raw;
358
+ try {
359
+ raw = fs.readFileSync(filePath, 'utf-8');
360
+ }
361
+ catch {
362
+ continue;
363
+ }
364
+ const sol = parseSolutionV3(raw);
365
+ if (!sol)
366
+ continue;
367
+ const tags = sol.frontmatter.tags ?? [];
368
+ const identifiers = sol.frontmatter.identifiers ?? [];
369
+ if (tags.length === 0 && identifiers.length === 0)
370
+ continue;
371
+ const hit = tags.some(t => toolNameLower.includes(t.toLowerCase()))
372
+ || identifiers.some(id => toolNameLower.includes(id.toLowerCase()));
373
+ if (!hit)
374
+ continue;
375
+ emitSolutionEvent({
376
+ sessionId,
377
+ solutionId: surf.solutionId,
378
+ eventType: 'acted_on',
379
+ signalSource: 'tool-pattern',
380
+ signalScore: 0.30,
381
+ meta: { tool: toolName, surface_ts: surf.ts },
382
+ });
383
+ }
384
+ }
385
+ }
386
+ catch (e) {
387
+ log.debug('tool-pattern acted_on emit 실패', e);
388
+ }
341
389
  saveModifiedFiles(modState);
342
390
  if (messages.length > 0) {
343
391
  console.log(approveWithWarning(messages.join('\n')));