@wooojin/forgen 0.4.10 → 0.4.12

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 (76) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +62 -0
  3. package/README.md +33 -1
  4. package/assets/claude/agents/forgen-verify.md +65 -0
  5. package/assets/claude/workflows/compound-extract.js +136 -0
  6. package/assets/claude/workflows/evidence-gate-audit.js +107 -0
  7. package/assets/shared/hook-registry.json +1 -0
  8. package/dist/checks/_shared/meta-guard-dispatch.d.ts +38 -0
  9. package/dist/checks/_shared/meta-guard-dispatch.js +80 -0
  10. package/dist/checks/_shared/text-sanitizer.js +15 -0
  11. package/dist/cli.js +57 -2
  12. package/dist/core/changelog-cli.d.ts +7 -0
  13. package/dist/core/changelog-cli.js +100 -0
  14. package/dist/core/doctor.d.ts +3 -0
  15. package/dist/core/doctor.js +38 -0
  16. package/dist/core/effort-advisory.d.ts +23 -0
  17. package/dist/core/effort-advisory.js +29 -0
  18. package/dist/core/explain-cli.d.ts +6 -0
  19. package/dist/core/explain-cli.js +99 -0
  20. package/dist/core/health-cli.d.ts +23 -0
  21. package/dist/core/health-cli.js +86 -0
  22. package/dist/core/probe-workflow-cli.d.ts +72 -0
  23. package/dist/core/probe-workflow-cli.js +282 -0
  24. package/dist/core/spawn.d.ts +13 -0
  25. package/dist/core/spawn.js +36 -8
  26. package/dist/core/stats-cli.d.ts +22 -9
  27. package/dist/core/stats-cli.js +149 -0
  28. package/dist/core/watch-cli.d.ts +7 -0
  29. package/dist/core/watch-cli.js +185 -0
  30. package/dist/core/workflows-cli.d.ts +26 -0
  31. package/dist/core/workflows-cli.js +120 -0
  32. package/dist/engine/compound-export.d.ts +12 -0
  33. package/dist/engine/compound-export.js +136 -14
  34. package/dist/engine/compound-extractor.d.ts +12 -43
  35. package/dist/engine/compound-extractor.js +27 -756
  36. package/dist/engine/extraction-diff.d.ts +11 -0
  37. package/dist/engine/extraction-diff.js +105 -0
  38. package/dist/engine/extraction-gates.d.ts +37 -0
  39. package/dist/engine/extraction-gates.js +100 -0
  40. package/dist/engine/extraction-git.d.ts +20 -0
  41. package/dist/engine/extraction-git.js +75 -0
  42. package/dist/engine/extraction-persistence.d.ts +27 -0
  43. package/dist/engine/extraction-persistence.js +140 -0
  44. package/dist/engine/extraction-session.d.ts +26 -0
  45. package/dist/engine/extraction-session.js +230 -0
  46. package/dist/engine/lifecycle/types.d.ts +1 -1
  47. package/dist/engine/meta-learning/matcher-weight-loader.d.ts +16 -0
  48. package/dist/engine/meta-learning/matcher-weight-loader.js +45 -0
  49. package/dist/engine/precision-guards.d.ts +14 -0
  50. package/dist/engine/precision-guards.js +39 -0
  51. package/dist/engine/ranking-pipeline.d.ts +45 -0
  52. package/dist/engine/ranking-pipeline.js +66 -0
  53. package/dist/engine/relevance-scorer.d.ts +43 -0
  54. package/dist/engine/relevance-scorer.js +81 -0
  55. package/dist/engine/scoring-algorithms.d.ts +31 -0
  56. package/dist/engine/scoring-algorithms.js +109 -0
  57. package/dist/engine/solution-matcher-eval.d.ts +97 -0
  58. package/dist/engine/solution-matcher-eval.js +122 -0
  59. package/dist/engine/solution-matcher.d.ts +21 -380
  60. package/dist/engine/solution-matcher.js +27 -828
  61. package/dist/fgx.js +1 -1
  62. package/dist/hooks/notepad-injector.js +7 -0
  63. package/dist/hooks/post-tool-use.js +8 -1
  64. package/dist/hooks/secret-filter.d.ts +1 -0
  65. package/dist/hooks/secret-filter.js +17 -7
  66. package/dist/hooks/shared/preflight-check.d.ts +15 -0
  67. package/dist/hooks/shared/preflight-check.js +51 -0
  68. package/dist/hooks/stop-guard.js +19 -60
  69. package/dist/hooks/subagent-stop-guard.d.ts +23 -0
  70. package/dist/hooks/subagent-stop-guard.js +158 -0
  71. package/dist/hooks/subagent-tracker.d.ts +36 -3
  72. package/dist/hooks/subagent-tracker.js +86 -39
  73. package/hooks/hooks.json +6 -1
  74. package/package.json +7 -7
  75. package/plugin.json +1 -1
  76. package/scripts/postinstall.js +10 -7
@@ -0,0 +1,282 @@
1
+ /**
2
+ * forgen probe-workflow — ADR-009 §1 결정적 미확인 변수 실측 도구.
3
+ *
4
+ * 질문: dynamic workflow **내부** 에이전트에 대해 SubagentStart/Stop·PostToolUse
5
+ * 훅이 발화하는가? (워크플로우 런타임은 "대화와 분리된 격리 백그라운드"로
6
+ * 명시되어 있어 발화 여부가 문서로 미확인.)
7
+ *
8
+ * 이 답이 ADR-009 §2(훅 라우트로 워크플로우까지 검증 가능) vs §3(워크플로우
9
+ * 품질은 템플릿 라우트로만 도달)의 선택을 가른다. forgen 프로젝트 룰상 가정
10
+ * 위에 §2 를 구현할 수 없으므로, 실제 실행 증거를 먼저 수집한다.
11
+ *
12
+ * 신호원 (훅이 부작용으로 남기는 state 파일 — forgen 자체 timing 계측의 공백과
13
+ * 무관하게 직접 관측 가능):
14
+ * - SubagentStart/Stop → ~/.forgen/state/active-agents-*.json (agents[])
15
+ * - PostToolUse → ~/.forgen/state/modified-files-*.json (mtime)
16
+ * - (보조) hook-timing.jsonl 의 event 별 엔트리
17
+ *
18
+ * 절차 (2단계 — 런타임이 격리되어 있어 단일 프로세스로는 트리거 불가):
19
+ * 1. `forgen probe-workflow arm` → baseline 마커 기록 + 안내 출력
20
+ * 2. 사용자가 Claude Code 에서 워크플로우 1회 실행 (그 사이 다른 작업 금지)
21
+ * 3. `forgen probe-workflow report` → baseline 이후 신호 수집 → verdict 박제
22
+ *
23
+ * 가정 (detailed-communication): arm~report 사이에 사용자가 **워크플로우만**
24
+ * 실행했다고 전제한다. 일반 Task-tool subagent 를 같이 돌리면 신호가 섞인다.
25
+ */
26
+ import * as fs from 'node:fs';
27
+ import * as path from 'node:path';
28
+ import { STATE_DIR } from './paths.js';
29
+ const isTTY = process.stdout.isTTY;
30
+ const C = {
31
+ reset: isTTY ? '\x1b[0m' : '',
32
+ bold: isTTY ? '\x1b[1m' : '',
33
+ dim: isTTY ? '\x1b[2m' : '',
34
+ green: isTTY ? '\x1b[32m' : '',
35
+ yellow: isTTY ? '\x1b[33m' : '',
36
+ red: isTTY ? '\x1b[31m' : '',
37
+ cyan: isTTY ? '\x1b[36m' : '',
38
+ };
39
+ const BASELINE_PATH = path.join(STATE_DIR, 'probe-workflow.json');
40
+ const RESULT_PATH = path.join(STATE_DIR, 'probe-workflow-result.json');
41
+ /** 일반 subagent 동시 실행 상한(MAX_CONCURRENT_AGENTS=10) 초과 → 워크플로우 강한 신호. */
42
+ const WORKFLOW_CONCURRENCY_HINT = 11;
43
+ /**
44
+ * 구간 [start, stop) 들의 최대 동시 겹침 수. stoppedAt 미지정(진행 중) 은 +∞ 로 간주.
45
+ * sweep-line: start 이벤트 +1, stop 이벤트 -1 을 시간순 정렬 후 누적 최대.
46
+ * 동일 시각에서는 start(+1) 를 stop(-1) 보다 먼저 처리해 겹침을 과소평가하지 않는다.
47
+ */
48
+ export function maxConcurrency(agents) {
49
+ const events = [];
50
+ for (const a of agents) {
51
+ events.push({ t: a.startedAtMs, delta: 1 });
52
+ events.push({ t: a.stoppedAtMs ?? Number.POSITIVE_INFINITY, delta: -1 });
53
+ }
54
+ events.sort((x, y) => (x.t === y.t ? y.delta - x.delta : x.t - y.t));
55
+ let cur = 0;
56
+ let peak = 0;
57
+ for (const e of events) {
58
+ cur += e.delta;
59
+ if (cur > peak)
60
+ peak = cur;
61
+ }
62
+ return peak;
63
+ }
64
+ /**
65
+ * Pure core — baseline + 관측치 → verdict. IO 없음 (단위 테스트 대상).
66
+ *
67
+ * 판정:
68
+ * - 에이전트 0 → 'workflow-hooks-absent' (단 "워크플로우를 실제로 띄웠는가"
69
+ * 확인 전제 — recommendation 에 명시). §2 는 워크플로우 내부에 도달 못 함.
70
+ * - 에이전트 >0 → 'workflow-hooks-fire'. 동시 ≥11 이면 워크플로우 강한 신호.
71
+ * §2(SubagentStop 검증)가 워크플로우까지 커버 가능.
72
+ */
73
+ export function analyzeProbe(obs) {
74
+ const agentCount = obs.agents.length;
75
+ const conc = maxConcurrency(obs.agents);
76
+ const types = [...new Set(obs.agents.map((a) => a.agentType).filter((t) => !!t))];
77
+ const subagentFired = agentCount > 0;
78
+ let outcome;
79
+ let recommendation;
80
+ if (!subagentFired) {
81
+ outcome = 'workflow-hooks-absent';
82
+ recommendation =
83
+ 'SubagentStart/Stop 신호 0건. 워크플로우를 실제로 실행했다면 → 워크플로우 ' +
84
+ '내부 에이전트는 forgen 훅을 거치지 않음. ADR-009 §2 는 Task-tool/team/swarm ' +
85
+ 'subagent 한정으로 제한하고, 워크플로우 품질은 §3 템플릿 라우트로만 달성한다. ' +
86
+ '(워크플로우를 안 띄웠다면 arm 후 재시도 — inconclusive.)';
87
+ }
88
+ else {
89
+ outcome = 'workflow-hooks-fire';
90
+ const strong = conc >= WORKFLOW_CONCURRENCY_HINT;
91
+ recommendation =
92
+ `SubagentStart/Stop 발화 확인(에이전트 ${agentCount}, 최대 동시 ${conc}` +
93
+ `${strong ? ', 워크플로우 동시성 신호 강함' : ', 동시성 낮음 — 일반 subagent 가능성 검토'}). ` +
94
+ `ADR-009 §2(SubagentStop 검증)가 워크플로우 내부까지 커버 가능. ` +
95
+ `PostToolUse=${obs.postToolUseFired ? '발화(§2d per-agent tool 추적 가능)' : '미관측(§2d 거짓양성 리스크 — 추가 확인 필요)'}.`;
96
+ }
97
+ return {
98
+ subagentStartStopFired: subagentFired,
99
+ postToolUseFired: obs.postToolUseFired,
100
+ agentCount,
101
+ maxConcurrency: conc,
102
+ agentTypes: types,
103
+ outcome,
104
+ recommendation,
105
+ };
106
+ }
107
+ function parseIsoMs(iso) {
108
+ if (!iso)
109
+ return undefined;
110
+ const ms = Date.parse(iso);
111
+ return Number.isFinite(ms) ? ms : undefined;
112
+ }
113
+ /** STATE_DIR 의 prefix-*.json 파일 경로 목록. 실패 시 []. */
114
+ function listStateFiles(prefix) {
115
+ try {
116
+ return fs
117
+ .readdirSync(STATE_DIR)
118
+ .filter((f) => f.startsWith(prefix) && f.endsWith('.json'))
119
+ .map((f) => path.join(STATE_DIR, f));
120
+ }
121
+ catch {
122
+ return [];
123
+ }
124
+ }
125
+ /** baseline 이후 신호 수집 (IO 셸). */
126
+ export function collectObservations(baselineMs) {
127
+ const agents = [];
128
+ for (const file of listStateFiles('active-agents-')) {
129
+ try {
130
+ const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
131
+ for (const a of data.agents ?? []) {
132
+ const startedAtMs = parseIsoMs(a.startedAt);
133
+ if (startedAtMs === undefined || startedAtMs < baselineMs)
134
+ continue;
135
+ agents.push({
136
+ agentId: a.agentId ?? 'unknown',
137
+ agentType: a.agentType,
138
+ model: a.model,
139
+ startedAtMs,
140
+ stoppedAtMs: parseIsoMs(a.stoppedAt),
141
+ });
142
+ }
143
+ }
144
+ catch {
145
+ /* skip malformed */
146
+ }
147
+ }
148
+ let postToolUseFired = false;
149
+ for (const file of listStateFiles('modified-files-')) {
150
+ try {
151
+ if (fs.statSync(file).mtimeMs >= baselineMs) {
152
+ postToolUseFired = true;
153
+ break;
154
+ }
155
+ }
156
+ catch {
157
+ /* skip */
158
+ }
159
+ }
160
+ const hookEvents = collectHookEvents(baselineMs);
161
+ return { agents, postToolUseFired, hookEvents };
162
+ }
163
+ /** hook-timing.jsonl 에서 baseline 이후 관측된 distinct event 이름 (보조 신호). */
164
+ function collectHookEvents(baselineMs) {
165
+ const p = path.join(STATE_DIR, 'hook-timing.jsonl');
166
+ try {
167
+ const lines = fs.readFileSync(p, 'utf-8').trim().split('\n');
168
+ const set = new Set();
169
+ for (const line of lines) {
170
+ try {
171
+ const e = JSON.parse(line);
172
+ if (typeof e.at === 'number' && e.at >= baselineMs && e.event)
173
+ set.add(e.event);
174
+ }
175
+ catch {
176
+ /* skip */
177
+ }
178
+ }
179
+ return [...set];
180
+ }
181
+ catch {
182
+ return [];
183
+ }
184
+ }
185
+ function armProbe() {
186
+ const now = Date.now();
187
+ const baseline = { armedAtMs: now, armedIso: new Date(now).toISOString() };
188
+ fs.mkdirSync(STATE_DIR, { recursive: true });
189
+ fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2));
190
+ console.log(`
191
+ ${C.bold}forgen probe-workflow — armed${C.reset} ${C.dim}(${baseline.armedIso})${C.reset}
192
+
193
+ ${C.cyan}다음을 정확히 순서대로 실행하세요:${C.reset}
194
+ 1. Claude Code (v2.1.154+, workflows 활성) 세션에서 ${C.bold}워크플로우 1회만${C.reset} 실행
195
+ 예: ${C.dim}Run a workflow to list files under src/${C.reset}
196
+ 또는: ${C.dim}/deep-research <질문>${C.reset}
197
+ 2. ${C.yellow}그 사이 다른 Task/subagent 작업은 돌리지 마세요${C.reset} (신호 오염 방지)
198
+ 3. 워크플로우가 끝나면: ${C.bold}forgen probe-workflow report${C.reset}
199
+
200
+ ${C.dim}전제: forgen 의 SubagentStart/Stop·PostToolUse 훅이 설치/활성 상태여야 합니다.
201
+ 불확실하면 'forgen config hooks' 로 확인하세요.${C.reset}
202
+ `);
203
+ }
204
+ function loadBaseline() {
205
+ try {
206
+ return JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8'));
207
+ }
208
+ catch {
209
+ return null;
210
+ }
211
+ }
212
+ function colorForOutcome(outcome) {
213
+ if (outcome === 'workflow-hooks-fire')
214
+ return C.green;
215
+ if (outcome === 'workflow-hooks-absent')
216
+ return C.yellow;
217
+ return C.dim;
218
+ }
219
+ function reportProbe() {
220
+ const baseline = loadBaseline();
221
+ if (!baseline) {
222
+ console.log(`\n ${C.red}✗ armed 상태가 아닙니다.${C.reset} 먼저 ${C.bold}forgen probe-workflow arm${C.reset} 를 실행하세요.\n`);
223
+ process.exitCode = 1;
224
+ return;
225
+ }
226
+ const obs = collectObservations(baseline.armedAtMs);
227
+ const verdict = analyzeProbe(obs);
228
+ persistResult(baseline, verdict);
229
+ const oc = colorForOutcome(verdict.outcome);
230
+ console.log(`
231
+ ${C.bold}forgen probe-workflow — report${C.reset} ${C.dim}(armed ${baseline.armedIso})${C.reset}
232
+
233
+ SubagentStart/Stop 발화 : ${verdict.subagentStartStopFired ? `${C.green}YES${C.reset}` : `${C.yellow}NO${C.reset}`}
234
+ PostToolUse 발화 : ${verdict.postToolUseFired ? `${C.green}YES${C.reset}` : `${C.yellow}NO${C.reset}`}
235
+ 관측 에이전트 : ${verdict.agentCount} (최대 동시 ${verdict.maxConcurrency})
236
+ agentType : ${verdict.agentTypes.length ? verdict.agentTypes.join(', ') : `${C.dim}(없음)${C.reset}`}
237
+ 보조 hook 이벤트 : ${obs.hookEvents.length ? obs.hookEvents.join(', ') : `${C.dim}(없음)${C.reset}`}
238
+
239
+ ${C.bold}판정:${C.reset} ${oc}${verdict.outcome}${C.reset}
240
+ ${verdict.recommendation}
241
+
242
+ ${C.dim}결과 박제: ${RESULT_PATH}${C.reset}
243
+ `);
244
+ }
245
+ function persistResult(baseline, verdict) {
246
+ try {
247
+ fs.mkdirSync(STATE_DIR, { recursive: true });
248
+ fs.writeFileSync(RESULT_PATH, JSON.stringify({ at: new Date().toISOString(), armedIso: baseline.armedIso, verdict }, null, 2));
249
+ }
250
+ catch {
251
+ /* best-effort */
252
+ }
253
+ }
254
+ function statusProbe() {
255
+ const baseline = loadBaseline();
256
+ console.log(baseline
257
+ ? `\n armed: ${C.cyan}${baseline.armedIso}${C.reset}\n → 워크플로우 실행 후 ${C.bold}forgen probe-workflow report${C.reset}\n`
258
+ : `\n ${C.dim}armed 상태 아님.${C.reset} ${C.bold}forgen probe-workflow arm${C.reset} 로 시작하세요.\n`);
259
+ }
260
+ export async function handleProbeWorkflow(args) {
261
+ const sub = args[0] ?? 'status';
262
+ switch (sub) {
263
+ case 'arm':
264
+ armProbe();
265
+ return;
266
+ case 'report':
267
+ reportProbe();
268
+ return;
269
+ case 'status':
270
+ statusProbe();
271
+ return;
272
+ default:
273
+ console.log(`
274
+ ${C.bold}forgen probe-workflow${C.reset} — ADR-009 §1: 워크플로우 훅 발화 실측
275
+
276
+ Usage:
277
+ forgen probe-workflow arm baseline 기록 + 안내 (먼저)
278
+ forgen probe-workflow report 워크플로우 실행 후 신호 수집 → 판정
279
+ forgen probe-workflow status 현재 armed 상태 확인
280
+ `);
281
+ }
282
+ }
@@ -1,5 +1,18 @@
1
1
  import type { V1HarnessContext } from './harness.js';
2
2
  import type { RuntimeHost } from './types.js';
3
+ /**
4
+ * 세션 종료 후 자동 compound 추출을 백그라운드(detached)로 시작.
5
+ *
6
+ * 과거에는 execFileSync 로 동기 실행하여 세션 종료가 최대 ~210초(haiku LLM 3회
7
+ * 순차: 솔루션 추출 90s + behavior 60s + 세션 학습 60s) 동안 블록되었다. 같은
8
+ * 작업을 context-guard Stop 훅(maybeSpawnAutoCompound)과 session-recovery 가 이미
9
+ * detached 로 spawn 하므로, 여기서도 detached + unref 로 전환해 세션 종료를 막지
10
+ * 않는다. 결과(추출 솔루션/패턴)는 다음 세션 시작 시 session-recovery 가 surface 한다.
11
+ *
12
+ * dedup: last-auto-compound.json 을 Stop 훅과 공유 — 같은 세션의 최근 run 이 있으면
13
+ * skip 하여 detached spawn 의 double-run 을 방지한다.
14
+ */
15
+ export declare function runAutoCompound(cwd: string, transcriptPath: string, sessionId: string): void;
3
16
  /**
4
17
  * Plan B-1: 세션 transcript 사후 스캔으로 rate-limit 감지.
5
18
  *
@@ -223,19 +223,47 @@ async function scanCommitDiffForActedOn(sessionId, cwd, sessionStartTime) {
223
223
  * 세션 종료 후 자동 compound 추출 + USER.md 업데이트.
224
224
  * auto-compound-runner.ts를 동기 실행하여 솔루션 추출 + 사용자 패턴 관찰.
225
225
  */
226
- async function runAutoCompound(cwd, transcriptPath, sessionId) {
227
- console.log('\n[forgen] 세션 분석 중... (자동 compound)');
226
+ // Stop 훅(maybeSpawnAutoCompound)/session-recovery 와 공유하는 dedup cooldown.
227
+ const AUTO_COMPOUND_DEDUP_COOLDOWN_MS = 5 * 60 * 1000; // 5min
228
+ /**
229
+ * 세션 종료 후 자동 compound 추출을 백그라운드(detached)로 시작.
230
+ *
231
+ * 과거에는 execFileSync 로 동기 실행하여 세션 종료가 최대 ~210초(haiku LLM 3회
232
+ * 순차: 솔루션 추출 90s + behavior 60s + 세션 학습 60s) 동안 블록되었다. 같은
233
+ * 작업을 context-guard Stop 훅(maybeSpawnAutoCompound)과 session-recovery 가 이미
234
+ * detached 로 spawn 하므로, 여기서도 detached + unref 로 전환해 세션 종료를 막지
235
+ * 않는다. 결과(추출 솔루션/패턴)는 다음 세션 시작 시 session-recovery 가 surface 한다.
236
+ *
237
+ * dedup: last-auto-compound.json 을 Stop 훅과 공유 — 같은 세션의 최근 run 이 있으면
238
+ * skip 하여 detached spawn 의 double-run 을 방지한다.
239
+ */
240
+ export function runAutoCompound(cwd, transcriptPath, sessionId) {
241
+ const markerPath = path.join(STATE_DIR, 'last-auto-compound.json');
242
+ try {
243
+ const parsed = JSON.parse(fs.readFileSync(markerPath, 'utf-8'));
244
+ if (parsed.sessionId === sessionId) {
245
+ const last = parsed.completedAt ? Date.parse(parsed.completedAt) : 0;
246
+ if (Number.isFinite(last) && Date.now() - last < AUTO_COMPOUND_DEDUP_COOLDOWN_MS) {
247
+ log.debug('auto-compound skip: 최근 동일 세션 run 존재 (dedup)');
248
+ return;
249
+ }
250
+ }
251
+ }
252
+ catch {
253
+ // 마커 없음(최초)/손상 — 진행 (fail-open).
254
+ }
228
255
  const runnerPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'auto-compound-runner.js');
229
256
  try {
230
- execFileSync('node', [runnerPath, cwd, transcriptPath, sessionId], {
257
+ const child = spawn('node', [runnerPath, cwd, transcriptPath, sessionId], {
231
258
  cwd,
232
- timeout: 120_000,
233
- stdio: ['pipe', 'inherit', 'inherit'],
259
+ detached: true,
260
+ stdio: 'ignore',
234
261
  });
235
- console.log('[forgen] 자동 compound 완료\n');
262
+ child.unref();
263
+ console.log('\n[forgen] 세션 분석을 백그라운드로 시작했습니다 (자동 compound) — 결과는 다음 세션 시작 시 반영됩니다.\n');
236
264
  }
237
265
  catch (e) {
238
- log.debug('auto-compound 실패', e);
266
+ log.debug('auto-compound 시작 실패', e);
239
267
  }
240
268
  }
241
269
  /**
@@ -415,7 +443,7 @@ export async function spawnClaude(args, context, runtime = 'claude') {
415
443
  // 4. 자동 compound (10+ user 메시지인 경우만) — 양 runtime 호환
416
444
  const userMsgCount = await countUserMessages(transcript);
417
445
  if (userMsgCount >= 10) {
418
- await runAutoCompound(context.cwd, transcript, sessionId);
446
+ runAutoCompound(context.cwd, transcript, sessionId);
419
447
  }
420
448
  else {
421
449
  console.log(`[forgen] 세션이 짧아 auto-compound 생략 (${userMsgCount} messages)`);
@@ -9,27 +9,40 @@ export interface StatsSnapshot {
9
9
  drift7d: number;
10
10
  retired7d: number;
11
11
  lastExtraction: string;
12
- /**
13
- * H3 / v0.4.1 — assist 축 가시화. enforcement(block/violation) 는 이미 표시되지만
14
- * assist(recall hit, surface, extraction) 는 v0.4.0 에서 8,000+ 번 작동했음에도
15
- * 사용자에게 0건 노출되었다. 오늘 기준 숫자로 "지금 학습되고 있다" 를 surface.
16
- */
17
12
  assistToday: {
18
13
  recallHits: number;
19
14
  surfaced: number;
20
15
  referenced: number;
21
16
  extractedToday: number;
22
17
  };
23
- /**
24
- * v0.4.1 철학 고도화 지표 — forge-profile.json 이 실제로 학습됐는지 한눈에.
25
- * 값이 있으면 가시화, 없으면 undefined.
26
- */
27
18
  philosophy?: {
28
19
  basePacks: string[];
29
20
  trustPolicy: string;
30
21
  axisScores: Record<string, number>;
31
22
  lastReclassification: string | null;
32
23
  };
24
+ /** v0.5.0: solution health — status 분포, 활용률 */
25
+ solutionHealth: {
26
+ total: number;
27
+ byStatus: Record<string, number>;
28
+ avgConfidence: number;
29
+ /** 지난 7일간 match-eval-log에서 한 번이라도 매칭된 솔루션 비율 */
30
+ utilization7d: number;
31
+ };
32
+ /** v0.5.0: 7일간 가장 많이 발동된 규칙 top-3 */
33
+ topRules7d: Array<{
34
+ name: string;
35
+ count: number;
36
+ }>;
37
+ /** v0.5.0: 이번주 vs 지난주 변화량 */
38
+ weeklyTrend: {
39
+ blocksThisWeek: number;
40
+ blocksLastWeek: number;
41
+ recallsThisWeek: number;
42
+ recallsLastWeek: number;
43
+ extractionsThisWeek: number;
44
+ extractionsLastWeek: number;
45
+ };
33
46
  }
34
47
  export declare function computeStats(): StatsSnapshot;
35
48
  export declare function renderStats(s: StatsSnapshot): string;
@@ -198,8 +198,125 @@ export function computeStats() {
198
198
  lastExtraction: readLastExtraction(),
199
199
  assistToday: computeAssistToday(),
200
200
  philosophy: computePhilosophy(),
201
+ solutionHealth: computeSolutionHealth(),
202
+ topRules7d: computeTopRules7d(realBlocks),
203
+ weeklyTrend: computeWeeklyTrend(realBlocks),
201
204
  };
202
205
  }
206
+ function computeSolutionHealth() {
207
+ const byStatus = {};
208
+ let total = 0;
209
+ let confidenceSum = 0;
210
+ const localNames = new Set();
211
+ try {
212
+ if (!fs.existsSync(SOLUTIONS_DIR))
213
+ return { total: 0, byStatus: {}, avgConfidence: 0, utilization7d: 0 };
214
+ const files = fs.readdirSync(SOLUTIONS_DIR).filter(f => f.endsWith('.md'));
215
+ for (const f of files) {
216
+ try {
217
+ const content = fs.readFileSync(path.join(SOLUTIONS_DIR, f), 'utf-8');
218
+ const statusMatch = content.match(/status:\s*"?([a-z]+)"?/);
219
+ const confMatch = content.match(/confidence:\s*([0-9.]+)/);
220
+ const st = statusMatch?.[1] ?? 'unknown';
221
+ byStatus[st] = (byStatus[st] ?? 0) + 1;
222
+ confidenceSum += parseFloat(confMatch?.[1] ?? '0');
223
+ localNames.add(f.replace(/\.md$/, ''));
224
+ total++;
225
+ }
226
+ catch { /* skip */ }
227
+ }
228
+ }
229
+ catch { /* fail-open */ }
230
+ const cutoff7d = Date.now() - 7 * MS_PER_DAY;
231
+ const matchLog = readJsonl(path.join(STATE_DIR, 'match-eval-log.jsonl'));
232
+ const matchedLocalNames = new Set();
233
+ for (const e of matchLog) {
234
+ const ts = typeof e.ts === 'string' ? Date.parse(e.ts) : NaN;
235
+ if (!Number.isFinite(ts) || ts < cutoff7d)
236
+ continue;
237
+ const ranked = e.rankedTopN;
238
+ if (Array.isArray(ranked)) {
239
+ for (const r of ranked) {
240
+ let name = null;
241
+ if (typeof r === 'string')
242
+ name = r;
243
+ else if (typeof r === 'object' && r !== null && typeof r.name === 'string') {
244
+ name = r.name;
245
+ }
246
+ // Only count matches against LOCAL solutions to avoid skew from
247
+ // starter-pack/team-pack matches that aren't in this user's me/solutions/.
248
+ if (name && localNames.has(name)) {
249
+ matchedLocalNames.add(name);
250
+ }
251
+ }
252
+ }
253
+ }
254
+ return {
255
+ total,
256
+ byStatus,
257
+ avgConfidence: total > 0 ? confidenceSum / total : 0,
258
+ utilization7d: total > 0 ? matchedLocalNames.size / total : 0,
259
+ };
260
+ }
261
+ function computeTopRules7d(violations) {
262
+ const cutoff = Date.now() - 7 * MS_PER_DAY;
263
+ const counts = new Map();
264
+ for (const v of violations) {
265
+ const ts = typeof v.at === 'string' ? Date.parse(v.at) : NaN;
266
+ if (!Number.isFinite(ts) || ts < cutoff)
267
+ continue;
268
+ const rule = typeof v.rule === 'string' ? v.rule
269
+ : typeof v.guard === 'string' ? v.guard
270
+ : typeof v.source === 'string' ? v.source
271
+ : 'unknown';
272
+ counts.set(rule, (counts.get(rule) ?? 0) + 1);
273
+ }
274
+ return [...counts.entries()]
275
+ .sort((a, b) => b[1] - a[1])
276
+ .slice(0, 3)
277
+ .map(([name, count]) => ({ name, count }));
278
+ }
279
+ function computeWeeklyTrend(violations) {
280
+ const now = Date.now();
281
+ const thisWeekStart = now - 7 * MS_PER_DAY;
282
+ const lastWeekStart = now - 14 * MS_PER_DAY;
283
+ const blocksThisWeek = countWithin(violations, 7);
284
+ let blocksLastWeek = 0;
285
+ for (const v of violations) {
286
+ const ts = typeof v.at === 'string' ? Date.parse(v.at) : NaN;
287
+ if (Number.isFinite(ts) && ts >= lastWeekStart && ts < thisWeekStart)
288
+ blocksLastWeek++;
289
+ }
290
+ const matchLog = readJsonl(path.join(STATE_DIR, 'match-eval-log.jsonl'));
291
+ let recallsThisWeek = 0;
292
+ let recallsLastWeek = 0;
293
+ for (const e of matchLog) {
294
+ const ts = typeof e.ts === 'string' ? Date.parse(e.ts) : NaN;
295
+ if (!Number.isFinite(ts))
296
+ continue;
297
+ if (ts >= thisWeekStart)
298
+ recallsThisWeek++;
299
+ else if (ts >= lastWeekStart)
300
+ recallsLastWeek++;
301
+ }
302
+ let extractionsThisWeek = 0;
303
+ let extractionsLastWeek = 0;
304
+ try {
305
+ if (fs.existsSync(SOLUTIONS_DIR)) {
306
+ for (const f of fs.readdirSync(SOLUTIONS_DIR)) {
307
+ if (!f.endsWith('.md'))
308
+ continue;
309
+ const mtime = fs.statSync(path.join(SOLUTIONS_DIR, f)).mtimeMs;
310
+ if (mtime >= thisWeekStart)
311
+ extractionsThisWeek++;
312
+ else if (mtime >= lastWeekStart)
313
+ extractionsLastWeek++;
314
+ }
315
+ }
316
+ }
317
+ catch { /* fail-open */ }
318
+ return { blocksThisWeek, blocksLastWeek, recallsThisWeek, recallsLastWeek, extractionsThisWeek, extractionsLastWeek };
319
+ }
203
320
  function padNum(n, width = 4) {
204
321
  return String(n).padStart(width);
205
322
  }
@@ -256,6 +373,38 @@ export function renderStats(s) {
256
373
  }
257
374
  }
258
375
  catch { /* fail-open: git 없거나 비-repo 환경 */ }
376
+ // v0.5.0: Solution health
377
+ if (s.solutionHealth.total > 0) {
378
+ const sh = s.solutionHealth;
379
+ const statusParts = Object.entries(sh.byStatus).map(([k, v]) => `${k}:${v}`).join(' ');
380
+ lines.push(' Solutions');
381
+ lines.push(` Total ${padNum(sh.total)} ${statusParts}`);
382
+ lines.push(` Avg confidence ${sh.avgConfidence.toFixed(2)}`);
383
+ lines.push(` Utilization (7d) ${Math.round(sh.utilization7d * 100)}% — matched at least once`);
384
+ lines.push('');
385
+ }
386
+ // v0.5.0: Top rules (7d)
387
+ if (s.topRules7d.length > 0) {
388
+ lines.push(' Top rules (7d)');
389
+ for (const r of s.topRules7d) {
390
+ lines.push(` ${padNum(r.count)}x ${r.name}`);
391
+ }
392
+ lines.push('');
393
+ }
394
+ // v0.5.0: Weekly trend
395
+ const wt = s.weeklyTrend;
396
+ const trendArrow = (curr, prev) => {
397
+ if (curr > prev)
398
+ return `+${curr - prev}`;
399
+ if (curr < prev)
400
+ return `${curr - prev}`;
401
+ return '=';
402
+ };
403
+ lines.push(' Weekly trend (this vs last)');
404
+ lines.push(` Blocks ${padNum(wt.blocksThisWeek)} → ${padNum(wt.blocksLastWeek, 2)} prev (${trendArrow(wt.blocksThisWeek, wt.blocksLastWeek)})`);
405
+ lines.push(` Recalls ${padNum(wt.recallsThisWeek)} → ${padNum(wt.recallsLastWeek, 2)} prev (${trendArrow(wt.recallsThisWeek, wt.recallsLastWeek)})`);
406
+ lines.push(` Extractions ${padNum(wt.extractionsThisWeek)} → ${padNum(wt.extractionsLastWeek, 2)} prev (${trendArrow(wt.extractionsThisWeek, wt.extractionsLastWeek)})`);
407
+ lines.push('');
259
408
  lines.push(` Last extraction: ${s.lastExtraction}`);
260
409
  lines.push('');
261
410
  return lines.join('\n');
@@ -0,0 +1,7 @@
1
+ /**
2
+ * forgen watch — real-time hook event stream.
3
+ *
4
+ * Tails hook-timing.jsonl, enforcement/violations.jsonl, and
5
+ * match-eval-log.jsonl to show live forgen activity in a terminal pane.
6
+ */
7
+ export declare function handleWatch(): Promise<void>;