@wooojin/forgen 0.3.2 → 0.4.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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +64 -0
- package/README.ja.md +61 -7
- package/README.ko.md +15 -1
- package/README.md +92 -6
- package/README.zh.md +61 -7
- package/dist/cli.js +137 -5
- package/dist/core/auto-compound-runner.js +10 -2
- package/dist/core/doctor.js +64 -10
- package/dist/core/inspect-cli.js +65 -5
- package/dist/core/state-gc.d.ts +19 -0
- package/dist/core/state-gc.js +48 -4
- package/dist/core/stats-cli.d.ts +15 -0
- package/dist/core/stats-cli.js +143 -0
- package/dist/core/uninstall.d.ts +1 -0
- package/dist/core/uninstall.js +24 -1
- package/dist/core/v1-bootstrap.js +9 -1
- package/dist/engine/classify-enforce-cli.d.ts +8 -0
- package/dist/engine/classify-enforce-cli.js +61 -0
- package/dist/engine/enforce-classifier.d.ts +31 -0
- package/dist/engine/enforce-classifier.js +123 -0
- package/dist/engine/lifecycle/bypass-detector.d.ts +34 -0
- package/dist/engine/lifecycle/bypass-detector.js +82 -0
- package/dist/engine/lifecycle/lifecycle-cli.d.ts +7 -0
- package/dist/engine/lifecycle/lifecycle-cli.js +102 -0
- package/dist/engine/lifecycle/meta-cli.d.ts +4 -0
- package/dist/engine/lifecycle/meta-cli.js +7 -0
- package/dist/engine/lifecycle/meta-reclassifier.d.ts +78 -0
- package/dist/engine/lifecycle/meta-reclassifier.js +351 -0
- package/dist/engine/lifecycle/orchestrator.d.ts +32 -0
- package/dist/engine/lifecycle/orchestrator.js +131 -0
- package/dist/engine/lifecycle/signals.d.ts +30 -0
- package/dist/engine/lifecycle/signals.js +142 -0
- package/dist/engine/lifecycle/trigger-t1-correction.d.ts +23 -0
- package/dist/engine/lifecycle/trigger-t1-correction.js +78 -0
- package/dist/engine/lifecycle/trigger-t2-violation.d.ts +18 -0
- package/dist/engine/lifecycle/trigger-t2-violation.js +42 -0
- package/dist/engine/lifecycle/trigger-t3-bypass.d.ts +17 -0
- package/dist/engine/lifecycle/trigger-t3-bypass.js +39 -0
- package/dist/engine/lifecycle/trigger-t4-decay.d.ts +18 -0
- package/dist/engine/lifecycle/trigger-t4-decay.js +40 -0
- package/dist/engine/lifecycle/trigger-t5-conflict.d.ts +16 -0
- package/dist/engine/lifecycle/trigger-t5-conflict.js +78 -0
- package/dist/engine/lifecycle/types.d.ts +52 -0
- package/dist/engine/lifecycle/types.js +7 -0
- package/dist/engine/rule-toggle-cli.d.ts +13 -0
- package/dist/engine/rule-toggle-cli.js +76 -0
- package/dist/forge/evidence-processor.js +10 -2
- package/dist/hooks/context-guard.js +71 -0
- package/dist/hooks/post-tool-use.js +62 -0
- package/dist/hooks/pre-tool-use.js +57 -1
- package/dist/hooks/secret-filter.d.ts +10 -0
- package/dist/hooks/secret-filter.js +20 -0
- package/dist/hooks/shared/atomic-write.d.ts +8 -1
- package/dist/hooks/shared/atomic-write.js +17 -3
- package/dist/hooks/shared/hook-response.d.ts +11 -0
- package/dist/hooks/shared/hook-response.js +18 -0
- package/dist/hooks/shared/safe-regex.d.ts +25 -0
- package/dist/hooks/shared/safe-regex.js +50 -0
- package/dist/hooks/shared/stop-triggers.d.ts +19 -0
- package/dist/hooks/shared/stop-triggers.js +19 -0
- package/dist/hooks/stop-guard.d.ts +84 -0
- package/dist/hooks/stop-guard.js +482 -0
- package/dist/mcp/tools.js +19 -2
- package/dist/store/evidence-store.d.ts +15 -0
- package/dist/store/evidence-store.js +50 -1
- package/dist/store/rule-lifecycle.d.ts +23 -0
- package/dist/store/rule-lifecycle.js +63 -0
- package/dist/store/rule-store.d.ts +21 -0
- package/dist/store/rule-store.js +128 -8
- package/dist/store/types.d.ts +83 -0
- package/dist/store/types.js +7 -1
- package/hooks/hook-registry.json +1 -0
- package/hooks/hooks.json +6 -1
- package/package.json +10 -2
- package/plugin.json +1 -1
|
@@ -0,0 +1,482 @@
|
|
|
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, blockStop, failOpenWithTracking } from './shared/hook-response.js';
|
|
26
|
+
import { recordHookTiming } from './shared/hook-timing.js';
|
|
27
|
+
import { isHookEnabled } from './hook-config.js';
|
|
28
|
+
import { loadActiveRules } from '../store/rule-store.js';
|
|
29
|
+
import { recordViolation, rotateIfBig } from '../engine/lifecycle/signals.js';
|
|
30
|
+
import { compileSafeRegex, safeRegexTest } from './shared/safe-regex.js';
|
|
31
|
+
const HOOK_NAME = 'stop-guard';
|
|
32
|
+
// R6-F2: shared single source of truth.
|
|
33
|
+
import { DEFAULT_STOP_TRIGGER_RE, DEFAULT_STOP_EXCLUDE_RE } from './shared/stop-triggers.js';
|
|
34
|
+
/**
|
|
35
|
+
* Stuck-loop guard 임계치.
|
|
36
|
+
* Day-3 smoke 에서 block reason 문구가 Claude 응답에 재매칭되어 6회 연속 block 된
|
|
37
|
+
* regression 관찰됨. 이 상한을 넘으면 force approve + drift 이벤트를 남겨
|
|
38
|
+
* ADR-002 Meta 트리거(규칙 자동 강등)로 연결한다.
|
|
39
|
+
*/
|
|
40
|
+
const STUCK_LOOP_THRESHOLD = 3;
|
|
41
|
+
const BLOCK_COUNT_DIR = path.join(os.homedir(), '.forgen', 'state', 'enforcement', 'block-count');
|
|
42
|
+
const DRIFT_LOG = path.join(os.homedir(), '.forgen', 'state', 'enforcement', 'drift.jsonl');
|
|
43
|
+
const ACK_LOG = path.join(os.homedir(), '.forgen', 'state', 'enforcement', 'acknowledgments.jsonl');
|
|
44
|
+
/**
|
|
45
|
+
* Spike scenarios.json 로더 — FORGEN_SPIKE_RULES 명시 시에만 로드.
|
|
46
|
+
* H1 (2026-04-22): 이전에는 process.cwd()/tests/spike/... 를 기본 폴백했으나,
|
|
47
|
+
* 사용자가 forgen 저장소 안에서 작업 중이면 테스트 픽스처가 프로덕션 hook 으로
|
|
48
|
+
* 활성되는 부작용이 있었음. 이제 env 명시 opt-in.
|
|
49
|
+
*/
|
|
50
|
+
function loadSpikeRules() {
|
|
51
|
+
const rulesPath = process.env.FORGEN_SPIKE_RULES;
|
|
52
|
+
if (!rulesPath)
|
|
53
|
+
return [];
|
|
54
|
+
try {
|
|
55
|
+
const raw = fs.readFileSync(rulesPath, 'utf-8');
|
|
56
|
+
const parsed = JSON.parse(raw);
|
|
57
|
+
return (parsed.rules ?? []).filter((r) => r.hook === 'Stop');
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 프로덕션 rule-store 로더.
|
|
65
|
+
* ~/.forgen/me/rules 의 Rule 중 `enforce_via` 에 `hook: 'Stop'` 이 있는 것만
|
|
66
|
+
* SpikeRule 내부 shape 로 변환해 반환한다.
|
|
67
|
+
*
|
|
68
|
+
* 변환 규칙:
|
|
69
|
+
* - `trigger_keywords_regex` 미지정 → DEFAULT_STOP_TRIGGER_RE (shared)
|
|
70
|
+
* - `trigger_exclude_regex` 미지정 → DEFAULT_STOP_EXCLUDE_RE (shared)
|
|
71
|
+
* - verifier.kind 는 `self_check_prompt` 또는 `artifact_check` 지원
|
|
72
|
+
* - 그 외 verifier 는 skip (PreToolUse 전용 tool_arg_regex 등)
|
|
73
|
+
*/
|
|
74
|
+
export function rulesFromStore(rules) {
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const rule of rules) {
|
|
77
|
+
const specs = rule.enforce_via ?? [];
|
|
78
|
+
for (let i = 0; i < specs.length; i++) {
|
|
79
|
+
const spec = specs[i];
|
|
80
|
+
if (spec.hook !== 'Stop')
|
|
81
|
+
continue;
|
|
82
|
+
if (!spec.verifier)
|
|
83
|
+
continue;
|
|
84
|
+
if (spec.verifier.kind !== 'self_check_prompt' && spec.verifier.kind !== 'artifact_check')
|
|
85
|
+
continue;
|
|
86
|
+
out.push({
|
|
87
|
+
id: rule.rule_id,
|
|
88
|
+
mech: spec.mech,
|
|
89
|
+
hook: 'Stop',
|
|
90
|
+
trigger: {
|
|
91
|
+
response_keywords_regex: spec.trigger_keywords_regex ?? DEFAULT_STOP_TRIGGER_RE,
|
|
92
|
+
context_exclude_regex: spec.trigger_exclude_regex ?? DEFAULT_STOP_EXCLUDE_RE,
|
|
93
|
+
},
|
|
94
|
+
verifier: {
|
|
95
|
+
kind: spec.verifier.kind,
|
|
96
|
+
params: spec.verifier.params,
|
|
97
|
+
},
|
|
98
|
+
block_message: spec.block_message,
|
|
99
|
+
system_tag: spec.system_tag,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
/** 전체 로더 — rule-store 우선, 비어 있으면 spike fallback. */
|
|
106
|
+
function loadStopRules() {
|
|
107
|
+
try {
|
|
108
|
+
const storeRules = rulesFromStore(loadActiveRules());
|
|
109
|
+
if (storeRules.length > 0)
|
|
110
|
+
return storeRules;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// fail-open: rule-store 로드 실패는 spike fallback 으로 자동 전이
|
|
114
|
+
}
|
|
115
|
+
return loadSpikeRules();
|
|
116
|
+
}
|
|
117
|
+
/** Stop hook input 에서 마지막 assistant 턴 텍스트를 반환. 실패 시 null. */
|
|
118
|
+
function readLastAssistantMessage(input) {
|
|
119
|
+
// Test/runner 주입 경로 (최우선)
|
|
120
|
+
const injected = process.env.FORGEN_SPIKE_LAST_MESSAGE;
|
|
121
|
+
if (injected)
|
|
122
|
+
return injected;
|
|
123
|
+
// Claude Code 공식 필드 — Stop hook 이 직접 제공 (A1 spike Day-3 확인)
|
|
124
|
+
if (input && typeof input.last_assistant_message === 'string' && input.last_assistant_message) {
|
|
125
|
+
return input.last_assistant_message;
|
|
126
|
+
}
|
|
127
|
+
const transcriptPath = input?.transcript_path;
|
|
128
|
+
if (!transcriptPath)
|
|
129
|
+
return null;
|
|
130
|
+
try {
|
|
131
|
+
const lines = fs.readFileSync(transcriptPath, 'utf-8').trim().split('\n');
|
|
132
|
+
// 최신부터 역순으로 assistant 턴 탐색 (JSONL 형식)
|
|
133
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
134
|
+
const line = lines[i].trim();
|
|
135
|
+
if (!line)
|
|
136
|
+
continue;
|
|
137
|
+
try {
|
|
138
|
+
const entry = JSON.parse(line);
|
|
139
|
+
if (entry.role !== 'assistant')
|
|
140
|
+
continue;
|
|
141
|
+
if (typeof entry.content === 'string')
|
|
142
|
+
return entry.content;
|
|
143
|
+
if (Array.isArray(entry.content)) {
|
|
144
|
+
const parts = entry.content
|
|
145
|
+
.map((p) => {
|
|
146
|
+
if (typeof p === 'string')
|
|
147
|
+
return p;
|
|
148
|
+
if (p && typeof p === 'object' && 'text' in p)
|
|
149
|
+
return String(p.text);
|
|
150
|
+
return '';
|
|
151
|
+
})
|
|
152
|
+
.filter(Boolean);
|
|
153
|
+
if (parts.length)
|
|
154
|
+
return parts.join('\n');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// skip malformed line
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function messageTriggersRule(message, rule) {
|
|
168
|
+
const t = rule.trigger;
|
|
169
|
+
if (!t.response_keywords_regex)
|
|
170
|
+
return false;
|
|
171
|
+
const includeRes = compileSafeRegex(t.response_keywords_regex, 'i');
|
|
172
|
+
if (!includeRes.regex)
|
|
173
|
+
return false;
|
|
174
|
+
if (!safeRegexTest(includeRes.regex, message))
|
|
175
|
+
return false;
|
|
176
|
+
if (t.context_exclude_regex) {
|
|
177
|
+
const excludeRes = compileSafeRegex(t.context_exclude_regex, 'i');
|
|
178
|
+
if (excludeRes.regex && safeRegexTest(excludeRes.regex, message))
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
function evaluateVerifier(rule) {
|
|
184
|
+
const v = rule.verifier;
|
|
185
|
+
if (v.kind === 'self_check_prompt') {
|
|
186
|
+
const q = String(v.params.question ?? rule.block_message ?? '자가점검 필요');
|
|
187
|
+
// self_check_prompt 는 증거가 없으면(artifact path 미지정/미존재) 위반 간주.
|
|
188
|
+
const evidencePath = v.params.evidence_path;
|
|
189
|
+
if (typeof evidencePath === 'string') {
|
|
190
|
+
const maxAge = Number(v.params.max_age_s ?? 0);
|
|
191
|
+
const ok = artifactFresh(String(evidencePath), maxAge);
|
|
192
|
+
if (ok)
|
|
193
|
+
return { violated: false, reason: '' };
|
|
194
|
+
}
|
|
195
|
+
return { violated: true, reason: q };
|
|
196
|
+
}
|
|
197
|
+
if (v.kind === 'artifact_check') {
|
|
198
|
+
const p = String(v.params.path ?? '');
|
|
199
|
+
const maxAge = Number(v.params.max_age_s ?? 0);
|
|
200
|
+
return artifactFresh(p, maxAge)
|
|
201
|
+
? { violated: false, reason: '' }
|
|
202
|
+
: { violated: true, reason: rule.block_message ?? `증거 파일(${p})이 최근 ${maxAge}s 내 갱신되지 않음` };
|
|
203
|
+
}
|
|
204
|
+
// tool_arg_regex 는 PreToolUse 전용 → Stop 에서는 no-op
|
|
205
|
+
return { violated: false, reason: '' };
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* artifact 경로 해석 + 최근 갱신 확인.
|
|
209
|
+
*
|
|
210
|
+
* H9 (2026-04-22): rule JSON 의 verifier.params.path 를 임의 절대 경로로 지정해
|
|
211
|
+
* /etc/shadow 존재/mtime 을 탐지하는 path-traversal reconnaissance 를 막기 위해
|
|
212
|
+
* 허용 루트 (`~/.forgen/state/` 와 project `.forgen/state/`) 안으로 containment.
|
|
213
|
+
* 루트 밖 경로는 존재 여부와 무관하게 false 반환.
|
|
214
|
+
*/
|
|
215
|
+
function artifactFresh(relOrAbs, maxAgeS) {
|
|
216
|
+
const homeBase = path.join(os.homedir(), '.forgen', 'state');
|
|
217
|
+
const projectBase = path.resolve(process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd(), '.forgen', 'state');
|
|
218
|
+
const allowedRoots = [homeBase, projectBase];
|
|
219
|
+
let p = relOrAbs;
|
|
220
|
+
if (relOrAbs.startsWith('.forgen/state/')) {
|
|
221
|
+
p = path.join(os.homedir(), relOrAbs);
|
|
222
|
+
}
|
|
223
|
+
else if (!path.isAbsolute(relOrAbs)) {
|
|
224
|
+
p = path.join(homeBase, relOrAbs);
|
|
225
|
+
}
|
|
226
|
+
const resolved = path.resolve(p);
|
|
227
|
+
const inside = allowedRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep));
|
|
228
|
+
if (!inside)
|
|
229
|
+
return false; // containment violation → 존재 확인 자체를 거부
|
|
230
|
+
// R4-B4: symlink 탈출 방어 — path.resolve 만으로는 symlink 를 해소하지 않으므로
|
|
231
|
+
// ~/.forgen/state/probe → /etc/shadow 심볼릭 링크로 bounded 영역 밖 파일의 존재/mtime 을
|
|
232
|
+
// 탐지하는 reconnaissance 가 가능했다. realpathSync 로 실경로 해소 후 재검사.
|
|
233
|
+
// allowed root 자체도 realpath 화 (macOS /tmp → /private/tmp 같은 플랫폼 symlink 대응).
|
|
234
|
+
let realPath;
|
|
235
|
+
let realRoots;
|
|
236
|
+
try {
|
|
237
|
+
realPath = fs.realpathSync(resolved);
|
|
238
|
+
realRoots = allowedRoots.map((r) => {
|
|
239
|
+
try {
|
|
240
|
+
return fs.realpathSync(r);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return r;
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return false; // 존재 안 함 → not fresh
|
|
249
|
+
}
|
|
250
|
+
const realInside = realRoots.some((root) => realPath === root || realPath.startsWith(root + path.sep));
|
|
251
|
+
if (!realInside)
|
|
252
|
+
return false; // symlink 가 루트 밖을 가리킴 → reject
|
|
253
|
+
try {
|
|
254
|
+
const st = fs.lstatSync(realPath);
|
|
255
|
+
if (maxAgeS <= 0)
|
|
256
|
+
return true;
|
|
257
|
+
const ageMs = Date.now() - st.mtimeMs;
|
|
258
|
+
return ageMs <= maxAgeS * 1000;
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** Pure core — 단위 테스트용. stdin/IO 없음. */
|
|
265
|
+
export function evaluateStop(lastAssistantMessage, rules) {
|
|
266
|
+
for (const rule of rules) {
|
|
267
|
+
if (rule.hook !== 'Stop')
|
|
268
|
+
continue;
|
|
269
|
+
if (!messageTriggersRule(lastAssistantMessage, rule))
|
|
270
|
+
continue;
|
|
271
|
+
const result = evaluateVerifier(rule);
|
|
272
|
+
if (result.violated) {
|
|
273
|
+
return { action: 'block', hit: rule, reason: result.reason };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return { action: 'approve', hit: null };
|
|
277
|
+
}
|
|
278
|
+
function blockCounterPath(sessionId, ruleId) {
|
|
279
|
+
// 파일명 안전화 — 경로 인젝션 방지
|
|
280
|
+
const safeSession = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80);
|
|
281
|
+
const safeRule = String(ruleId).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 40);
|
|
282
|
+
return path.join(BLOCK_COUNT_DIR, `${safeSession}__${safeRule}.json`);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* 같은 (session, rule) 조합의 연속 block 카운트. approve 가 일어나면 0 으로 초기화.
|
|
286
|
+
* export for tests. 부수효과: 디렉토리 생성 + 파일 쓰기.
|
|
287
|
+
*/
|
|
288
|
+
export function incrementBlockCount(sessionId, ruleId) {
|
|
289
|
+
try {
|
|
290
|
+
fs.mkdirSync(BLOCK_COUNT_DIR, { recursive: true });
|
|
291
|
+
const p = blockCounterPath(sessionId, ruleId);
|
|
292
|
+
let state;
|
|
293
|
+
try {
|
|
294
|
+
const raw = fs.readFileSync(p, 'utf-8');
|
|
295
|
+
state = JSON.parse(raw);
|
|
296
|
+
if (state.sessionId !== sessionId || state.ruleId !== ruleId) {
|
|
297
|
+
state = { sessionId, ruleId, count: 0, firstBlockAt: new Date().toISOString(), lastBlockAt: new Date().toISOString() };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
state = { sessionId, ruleId, count: 0, firstBlockAt: new Date().toISOString(), lastBlockAt: new Date().toISOString() };
|
|
302
|
+
}
|
|
303
|
+
state.count += 1;
|
|
304
|
+
state.lastBlockAt = new Date().toISOString();
|
|
305
|
+
fs.writeFileSync(p, JSON.stringify(state));
|
|
306
|
+
return state.count;
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return 1; // fail-open: 카운트 실패는 block 자체를 막지 않음
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
export function resetBlockCount(sessionId, ruleId) {
|
|
313
|
+
try {
|
|
314
|
+
const p = blockCounterPath(sessionId, ruleId);
|
|
315
|
+
fs.unlinkSync(p);
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
// already gone
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* R9-PA2: approve 시점에 같은 session 의 pending block 을 찾아 ack 이벤트로 기록.
|
|
323
|
+
* Mech-B 의 핵심 가치(block → retract → pass)가 실제 작동했음을 관측 가능하게 한다.
|
|
324
|
+
* Best-effort: 실패해도 approve 자체는 영향받지 않는다.
|
|
325
|
+
*
|
|
326
|
+
* 기록 후 block-count 파일은 cleanup — 같은 session 의 같은 rule 이 다시 block 되면
|
|
327
|
+
* 새로운 카운트로 시작 (block-count 의미 보존).
|
|
328
|
+
*/
|
|
329
|
+
export function acknowledgeSessionBlocks(sessionId) {
|
|
330
|
+
if (!sessionId || sessionId === 'unknown')
|
|
331
|
+
return 0;
|
|
332
|
+
let acked = 0;
|
|
333
|
+
try {
|
|
334
|
+
if (!fs.existsSync(BLOCK_COUNT_DIR))
|
|
335
|
+
return 0;
|
|
336
|
+
const safeSession = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80);
|
|
337
|
+
const prefix = `${safeSession}__`;
|
|
338
|
+
const now = new Date().toISOString();
|
|
339
|
+
for (const file of fs.readdirSync(BLOCK_COUNT_DIR)) {
|
|
340
|
+
if (!file.startsWith(prefix) || !file.endsWith('.json'))
|
|
341
|
+
continue;
|
|
342
|
+
const full = path.join(BLOCK_COUNT_DIR, file);
|
|
343
|
+
let state = null;
|
|
344
|
+
try {
|
|
345
|
+
state = JSON.parse(fs.readFileSync(full, 'utf-8'));
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
// partial-write / malformed — 다음 scan 에서 다시 시도. 삭제하지 않음.
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (!state || state.sessionId !== sessionId) {
|
|
352
|
+
// session prefix 매칭 됐는데 내부 sessionId 가 다름 → 안전하게 cleanup.
|
|
353
|
+
try {
|
|
354
|
+
fs.unlinkSync(full);
|
|
355
|
+
}
|
|
356
|
+
catch { /* ignore */ }
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
fs.mkdirSync(path.dirname(ACK_LOG), { recursive: true });
|
|
361
|
+
rotateIfBig(ACK_LOG);
|
|
362
|
+
fs.appendFileSync(ACK_LOG, JSON.stringify({
|
|
363
|
+
at: now,
|
|
364
|
+
session_id: state.sessionId,
|
|
365
|
+
rule_id: state.ruleId,
|
|
366
|
+
block_count: state.count,
|
|
367
|
+
first_block_at: state.firstBlockAt,
|
|
368
|
+
last_block_at: state.lastBlockAt,
|
|
369
|
+
}) + '\n');
|
|
370
|
+
acked += 1;
|
|
371
|
+
}
|
|
372
|
+
catch { /* append failure: still try cleanup */ }
|
|
373
|
+
try {
|
|
374
|
+
fs.unlinkSync(full);
|
|
375
|
+
}
|
|
376
|
+
catch { /* ignore */ }
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
// fail-open — telemetry must never block approve
|
|
381
|
+
}
|
|
382
|
+
return acked;
|
|
383
|
+
}
|
|
384
|
+
export function logDriftEvent(event) {
|
|
385
|
+
try {
|
|
386
|
+
fs.mkdirSync(path.dirname(DRIFT_LOG), { recursive: true });
|
|
387
|
+
rotateIfBig(DRIFT_LOG);
|
|
388
|
+
fs.appendFileSync(DRIFT_LOG, JSON.stringify({ at: new Date().toISOString(), ...event }) + '\n');
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
// best-effort
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
export function getStuckLoopThreshold() {
|
|
395
|
+
const env = Number(process.env.FORGEN_STUCK_LOOP_THRESHOLD);
|
|
396
|
+
if (Number.isFinite(env) && env > 0)
|
|
397
|
+
return env;
|
|
398
|
+
return STUCK_LOOP_THRESHOLD;
|
|
399
|
+
}
|
|
400
|
+
export async function main() {
|
|
401
|
+
const started = Date.now();
|
|
402
|
+
try {
|
|
403
|
+
if (!isHookEnabled(HOOK_NAME)) {
|
|
404
|
+
console.log(approve());
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const input = await readStdinJSON();
|
|
408
|
+
const lastMessage = readLastAssistantMessage(input);
|
|
409
|
+
if (!lastMessage) {
|
|
410
|
+
console.log(approve());
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const rules = loadStopRules();
|
|
414
|
+
if (rules.length === 0) {
|
|
415
|
+
console.log(approve());
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const result = evaluateStop(lastMessage, rules);
|
|
419
|
+
const sessionId = input?.session_id ?? 'unknown';
|
|
420
|
+
if (result.action === 'approve') {
|
|
421
|
+
// R9-PA2: 같은 session 에 pending block 이 있었다면 retract→pass 루프가
|
|
422
|
+
// 실제 작동한 것 — acknowledgment 이벤트로 기록. block-count 는 cleanup.
|
|
423
|
+
acknowledgeSessionBlocks(sessionId);
|
|
424
|
+
console.log(approve());
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const { hit, reason } = result;
|
|
428
|
+
// R7-U1: FORGEN_USER_CONFIRMED=1 으로 사용자가 명시적 우회 → audit 기록 후 approve.
|
|
429
|
+
// pre-tool-use 와 동일한 탈출 경로 일관성 확보.
|
|
430
|
+
if (process.env.FORGEN_USER_CONFIRMED === '1') {
|
|
431
|
+
recordViolation({
|
|
432
|
+
rule_id: hit.id, session_id: sessionId, source: 'stop-guard',
|
|
433
|
+
kind: 'correction',
|
|
434
|
+
message_preview: `[FORGEN_USER_CONFIRMED=1 bypass] ${lastMessage.slice(0, 100)}`,
|
|
435
|
+
});
|
|
436
|
+
console.log(approve());
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
// T2 signal: block 은 rule 위반 증거 — violations.jsonl 에 기록.
|
|
440
|
+
// (stuck-loop force approve 는 아래에서 처리되므로 실제 block 시에만 기록)
|
|
441
|
+
recordViolation({
|
|
442
|
+
rule_id: hit.id,
|
|
443
|
+
session_id: sessionId,
|
|
444
|
+
source: 'stop-guard',
|
|
445
|
+
kind: 'block',
|
|
446
|
+
message_preview: lastMessage.slice(0, 120),
|
|
447
|
+
});
|
|
448
|
+
// G8 + R4-UX1 + R7-U1/U2: 브랜드 prefix + 사람-읽기 동사 기반 override 힌트.
|
|
449
|
+
// pre-tool-use 와 일관된 FORGEN_USER_CONFIRMED=1 탈출구 + 영구 비활성화 CLI 노출.
|
|
450
|
+
const reasonWithHint = `[forgen:stop-guard/${hit.id.slice(0, 8)}] ${reason}
|
|
451
|
+
|
|
452
|
+
(Override this turn: set FORGEN_USER_CONFIRMED=1 (audited). Disable rule permanently: \`forgen suppress-rule ${hit.id}\`. See recent blocks: \`forgen last-block\`.)`;
|
|
453
|
+
const count = incrementBlockCount(sessionId, hit.id);
|
|
454
|
+
const threshold = getStuckLoopThreshold();
|
|
455
|
+
if (count > threshold) {
|
|
456
|
+
// Stuck-loop: force approve 하고 drift 기록. Claude 가 block reason 문구에
|
|
457
|
+
// 말려들어가는 경우를 끊는다. ADR-002 Meta 트리거 (rule 자동 강등) 에 연결.
|
|
458
|
+
logDriftEvent({
|
|
459
|
+
kind: 'stuck_loop_force_approve',
|
|
460
|
+
session_id: sessionId,
|
|
461
|
+
rule_id: hit.id,
|
|
462
|
+
count,
|
|
463
|
+
reason_preview: reason.slice(0, 120),
|
|
464
|
+
message_preview: lastMessage.slice(0, 120),
|
|
465
|
+
});
|
|
466
|
+
resetBlockCount(sessionId, hit.id);
|
|
467
|
+
console.log(approve());
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
console.log(blockStop(reasonWithHint, hit.system_tag));
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
console.log(failOpenWithTracking(HOOK_NAME));
|
|
474
|
+
}
|
|
475
|
+
finally {
|
|
476
|
+
recordHookTiming(HOOK_NAME, Date.now() - started, 'Stop');
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const isMain = import.meta.url === `file://${process.argv[1]}`;
|
|
480
|
+
if (isMain) {
|
|
481
|
+
void main();
|
|
482
|
+
}
|
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
|
-
|
|
299
|
+
`${userVisibleConfirm}${axis} — kind: ${kindLabel}`,
|
|
300
|
+
`Evidence: ${result.evidence_event_id}`,
|
|
289
301
|
];
|
|
290
302
|
if (result.temporary_rule) {
|
|
291
|
-
lines.push(`Temporary rule
|
|
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.');
|
|
@@ -16,6 +16,21 @@ export declare function createEvidence(params: {
|
|
|
16
16
|
raw_payload?: Record<string, unknown>;
|
|
17
17
|
}): Evidence;
|
|
18
18
|
export declare function saveEvidence(evidence: Evidence): void;
|
|
19
|
+
/**
|
|
20
|
+
* ADR-002 T1 — explicit_correction evidence 저장 + orchestrator 호출.
|
|
21
|
+
*
|
|
22
|
+
* saveEvidence 와의 차이:
|
|
23
|
+
* - type='explicit_correction' 인 경우 T1 detect 실행 → 매칭된 rule 상태 전이 적용.
|
|
24
|
+
* - orchestrator 호출은 best-effort (실패해도 evidence 저장은 유지).
|
|
25
|
+
* - correction_kind 는 raw_payload.kind 에서 추론 (CorrectionRequest 와 호환).
|
|
26
|
+
*
|
|
27
|
+
* 기존 saveEvidence 를 호출하는 코드는 그대로 둬도 됨 (하위 호환). T1 emission 이 필요한
|
|
28
|
+
* 호출지(correction-record MCP, evidence-processor)만 이 함수로 전환.
|
|
29
|
+
*/
|
|
30
|
+
export declare function appendEvidence(evidence: Evidence): {
|
|
31
|
+
saved: true;
|
|
32
|
+
t1_events: number;
|
|
33
|
+
};
|
|
19
34
|
export declare function loadEvidence(evidenceId: string): Evidence | null;
|
|
20
35
|
export declare function loadAllEvidence(): Evidence[];
|
|
21
36
|
export declare function loadEvidenceBySession(sessionId: string): Evidence[];
|
|
@@ -10,6 +10,10 @@ import * as crypto from 'node:crypto';
|
|
|
10
10
|
import { ME_BEHAVIOR } from '../core/paths.js';
|
|
11
11
|
import { atomicWriteJSON, safeReadJSON } from '../hooks/shared/atomic-write.js';
|
|
12
12
|
import { createRule, saveRule, loadActiveRules } from './rule-store.js';
|
|
13
|
+
import { classify, applyProposal } from '../engine/enforce-classifier.js';
|
|
14
|
+
import { detect as detectT1 } from '../engine/lifecycle/trigger-t1-correction.js';
|
|
15
|
+
import { foldEvents } from '../engine/lifecycle/orchestrator.js';
|
|
16
|
+
import { appendLifecycleEvents } from '../engine/lifecycle/meta-reclassifier.js';
|
|
13
17
|
function evidencePath(evidenceId) {
|
|
14
18
|
return path.join(ME_BEHAVIOR, `${evidenceId}.json`);
|
|
15
19
|
}
|
|
@@ -30,6 +34,45 @@ export function createEvidence(params) {
|
|
|
30
34
|
export function saveEvidence(evidence) {
|
|
31
35
|
atomicWriteJSON(evidencePath(evidence.evidence_id), evidence, { pretty: true });
|
|
32
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* ADR-002 T1 — explicit_correction evidence 저장 + orchestrator 호출.
|
|
39
|
+
*
|
|
40
|
+
* saveEvidence 와의 차이:
|
|
41
|
+
* - type='explicit_correction' 인 경우 T1 detect 실행 → 매칭된 rule 상태 전이 적용.
|
|
42
|
+
* - orchestrator 호출은 best-effort (실패해도 evidence 저장은 유지).
|
|
43
|
+
* - correction_kind 는 raw_payload.kind 에서 추론 (CorrectionRequest 와 호환).
|
|
44
|
+
*
|
|
45
|
+
* 기존 saveEvidence 를 호출하는 코드는 그대로 둬도 됨 (하위 호환). T1 emission 이 필요한
|
|
46
|
+
* 호출지(correction-record MCP, evidence-processor)만 이 함수로 전환.
|
|
47
|
+
*/
|
|
48
|
+
export function appendEvidence(evidence) {
|
|
49
|
+
saveEvidence(evidence);
|
|
50
|
+
if (evidence.type !== 'explicit_correction')
|
|
51
|
+
return { saved: true, t1_events: 0 };
|
|
52
|
+
try {
|
|
53
|
+
const rawKind = evidence.raw_payload?.kind;
|
|
54
|
+
const correctionKind = rawKind === 'avoid-this' || rawKind === 'fix-now' || rawKind === 'prefer-from-now'
|
|
55
|
+
? rawKind
|
|
56
|
+
: undefined;
|
|
57
|
+
const rules = loadActiveRules();
|
|
58
|
+
const events = detectT1({ evidence, correction_kind: correctionKind, rules });
|
|
59
|
+
if (events.length === 0)
|
|
60
|
+
return { saved: true, t1_events: 0 };
|
|
61
|
+
const folded = foldEvents(rules, events);
|
|
62
|
+
for (const [ruleId, updated] of folded.entries()) {
|
|
63
|
+
const original = rules.find((r) => r.rule_id === ruleId);
|
|
64
|
+
if (!original || updated === original)
|
|
65
|
+
continue;
|
|
66
|
+
saveRule(updated);
|
|
67
|
+
}
|
|
68
|
+
appendLifecycleEvents(events);
|
|
69
|
+
return { saved: true, t1_events: events.length };
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// best-effort: orchestrator 실패는 evidence 저장 자체를 막지 않는다.
|
|
73
|
+
return { saved: true, t1_events: 0 };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
33
76
|
export function loadEvidence(evidenceId) {
|
|
34
77
|
return safeReadJSON(evidencePath(evidenceId), null);
|
|
35
78
|
}
|
|
@@ -91,7 +134,7 @@ export function promoteSessionCandidates(sessionId) {
|
|
|
91
134
|
const category = axisHint === 'quality_safety' ? 'quality'
|
|
92
135
|
: axisHint === 'autonomy' ? 'autonomy'
|
|
93
136
|
: 'workflow';
|
|
94
|
-
|
|
137
|
+
let rule = createRule({
|
|
95
138
|
category,
|
|
96
139
|
scope: 'me',
|
|
97
140
|
trigger: target,
|
|
@@ -101,6 +144,12 @@ export function promoteSessionCandidates(sessionId) {
|
|
|
101
144
|
evidence_refs: [candidate.evidence_id],
|
|
102
145
|
render_key: renderKey,
|
|
103
146
|
});
|
|
147
|
+
// ADR-001 auto-classify — 승격되는 rule 에도 enforce_via 자동 주입.
|
|
148
|
+
try {
|
|
149
|
+
const proposal = classify(rule);
|
|
150
|
+
rule = applyProposal(rule, proposal);
|
|
151
|
+
}
|
|
152
|
+
catch { /* fail-open */ }
|
|
104
153
|
saveRule(rule);
|
|
105
154
|
existingRenderKeys.add(renderKey);
|
|
106
155
|
promoted++;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule lifecycle factory + helpers — single source of truth for defaults/normalization.
|
|
3
|
+
*
|
|
4
|
+
* R6-F1 (2026-04-22): 이전에는 `rule.lifecycle ?? { phase: 'active', inject_count: 0, ... }`
|
|
5
|
+
* 리터럴이 rule-store, orchestrator, meta-reclassifier 등 5곳에 복제되어 필드 추가 시 동시
|
|
6
|
+
* 수정 필수였다. 한 곳에서 불변식을 걸고 모든 호출자가 이 함수를 통해 lifecycle 을 얻도록 통합.
|
|
7
|
+
*
|
|
8
|
+
* root-cause-analyst (R6) 분석: "Rule 이 data file + state machine 이중 정체성을 가지면서
|
|
9
|
+
* 기본값 재합성이 N 군데에 분산" 이 R4-B2(음수 corruption)/R5-B1(orphan)/R5-B2(mutex)/api-H1
|
|
10
|
+
* 등 버그 클러스터의 공통 뿌리. 이 factory 가 그 뿌리를 차단.
|
|
11
|
+
*/
|
|
12
|
+
import type { Rule, LifecycleState, MetaPromotion } from './types.js';
|
|
13
|
+
/** safe non-negative integer normalization — 파일 corruption / 다중 writer race 방어. */
|
|
14
|
+
export declare function safeCount(n: unknown): number;
|
|
15
|
+
/**
|
|
16
|
+
* Rule 에 대해 정규화된 LifecycleState 반환 (pure — rule 을 변경하지 않음).
|
|
17
|
+
* 기존 lifecycle 이 있으면 카운터를 safeCount 로 정규화한 사본, 없으면 초기 상태.
|
|
18
|
+
*/
|
|
19
|
+
export declare function initLifecycle(rule: Rule): LifecycleState;
|
|
20
|
+
/** inject count + last_inject_at 을 한 단계 증가 — markRulesInjected 의 공통 로직. */
|
|
21
|
+
export declare function bumpInject(lifecycle: LifecycleState, nowIso: string): LifecycleState;
|
|
22
|
+
/** meta_promotions 에 새 entry append (immutable). */
|
|
23
|
+
export declare function appendMetaPromotion(lifecycle: LifecycleState, promotion: MetaPromotion): LifecycleState;
|