@wooojin/forgen 0.4.13 → 0.5.0

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 (139) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +94 -0
  3. package/README.ja.md +4 -4
  4. package/README.md +89 -27
  5. package/README.zh.md +4 -4
  6. package/assets/claude/commands/forge-loop.md +7 -1
  7. package/assets/claude/commands/ship.md +18 -1
  8. package/assets/opencode/forgen.ts +52 -0
  9. package/dist/checks/_shared/meta-guard-dispatch.d.ts +7 -0
  10. package/dist/checks/_shared/meta-guard-dispatch.js +12 -2
  11. package/dist/checks/_shared/model-profile.d.ts +25 -0
  12. package/dist/checks/_shared/model-profile.js +63 -0
  13. package/dist/cli.js +99 -153
  14. package/dist/core/auto-compound-runner.d.ts +0 -11
  15. package/dist/core/auto-compound-runner.js +127 -79
  16. package/dist/core/compound-consent.d.ts +15 -0
  17. package/dist/core/compound-consent.js +48 -0
  18. package/dist/core/compound-sweep-cli.d.ts +57 -0
  19. package/dist/core/compound-sweep-cli.js +354 -0
  20. package/dist/core/config-injector.d.ts +16 -1
  21. package/dist/core/config-injector.js +47 -28
  22. package/dist/core/dashboard-cli.js +40 -16
  23. package/dist/core/dashboard.d.ts +3 -4
  24. package/dist/core/dashboard.js +15 -34
  25. package/dist/core/dev-cli.d.ts +13 -0
  26. package/dist/core/dev-cli.js +70 -0
  27. package/dist/core/doctor.d.ts +16 -5
  28. package/dist/core/doctor.js +161 -176
  29. package/dist/core/drift-score.d.ts +2 -0
  30. package/dist/core/drift-score.js +9 -1
  31. package/dist/core/harness.js +115 -43
  32. package/dist/core/health-cli.d.ts +2 -0
  33. package/dist/core/health-cli.js +6 -1
  34. package/dist/core/host-detect.d.ts +3 -1
  35. package/dist/core/host-detect.js +26 -1
  36. package/dist/core/migrate-cli.js +15 -0
  37. package/dist/core/migrate-evidence-host.d.ts +2 -1
  38. package/dist/core/migrate-tenetx.d.ts +50 -0
  39. package/dist/core/migrate-tenetx.js +262 -0
  40. package/dist/core/probe-workflow-cli.d.ts +3 -3
  41. package/dist/core/probe-workflow-cli.js +13 -13
  42. package/dist/core/recall-cli.js +1 -1
  43. package/dist/core/regress-map-cli.js +1 -1
  44. package/dist/core/rendered-rules-manifest.d.ts +29 -0
  45. package/dist/core/rendered-rules-manifest.js +60 -0
  46. package/dist/core/session-store.js +14 -3
  47. package/dist/core/settings-injector.d.ts +3 -0
  48. package/dist/core/settings-injector.js +12 -16
  49. package/dist/core/spawn.d.ts +11 -1
  50. package/dist/core/spawn.js +79 -7
  51. package/dist/core/state-gc.js +1 -0
  52. package/dist/core/status-cli.d.ts +20 -0
  53. package/dist/core/status-cli.js +100 -0
  54. package/dist/core/statusline-cli.d.ts +7 -0
  55. package/dist/core/statusline-cli.js +63 -19
  56. package/dist/core/transcript-summary.d.ts +18 -0
  57. package/dist/core/transcript-summary.js +81 -0
  58. package/dist/core/trust-layer-intent.d.ts +21 -1
  59. package/dist/core/trust-layer-intent.js +7 -0
  60. package/dist/core/types.d.ts +3 -2
  61. package/dist/core/uninstall.js +12 -0
  62. package/dist/core/usage-telemetry.d.ts +7 -1
  63. package/dist/core/usage-telemetry.js +7 -9
  64. package/dist/core/v1-bootstrap.js +1 -1
  65. package/dist/core/watch-cli.js +1 -1
  66. package/dist/engine/compound-extractor.js +10 -0
  67. package/dist/engine/compound-loop.js +35 -6
  68. package/dist/engine/compound-share.d.ts +85 -0
  69. package/dist/engine/compound-share.js +606 -0
  70. package/dist/engine/correction-cluster-runner.d.ts +38 -0
  71. package/dist/engine/correction-cluster-runner.js +188 -0
  72. package/dist/engine/correction-clustering.d.ts +80 -0
  73. package/dist/engine/correction-clustering.js +167 -0
  74. package/dist/engine/enforce-classifier.d.ts +10 -1
  75. package/dist/engine/enforce-classifier.js +111 -22
  76. package/dist/engine/extraction-session.js +10 -3
  77. package/dist/engine/private-filter.d.ts +36 -0
  78. package/dist/engine/private-filter.js +100 -0
  79. package/dist/engine/ranking-pipeline.js +4 -2
  80. package/dist/engine/relevance-gate.d.ts +12 -0
  81. package/dist/engine/relevance-gate.js +12 -0
  82. package/dist/engine/roi-demotion.d.ts +79 -0
  83. package/dist/engine/roi-demotion.js +159 -0
  84. package/dist/engine/solution-format.d.ts +1 -0
  85. package/dist/engine/solution-format.js +26 -0
  86. package/dist/engine/solution-matcher.js +10 -1
  87. package/dist/fgx.js +7 -6
  88. package/dist/forge/cli.js +8 -2
  89. package/dist/hooks/compound-reflection.js +6 -1
  90. package/dist/hooks/context-guard.d.ts +16 -1
  91. package/dist/hooks/context-guard.js +92 -46
  92. package/dist/hooks/post-tool-use.js +2 -3
  93. package/dist/hooks/pre-compact.js +14 -0
  94. package/dist/hooks/pre-tool-use.js +5 -1
  95. package/dist/hooks/shared/stop-triggers.d.ts +29 -2
  96. package/dist/hooks/shared/stop-triggers.js +35 -2
  97. package/dist/hooks/solution-injector.d.ts +28 -0
  98. package/dist/hooks/solution-injector.js +126 -28
  99. package/dist/hooks/stop-guard.js +5 -2
  100. package/dist/hooks/subagent-stop-guard.js +4 -1
  101. package/dist/host/capabilities-claude.js +1 -0
  102. package/dist/host/capabilities-codex.js +1 -0
  103. package/dist/host/capabilities-opencode.d.ts +26 -0
  104. package/dist/host/capabilities-opencode.js +78 -0
  105. package/dist/host/capabilities-registry.d.ts +7 -0
  106. package/dist/host/capabilities-registry.js +14 -0
  107. package/dist/host/exec-host.d.ts +4 -3
  108. package/dist/host/exec-host.js +2 -0
  109. package/dist/host/host-binding.d.ts +27 -0
  110. package/dist/host/host-binding.js +11 -0
  111. package/dist/host/host-runtime.js +18 -0
  112. package/dist/host/install-codex.d.ts +8 -0
  113. package/dist/host/install-codex.js +2 -2
  114. package/dist/host/install-opencode.d.ts +38 -0
  115. package/dist/host/install-opencode.js +148 -0
  116. package/dist/host/install-orchestrator.d.ts +4 -1
  117. package/dist/host/install-orchestrator.js +20 -1
  118. package/dist/host/invoke-agent.d.ts +3 -2
  119. package/dist/host/opencode/context-cli.d.ts +15 -0
  120. package/dist/host/opencode/context-cli.js +25 -0
  121. package/dist/host/opencode/guard-cli.d.ts +13 -0
  122. package/dist/host/opencode/guard-cli.js +39 -0
  123. package/dist/host/opencode/plugin/forgen.d.ts +31 -0
  124. package/dist/host/opencode/plugin/forgen.js +60 -0
  125. package/dist/host/opencode/translate.d.ts +49 -0
  126. package/dist/host/opencode/translate.js +96 -0
  127. package/dist/host/parity-harness.d.ts +10 -2
  128. package/dist/host/projection.js +11 -0
  129. package/dist/mcp/tools.js +17 -4
  130. package/dist/store/evidence-store.d.ts +2 -6
  131. package/dist/store/evidence-store.js +68 -14
  132. package/dist/store/host-mismatch.d.ts +2 -1
  133. package/dist/store/host-mismatch.js +8 -8
  134. package/dist/store/profile-store.d.ts +3 -2
  135. package/dist/store/types.d.ts +9 -2
  136. package/package.json +3 -2
  137. package/plugin.json +2 -2
  138. package/skills/forge-loop/SKILL.md +7 -1
  139. package/skills/ship/SKILL.md +18 -1
@@ -0,0 +1,354 @@
1
+ /**
2
+ * forgen compound-sweep — 시간-기반 compound backstop (ADR-011).
3
+ *
4
+ * 이벤트 훅(Stop/PreCompact)이 fast path 라면, 이 sweep 는 **보장**이다:
5
+ * 최근 수정된 Claude transcript 중 세션별로 오래(stale) compound 안 된 것을 쓸어담아
6
+ * auto-compound-runner 를 돌린다. 세션 경계(재시작)에 의존하는 기존 backstop 의 갭
7
+ * (재시작 않는 긴 세션 유실)을 시간-트리거로 메운다. cron(시간당 권장)에서 호출.
8
+ *
9
+ * 순수 선택 로직(selectSweepCandidates)과 IO(runCompoundSweep) 분리 — 전자는 테스트 가능.
10
+ * 멱등·fail-open: 실패해도 세션은 다음 sweep 에서 재시도.
11
+ */
12
+ import { execFileSync } from 'node:child_process';
13
+ import * as fs from 'node:fs';
14
+ import * as os from 'node:os';
15
+ import * as path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { STATE_DIR } from './paths.js';
18
+ import { runAutoCompound } from './spawn.js';
19
+ import { createLogger } from './logger.js';
20
+ const log = createLogger('compound-sweep');
21
+ export const DEFAULT_SWEEP_OPTS = {
22
+ windowMs: 24 * 60 * 60 * 1000,
23
+ staleMs: 2 * 60 * 60 * 1000,
24
+ minPrompts: 10,
25
+ maxPerRun: 5,
26
+ };
27
+ /**
28
+ * 순수 선택: 최근·충분한 대화·stale(오래 안 쓸린) 세션을, oldest-swept 우선으로 maxPerRun 만큼.
29
+ */
30
+ export function selectSweepCandidates(transcripts, sweepState, opts) {
31
+ return transcripts
32
+ .filter((t) => opts.nowMs - t.mtimeMs <= opts.windowMs)
33
+ .filter((t) => t.promptCount >= opts.minPrompts)
34
+ .filter((t) => {
35
+ const s = sweepState[t.sessionId];
36
+ return !s || opts.nowMs - s.sweptAt >= opts.staleMs;
37
+ })
38
+ .sort((a, b) => (sweepState[a.sessionId]?.sweptAt ?? 0) - (sweepState[b.sessionId]?.sweptAt ?? 0))
39
+ .slice(0, Math.max(0, opts.maxPerRun));
40
+ }
41
+ function claudeProjectsDir() {
42
+ const base = process.env.CLAUDE_CONFIG_DIR
43
+ ? process.env.CLAUDE_CONFIG_DIR
44
+ : path.join(os.homedir(), '.claude');
45
+ return path.join(base, 'projects');
46
+ }
47
+ /** user 턴 판정 — tool_result 캐리어(type:'user'지만 content 가 tool_result)는 제외.
48
+ * (리뷰 SEV-2: tool_result 가 promptCount 를 3~5배 부풀려 ≥10 게이트를 무력화). */
49
+ function isRealUserTurn(e) {
50
+ if (e.type !== 'user' && e.role !== 'user' && e.message?.role !== 'user')
51
+ return false;
52
+ const content = e.message?.content;
53
+ if (Array.isArray(content) && content.some((c) => c?.type === 'tool_result')) {
54
+ return false; // 도구 결과 반환 — 실제 사용자 프롬프트 아님
55
+ }
56
+ return true;
57
+ }
58
+ /** transcript 에서 cwd + 실제 user 프롬프트 수 추출. cwd 는 라인의 cwd 필드 우선, 없으면 dir 명 역산. */
59
+ function readTranscriptMeta(transcriptPath, projectDirName) {
60
+ let cwd = projectDirName.replace(/-/g, '/'); // fallback: sanitized dir → cwd (lossy, cwd 필드 없을 때만)
61
+ let promptCount = 0;
62
+ try {
63
+ const lines = fs.readFileSync(transcriptPath, 'utf-8').split('\n');
64
+ let cwdFound = false;
65
+ for (const line of lines) {
66
+ if (!line.trim())
67
+ continue;
68
+ try {
69
+ const e = JSON.parse(line);
70
+ if (!cwdFound && typeof e.cwd === 'string' && e.cwd) {
71
+ cwd = e.cwd;
72
+ cwdFound = true;
73
+ }
74
+ if (isRealUserTurn(e))
75
+ promptCount += 1;
76
+ }
77
+ catch {
78
+ /* skip malformed line */
79
+ }
80
+ }
81
+ }
82
+ catch {
83
+ /* unreadable — leave defaults */
84
+ }
85
+ return { cwd, promptCount };
86
+ }
87
+ /** Claude projects 하위의 최근 transcript 를 열거해 메타 구성. */
88
+ export function enumerateTranscripts(windowMs, nowMs) {
89
+ const root = claudeProjectsDir();
90
+ let projDirs;
91
+ try {
92
+ projDirs = fs.readdirSync(root);
93
+ }
94
+ catch {
95
+ return []; // root 없음/접근불가 — fail-open
96
+ }
97
+ const out = [];
98
+ for (const projDir of projDirs) {
99
+ const abs = path.join(root, projDir);
100
+ let files;
101
+ try {
102
+ if (!fs.statSync(abs).isDirectory())
103
+ continue;
104
+ files = fs.readdirSync(abs).filter((f) => f.endsWith('.jsonl'));
105
+ }
106
+ catch {
107
+ continue;
108
+ }
109
+ for (const f of files) {
110
+ const fp = path.join(abs, f);
111
+ let mtimeMs;
112
+ try {
113
+ mtimeMs = fs.statSync(fp).mtimeMs;
114
+ }
115
+ catch {
116
+ continue;
117
+ }
118
+ if (nowMs - mtimeMs > windowMs)
119
+ continue; // 창 밖은 파싱 비용도 아낌
120
+ const { cwd, promptCount } = readTranscriptMeta(fp, projDir);
121
+ out.push({ sessionId: f.replace(/\.jsonl$/, ''), transcriptPath: fp, cwd, mtimeMs, promptCount });
122
+ }
123
+ }
124
+ return out;
125
+ }
126
+ function loadSweepState() {
127
+ try {
128
+ return JSON.parse(fs.readFileSync(path.join(STATE_DIR, 'compound-sweep-state.json'), 'utf-8'));
129
+ }
130
+ catch {
131
+ return {};
132
+ }
133
+ }
134
+ /** sweep-state 저장. 실패 시 true 반환(호출측이 무인 재과금 위험을 경고). */
135
+ function saveSweepState(state) {
136
+ try {
137
+ fs.mkdirSync(STATE_DIR, { recursive: true });
138
+ fs.writeFileSync(path.join(STATE_DIR, 'compound-sweep-state.json'), JSON.stringify(state, null, 2));
139
+ return true;
140
+ }
141
+ catch (e) {
142
+ log.debug('sweep-state 저장 실패', e);
143
+ return false;
144
+ }
145
+ }
146
+ /** window 밖으로 사라진 세션 엔트리 정리 (무한 증가 방지, 리뷰 SEV-3). */
147
+ function pruneSweepState(state, liveSessionIds, nowMs, windowMs) {
148
+ for (const sid of Object.keys(state)) {
149
+ if (!liveSessionIds.has(sid) && nowMs - state[sid].sweptAt > windowMs)
150
+ delete state[sid];
151
+ }
152
+ }
153
+ /**
154
+ * cron/CLI 진입점. dryRun 이면 실행 없이 후보만 보고.
155
+ * 무인 실행 안전장치: (1) 동시 실행 lock, (2) 실제 spawn('spawned') 만 sweptAt 기록,
156
+ * (3) state 저장 실패 시 경고 플래그.
157
+ */
158
+ export function runCompoundSweep(opts = {}) {
159
+ const nowMs = Date.now();
160
+ const full = { nowMs, ...DEFAULT_SWEEP_OPTS, ...opts };
161
+ // 동시 cron fire 겹침 방지 (리뷰 SEV-3): stale(>1h) lock 은 무시.
162
+ const lockPath = path.join(STATE_DIR, 'compound-sweep.lock');
163
+ if (!opts.dryRun) {
164
+ try {
165
+ const raw = fs.readFileSync(lockPath, 'utf-8');
166
+ const { at } = JSON.parse(raw);
167
+ if (Number.isFinite(at) && nowMs - at < 60 * 60 * 1000) {
168
+ return { scanned: 0, eligible: 0, swept: [] }; // 다른 sweep 진행 중
169
+ }
170
+ }
171
+ catch {
172
+ /* lock 없음/손상 — 진행 */
173
+ }
174
+ try {
175
+ fs.mkdirSync(STATE_DIR, { recursive: true });
176
+ fs.writeFileSync(lockPath, JSON.stringify({ at: nowMs, pid: process.pid }));
177
+ }
178
+ catch {
179
+ /* lock 기록 실패 — 진행(단일 실행 가정) */
180
+ }
181
+ }
182
+ try {
183
+ const transcripts = enumerateTranscripts(full.windowMs, nowMs);
184
+ const state = loadSweepState();
185
+ const candidates = selectSweepCandidates(transcripts, state, full);
186
+ const swept = [];
187
+ for (const c of candidates) {
188
+ if (opts.dryRun) {
189
+ swept.push(c.sessionId);
190
+ continue;
191
+ }
192
+ // 실제 spawn 된 경우만 sweptAt 기록 (리뷰 SEV-2: dedup-skip/실패를 완료로 오기록 금지).
193
+ const status = runAutoCompound(c.cwd, c.transcriptPath, c.sessionId, c.promptCount);
194
+ if (status === 'spawned') {
195
+ state[c.sessionId] = { sweptAt: nowMs };
196
+ swept.push(c.sessionId);
197
+ }
198
+ }
199
+ let stateSaveFailed = false;
200
+ if (!opts.dryRun) {
201
+ pruneSweepState(state, new Set(transcripts.map((t) => t.sessionId)), nowMs, full.windowMs);
202
+ stateSaveFailed = !saveSweepState(state);
203
+ }
204
+ return { scanned: transcripts.length, eligible: candidates.length, swept, stateSaveFailed };
205
+ }
206
+ catch (e) {
207
+ log.debug('compound-sweep 실행 실패 (fail-open)', e);
208
+ return { scanned: 0, eligible: 0, swept: [] };
209
+ }
210
+ finally {
211
+ if (!opts.dryRun)
212
+ fs.rmSync(lockPath, { force: true });
213
+ }
214
+ }
215
+ // ── cron 자동설치 (ADR-011 §Consequences) ────────────────────────────────────
216
+ const CRON_MARKER = '# forgen-compound-sweep (ADR-011)';
217
+ const CRON_SCHEDULE = '17 * * * *'; // 매시 17분 — 정각 몰림 회피
218
+ /** cron 은 각 라인을 /bin/sh 로 실행 → 공백/메타문자 경로가 word-split/injection 되지 않게 single-quote. */
219
+ function shq(s) {
220
+ return `'${s.replace(/'/g, "'\\''")}'`;
221
+ }
222
+ /** crontab 라인 구성 (순수 — 테스트 가능). 경로는 shell-quote(리뷰 SEV-2). */
223
+ export function buildCronLine(nodePath, cliPath, logPath, schedule = CRON_SCHEDULE) {
224
+ return `${schedule} ${shq(nodePath)} ${shq(cliPath)} compound sweep >> ${shq(logPath)} 2>&1 ${CRON_MARKER}`;
225
+ }
226
+ /** 기존 crontab 에서 forgen 라인을 upsert/제거 (순수 — 테스트 가능). */
227
+ export function upsertCronText(existing, line) {
228
+ const kept = existing
229
+ .split('\n')
230
+ .filter((l) => l.trim() && !l.includes(CRON_MARKER));
231
+ if (line)
232
+ kept.push(line);
233
+ return `${kept.join('\n')}\n`;
234
+ }
235
+ /**
236
+ * 현재 crontab 읽기. 리뷰 SEV-1: `crontab -l` 실패를 무조건 ''(빈 crontab)로 취급하면
237
+ * 일시적 read 실패 시 기존 사용자 job 전체를 덮어써 파괴한다. "no crontab" 만 ''로 보고,
238
+ * 그 외 실 오류는 throw 하여 install/uninstall 이 **쓰지 않고 중단**하게 한다.
239
+ */
240
+ function readCrontab() {
241
+ try {
242
+ return execFileSync('crontab', ['-l'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
243
+ }
244
+ catch (e) {
245
+ const err = e;
246
+ const stderr = String(err.stderr ?? '');
247
+ // "no crontab for <user>" = 정상적으로 비어있음 → '' (생성 안전).
248
+ if (/no crontab for/i.test(stderr))
249
+ return '';
250
+ // 그 외(권한/일시장애 등)는 알 수 없음 → 파괴적 쓰기 방지 위해 throw.
251
+ throw new Error(`crontab -l 실패 (기존 job 보호 위해 중단): ${stderr.trim() || e.message}`);
252
+ }
253
+ }
254
+ function distCliPath() {
255
+ // 이 파일 = dist/core/compound-sweep-cli.js → dist/cli.js
256
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'cli.js');
257
+ }
258
+ export function installCron() {
259
+ const nodePath = process.execPath;
260
+ const cliPath = distCliPath();
261
+ const logPath = path.join(STATE_DIR, 'compound-sweep.log');
262
+ const line = buildCronLine(nodePath, cliPath, logPath);
263
+ if (process.platform === 'win32') {
264
+ return {
265
+ ok: false,
266
+ message: 'Windows 는 crontab 미지원 — Task Scheduler 에 시간당 작업을 수동 등록하세요:\n' +
267
+ ` 프로그램: ${nodePath}\n 인수: "${cliPath}" compound sweep`,
268
+ };
269
+ }
270
+ let existing;
271
+ try {
272
+ existing = readCrontab(); // read 실패(‘no crontab’ 제외) 시 throw → 아래서 중단
273
+ }
274
+ catch (e) {
275
+ return { ok: false, message: `${e.message}\n수동 등록: crontab -e 에 아래 추가:\n ${line}` };
276
+ }
277
+ try {
278
+ execFileSync('crontab', ['-'], { input: upsertCronText(existing, line), encoding: 'utf-8' });
279
+ return { ok: true, message: `compound sweep cron 설치됨 (매시 17분):\n ${line}` };
280
+ }
281
+ catch (e) {
282
+ return {
283
+ ok: false,
284
+ message: `crontab 쓰기 실패: ${e.message}\n수동 등록: crontab -e 에 아래 추가:\n ${line}`,
285
+ };
286
+ }
287
+ }
288
+ export function uninstallCron() {
289
+ if (process.platform === 'win32')
290
+ return { ok: false, message: 'Windows: Task Scheduler 에서 수동 제거하세요.' };
291
+ let existing;
292
+ try {
293
+ existing = readCrontab();
294
+ }
295
+ catch (e) {
296
+ return { ok: false, message: `${e.message} (제거 중단 — 기존 crontab 보호)` };
297
+ }
298
+ if (!existing.includes(CRON_MARKER))
299
+ return { ok: true, message: 'compound sweep cron 없음 (이미 미설치).' };
300
+ try {
301
+ execFileSync('crontab', ['-'], { input: upsertCronText(existing, null), encoding: 'utf-8' });
302
+ return { ok: true, message: 'compound sweep cron 제거됨.' };
303
+ }
304
+ catch (e) {
305
+ return { ok: false, message: `crontab 쓰기 실패: ${e.message}` };
306
+ }
307
+ }
308
+ export function cronStatus() {
309
+ if (process.platform === 'win32')
310
+ return { ok: false, message: 'Windows: Task Scheduler 확인.' };
311
+ try {
312
+ return readCrontab().includes(CRON_MARKER)
313
+ ? { ok: true, message: '설치됨 (매시 17분).' }
314
+ : { ok: false, message: '미설치. `forgen compound sweep --install-cron` 로 설치.' };
315
+ }
316
+ catch (e) {
317
+ return { ok: false, message: e.message };
318
+ }
319
+ }
320
+ /** CLI: forgen compound sweep [--dry-run|--install-cron|--uninstall-cron|--cron-status] [--window-hours N] [--stale-hours M] */
321
+ export function compoundSweepCli(args) {
322
+ // cron 관리 플래그 (상호배타 — 우선 처리)
323
+ if (args.includes('--install-cron')) {
324
+ const r = installCron();
325
+ console.log(`[forgen compound sweep] ${r.message}`);
326
+ return;
327
+ }
328
+ if (args.includes('--uninstall-cron')) {
329
+ console.log(`[forgen compound sweep] ${uninstallCron().message}`);
330
+ return;
331
+ }
332
+ if (args.includes('--cron-status')) {
333
+ console.log(`[forgen compound sweep] cron: ${cronStatus().message}`);
334
+ return;
335
+ }
336
+ const dryRun = args.includes('--dry-run');
337
+ const num = (flag) => {
338
+ const i = args.indexOf(flag);
339
+ return i >= 0 && args[i + 1] ? Number(args[i + 1]) : undefined;
340
+ };
341
+ const wh = num('--window-hours');
342
+ const sh = num('--stale-hours');
343
+ const r = runCompoundSweep({
344
+ dryRun,
345
+ ...(wh ? { windowMs: wh * 3600_000 } : {}),
346
+ ...(sh ? { staleMs: sh * 3600_000 } : {}),
347
+ });
348
+ console.log(`[forgen compound sweep] scanned=${r.scanned} eligible=${r.eligible} ` +
349
+ `${dryRun ? 'dry-run' : 'swept'}=${r.swept.length}${r.swept.length ? ` (${r.swept.map((s) => s.slice(0, 8)).join(', ')})` : ''}`);
350
+ if (r.stateSaveFailed) {
351
+ console.error(' ⚠ sweep-state 저장 실패 — 다음 cron 이 동일 세션을 재-compound 할 수 있음(Haiku 재과금). ' +
352
+ '~/.forgen/state 쓰기 권한을 확인하세요.');
353
+ }
354
+ }
@@ -8,11 +8,26 @@
8
8
  */
9
9
  import type { RuntimeHost } from './types.js';
10
10
  /** 보안 규칙 (정적 — v1 GLOBAL_SAFETY_RULES와 동일 맥락) */
11
+ /**
12
+ * ADR-010 W2-3: 보안 규칙 prose → 2줄 포인터로 축약.
13
+ * 실제 강제는 훅(secret-filter, db-guard)이 결정적으로 수행하며,
14
+ * prose 는 순수 토큰 비용이었다 (native /doctor 도 "코드에서 유추 가능한
15
+ * 규칙"으로 트리밍 제안하는 부류). 차단 발생 시 확인: `forgen explain`.
16
+ */
11
17
  export declare function generateSecurityRules(): string;
12
- /** 안티패턴 감지 규칙 (정적) */
18
+ /** ADR-010 W2-3: prose 제거 — 포인터는 generateSecurityRules 로 통합됨 */
13
19
  export declare function generateAntiPatternRules(): string;
14
20
  /** compound loop + 개인 규칙 (me/rules) 로드 */
15
21
  export declare function generateCompoundRules(cwd: string): string;
22
+ /**
23
+ * ADR-010 W1-2: 캡처 사이드 공유 판정 — 첫 유효 라인이 Claude-voice 에코인가.
24
+ *
25
+ * auto-compound-runner 가 behavior 파일을 디스크에 쓰기 전에 호출한다.
26
+ * 렌더 타임 필터(위)만으로는 오염 데이터가 스토어에 계속 쌓이므로,
27
+ * 저장 자체를 차단하는 것이 우선이다. 판정은 첫 유효 라인 기준 —
28
+ * 에코는 항상 assistant 발화로 시작하고, 진짜 패턴 요약은 지시문으로 시작한다.
29
+ */
30
+ export declare function isSelfReferentialEcho(text: string): boolean;
16
31
  /**
17
32
  * Strip formatting that already exists in the source line BEFORE the
18
33
  * renderer adds its own prefix/suffix. Without this, a behavior file
@@ -108,40 +108,24 @@ function loadProjectMapSummary(cwd) {
108
108
  }
109
109
  // ── v1 Static Rules ──
110
110
  /** 보안 규칙 (정적 — v1 GLOBAL_SAFETY_RULES와 동일 맥락) */
111
+ /**
112
+ * ADR-010 W2-3: 보안 규칙 prose → 2줄 포인터로 축약.
113
+ * 실제 강제는 훅(secret-filter, db-guard)이 결정적으로 수행하며,
114
+ * prose 는 순수 토큰 비용이었다 (native /doctor 도 "코드에서 유추 가능한
115
+ * 규칙"으로 트리밍 제안하는 부류). 차단 발생 시 확인: `forgen explain`.
116
+ */
111
117
  export function generateSecurityRules() {
112
118
  return [
113
- '# Forgen — Security Rules',
119
+ '# Forgen — Security & Anti-Pattern',
114
120
  '',
115
- '## Dangerous Command Warning',
116
- '- Always confirm before executing destructive commands like `rm -rf`, `git push --force`, `DROP TABLE`',
117
- '- Double confirmation required for production environment access',
118
- '',
119
- '## Secret Key Protection',
120
- '- Do not commit sensitive information such as `.env`, `credentials.json`, API keys',
121
- '- Manage through environment variables or a secrets manager',
122
- '- Detect hardcoded secrets during code review',
121
+ '- 보안/위험명령/안티패턴 강제는 forgen 훅이 수행: secret-filter(비밀키 커밋 차단), db-guard(위험 SQL/rm -rf 확인), slop-detector(AI 슬롭 감지).',
122
+ '- 차단이 발생하면 `forgen status --blocks` 로 규칙·사유·해결책 확인.',
123
123
  '',
124
124
  ].join('\n');
125
125
  }
126
- /** 안티패턴 감지 규칙 (정적) */
126
+ /** ADR-010 W2-3: prose 제거 — 포인터는 generateSecurityRules 로 통합됨 */
127
127
  export function generateAntiPatternRules() {
128
- return [
129
- '# Forgen — Anti-Pattern Detection',
130
- '',
131
- '## Repeated Edit Warning',
132
- '- Stop immediately when editing the same file 3+ times → full structure redesign required',
133
- '- For 5+ edits, always check current state with Read before replacing with a single Write',
134
- '',
135
- '## Error Suppression Warning',
136
- '- No empty catch blocks — at minimum log or re-throw',
137
- '- Minimize suppression comments like eslint-disable, @ts-ignore',
138
- '',
139
- '## Excessive Complexity Warning',
140
- '- Consider splitting single functions exceeding 50 lines',
141
- '- Apply early return pattern when nesting depth exceeds 4',
142
- '- No unnecessary abstraction — implement only what is currently needed',
143
- '',
144
- ].join('\n');
128
+ return '';
145
129
  }
146
130
  /** compound loop + 개인 규칙 (me/rules) 로드 */
147
131
  export function generateCompoundRules(cwd) {
@@ -230,11 +214,38 @@ const SELF_REFERENTIAL_PATTERNS = Object.freeze([
230
214
  /^I['\u2019]?ll\s+(analyze|review|check|update|add|create|run|fix)/i,
231
215
  /^Let me\s+(analyze|check|look|verify|update|add)/i,
232
216
  /^I['\u2019]?ve\s+(added|updated|created|fixed|completed)/i,
217
+ // ADR-010 W1-2 (2026-07-16): \uc2e4\uc720\ucd9c 60\uac74(behavior-echoes \ubc31\uc5c5 = fixture \uc18c\uc2a4)\uc5d0\uc11c
218
+ // \ud655\uc778\ub41c \ucd94\uac00 \uc5d0\ucf54 \ud615\ud0dc. \uc804\ubd80 line-start \uc575\ucee4 \u2014 H-2 \uc624\ud0d0 \uaddc\uce59 \uc900\uc218.
219
+ // \uc774 \ubaa9\ub85d\uc740 \ubcf4\uc870 \ubc29\uc5b4\ub2e4: \uc8fc\ub825\uc740 observedCount >= 2 \uac8c\uc774\ud2b8 (\uc544\ub798 \ub80c\ub354 \ub8e8\ud504).
220
+ /^I (see|notice|understand)\b/i,
221
+ /^I['\u2019]?m ready\b/i,
222
+ /^Understood\b/i,
223
+ /^Got it\b/i,
224
+ /^[\u26a0\u2705\u274c\u{1f534}\u{1f4cb}]/u, // \u26a0 \u2705 \u274c \ud83d\udd34 \ud83d\udccb \uc120\ub450 \u2014 assistant \uc0c1\ud0dc \ub9c8\ucee4
225
+ /^(\uc774\ud574\ud588\uc2b5\ub2c8\ub2e4|\uc54c\uaca0\uc2b5\ub2c8\ub2e4|\ud655\uc778\ud588\uc2b5\ub2c8\ub2e4|\ud655\uc778\ud558\uaca0\uc2b5\ub2c8\ub2e4|\ud30c\uc545\ud588\uc2b5\ub2c8\ub2e4)/,
226
+ // "\ud604\uc7ac \uc0c1\ud669"/"\uc900\ube44 \uc644\ub8cc"\ub294 Claude-voice \ubb38\uc7a5 \uc644\uacb0\ud615\ub9cc \ub9e4\uce58 \u2014 \uc0ac\uc6a9\uc790 \uc9c0\uc2dc\ubb38
227
+ // "\ud604\uc7ac \uc0c1\ud669 \ud30c\uc545 \ud6c4 \uc791\uc5c5 \uc2dc\uc791", "\uc900\ube44 \uc644\ub8cc\ub418\uba74 \uc54c\ub824\uc8fc\uc138\uc694"\ub294 \ud1b5\uacfc\ud574\uc57c \ud55c\ub2e4 (H-2).
228
+ /^(\uc791\uc5c5 \uc0c1\ud0dc\ub97c \ud655\uc778|\uc0c1\ud669\uc744 \ud30c\uc545\ud588|\uc548\ub155\ud558\uc138\uc694)/,
229
+ /^\ud604\uc7ac \uc0c1\ud669(:|\uc774|\uc740|\uc744 (\ud30c\uc545|\ud655\uc778|\uc815\ub9ac))/,
230
+ /^\uc900\ube44 \uc644\ub8cc(\uc785\ub2c8\ub2e4|\ud588\uc2b5\ub2c8\ub2e4|[.!])/,
231
+ /^\ubc31\uadf8\ub77c\uc6b4\ub4dc .*(\uc644\ub8cc|\uc911\ub2e8)/,
233
232
  // Object.freeze is defense-in-depth: the readonly type is compile-time
234
233
  // only. Freezing prevents runtime mutation by any other module loaded
235
234
  // in the same process from silently disabling the filter by pushing
236
235
  // an over-broad pattern or emptying the array.
237
236
  ]);
237
+ /**
238
+ * ADR-010 W1-2: 캡처 사이드 공유 판정 — 첫 유효 라인이 Claude-voice 에코인가.
239
+ *
240
+ * auto-compound-runner 가 behavior 파일을 디스크에 쓰기 전에 호출한다.
241
+ * 렌더 타임 필터(위)만으로는 오염 데이터가 스토어에 계속 쌓이므로,
242
+ * 저장 자체를 차단하는 것이 우선이다. 판정은 첫 유효 라인 기준 —
243
+ * 에코는 항상 assistant 발화로 시작하고, 진짜 패턴 요약은 지시문으로 시작한다.
244
+ */
245
+ export function isSelfReferentialEcho(text) {
246
+ const firstLine = text.split('\n').map((l) => l.trim()).find((l) => l.length > 0) ?? '';
247
+ return SELF_REFERENTIAL_PATTERNS.some((re) => re.test(firstLine));
248
+ }
238
249
  /**
239
250
  * Strip formatting that already exists in the source line BEFORE the
240
251
  * renderer adds its own prefix/suffix. Without this, a behavior file
@@ -284,6 +295,14 @@ function generateBehavioralRules() {
284
295
  const countMatch = fm.match(/^observedCount:\s*(\d+)/m);
285
296
  const kind = kindMatch?.[1]?.trim().replace(/^["']|["']$/g, '') ?? '';
286
297
  const observedCount = countMatch ? parseInt(countMatch[1], 10) : 0;
298
+ // ADR-010 W1-2 주력 방어 — 1회 관찰 항목은 렌더하지 않는다 (모든 kind).
299
+ // 실측(2026-07-16): 오염된 behavior 엔트리 49/49(100%)가 observedCount=1.
300
+ // 진짜 사용자 패턴은 재관찰되어 mergeOrCreateBehavior 가 count 를 누적한다.
301
+ // 주의(정직한 한계): count 는 50% word-overlap merge 로도 오르므로, regex
302
+ // 3중 방어(캡처/렌더)를 모두 뚫는 novel echo 2건이 서로 merge 되면 이 게이트도
303
+ // 뚫린다 — 알려진 60건 형태는 캡처 사이드에서 차단되므로 잔존 리스크는 낮음.
304
+ if (observedCount < 2)
305
+ continue;
287
306
  const contentIdx = body.indexOf('## Content');
288
307
  const contentBody = contentIdx >= 0 ? body.slice(contentIdx + '## Content'.length) : body;
289
308
  const rawDesc = contentBody.split('\n').find(l => {
@@ -414,7 +433,7 @@ export function generateClaudeRules(cwd, v1RenderedRules) {
414
433
  export async function registerTmuxBindings() {
415
434
  const { execFileSync } = await import('node:child_process');
416
435
  try {
417
- execFileSync('tmux', ['bind-key', 'T', 'run-shell', 'forgen me'], { stdio: 'ignore' });
436
+ execFileSync('tmux', ['bind-key', 'T', 'run-shell', 'forgen status --profile'], { stdio: 'ignore' });
418
437
  }
419
438
  catch (e) {
420
439
  log.debug('tmux 키바인딩 등��� 실패', e);
@@ -7,6 +7,7 @@
7
7
  import * as fs from 'node:fs';
8
8
  import * as path from 'node:path';
9
9
  import { getUsageStats } from './usage-telemetry.js';
10
+ import { loadRoiDemotions, isRoiQuarantined } from '../engine/roi-demotion.js';
10
11
  import { classifySolutions } from './lifecycle-classifier.js';
11
12
  import { STATE_DIR } from './paths.js';
12
13
  import { FORGEN_HOME } from './paths.js';
@@ -22,7 +23,7 @@ const C = {
22
23
  red: isTTY ? '\x1b[31m' : '',
23
24
  };
24
25
  const BOX_WIDTH = 66;
25
- function bar(pct, width = 10) {
26
+ function _bar(pct, width = 10) {
26
27
  const filled = Math.round(pct * width);
27
28
  const empty = width - filled;
28
29
  return '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, empty));
@@ -47,13 +48,28 @@ function boxBottom() {
47
48
  }
48
49
  function collectData() {
49
50
  const now = new Date();
50
- // usage
51
+ // usage (deprecated — W2-2): 과거 축적분만 읽힘, 신규 기록 없음
51
52
  const usage = (() => {
52
53
  try {
53
- return getUsageStats();
54
+ return { ...getUsageStats(), deprecated: true };
54
55
  }
55
56
  catch {
56
- return { hour5: { claude: 0, codex: 0, total: 0 }, week: { claude: 0, codex: 0, total: 0 } };
57
+ return { hour5: { claude: 0, codex: 0, total: 0 }, week: { claude: 0, codex: 0, total: 0 }, deprecated: true };
58
+ }
59
+ })();
60
+ // ROI 강등 (W3-1): surfaced-but-ignored top-5
61
+ const roiDemoted = (() => {
62
+ try {
63
+ return Object.values(loadRoiDemotions())
64
+ .sort((a, b) => b.surfaced - a.surfaced)
65
+ .slice(0, 5)
66
+ .map(e => ({
67
+ id: e.solutionId, surfaced: e.surfaced, actedOn: e.actedOn,
68
+ windowCount: e.windowCount, quarantined: isRoiQuarantined(e),
69
+ }));
70
+ }
71
+ catch {
72
+ return [];
57
73
  }
58
74
  })();
59
75
  // today extracted: me/solutions/*.md mtime이 오늘인 것 (또는 last-extraction.json)
@@ -137,6 +153,7 @@ function collectData() {
137
153
  return {
138
154
  timestamp: now.toISOString(),
139
155
  usage,
156
+ roiDemoted,
140
157
  todayExtracted,
141
158
  solutions: {
142
159
  total: classified.length,
@@ -152,16 +169,23 @@ function renderTTY(data) {
152
169
  const lines = [];
153
170
  lines.push(boxTop());
154
171
  lines.push(boxEmpty());
155
- // Usage
172
+ // Usage — ADR-010 W2-2: recordToolCall no-op 이후 이 카운트는 영구 0 으로
173
+ // 수렴한다. 침묵 열화 대신 이관 안내를 표시 (JSON 은 deprecated 플래그).
156
174
  lines.push(boxLine(`${C.bold}Usage${C.reset}`));
157
- const h5total = data.usage.hour5.total;
158
- const h5pct = Math.min(1, h5total / Math.max(h5total + 10, 50));
159
- const h5bar = bar(h5pct);
160
- lines.push(boxLine(` 5h window: ${C.yellow}${h5bar}${C.reset} (${h5total} tool calls)`));
161
- const wktotal = data.usage.week.total;
162
- const wkpct = Math.min(1, wktotal / Math.max(wktotal + 10, 100));
163
- const wkbar = bar(wkpct);
164
- lines.push(boxLine(` weekly: ${C.yellow}${wkbar}${C.reset} (${wktotal} tool calls)`));
175
+ lines.push(boxLine(` ${C.dim}→ native /usage 로 이동 (plan limit 을 skill/subagent별 분해)${C.reset}`));
176
+ lines.push(boxEmpty());
177
+ // Surfaced-but-ignored (W3-1): 저 ROI 주입 top-5 — δ 는 injection 에서 오므로
178
+ // "노출되는데 안 쓰이는" 솔루션이 곧 낭비되는 컨텍스트다.
179
+ lines.push(boxLine(`${C.bold}Surfaced but ignored (low ROI)${C.reset}`));
180
+ if (data.roiDemoted.length === 0) {
181
+ lines.push(boxLine(` ${C.dim}none — 주입되는 솔루션이 실제로 쓰이고 있음${C.reset}`));
182
+ }
183
+ else {
184
+ for (const e of data.roiDemoted) {
185
+ const mark = e.quarantined ? `${C.red}⛔ quarantined` : `${C.yellow}↓ demoted`;
186
+ lines.push(boxLine(` ${mark}${C.reset} ${e.id} (surfaced ${e.surfaced}, acted ${e.actedOn})`));
187
+ }
188
+ }
165
189
  lines.push(boxEmpty());
166
190
  // Today compound
167
191
  lines.push(boxLine(`${C.bold}Today's compound${C.reset}`));
@@ -181,7 +205,7 @@ function renderTTY(data) {
181
205
  lines.push(boxLine(`${C.bold}Top ${data.solutions.topHot.length} hot (90d)${C.reset}`));
182
206
  for (const h of data.solutions.topHot) {
183
207
  const pctStr = `${Math.round(h.rate * 100)}%`;
184
- const idShort = h.id.length > 36 ? h.id.slice(0, 33) + '...' : h.id;
208
+ const idShort = h.id.length > 36 ? `${h.id.slice(0, 33)}...` : h.id;
185
209
  lines.push(boxLine(` · ${C.yellow}${idShort}${C.reset} surf=${h.surfaced} acted=${h.acted} (${pctStr})`));
186
210
  }
187
211
  lines.push(boxEmpty());
@@ -203,14 +227,14 @@ export async function runDashboard(opts) {
203
227
  if (opts.json) {
204
228
  // Strip classified array (verbose) from JSON output for cleaner schema
205
229
  const { solutions: { classified: _c, ...solutionStats }, ...rest } = data;
206
- process.stdout.write(JSON.stringify({ ...rest, solutions: solutionStats }, null, 2) + '\n');
230
+ process.stdout.write(`${JSON.stringify({ ...rest, solutions: solutionStats }, null, 2)}\n`);
207
231
  return;
208
232
  }
209
233
  const output = renderTTY(data);
210
234
  if (opts.watch) {
211
235
  process.stdout.write('\x1b[2J\x1b[H'); // clear screen
212
236
  }
213
- process.stdout.write(output + '\n');
237
+ process.stdout.write(`${output}\n`);
214
238
  };
215
239
  render();
216
240
  if (opts.watch) {
@@ -96,18 +96,17 @@ export declare function collectMultiHostData(): MultiHostData;
96
96
  export interface LearningCurve {
97
97
  correctionsLast7d: number;
98
98
  correctionsPrev7d: number;
99
- correctionTrend: 'improving' | 'stable' | 'worsening';
99
+ /** 교정 카운트 델타의 관찰 (인과 효과 아님 — down=count 감소일 뿐). */
100
+ correctionTrend: 'down' | 'flat' | 'up';
100
101
  evidenceTotalDays: number;
101
102
  sessionsAnalyzed: number;
102
- estimatedMinutesSaved: number;
103
103
  topCorrectionAxes: Array<{
104
104
  axis: string;
105
105
  count: number;
106
106
  }>;
107
107
  }
108
108
  /**
109
- * Learning Curve 수집.
110
- * evidence 파일(교정 기록)과 compound 활용률을 교차 분석하여 "쓸수록 나아진다"를 정량화.
109
+ * 교정·활동 요약 수집 (관찰). evidence 파일(교정 기록) 기반. 인과 효과/절약은 미주장.
111
110
  */
112
111
  export declare function collectLearningCurve(): LearningCurve;
113
112
  export declare function renderDashboard(): string;