@wooojin/forgen 0.4.7 → 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 (159) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +40 -0
  3. package/assets/dev-guide/be/README.md +226 -0
  4. package/assets/dev-guide/be/adapters/build-agents-md.sh +63 -0
  5. package/assets/dev-guide/be/principles/common.md +433 -0
  6. package/assets/dev-guide/be/principles/go.md +469 -0
  7. package/assets/dev-guide/be/principles/node.md +388 -0
  8. package/assets/dev-guide/be/skills/go/be-build/SKILL.md +262 -0
  9. package/assets/dev-guide/be/skills/go/be-perf/SKILL.md +308 -0
  10. package/assets/dev-guide/be/skills/go/be-review/SKILL.md +119 -0
  11. package/assets/dev-guide/be/skills/go/be-security/SKILL.md +362 -0
  12. package/assets/dev-guide/be/skills/node/be-build/SKILL.md +239 -0
  13. package/assets/dev-guide/be/skills/node/be-perf/SKILL.md +272 -0
  14. package/assets/dev-guide/be/skills/node/be-review/SKILL.md +118 -0
  15. package/assets/dev-guide/be/skills/node/be-security/SKILL.md +355 -0
  16. package/assets/dev-guide/be/sources/12factor/INDEX.md +53 -0
  17. package/assets/dev-guide/be/sources/api-design/INDEX.md +56 -0
  18. package/assets/dev-guide/be/sources/ddia/INDEX.md +55 -0
  19. package/assets/dev-guide/be/sources/go-runtime/INDEX.md +62 -0
  20. package/assets/dev-guide/be/sources/node-runtime/INDEX.md +60 -0
  21. package/assets/dev-guide/be/sources/otel/INDEX.md +53 -0
  22. package/assets/dev-guide/be/sources/owasp-api/INDEX.md +52 -0
  23. package/assets/dev-guide/be/sources/postgres/INDEX.md +55 -0
  24. package/assets/dev-guide/be/sources/sre-book/INDEX.md +48 -0
  25. package/assets/dev-guide/fe/README.md +197 -0
  26. package/assets/dev-guide/fe/adapters/build-agents-md.sh +63 -0
  27. package/assets/dev-guide/fe/adapters/refresh.sh +68 -0
  28. package/assets/dev-guide/fe/principles/common.md +160 -0
  29. package/assets/dev-guide/fe/principles/react.md +183 -0
  30. package/assets/dev-guide/fe/principles/vue.md +196 -0
  31. package/assets/dev-guide/fe/skills/react/fe-build/SKILL.md +139 -0
  32. package/assets/dev-guide/fe/skills/react/fe-perf/SKILL.md +179 -0
  33. package/assets/dev-guide/fe/skills/react/fe-review/SKILL.md +141 -0
  34. package/assets/dev-guide/fe/skills/vue/fe-build/SKILL.md +148 -0
  35. package/assets/dev-guide/fe/skills/vue/fe-perf/SKILL.md +163 -0
  36. package/assets/dev-guide/fe/skills/vue/fe-review/SKILL.md +136 -0
  37. package/assets/dev-guide/fe/sources/a11y-dx/INDEX.md +41 -0
  38. package/assets/dev-guide/fe/sources/a11y-dx/chrome-devtools-memory.md +150 -0
  39. package/assets/dev-guide/fe/sources/a11y-dx/chrome-devtools-performance.md +99 -0
  40. package/assets/dev-guide/fe/sources/a11y-dx/lighthouse-audits.md +146 -0
  41. package/assets/dev-guide/fe/sources/a11y-dx/react-devtools-profiler.md +128 -0
  42. package/assets/dev-guide/fe/sources/a11y-dx/wcag22-new-criteria.md +174 -0
  43. package/assets/dev-guide/fe/sources/perf/01-core-web-vitals.md +58 -0
  44. package/assets/dev-guide/fe/sources/perf/02-inp.md +83 -0
  45. package/assets/dev-guide/fe/sources/perf/03-lcp-cls.md +130 -0
  46. package/assets/dev-guide/fe/sources/perf/04-speculation-rules.md +148 -0
  47. package/assets/dev-guide/fe/sources/perf/05-view-transitions.md +153 -0
  48. package/assets/dev-guide/fe/sources/perf/06-nextjs-caching.md +188 -0
  49. package/assets/dev-guide/fe/sources/perf/07-server-components.md +181 -0
  50. package/assets/dev-guide/fe/sources/perf/08-ppr.md +133 -0
  51. package/assets/dev-guide/fe/sources/perf/09-nextjs-image.md +200 -0
  52. package/assets/dev-guide/fe/sources/perf/10-optimize-lcp.md +201 -0
  53. package/assets/dev-guide/fe/sources/perf/INDEX.md +88 -0
  54. package/assets/dev-guide/fe/sources/react/INDEX.md +41 -0
  55. package/assets/dev-guide/fe/sources/react/keeping-components-pure.md +135 -0
  56. package/assets/dev-guide/fe/sources/react/no-effect-patterns.md +183 -0
  57. package/assets/dev-guide/fe/sources/react/react-compiler.md +182 -0
  58. package/assets/dev-guide/fe/sources/react/server-components.md +194 -0
  59. package/assets/dev-guide/fe/sources/react/server-functions.md +192 -0
  60. package/assets/dev-guide/fe/sources/react/suspense.md +218 -0
  61. package/assets/dev-guide/fe/sources/react/use-action-state.md +123 -0
  62. package/assets/dev-guide/fe/sources/react/use-form-status.md +158 -0
  63. package/assets/dev-guide/fe/sources/react/use-hook.md +153 -0
  64. package/assets/dev-guide/fe/sources/react/use-optimistic.md +194 -0
  65. package/assets/dev-guide/fe/sources/toss-ff/INDEX.md +58 -0
  66. package/assets/dev-guide/fe/sources/toss-ff/cohesion-code-directory.md +79 -0
  67. package/assets/dev-guide/fe/sources/toss-ff/cohesion-form-fields.md +110 -0
  68. package/assets/dev-guide/fe/sources/toss-ff/cohesion-magic-number.md +47 -0
  69. package/assets/dev-guide/fe/sources/toss-ff/coupling-item-edit-modal.md +124 -0
  70. package/assets/dev-guide/fe/sources/toss-ff/coupling-use-bottom-sheet.md +57 -0
  71. package/assets/dev-guide/fe/sources/toss-ff/coupling-use-page-state.md +71 -0
  72. package/assets/dev-guide/fe/sources/toss-ff/overview-4-principles.md +77 -0
  73. package/assets/dev-guide/fe/sources/toss-ff/predictability-hidden-logic.md +59 -0
  74. package/assets/dev-guide/fe/sources/toss-ff/predictability-http.md +77 -0
  75. package/assets/dev-guide/fe/sources/toss-ff/predictability-use-user.md +110 -0
  76. package/assets/dev-guide/fe/sources/toss-ff/readability-comparison-order.md +52 -0
  77. package/assets/dev-guide/fe/sources/toss-ff/readability-condition-name.md +64 -0
  78. package/assets/dev-guide/fe/sources/toss-ff/readability-login-start-page.md +183 -0
  79. package/assets/dev-guide/fe/sources/toss-ff/readability-magic-number.md +53 -0
  80. package/assets/dev-guide/fe/sources/toss-ff/readability-submit-button.md +73 -0
  81. package/assets/dev-guide/fe/sources/toss-ff/readability-ternary-operator.md +38 -0
  82. package/assets/dev-guide/fe/sources/toss-ff/readability-use-page-state.md +77 -0
  83. package/assets/dev-guide/fe/sources/toss-ff/readability-user-policy.md +98 -0
  84. package/assets/dev-guide/fe/sources/vue/INDEX.md +17 -0
  85. package/assets/dev-guide/fe/sources/vue/composition-api.md +251 -0
  86. package/assets/dev-guide/fe/sources/vue/nuxt-data-fetching.md +232 -0
  87. package/assets/dev-guide/fe/sources/vue/pinia-state-management.md +134 -0
  88. package/assets/dev-guide/fe/sources/vue/reactivity-pitfalls.md +261 -0
  89. package/assets/dev-guide/fe/sources/vue/style-guide-priority-a.md +117 -0
  90. package/assets/dev-guide/fe/sources/vue/style-guide-priority-b.md +231 -0
  91. package/assets/dev-guide/fe/sources/vue/style-guide-priority-c.md +86 -0
  92. package/assets/dev-guide/fe/sources/vue/style-guide-priority-d.md +72 -0
  93. package/dist/checks/self-score-deflation.js +6 -4
  94. package/dist/cli.js +47 -2
  95. package/dist/core/auto-compound-runner.js +6 -2
  96. package/dist/core/dashboard-cli.d.ts +12 -0
  97. package/dist/core/dashboard-cli.js +226 -0
  98. package/dist/core/dashboard.js +2 -2
  99. package/dist/core/dev-guide-injector.d.ts +26 -0
  100. package/dist/core/dev-guide-injector.js +137 -0
  101. package/dist/core/doctor.d.ts +10 -0
  102. package/dist/core/doctor.js +49 -8
  103. package/dist/core/harness.js +8 -2
  104. package/dist/core/init.js +53 -0
  105. package/dist/core/inspect-cli.js +4 -4
  106. package/dist/core/lifecycle-classifier.d.ts +23 -0
  107. package/dist/core/lifecycle-classifier.js +104 -0
  108. package/dist/core/migrate-evidence-host.js +1 -1
  109. package/dist/core/notify.js +7 -0
  110. package/dist/core/observability-backfill.d.ts +31 -0
  111. package/dist/core/observability-backfill.js +178 -0
  112. package/dist/core/observability-store.d.ts +58 -0
  113. package/dist/core/observability-store.js +195 -0
  114. package/dist/core/paths.d.ts +16 -2
  115. package/dist/core/paths.js +16 -2
  116. package/dist/core/session-store.d.ts +12 -1
  117. package/dist/core/session-store.js +77 -1
  118. package/dist/core/spawn.d.ts +17 -0
  119. package/dist/core/spawn.js +191 -8
  120. package/dist/core/statusline-cli.js +34 -1
  121. package/dist/core/v1-bootstrap.d.ts +7 -0
  122. package/dist/core/v1-bootstrap.js +28 -6
  123. package/dist/engine/compound-extractor.js +40 -1
  124. package/dist/engine/compound-loop.js +6 -0
  125. package/dist/engine/compound-retire.d.ts +20 -0
  126. package/dist/engine/compound-retire.js +85 -0
  127. package/dist/engine/learn-cli.js +2 -2
  128. package/dist/engine/lifecycle/bypass-detector.js +3 -2
  129. package/dist/engine/lifecycle/meta-reclassifier.js +1 -1
  130. package/dist/engine/lifecycle/signals.js +2 -2
  131. package/dist/engine/lifecycle/trigger-t1-correction.js +1 -1
  132. package/dist/engine/solution-candidate.js +1 -1
  133. package/dist/engine/solution-outcomes.js +1 -1
  134. package/dist/engine/solution-quarantine.js +1 -1
  135. package/dist/engine/solution-weakness.js +8 -2
  136. package/dist/forge/cli.js +1 -1
  137. package/dist/hooks/context-guard.js +25 -1
  138. package/dist/hooks/keyword-detector.js +1 -1
  139. package/dist/hooks/post-tool-use.js +48 -0
  140. package/dist/hooks/secret-filter.js +2 -2
  141. package/dist/hooks/shared/hook-response.js +1 -1
  142. package/dist/hooks/shared/hook-timing.js +3 -3
  143. package/dist/hooks/solution-injector.js +94 -1
  144. package/dist/hooks/stop-guard.js +3 -3
  145. package/dist/host/install-claude.d.ts +6 -2
  146. package/dist/host/install-claude.js +74 -2
  147. package/dist/host/install-codex.d.ts +4 -0
  148. package/dist/host/install-codex.js +72 -1
  149. package/dist/host/install-orchestrator.js +1 -0
  150. package/dist/mcp/tools.js +1 -1
  151. package/dist/preset/facet-catalog.js +2 -2
  152. package/dist/renderer/rule-renderer.js +7 -7
  153. package/dist/store/compound-usage-store.js +1 -1
  154. package/dist/store/implicit-feedback-store.js +2 -2
  155. package/dist/store/profile-store.d.ts +11 -0
  156. package/dist/store/profile-store.js +23 -0
  157. package/package.json +6 -6
  158. package/plugin.json +1 -1
  159. package/scripts/postinstall.js +134 -0
@@ -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';
@@ -557,7 +558,7 @@ function findCommonPrefix(strings) {
557
558
  return prefix.replace(/-$/, '');
558
559
  }
559
560
  /** Save an extracted solution as experiment */
560
- function saveExtractedSolution(sol, sessionId) {
561
+ function saveExtractedSolution(sol, _sessionId) {
561
562
  const today = new Date().toISOString().split('T')[0];
562
563
  const slugName = sol.name.toLowerCase()
563
564
  .replace(/[^a-z0-9가-힣\s-]/g, '')
@@ -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
+ }
@@ -136,7 +136,7 @@ function runFitness(args) {
136
136
  console.log(` ${'name'.padEnd(48)} ${'state'.padEnd(14)} ${'inj'.padStart(4)} ${'acc/cor/err'.padStart(11)} ${'fit'.padStart(6)}`);
137
137
  console.log(` ${'-'.repeat(48)} ${'-'.repeat(14)} ${'-'.repeat(4)} ${'-'.repeat(11)} ${'-'.repeat(6)}`);
138
138
  for (const r of records) {
139
- const name = r.solution.length > 47 ? r.solution.slice(0, 45) + '..' : r.solution;
139
+ const name = r.solution.length > 47 ? `${r.solution.slice(0, 45)}..` : r.solution;
140
140
  const acr = `${r.accepted}/${r.corrected}/${r.errored}`;
141
141
  console.log(` ${name.padEnd(48)} ${r.state.padEnd(14)} ${String(r.injected).padStart(4)} ${acr.padStart(11)} ${r.fitness.toFixed(2).padStart(6)}`);
142
142
  }
@@ -222,7 +222,7 @@ function runEvolvePromote(candidateNameOrList) {
222
222
  }
223
223
  const result = promoteCandidate(candidateNameOrList);
224
224
  if (result.ok) {
225
- console.log(`\n ✓ Promoted: ${path.basename(result.dest)}`);
225
+ console.log(`\n ✓ Promoted: ${result.dest ? path.basename(result.dest) : '(unknown)'}`);
226
226
  console.log(` from: ${result.source}`);
227
227
  console.log(` to: ${result.dest}`);
228
228
  console.log(` Cold-start bonus active until 5 injections accumulate (auto-promotes to verified).\n`);
@@ -51,9 +51,10 @@ function extractParenthesizedExamples(p) {
51
51
  const out = [];
52
52
  // Match (...) groups; multiple groups in policy are uncommon but supported
53
53
  const re = /\(([^)]+)\)/g;
54
- let m;
55
- while ((m = re.exec(p))) {
54
+ let m = re.exec(p);
55
+ while (m !== null) {
56
56
  const inside = m[1];
57
+ m = re.exec(p);
57
58
  // Skip if it looks like a path (contains "/" before any obvious separator commitment)
58
59
  if (/[a-zA-Z]+\/[a-zA-Z]/.test(inside))
59
60
  continue;
@@ -294,7 +294,7 @@ export function appendLifecycleEvents(events, now = Date.now()) {
294
294
  }
295
295
  }
296
296
  catch { /* missing → no rotate */ }
297
- const body = events.map((e) => JSON.stringify(e)).join('\n') + '\n';
297
+ const body = `${events.map((e) => JSON.stringify(e)).join('\n')}\n`;
298
298
  fs.appendFileSync(logPath, body);
299
299
  }
300
300
  catch (e) {
@@ -62,7 +62,7 @@ export function recordViolation(entry) {
62
62
  fs.mkdirSync(ENFORCEMENT_DIR, { recursive: true });
63
63
  rotateIfBig(VIOLATIONS_PATH);
64
64
  const full = { at: new Date().toISOString(), ...entry };
65
- fs.appendFileSync(VIOLATIONS_PATH, JSON.stringify(full) + '\n');
65
+ fs.appendFileSync(VIOLATIONS_PATH, `${JSON.stringify(full)}\n`);
66
66
  }
67
67
  catch (e) {
68
68
  // best-effort, 실패 시 debug 로그 (silent swallow 방지)
@@ -76,7 +76,7 @@ export function recordBypass(entry) {
76
76
  fs.mkdirSync(ENFORCEMENT_DIR, { recursive: true });
77
77
  rotateIfBig(BYPASS_PATH);
78
78
  const full = { at: new Date().toISOString(), ...entry };
79
- fs.appendFileSync(BYPASS_PATH, JSON.stringify(full) + '\n');
79
+ fs.appendFileSync(BYPASS_PATH, `${JSON.stringify(full)}\n`);
80
80
  }
81
81
  catch (e) {
82
82
  if (process.env.FORGEN_DEBUG_SIGNALS === '1') {
@@ -39,7 +39,7 @@ function matchesRule(evidence, rule) {
39
39
  .toLowerCase();
40
40
  return keyTokens.some((t) => {
41
41
  const tokLower = t.toLowerCase();
42
- return summaryLower.includes(tokLower) || (targetToken && targetToken.includes(tokLower));
42
+ return summaryLower.includes(tokLower) || (targetToken?.includes(tokLower));
43
43
  });
44
44
  }
45
45
  export function detect(input) {
@@ -100,7 +100,7 @@ export function rollbackSince(epochMs) {
100
100
  continue;
101
101
  try {
102
102
  fs.mkdirSync(archiveDir, { recursive: true });
103
- const destName = path.basename(dir) + '__' + file;
103
+ const destName = `${path.basename(dir)}__${file}`;
104
104
  fs.renameSync(filePath, path.join(archiveDir, destName));
105
105
  archived.push(filePath);
106
106
  }
@@ -30,7 +30,7 @@ function writePending(sessionId, state) {
30
30
  }
31
31
  function appendOutcome(event) {
32
32
  fs.mkdirSync(OUTCOMES_DIR, { recursive: true });
33
- fs.appendFileSync(outcomesPath(event.session_id), JSON.stringify(event) + '\n');
33
+ fs.appendFileSync(outcomesPath(event.session_id), `${JSON.stringify(event)}\n`);
34
34
  }
35
35
  /**
36
36
  * Run a read-modify-write pending-state mutation under a file lock
@@ -49,7 +49,7 @@ export function recordQuarantine(filePath, errors) {
49
49
  at: new Date().toISOString(),
50
50
  errors,
51
51
  };
52
- fs.appendFileSync(SOLUTION_QUARANTINE_PATH, JSON.stringify(entry) + '\n');
52
+ fs.appendFileSync(SOLUTION_QUARANTINE_PATH, `${JSON.stringify(entry)}\n`);
53
53
  }
54
54
  catch (e) {
55
55
  log.debug(`quarantine write failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -97,14 +97,20 @@ function findConflictClusters(rows, fitnessByName) {
97
97
  const underperformers = rows.filter((r) => fitnessByName.get(r.name)?.state === 'underperform');
98
98
  const clusters = [];
99
99
  for (const ch of champions) {
100
+ const chFitness = fitnessByName.get(ch.name)?.fitness;
101
+ if (chFitness === undefined)
102
+ continue;
100
103
  for (const up of underperformers) {
104
+ const upFitness = fitnessByName.get(up.name)?.fitness;
105
+ if (upFitness === undefined)
106
+ continue;
101
107
  const shared = ch.tags.filter((t) => up.tags.includes(t));
102
108
  if (shared.length < 2)
103
109
  continue;
104
110
  clusters.push({
105
111
  shared_tags: shared,
106
- champion: { name: ch.name, fitness: fitnessByName.get(ch.name).fitness },
107
- underperform: { name: up.name, fitness: fitnessByName.get(up.name).fitness },
112
+ champion: { name: ch.name, fitness: chFitness },
113
+ underperform: { name: up.name, fitness: upFitness },
108
114
  });
109
115
  }
110
116
  }
package/dist/forge/cli.js CHANGED
@@ -39,7 +39,7 @@ function handleShowProfile() {
39
39
  console.log('\n No v1 profile found. Run `forgen forge` or `forgen onboarding`.\n');
40
40
  return;
41
41
  }
42
- console.log('\n' + renderProfile(profile) + '\n');
42
+ console.log(`\n${renderProfile(profile)}\n`);
43
43
  }
44
44
  function handleExport() {
45
45
  const profile = loadProfile();
@@ -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) {
@@ -192,7 +192,7 @@ async function main() {
192
192
  return;
193
193
  }
194
194
  const match = detectKeyword(input.prompt);
195
- const sessionId = input.session_id ?? 'unknown';
195
+ const _sessionId = input.session_id ?? 'unknown';
196
196
  // v1: regex 기반 prompt 학습 제거. Evidence 기반으로 전환됨.
197
197
  if (!match) {
198
198
  console.log(approve());
@@ -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')));
@@ -34,10 +34,10 @@ export function redactSecrets(text) {
34
34
  let out = text;
35
35
  for (const sp of SECRET_PATTERNS) {
36
36
  // regex 복제 (global flag 없이 repeated test 되는 경우 lastIndex 안전)
37
- const re = new RegExp(sp.pattern.source, (sp.pattern.flags.includes('g') ? sp.pattern.flags : sp.pattern.flags + 'g'));
37
+ const re = new RegExp(sp.pattern.source, (sp.pattern.flags.includes('g') ? sp.pattern.flags : `${sp.pattern.flags}g`));
38
38
  if (re.test(out)) {
39
39
  hits.push(sp);
40
- const re2 = new RegExp(sp.pattern.source, (sp.pattern.flags.includes('g') ? sp.pattern.flags : sp.pattern.flags + 'g'));
40
+ const re2 = new RegExp(sp.pattern.source, (sp.pattern.flags.includes('g') ? sp.pattern.flags : `${sp.pattern.flags}g`));
41
41
  out = out.replace(re2, `[REDACTED:${sp.name}]`);
42
42
  }
43
43
  }
@@ -146,7 +146,7 @@ export function failOpenWithTracking(hookName, err) {
146
146
  }
147
147
  }
148
148
  const entry = JSON.stringify(payload);
149
- fs.appendFileSync(logPath, entry + '\n');
149
+ fs.appendFileSync(logPath, `${entry}\n`);
150
150
  }
151
151
  catch { /* fail-open: tracking itself must not throw */ }
152
152
  return JSON.stringify({ continue: true });
@@ -19,7 +19,7 @@ export function recordHookTiming(hookName, durationMs, event) {
19
19
  try {
20
20
  fs.mkdirSync(STATE_DIR, { recursive: true });
21
21
  const entry = JSON.stringify({ hook: hookName, ms: durationMs, event, at: Date.now() });
22
- fs.appendFileSync(TIMING_LOG, entry + '\n');
22
+ fs.appendFileSync(TIMING_LOG, `${entry}\n`);
23
23
  // Rotate if too large — size-gated (statSync only, skip read/write 대부분의 호출)
24
24
  try {
25
25
  const size = fs.statSync(TIMING_LOG).size;
@@ -28,7 +28,7 @@ export function recordHookTiming(hookName, durationMs, event) {
28
28
  const content = fs.readFileSync(TIMING_LOG, 'utf-8');
29
29
  const lines = content.trim().split('\n');
30
30
  if (lines.length > MAX_LINES) {
31
- fs.writeFileSync(TIMING_LOG, lines.slice(-MAX_LINES).join('\n') + '\n');
31
+ fs.writeFileSync(TIMING_LOG, `${lines.slice(-MAX_LINES).join('\n')}\n`);
32
32
  }
33
33
  }
34
34
  catch { /* skip rotation on error */ }
@@ -52,7 +52,7 @@ export function getTimingStats() {
52
52
  for (const e of entries) {
53
53
  if (!byHook.has(e.hook))
54
54
  byHook.set(e.hook, []);
55
- byHook.get(e.hook).push(e.ms);
55
+ byHook.get(e.hook)?.push(e.ms);
56
56
  }
57
57
  const stats = [];
58
58
  for (const [hook, times] of byHook) {
@@ -30,6 +30,9 @@ import { STATE_DIR } from '../core/paths.js';
30
30
  import { recordHookTiming } from './shared/hook-timing.js';
31
31
  import { appendPending, flushAccept } from '../engine/solution-outcomes.js';
32
32
  import { appendImplicitFeedback } from '../store/implicit-feedback-store.js';
33
+ import { emitSolutionEvent, querySurfacedWithin } from '../core/observability-store.js';
34
+ import { parseSolutionV3 } from '../engine/solution-format.js';
35
+ import { ME_SOLUTIONS } from '../core/paths.js';
33
36
  const MAX_SOLUTIONS_PER_SESSION = 10;
34
37
  /**
35
38
  * Minimum relevance thresholds by fitness state (2026-04-21 gate sweep).
@@ -252,6 +255,56 @@ function backfillCacheTagsOnDisk(cachePath, allMatched) {
252
255
  }
253
256
  }
254
257
  }
258
+ /**
259
+ * 직전 5분 내 surfaced 된 솔루션이 현재 프롬프트와 키워드 매칭 시 acted_on emit.
260
+ * tags/identifiers 가 없는 솔루션은 skip. fail-open.
261
+ */
262
+ async function detectActOnFromPriorSurface(sessionId, promptLower) {
263
+ try {
264
+ const recentSurfaces = querySurfacedWithin(sessionId, 5);
265
+ if (recentSurfaces.length === 0)
266
+ return;
267
+ const seen = new Set();
268
+ for (const surf of recentSurfaces) {
269
+ if (seen.has(surf.solutionId))
270
+ continue;
271
+ seen.add(surf.solutionId);
272
+ // 솔루션 파일 로드 (ME_SOLUTIONS 기준)
273
+ const filePath = path.join(ME_SOLUTIONS, `${surf.solutionId}.md`);
274
+ if (!fs.existsSync(filePath))
275
+ continue;
276
+ let raw;
277
+ try {
278
+ raw = fs.readFileSync(filePath, 'utf-8');
279
+ }
280
+ catch {
281
+ continue;
282
+ }
283
+ const sol = parseSolutionV3(raw);
284
+ if (!sol)
285
+ continue;
286
+ const tags = sol.frontmatter.tags ?? [];
287
+ const identifiers = sol.frontmatter.identifiers ?? [];
288
+ if (tags.length === 0 && identifiers.length === 0)
289
+ continue;
290
+ const hit = tags.some(t => promptLower.includes(t.toLowerCase()))
291
+ || identifiers.some(id => promptLower.includes(id.toLowerCase()));
292
+ if (!hit)
293
+ continue;
294
+ emitSolutionEvent({
295
+ sessionId,
296
+ solutionId: surf.solutionId,
297
+ eventType: 'acted_on',
298
+ signalSource: 'prompt-keyword',
299
+ signalScore: 0.20,
300
+ meta: { surface_ts: surf.ts },
301
+ });
302
+ }
303
+ }
304
+ catch (e) {
305
+ log.debug('detectActOnFromPriorSurface 실패', e);
306
+ }
307
+ }
255
308
  async function main() {
256
309
  const _hookStart = Date.now();
257
310
  try {
@@ -265,6 +318,8 @@ async function main() {
265
318
  return;
266
319
  }
267
320
  const sessionId = input.session_id ?? 'default';
321
+ // Observability P2: 직전 surfaced 솔루션과 현재 프롬프트 키워드 매칭 → acted_on emit
322
+ await detectActOnFromPriorSurface(sessionId, input.prompt.toLowerCase());
268
323
  // v1: 교정 감지 → correction-record 호출 유도 hint
269
324
  const correctionPatterns = /하지\s*마|그렇게\s*말고|앞으로는|이렇게\s*해|stop\s+doing|don'?t\s+do|always\s+do|never\s+do|아니\s*그게\s*아니라/i;
270
325
  if (correctionPatterns.test(input.prompt)) {
@@ -294,6 +349,25 @@ async function main() {
294
349
  // 다시 매칭되면 그 정보로 cache의 missing tags를 채울 수 있다.
295
350
  // matches는 새 주입 후보 (이미 injected는 제외).
296
351
  const allMatched = matchSolutions(input.prompt, scope, cwd);
352
+ // Observability P1: matched emit — top-5 후보 각각 기록
353
+ try {
354
+ for (const candidate of allMatched.slice(0, 5)) {
355
+ emitSolutionEvent({
356
+ sessionId,
357
+ solutionId: candidate.name,
358
+ eventType: 'matched',
359
+ signalSource: 'matcher',
360
+ signalScore: candidate.relevance,
361
+ meta: {
362
+ matchedTags: candidate.matchedTags,
363
+ matchedIdentifiers: candidate.matchedIdentifiers,
364
+ },
365
+ });
366
+ }
367
+ }
368
+ catch (e) {
369
+ log.debug('matched emit 실패', e);
370
+ }
297
371
  const matches = allMatched.filter(m => !injected.has(m.name));
298
372
  // T3: emit a ranking-decision record for offline analysis. Fail-open —
299
373
  // the logger swallows any error so this never blocks hook approval.
@@ -409,7 +483,7 @@ async function main() {
409
483
  .filter(l => l.length > 0);
410
484
  contentSnippet = lines.slice(0, 3).join('\n');
411
485
  if (contentSnippet.length > SUMMARY_MAX_CHARS) {
412
- contentSnippet = contentSnippet.slice(0, SUMMARY_MAX_CHARS - 3) + '...';
486
+ contentSnippet = `${contentSnippet.slice(0, SUMMARY_MAX_CHARS - 3)}...`;
413
487
  }
414
488
  }
415
489
  }
@@ -566,6 +640,25 @@ async function main() {
566
640
  catch (e) {
567
641
  log.debug('recommendation_surfaced emit 실패', e);
568
642
  }
643
+ // Observability P1: surfaced emit — approveWithContext 직전 effectiveToInject 각각 기록
644
+ try {
645
+ for (const sol of effectiveToInject) {
646
+ emitSolutionEvent({
647
+ sessionId,
648
+ solutionId: sol.name,
649
+ eventType: 'surfaced',
650
+ signalSource: 'hook-prepend',
651
+ signalScore: sol.relevance,
652
+ meta: {
653
+ surfaceChars: fullInjection.length,
654
+ injectionMode: 'context-prepend',
655
+ },
656
+ });
657
+ }
658
+ }
659
+ catch (e) {
660
+ log.debug('surfaced emit 실패', e);
661
+ }
569
662
  // H1: 사용자 UI 에 recall hit 1줄 노출. additionalContext 는 모델 전용이라
570
663
  // v0.4.0 에서 8,000+ 주입이 발생했는데도 사용자는 0건을 봤다. systemMessage
571
664
  // 로 "N개 솔루션 참조" 를 surface → 사용자가 어떤 축적 지식이 붙었는지 인식.
@@ -370,14 +370,14 @@ export function acknowledgeSessionBlocks(sessionId) {
370
370
  try {
371
371
  fs.mkdirSync(path.dirname(ACK_LOG), { recursive: true });
372
372
  rotateIfBig(ACK_LOG);
373
- fs.appendFileSync(ACK_LOG, JSON.stringify({
373
+ fs.appendFileSync(ACK_LOG, `${JSON.stringify({
374
374
  at: now,
375
375
  session_id: state.sessionId,
376
376
  rule_id: state.ruleId,
377
377
  block_count: state.count,
378
378
  first_block_at: state.firstBlockAt,
379
379
  last_block_at: state.lastBlockAt,
380
- }) + '\n');
380
+ })}\n`);
381
381
  acked += 1;
382
382
  }
383
383
  catch { /* append failure: still try cleanup */ }
@@ -396,7 +396,7 @@ export function logDriftEvent(event) {
396
396
  try {
397
397
  fs.mkdirSync(path.dirname(DRIFT_LOG), { recursive: true });
398
398
  rotateIfBig(DRIFT_LOG);
399
- fs.appendFileSync(DRIFT_LOG, JSON.stringify({ at: new Date().toISOString(), ...event }) + '\n');
399
+ fs.appendFileSync(DRIFT_LOG, `${JSON.stringify({ at: new Date().toISOString(), ...event })}\n`);
400
400
  }
401
401
  catch {
402
402
  // best-effort