@wooojin/forgen 0.3.2 → 0.4.1

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 (124) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +94 -0
  3. package/README.ja.md +119 -8
  4. package/README.ko.md +73 -2
  5. package/README.md +163 -9
  6. package/README.zh.md +87 -7
  7. package/dist/checks/conclusion-verification-ratio.d.ts +37 -0
  8. package/dist/checks/conclusion-verification-ratio.js +86 -0
  9. package/dist/checks/fact-vs-agreement.d.ts +47 -0
  10. package/dist/checks/fact-vs-agreement.js +92 -0
  11. package/dist/checks/self-score-deflation.d.ts +38 -0
  12. package/dist/checks/self-score-deflation.js +108 -0
  13. package/dist/cli.js +158 -6
  14. package/dist/core/auto-compound-runner.js +85 -13
  15. package/dist/core/dashboard.js +9 -2
  16. package/dist/core/doctor.js +90 -15
  17. package/dist/core/extraction-notice.d.ts +18 -0
  18. package/dist/core/extraction-notice.js +64 -0
  19. package/dist/core/init-cli.d.ts +26 -0
  20. package/dist/core/init-cli.js +104 -0
  21. package/dist/core/init.js +17 -0
  22. package/dist/core/inspect-cli.js +64 -5
  23. package/dist/core/migrate-cli.d.ts +10 -0
  24. package/dist/core/migrate-cli.js +34 -0
  25. package/dist/core/paths.d.ts +8 -1
  26. package/dist/core/paths.js +11 -2
  27. package/dist/core/recall-cli.d.ts +26 -0
  28. package/dist/core/recall-cli.js +125 -0
  29. package/dist/core/recall-reference-detector.d.ts +43 -0
  30. package/dist/core/recall-reference-detector.js +65 -0
  31. package/dist/core/state-gc.d.ts +19 -0
  32. package/dist/core/state-gc.js +48 -4
  33. package/dist/core/stats-cli.d.ts +36 -0
  34. package/dist/core/stats-cli.js +254 -0
  35. package/dist/core/uninstall.d.ts +1 -0
  36. package/dist/core/uninstall.js +25 -1
  37. package/dist/core/v1-bootstrap.js +9 -1
  38. package/dist/engine/classify-enforce-cli.d.ts +8 -0
  39. package/dist/engine/classify-enforce-cli.js +61 -0
  40. package/dist/engine/compound-cli.js +1 -0
  41. package/dist/engine/compound-export.js +8 -3
  42. package/dist/engine/enforce-classifier.d.ts +31 -0
  43. package/dist/engine/enforce-classifier.js +123 -0
  44. package/dist/engine/learn-cli.js +1 -4
  45. package/dist/engine/lifecycle/bypass-detector.d.ts +34 -0
  46. package/dist/engine/lifecycle/bypass-detector.js +82 -0
  47. package/dist/engine/lifecycle/lifecycle-cli.d.ts +7 -0
  48. package/dist/engine/lifecycle/lifecycle-cli.js +102 -0
  49. package/dist/engine/lifecycle/meta-cli.d.ts +4 -0
  50. package/dist/engine/lifecycle/meta-cli.js +7 -0
  51. package/dist/engine/lifecycle/meta-reclassifier.d.ts +78 -0
  52. package/dist/engine/lifecycle/meta-reclassifier.js +351 -0
  53. package/dist/engine/lifecycle/orchestrator.d.ts +32 -0
  54. package/dist/engine/lifecycle/orchestrator.js +131 -0
  55. package/dist/engine/lifecycle/signals.d.ts +30 -0
  56. package/dist/engine/lifecycle/signals.js +142 -0
  57. package/dist/engine/lifecycle/trigger-t1-correction.d.ts +23 -0
  58. package/dist/engine/lifecycle/trigger-t1-correction.js +78 -0
  59. package/dist/engine/lifecycle/trigger-t2-violation.d.ts +18 -0
  60. package/dist/engine/lifecycle/trigger-t2-violation.js +42 -0
  61. package/dist/engine/lifecycle/trigger-t3-bypass.d.ts +17 -0
  62. package/dist/engine/lifecycle/trigger-t3-bypass.js +39 -0
  63. package/dist/engine/lifecycle/trigger-t4-decay.d.ts +18 -0
  64. package/dist/engine/lifecycle/trigger-t4-decay.js +40 -0
  65. package/dist/engine/lifecycle/trigger-t5-conflict.d.ts +16 -0
  66. package/dist/engine/lifecycle/trigger-t5-conflict.js +78 -0
  67. package/dist/engine/lifecycle/types.d.ts +52 -0
  68. package/dist/engine/lifecycle/types.js +7 -0
  69. package/dist/engine/meta-learning/session-quality-scorer.d.ts +1 -6
  70. package/dist/engine/meta-learning/session-quality-scorer.js +2 -21
  71. package/dist/engine/rule-toggle-cli.d.ts +13 -0
  72. package/dist/engine/rule-toggle-cli.js +76 -0
  73. package/dist/engine/skill-promoter.js +3 -6
  74. package/dist/forge/evidence-processor.js +10 -2
  75. package/dist/hooks/context-guard.js +72 -1
  76. package/dist/hooks/dangerous-patterns.json +3 -3
  77. package/dist/hooks/db-guard.js +18 -2
  78. package/dist/hooks/intent-classifier.js +1 -1
  79. package/dist/hooks/keyword-detector.js +1 -1
  80. package/dist/hooks/notepad-injector.js +1 -1
  81. package/dist/hooks/permission-handler.js +1 -1
  82. package/dist/hooks/post-tool-failure.js +1 -1
  83. package/dist/hooks/post-tool-use.d.ts +6 -0
  84. package/dist/hooks/post-tool-use.js +94 -14
  85. package/dist/hooks/pre-compact.js +1 -1
  86. package/dist/hooks/pre-tool-use.d.ts +7 -0
  87. package/dist/hooks/pre-tool-use.js +79 -5
  88. package/dist/hooks/rate-limiter.js +1 -1
  89. package/dist/hooks/secret-filter.d.ts +10 -0
  90. package/dist/hooks/secret-filter.js +21 -1
  91. package/dist/hooks/session-recovery.js +1 -1
  92. package/dist/hooks/shared/atomic-write.d.ts +8 -1
  93. package/dist/hooks/shared/atomic-write.js +17 -3
  94. package/dist/hooks/shared/command-parser.d.ts +44 -0
  95. package/dist/hooks/shared/command-parser.js +50 -0
  96. package/dist/hooks/shared/hook-response.d.ts +23 -2
  97. package/dist/hooks/shared/hook-response.js +48 -3
  98. package/dist/hooks/shared/safe-regex.d.ts +25 -0
  99. package/dist/hooks/shared/safe-regex.js +50 -0
  100. package/dist/hooks/shared/stop-triggers.d.ts +19 -0
  101. package/dist/hooks/shared/stop-triggers.js +19 -0
  102. package/dist/hooks/skill-injector.js +1 -1
  103. package/dist/hooks/slop-detector.js +2 -2
  104. package/dist/hooks/solution-injector.d.ts +9 -0
  105. package/dist/hooks/solution-injector.js +48 -5
  106. package/dist/hooks/stop-guard.d.ts +84 -0
  107. package/dist/hooks/stop-guard.js +606 -0
  108. package/dist/hooks/subagent-tracker.js +1 -1
  109. package/dist/i18n/index.js +3 -5
  110. package/dist/mcp/tools.js +19 -2
  111. package/dist/store/evidence-store.d.ts +15 -0
  112. package/dist/store/evidence-store.js +61 -1
  113. package/dist/store/implicit-feedback-store.d.ts +59 -0
  114. package/dist/store/implicit-feedback-store.js +153 -0
  115. package/dist/store/rule-lifecycle.d.ts +23 -0
  116. package/dist/store/rule-lifecycle.js +63 -0
  117. package/dist/store/rule-store.d.ts +21 -0
  118. package/dist/store/rule-store.js +136 -8
  119. package/dist/store/types.d.ts +83 -0
  120. package/dist/store/types.js +7 -1
  121. package/hooks/hook-registry.json +1 -0
  122. package/hooks/hooks.json +6 -1
  123. package/package.json +11 -3
  124. package/plugin.json +1 -1
@@ -0,0 +1,606 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Forgen — Stop Guard (Mech-B prototype, spike/mech-b-a1)
4
+ *
5
+ * Stop hook: 어시스턴트 직전 응답에서 "완료 선언" 패턴을 감지하고, 연결된
6
+ * Mech-A(artifact_check) / Mech-B(self_check_prompt) 규칙을 평가하여
7
+ * 위반 시 blockStop 으로 세션을 재개시킨다.
8
+ *
9
+ * Prototype scope (spike only — NOT v0.4.0 final):
10
+ * - 규칙은 tests/spike/mech-b-inject/scenarios.json 에서 로드
11
+ * (FORGEN_SPIKE_RULES env 로 override 가능)
12
+ * - 어시스턴트 메시지는 transcript_path 에서 마지막 assistant 턴을 뽑거나
13
+ * FORGEN_SPIKE_LAST_MESSAGE env 로 주입 가능 (runner/단위테스트용)
14
+ * - artifact_check 는 `~/.forgen/state/<relative>` 경로를 기준으로 평가
15
+ *
16
+ * 설계 제약 (ADR-001, Day-1 verification):
17
+ * - self_check_prompt 질문은 **reason** 에 전체를 담는다 (모델 도달).
18
+ * - systemMessage 는 rule tag 한 줄만 (UI 표시 보조).
19
+ * - 외부 LLM API 호출 없음 (β1 유지).
20
+ */
21
+ import * as fs from 'node:fs';
22
+ import * as path from 'node:path';
23
+ import * as os from 'node:os';
24
+ import { readStdinJSON } from './shared/read-stdin.js';
25
+ import { approve, approveWithWarning, blockStop, failOpenWithTracking } from './shared/hook-response.js';
26
+ import { takeLastExtractionNotice } from '../core/extraction-notice.js';
27
+ import { checkConclusionVerificationRatio } from '../checks/conclusion-verification-ratio.js';
28
+ import { checkSelfScoreInflation } from '../checks/self-score-deflation.js';
29
+ import { STATE_DIR } from '../core/paths.js';
30
+ import { sanitizeId } from './shared/sanitize-id.js';
31
+ import { detectRecallReferences } from '../core/recall-reference-detector.js';
32
+ import { appendImplicitFeedback } from '../store/implicit-feedback-store.js';
33
+ import { atomicWriteJSON } from './shared/atomic-write.js';
34
+ import { recordHookTiming } from './shared/hook-timing.js';
35
+ import { isHookEnabled } from './hook-config.js';
36
+ import { loadActiveRules } from '../store/rule-store.js';
37
+ import { recordViolation, rotateIfBig } from '../engine/lifecycle/signals.js';
38
+ import { compileSafeRegex, safeRegexTest } from './shared/safe-regex.js';
39
+ const HOOK_NAME = 'stop-guard';
40
+ // R6-F2: shared single source of truth.
41
+ import { DEFAULT_STOP_TRIGGER_RE, DEFAULT_STOP_EXCLUDE_RE } from './shared/stop-triggers.js';
42
+ /**
43
+ * Stuck-loop guard 임계치.
44
+ * Day-3 smoke 에서 block reason 문구가 Claude 응답에 재매칭되어 6회 연속 block 된
45
+ * regression 관찰됨. 이 상한을 넘으면 force approve + drift 이벤트를 남겨
46
+ * ADR-002 Meta 트리거(규칙 자동 강등)로 연결한다.
47
+ */
48
+ const STUCK_LOOP_THRESHOLD = 3;
49
+ const BLOCK_COUNT_DIR = path.join(STATE_DIR, 'enforcement', 'block-count');
50
+ const DRIFT_LOG = path.join(STATE_DIR, 'enforcement', 'drift.jsonl');
51
+ const ACK_LOG = path.join(STATE_DIR, 'enforcement', 'acknowledgments.jsonl');
52
+ /**
53
+ * Spike scenarios.json 로더 — FORGEN_SPIKE_RULES 명시 시에만 로드.
54
+ * H1 (2026-04-22): 이전에는 process.cwd()/tests/spike/... 를 기본 폴백했으나,
55
+ * 사용자가 forgen 저장소 안에서 작업 중이면 테스트 픽스처가 프로덕션 hook 으로
56
+ * 활성되는 부작용이 있었음. 이제 env 명시 opt-in.
57
+ */
58
+ function loadSpikeRules() {
59
+ const rulesPath = process.env.FORGEN_SPIKE_RULES;
60
+ if (!rulesPath)
61
+ return [];
62
+ try {
63
+ const raw = fs.readFileSync(rulesPath, 'utf-8');
64
+ const parsed = JSON.parse(raw);
65
+ return (parsed.rules ?? []).filter((r) => r.hook === 'Stop');
66
+ }
67
+ catch {
68
+ return [];
69
+ }
70
+ }
71
+ /**
72
+ * 프로덕션 rule-store 로더.
73
+ * ~/.forgen/me/rules 의 Rule 중 `enforce_via` 에 `hook: 'Stop'` 이 있는 것만
74
+ * SpikeRule 내부 shape 로 변환해 반환한다.
75
+ *
76
+ * 변환 규칙:
77
+ * - `trigger_keywords_regex` 미지정 → DEFAULT_STOP_TRIGGER_RE (shared)
78
+ * - `trigger_exclude_regex` 미지정 → DEFAULT_STOP_EXCLUDE_RE (shared)
79
+ * - verifier.kind 는 `self_check_prompt` 또는 `artifact_check` 지원
80
+ * - 그 외 verifier 는 skip (PreToolUse 전용 tool_arg_regex 등)
81
+ */
82
+ export function rulesFromStore(rules) {
83
+ const out = [];
84
+ for (const rule of rules) {
85
+ const specs = rule.enforce_via ?? [];
86
+ for (let i = 0; i < specs.length; i++) {
87
+ const spec = specs[i];
88
+ if (spec.hook !== 'Stop')
89
+ continue;
90
+ if (!spec.verifier)
91
+ continue;
92
+ if (spec.verifier.kind !== 'self_check_prompt' && spec.verifier.kind !== 'artifact_check')
93
+ continue;
94
+ out.push({
95
+ id: rule.rule_id,
96
+ mech: spec.mech,
97
+ hook: 'Stop',
98
+ trigger: {
99
+ response_keywords_regex: spec.trigger_keywords_regex ?? DEFAULT_STOP_TRIGGER_RE,
100
+ context_exclude_regex: spec.trigger_exclude_regex ?? DEFAULT_STOP_EXCLUDE_RE,
101
+ },
102
+ verifier: {
103
+ kind: spec.verifier.kind,
104
+ params: spec.verifier.params,
105
+ },
106
+ block_message: spec.block_message,
107
+ system_tag: spec.system_tag,
108
+ });
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+ /** 전체 로더 — rule-store 우선, 비어 있으면 spike fallback. */
114
+ function loadStopRules() {
115
+ try {
116
+ const storeRules = rulesFromStore(loadActiveRules());
117
+ if (storeRules.length > 0)
118
+ return storeRules;
119
+ }
120
+ catch {
121
+ // fail-open: rule-store 로드 실패는 spike fallback 으로 자동 전이
122
+ }
123
+ return loadSpikeRules();
124
+ }
125
+ /** Stop hook input 에서 마지막 assistant 턴 텍스트를 반환. 실패 시 null. */
126
+ function readLastAssistantMessage(input) {
127
+ // Test/runner 주입 경로 (최우선)
128
+ const injected = process.env.FORGEN_SPIKE_LAST_MESSAGE;
129
+ if (injected)
130
+ return injected;
131
+ // Claude Code 공식 필드 — Stop hook 이 직접 제공 (A1 spike Day-3 확인)
132
+ if (input && typeof input.last_assistant_message === 'string' && input.last_assistant_message) {
133
+ return input.last_assistant_message;
134
+ }
135
+ const transcriptPath = input?.transcript_path;
136
+ if (!transcriptPath)
137
+ return null;
138
+ try {
139
+ const lines = fs.readFileSync(transcriptPath, 'utf-8').trim().split('\n');
140
+ // 최신부터 역순으로 assistant 턴 탐색 (JSONL 형식)
141
+ for (let i = lines.length - 1; i >= 0; i--) {
142
+ const line = lines[i].trim();
143
+ if (!line)
144
+ continue;
145
+ try {
146
+ const entry = JSON.parse(line);
147
+ if (entry.role !== 'assistant')
148
+ continue;
149
+ if (typeof entry.content === 'string')
150
+ return entry.content;
151
+ if (Array.isArray(entry.content)) {
152
+ const parts = entry.content
153
+ .map((p) => {
154
+ if (typeof p === 'string')
155
+ return p;
156
+ if (p && typeof p === 'object' && 'text' in p)
157
+ return String(p.text);
158
+ return '';
159
+ })
160
+ .filter(Boolean);
161
+ if (parts.length)
162
+ return parts.join('\n');
163
+ }
164
+ }
165
+ catch {
166
+ // skip malformed line
167
+ }
168
+ }
169
+ return null;
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ }
175
+ function messageTriggersRule(message, rule) {
176
+ const t = rule.trigger;
177
+ if (!t.response_keywords_regex)
178
+ return false;
179
+ const includeRes = compileSafeRegex(t.response_keywords_regex, 'i');
180
+ if (!includeRes.regex)
181
+ return false;
182
+ if (!safeRegexTest(includeRes.regex, message))
183
+ return false;
184
+ if (t.context_exclude_regex) {
185
+ const excludeRes = compileSafeRegex(t.context_exclude_regex, 'i');
186
+ if (excludeRes.regex && safeRegexTest(excludeRes.regex, message))
187
+ return false;
188
+ }
189
+ return true;
190
+ }
191
+ function evaluateVerifier(rule) {
192
+ const v = rule.verifier;
193
+ if (v.kind === 'self_check_prompt') {
194
+ const q = String(v.params.question ?? rule.block_message ?? '자가점검 필요');
195
+ // self_check_prompt 는 증거가 없으면(artifact path 미지정/미존재) 위반 간주.
196
+ const evidencePath = v.params.evidence_path;
197
+ if (typeof evidencePath === 'string') {
198
+ const maxAge = Number(v.params.max_age_s ?? 0);
199
+ const ok = artifactFresh(String(evidencePath), maxAge);
200
+ if (ok)
201
+ return { violated: false, reason: '' };
202
+ }
203
+ return { violated: true, reason: q };
204
+ }
205
+ if (v.kind === 'artifact_check') {
206
+ const p = String(v.params.path ?? '');
207
+ const maxAge = Number(v.params.max_age_s ?? 0);
208
+ return artifactFresh(p, maxAge)
209
+ ? { violated: false, reason: '' }
210
+ : { violated: true, reason: rule.block_message ?? `증거 파일(${p})이 최근 ${maxAge}s 내 갱신되지 않음` };
211
+ }
212
+ // tool_arg_regex 는 PreToolUse 전용 → Stop 에서는 no-op
213
+ return { violated: false, reason: '' };
214
+ }
215
+ /**
216
+ * artifact 경로 해석 + 최근 갱신 확인.
217
+ *
218
+ * H9 (2026-04-22): rule JSON 의 verifier.params.path 를 임의 절대 경로로 지정해
219
+ * /etc/shadow 존재/mtime 을 탐지하는 path-traversal reconnaissance 를 막기 위해
220
+ * 허용 루트 (`~/.forgen/state/` 와 project `.forgen/state/`) 안으로 containment.
221
+ * 루트 밖 경로는 존재 여부와 무관하게 false 반환.
222
+ */
223
+ function artifactFresh(relOrAbs, maxAgeS) {
224
+ const homeBase = STATE_DIR;
225
+ const projectBase = path.resolve(process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd(), '.forgen', 'state');
226
+ const allowedRoots = [homeBase, projectBase];
227
+ let p = relOrAbs;
228
+ if (relOrAbs.startsWith('.forgen/state/')) {
229
+ p = path.join(os.homedir(), relOrAbs);
230
+ }
231
+ else if (!path.isAbsolute(relOrAbs)) {
232
+ p = path.join(homeBase, relOrAbs);
233
+ }
234
+ const resolved = path.resolve(p);
235
+ const inside = allowedRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep));
236
+ if (!inside)
237
+ return false; // containment violation → 존재 확인 자체를 거부
238
+ // R4-B4: symlink 탈출 방어 — path.resolve 만으로는 symlink 를 해소하지 않으므로
239
+ // ~/.forgen/state/probe → /etc/shadow 심볼릭 링크로 bounded 영역 밖 파일의 존재/mtime 을
240
+ // 탐지하는 reconnaissance 가 가능했다. realpathSync 로 실경로 해소 후 재검사.
241
+ // allowed root 자체도 realpath 화 (macOS /tmp → /private/tmp 같은 플랫폼 symlink 대응).
242
+ let realPath;
243
+ let realRoots;
244
+ try {
245
+ realPath = fs.realpathSync(resolved);
246
+ realRoots = allowedRoots.map((r) => {
247
+ try {
248
+ return fs.realpathSync(r);
249
+ }
250
+ catch {
251
+ return r;
252
+ }
253
+ });
254
+ }
255
+ catch {
256
+ return false; // 존재 안 함 → not fresh
257
+ }
258
+ const realInside = realRoots.some((root) => realPath === root || realPath.startsWith(root + path.sep));
259
+ if (!realInside)
260
+ return false; // symlink 가 루트 밖을 가리킴 → reject
261
+ try {
262
+ const st = fs.lstatSync(realPath);
263
+ if (maxAgeS <= 0)
264
+ return true;
265
+ const ageMs = Date.now() - st.mtimeMs;
266
+ return ageMs <= maxAgeS * 1000;
267
+ }
268
+ catch {
269
+ return false;
270
+ }
271
+ }
272
+ /** Pure core — 단위 테스트용. stdin/IO 없음. */
273
+ export function evaluateStop(lastAssistantMessage, rules) {
274
+ for (const rule of rules) {
275
+ if (rule.hook !== 'Stop')
276
+ continue;
277
+ if (!messageTriggersRule(lastAssistantMessage, rule))
278
+ continue;
279
+ const result = evaluateVerifier(rule);
280
+ if (result.violated) {
281
+ return { action: 'block', hit: rule, reason: result.reason };
282
+ }
283
+ }
284
+ return { action: 'approve', hit: null };
285
+ }
286
+ function blockCounterPath(sessionId, ruleId) {
287
+ // 파일명 안전화 — 경로 인젝션 방지
288
+ const safeSession = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80);
289
+ const safeRule = String(ruleId).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 40);
290
+ return path.join(BLOCK_COUNT_DIR, `${safeSession}__${safeRule}.json`);
291
+ }
292
+ /**
293
+ * 같은 (session, rule) 조합의 연속 block 카운트. approve 가 일어나면 0 으로 초기화.
294
+ * export for tests. 부수효과: 디렉토리 생성 + 파일 쓰기.
295
+ */
296
+ export function incrementBlockCount(sessionId, ruleId) {
297
+ try {
298
+ fs.mkdirSync(BLOCK_COUNT_DIR, { recursive: true });
299
+ const p = blockCounterPath(sessionId, ruleId);
300
+ let state;
301
+ try {
302
+ const raw = fs.readFileSync(p, 'utf-8');
303
+ state = JSON.parse(raw);
304
+ if (state.sessionId !== sessionId || state.ruleId !== ruleId) {
305
+ state = { sessionId, ruleId, count: 0, firstBlockAt: new Date().toISOString(), lastBlockAt: new Date().toISOString() };
306
+ }
307
+ }
308
+ catch {
309
+ state = { sessionId, ruleId, count: 0, firstBlockAt: new Date().toISOString(), lastBlockAt: new Date().toISOString() };
310
+ }
311
+ state.count += 1;
312
+ state.lastBlockAt = new Date().toISOString();
313
+ fs.writeFileSync(p, JSON.stringify(state));
314
+ return state.count;
315
+ }
316
+ catch {
317
+ return 1; // fail-open: 카운트 실패는 block 자체를 막지 않음
318
+ }
319
+ }
320
+ export function resetBlockCount(sessionId, ruleId) {
321
+ try {
322
+ const p = blockCounterPath(sessionId, ruleId);
323
+ fs.unlinkSync(p);
324
+ }
325
+ catch {
326
+ // already gone
327
+ }
328
+ }
329
+ /**
330
+ * R9-PA2: approve 시점에 같은 session 의 pending block 을 찾아 ack 이벤트로 기록.
331
+ * Mech-B 의 핵심 가치(block → retract → pass)가 실제 작동했음을 관측 가능하게 한다.
332
+ * Best-effort: 실패해도 approve 자체는 영향받지 않는다.
333
+ *
334
+ * 기록 후 block-count 파일은 cleanup — 같은 session 의 같은 rule 이 다시 block 되면
335
+ * 새로운 카운트로 시작 (block-count 의미 보존).
336
+ */
337
+ export function acknowledgeSessionBlocks(sessionId) {
338
+ if (!sessionId || sessionId === 'unknown')
339
+ return 0;
340
+ let acked = 0;
341
+ try {
342
+ if (!fs.existsSync(BLOCK_COUNT_DIR))
343
+ return 0;
344
+ const safeSession = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80);
345
+ const prefix = `${safeSession}__`;
346
+ const now = new Date().toISOString();
347
+ for (const file of fs.readdirSync(BLOCK_COUNT_DIR)) {
348
+ if (!file.startsWith(prefix) || !file.endsWith('.json'))
349
+ continue;
350
+ const full = path.join(BLOCK_COUNT_DIR, file);
351
+ let state = null;
352
+ try {
353
+ state = JSON.parse(fs.readFileSync(full, 'utf-8'));
354
+ }
355
+ catch {
356
+ // partial-write / malformed — 다음 scan 에서 다시 시도. 삭제하지 않음.
357
+ continue;
358
+ }
359
+ if (!state || state.sessionId !== sessionId) {
360
+ // session prefix 매칭 됐는데 내부 sessionId 가 다름 → 안전하게 cleanup.
361
+ try {
362
+ fs.unlinkSync(full);
363
+ }
364
+ catch { /* ignore */ }
365
+ continue;
366
+ }
367
+ try {
368
+ fs.mkdirSync(path.dirname(ACK_LOG), { recursive: true });
369
+ rotateIfBig(ACK_LOG);
370
+ fs.appendFileSync(ACK_LOG, JSON.stringify({
371
+ at: now,
372
+ session_id: state.sessionId,
373
+ rule_id: state.ruleId,
374
+ block_count: state.count,
375
+ first_block_at: state.firstBlockAt,
376
+ last_block_at: state.lastBlockAt,
377
+ }) + '\n');
378
+ acked += 1;
379
+ }
380
+ catch { /* append failure: still try cleanup */ }
381
+ try {
382
+ fs.unlinkSync(full);
383
+ }
384
+ catch { /* ignore */ }
385
+ }
386
+ }
387
+ catch {
388
+ // fail-open — telemetry must never block approve
389
+ }
390
+ return acked;
391
+ }
392
+ export function logDriftEvent(event) {
393
+ try {
394
+ fs.mkdirSync(path.dirname(DRIFT_LOG), { recursive: true });
395
+ rotateIfBig(DRIFT_LOG);
396
+ fs.appendFileSync(DRIFT_LOG, JSON.stringify({ at: new Date().toISOString(), ...event }) + '\n');
397
+ }
398
+ catch {
399
+ // best-effort
400
+ }
401
+ }
402
+ export function getStuckLoopThreshold() {
403
+ const env = Number(process.env.FORGEN_STUCK_LOOP_THRESHOLD);
404
+ if (Number.isFinite(env) && env > 0)
405
+ return env;
406
+ return STUCK_LOOP_THRESHOLD;
407
+ }
408
+ /**
409
+ * H4 완결 (2026-04-24): recall_referenced emit 경로.
410
+ * Stop hook 에서 Claude 의 직전 응답 텍스트와 이 세션의 injection-cache 를 대조해,
411
+ * 주입된 솔루션 이름이 응답에 등장하면 `recall_referenced` 이벤트 기록. 동일
412
+ * 솔루션 중복 emit 방지용으로 injection-cache 의 해당 엔트리에 `_referenced: true`
413
+ * 플래그 세팅 후 atomic 재기록. fail-open — 어떤 단계든 throw 시 스킵.
414
+ */
415
+ function emitRecallReferencesFailOpen(sessionId, lastMessage) {
416
+ try {
417
+ const cachePath = path.join(STATE_DIR, `injection-cache-${sanitizeId(sessionId)}.json`);
418
+ if (!fs.existsSync(cachePath))
419
+ return;
420
+ const raw = fs.readFileSync(cachePath, 'utf-8');
421
+ const cache = JSON.parse(raw);
422
+ const sols = Array.isArray(cache.solutions) ? cache.solutions : [];
423
+ if (sols.length === 0)
424
+ return;
425
+ const { newlyReferenced } = detectRecallReferences(lastMessage, sols);
426
+ if (newlyReferenced.length === 0)
427
+ return;
428
+ const now = new Date().toISOString();
429
+ for (const name of newlyReferenced) {
430
+ appendImplicitFeedback({
431
+ type: 'recall_referenced',
432
+ category: 'positive',
433
+ solution: name,
434
+ at: now,
435
+ sessionId,
436
+ });
437
+ }
438
+ // 중복 emit 방지 — cache 에 플래그 저장 후 재기록
439
+ const refSet = new Set(newlyReferenced);
440
+ const updated = {
441
+ ...cache,
442
+ solutions: sols.map((s) => (refSet.has(s.name) ? { ...s, _referenced: true } : s)),
443
+ updatedAt: now,
444
+ };
445
+ atomicWriteJSON(cachePath, updated, { mode: 0o600, dirMode: 0o700 });
446
+ }
447
+ catch {
448
+ /* fail-open */
449
+ }
450
+ }
451
+ /**
452
+ * TEST-2 support: post-tool-use 가 저장한 modified-files-{sessionId}.json 에서
453
+ * recentToolNames 윈도우를 로드. 파일이 없거나 깨져도 빈 배열로 fail-open.
454
+ */
455
+ function loadRecentToolNames(sessionId) {
456
+ try {
457
+ const p = path.join(STATE_DIR, `modified-files-${sanitizeId(sessionId)}.json`);
458
+ if (!fs.existsSync(p))
459
+ return [];
460
+ const raw = fs.readFileSync(p, 'utf-8');
461
+ const data = JSON.parse(raw);
462
+ if (Array.isArray(data.recentToolNames)) {
463
+ return data.recentToolNames.filter((n) => typeof n === 'string');
464
+ }
465
+ return [];
466
+ }
467
+ catch {
468
+ return [];
469
+ }
470
+ }
471
+ /**
472
+ * H2: Stop hook approve 시 이전 세션의 auto-compound 추출 결과를 1회만 surface.
473
+ * takeLastExtractionNotice 가 null 이면 일반 approve, 아니면 systemMessage 포함.
474
+ */
475
+ function approveWithOptionalExtractionNotice() {
476
+ const notice = takeLastExtractionNotice();
477
+ if (notice)
478
+ return approveWithWarning(notice);
479
+ return approve();
480
+ }
481
+ export async function main() {
482
+ const started = Date.now();
483
+ try {
484
+ if (!isHookEnabled(HOOK_NAME)) {
485
+ console.log(approveWithOptionalExtractionNotice());
486
+ return;
487
+ }
488
+ const input = await readStdinJSON();
489
+ const lastMessage = readLastAssistantMessage(input);
490
+ if (!lastMessage) {
491
+ console.log(approveWithOptionalExtractionNotice());
492
+ return;
493
+ }
494
+ // H4 완결: 응답 텍스트와 injection-cache 대조해 recall_referenced emit.
495
+ // block/approve 어느 경로이든 동일하게 기록 (참조는 응답 내용이 결정).
496
+ const sessionIdForRef = input?.session_id ?? 'unknown';
497
+ emitRecallReferencesFailOpen(sessionIdForRef, lastMessage);
498
+ // TEST-2/3: rule-free meta guards — FORGEN_USER_CONFIRMED=1 우회 공통.
499
+ if (process.env.FORGEN_USER_CONFIRMED !== '1') {
500
+ const sessionId = input?.session_id ?? 'unknown';
501
+ // TEST-2 (자가 점수 인플레이션): 숫자 점수 상승 선언 + 측정 도구 0회 → block.
502
+ // TEST-3 보다 강한 신호라 먼저 평가.
503
+ const recentTools = loadRecentToolNames(sessionId);
504
+ const score = checkSelfScoreInflation({ text: lastMessage, recentTools });
505
+ if (score.block) {
506
+ recordViolation({
507
+ rule_id: 'builtin:self-score-inflation',
508
+ session_id: sessionId,
509
+ source: 'stop-guard',
510
+ kind: 'block',
511
+ message_preview: lastMessage.slice(0, 120),
512
+ });
513
+ const reasonText = `[forgen:stop-guard/self-score-inflation] ${score.reason}
514
+
515
+ (Override this turn: set FORGEN_USER_CONFIRMED=1 (audited).)`;
516
+ console.log(blockStop(reasonText, 'rule:TEST-2 — self-score inflation'));
517
+ return;
518
+ }
519
+ // TEST-3: 결론/검증 비율 — Claude 가 실제 측정 도구는 돌렸지만 서술이
520
+ // 결론-편향이면 여전히 block.
521
+ const ratio = checkConclusionVerificationRatio({ text: lastMessage });
522
+ if (ratio.block) {
523
+ recordViolation({
524
+ rule_id: 'builtin:conclusion-verification-ratio',
525
+ session_id: sessionId,
526
+ source: 'stop-guard',
527
+ kind: 'block',
528
+ message_preview: lastMessage.slice(0, 120),
529
+ });
530
+ const reasonText = `[forgen:stop-guard/conclusion-ratio] ${ratio.reason}
531
+
532
+ (Override this turn: set FORGEN_USER_CONFIRMED=1 (audited).)`;
533
+ console.log(blockStop(reasonText, 'rule:TEST-3 — conclusion/verification ratio'));
534
+ return;
535
+ }
536
+ }
537
+ const rules = loadStopRules();
538
+ if (rules.length === 0) {
539
+ console.log(approveWithOptionalExtractionNotice());
540
+ return;
541
+ }
542
+ const result = evaluateStop(lastMessage, rules);
543
+ const sessionId = input?.session_id ?? 'unknown';
544
+ if (result.action === 'approve') {
545
+ // R9-PA2: 같은 session 에 pending block 이 있었다면 retract→pass 루프가
546
+ // 실제 작동한 것 — acknowledgment 이벤트로 기록. block-count 는 cleanup.
547
+ acknowledgeSessionBlocks(sessionId);
548
+ console.log(approveWithOptionalExtractionNotice());
549
+ return;
550
+ }
551
+ const { hit, reason } = result;
552
+ // R7-U1: FORGEN_USER_CONFIRMED=1 으로 사용자가 명시적 우회 → audit 기록 후 approve.
553
+ // pre-tool-use 와 동일한 탈출 경로 일관성 확보.
554
+ if (process.env.FORGEN_USER_CONFIRMED === '1') {
555
+ recordViolation({
556
+ rule_id: hit.id, session_id: sessionId, source: 'stop-guard',
557
+ kind: 'correction',
558
+ message_preview: `[FORGEN_USER_CONFIRMED=1 bypass] ${lastMessage.slice(0, 100)}`,
559
+ });
560
+ console.log(approveWithOptionalExtractionNotice());
561
+ return;
562
+ }
563
+ // T2 signal: block 은 rule 위반 증거 — violations.jsonl 에 기록.
564
+ // (stuck-loop force approve 는 아래에서 처리되므로 실제 block 시에만 기록)
565
+ recordViolation({
566
+ rule_id: hit.id,
567
+ session_id: sessionId,
568
+ source: 'stop-guard',
569
+ kind: 'block',
570
+ message_preview: lastMessage.slice(0, 120),
571
+ });
572
+ // G8 + R4-UX1 + R7-U1/U2: 브랜드 prefix + 사람-읽기 동사 기반 override 힌트.
573
+ // pre-tool-use 와 일관된 FORGEN_USER_CONFIRMED=1 탈출구 + 영구 비활성화 CLI 노출.
574
+ const reasonWithHint = `[forgen:stop-guard/${hit.id.slice(0, 8)}] ${reason}
575
+
576
+ (Override this turn: set FORGEN_USER_CONFIRMED=1 (audited). Disable rule permanently: \`forgen suppress-rule ${hit.id}\`. See recent blocks: \`forgen last-block\`.)`;
577
+ const count = incrementBlockCount(sessionId, hit.id);
578
+ const threshold = getStuckLoopThreshold();
579
+ if (count > threshold) {
580
+ // Stuck-loop: force approve 하고 drift 기록. Claude 가 block reason 문구에
581
+ // 말려들어가는 경우를 끊는다. ADR-002 Meta 트리거 (rule 자동 강등) 에 연결.
582
+ logDriftEvent({
583
+ kind: 'stuck_loop_force_approve',
584
+ session_id: sessionId,
585
+ rule_id: hit.id,
586
+ count,
587
+ reason_preview: reason.slice(0, 120),
588
+ message_preview: lastMessage.slice(0, 120),
589
+ });
590
+ resetBlockCount(sessionId, hit.id);
591
+ console.log(approveWithOptionalExtractionNotice());
592
+ return;
593
+ }
594
+ console.log(blockStop(reasonWithHint, hit.system_tag));
595
+ }
596
+ catch (e) {
597
+ console.log(failOpenWithTracking(HOOK_NAME, e));
598
+ }
599
+ finally {
600
+ recordHookTiming(HOOK_NAME, Date.now() - started, 'Stop');
601
+ }
602
+ }
603
+ const isMain = import.meta.url === `file://${process.argv[1]}`;
604
+ if (isMain) {
605
+ void main();
606
+ }
@@ -86,5 +86,5 @@ async function main() {
86
86
  }
87
87
  main().catch((e) => {
88
88
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
89
- console.log(failOpenWithTracking('subagent-tracker'));
89
+ console.log(failOpenWithTracking('subagent-tracker', e));
90
90
  });
@@ -5,8 +5,7 @@
5
5
  * 사용자 대면 출력만 로케일에 따라 전환.
6
6
  */
7
7
  import * as fs from 'node:fs';
8
- import * as path from 'node:path';
9
- import * as os from 'node:os';
8
+ import { GLOBAL_CONFIG } from '../core/paths.js';
10
9
  // ── Pack Display Names ──
11
10
  const QUALITY_NAMES = {
12
11
  ko: { '보수형': '보수형', '균형형': '균형형', '속도형': '속도형' },
@@ -215,10 +214,9 @@ export function getLocale() { return _currentLocale; }
215
214
  /** GlobalConfig에서 locale을 읽어 설정. 없으면 'en' 기본값. */
216
215
  export function initLocaleFromConfig() {
217
216
  try {
218
- const configPath = path.join(os.homedir(), '.forgen', 'config.json');
219
- if (!fs.existsSync(configPath))
217
+ if (!fs.existsSync(GLOBAL_CONFIG))
220
218
  return;
221
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
219
+ const config = JSON.parse(fs.readFileSync(GLOBAL_CONFIG, 'utf-8'));
222
220
  if (config.locale === 'ko' || config.locale === 'en') {
223
221
  _currentLocale = config.locale;
224
222
  }
package/dist/mcp/tools.js CHANGED
@@ -251,6 +251,11 @@ export function registerTools(server) {
251
251
  'Call this when the user explicitly corrects your behavior (e.g., "don\'t do X", "always do Y", "fix this now").',
252
252
  'This creates an Evidence record and optionally a temporary session Rule.',
253
253
  '',
254
+ 'IMPORTANT (R7-U4): After calling this tool, you MUST include the returned',
255
+ 'confirmation line ("✓ [forgen] correction recorded (...) — kind: ...") verbatim',
256
+ 'in your response to the user. Do not paraphrase or omit. The user needs this',
257
+ 'visual confirmation to know their correction was captured.',
258
+ '',
254
259
  'kind values:',
255
260
  ' fix-now — immediate fix needed, creates a session-scoped temporary rule',
256
261
  ' prefer-from-now — long-term preference, records evidence for future promotion',
@@ -284,11 +289,23 @@ export function registerTools(server) {
284
289
  attributeCorrection(effectiveSessionId);
285
290
  }
286
291
  catch { /* ignore */ }
292
+ // R4-UX1: 사용자 가시 confirm — Claude 가 이 응답을 사용자에게 보여주도록 강제
293
+ // 하기 위해 맨 앞에 user-visible marker 를 둔다. ADR-001/002 는 조용한 기록을
294
+ // 원칙으로 하나, 사용자가 "내 교정이 기록됐나?" 불안을 해소하는 피드백 루프는 필수.
295
+ const userVisibleConfirm = `✓ [forgen] correction recorded`;
296
+ const axis = axis_hint ? ` (axis: ${axis_hint})` : '';
297
+ const kindLabel = { 'fix-now': '즉시 수정', 'prefer-from-now': '장기 선호', 'avoid-this': '회피' }[kind] ?? kind;
287
298
  const lines = [
288
- `Evidence recorded: ${result.evidence_event_id}`,
299
+ `${userVisibleConfirm}${axis} — kind: ${kindLabel}`,
300
+ `Evidence: ${result.evidence_event_id}`,
289
301
  ];
290
302
  if (result.temporary_rule) {
291
- lines.push(`Temporary rule created: "${result.temporary_rule.policy}" (${result.temporary_rule.strength}, scope: ${result.temporary_rule.scope})`);
303
+ lines.push(`Temporary rule: "${result.temporary_rule.policy}" (${result.temporary_rule.strength}, scope: ${result.temporary_rule.scope})`);
304
+ const enforceViaCount = result.temporary_rule.enforce_via?.length ?? 0;
305
+ if (enforceViaCount > 0) {
306
+ const mechs = result.temporary_rule.enforce_via?.map((s) => `${s.mech}@${s.hook}`).join(', ') ?? '';
307
+ lines.push(`enforce_via (auto-classified): [${mechs}]`);
308
+ }
292
309
  }
293
310
  if (result.recompose_required) {
294
311
  lines.push('Session recomposition recommended — the temporary rule should be applied to current session behavior.');