@wooojin/forgen 0.3.1 → 0.3.2
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 +7 -2
- package/CHANGELOG.md +100 -0
- package/README.ja.md +29 -0
- package/README.ko.md +29 -0
- package/README.md +36 -3
- package/README.zh.md +29 -0
- package/dist/cli.js +3 -3
- package/dist/core/auto-compound-runner.js +6 -3
- package/dist/core/dashboard.js +11 -4
- package/dist/core/doctor.d.ts +6 -1
- package/dist/core/doctor.js +21 -1
- package/dist/core/global-config.d.ts +2 -2
- package/dist/core/global-config.js +6 -14
- package/dist/core/harness.d.ts +3 -5
- package/dist/core/harness.js +34 -338
- package/dist/core/installer.d.ts +10 -0
- package/dist/core/installer.js +185 -0
- package/dist/core/paths.d.ts +0 -34
- package/dist/core/paths.js +0 -35
- package/dist/core/settings-injector.d.ts +13 -0
- package/dist/core/settings-injector.js +167 -0
- package/dist/core/settings-lock.d.ts +35 -2
- package/dist/core/settings-lock.js +65 -7
- package/dist/core/spawn.js +100 -39
- package/dist/core/state-gc.d.ts +30 -0
- package/dist/core/state-gc.js +119 -0
- package/dist/core/uninstall.js +12 -4
- package/dist/core/v1-bootstrap.js +2 -2
- package/dist/engine/compound-cli.d.ts +27 -2
- package/dist/engine/compound-cli.js +69 -16
- package/dist/engine/compound-export.d.ts +15 -0
- package/dist/engine/compound-export.js +32 -5
- package/dist/engine/compound-loop.js +3 -2
- package/dist/engine/learn-cli.js +52 -0
- package/dist/engine/match-eval-log.js +45 -0
- package/dist/engine/solution-format.d.ts +0 -2
- package/dist/engine/solution-format.js +0 -4
- package/dist/engine/solution-matcher.d.ts +8 -0
- package/dist/engine/solution-matcher.js +7 -4
- package/dist/engine/solution-outcomes.d.ts +4 -0
- package/dist/engine/solution-outcomes.js +174 -97
- package/dist/engine/solution-writer.d.ts +8 -5
- package/dist/engine/solution-writer.js +43 -19
- package/dist/fgx.js +9 -2
- package/dist/forge/cli.js +7 -7
- package/dist/hooks/context-guard.js +15 -1
- package/dist/hooks/hook-config.d.ts +9 -1
- package/dist/hooks/hook-config.js +25 -3
- package/dist/hooks/internal/run-lifecycle-check.d.ts +2 -0
- package/dist/hooks/internal/run-lifecycle-check.js +32 -0
- package/dist/hooks/notepad-injector.js +6 -3
- package/dist/hooks/permission-handler.d.ts +10 -2
- package/dist/hooks/permission-handler.js +31 -12
- package/dist/hooks/pre-tool-use.js +10 -4
- package/dist/hooks/secret-filter.js +6 -0
- package/dist/hooks/session-recovery.js +15 -7
- package/dist/hooks/shared/hook-response.d.ts +0 -2
- package/dist/hooks/shared/hook-response.js +3 -8
- package/dist/hooks/shared/hook-timing.js +10 -1
- package/dist/hooks/solution-injector.d.ts +21 -0
- package/dist/hooks/solution-injector.js +60 -1
- package/dist/mcp/solution-reader.d.ts +2 -0
- package/dist/mcp/solution-reader.js +28 -1
- package/dist/mcp/tools.js +5 -2
- package/dist/preset/preset-manager.js +12 -2
- package/dist/store/evidence-store.js +5 -5
- package/dist/store/profile-store.d.ts +9 -0
- package/dist/store/profile-store.js +25 -4
- package/dist/store/rule-store.js +8 -8
- package/package.json +1 -1
- package/plugin.json +7 -2
- package/scripts/postinstall.js +52 -5
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forgen — Internal runner: compound lifecycle check.
|
|
4
|
+
*
|
|
5
|
+
* Spawned by `session-recovery.ts` as a detached background process.
|
|
6
|
+
* Exists as a dedicated script file so the caller can pass `sessionId`
|
|
7
|
+
* via argv instead of interpolating it into a `-e` template literal.
|
|
8
|
+
*
|
|
9
|
+
* Audit finding #5 (2026-04-21): prior call site used
|
|
10
|
+
* spawn('node', ['--input-type=module', '-e',
|
|
11
|
+
* `import('${path}').then(m => m.runLifecycleCheck('${sessionId}'))`])
|
|
12
|
+
* which interpolated `sessionId` (originating from hook stdin) into
|
|
13
|
+
* executable JS source. An attacker-controlled session id of the shape
|
|
14
|
+
* `a'); malicious(); //` would have executed arbitrary JS under the
|
|
15
|
+
* user's Claude-Code privileges. A dedicated script + argv lookup has
|
|
16
|
+
* no shell or eval surface.
|
|
17
|
+
*
|
|
18
|
+
* Contract: `process.argv[2]` is the session id. Any extra args are
|
|
19
|
+
* ignored. stdout/stderr are ignored by the caller (`stdio: 'ignore'`).
|
|
20
|
+
*/
|
|
21
|
+
import { runLifecycleCheck } from '../../engine/compound-lifecycle.js';
|
|
22
|
+
const sessionId = process.argv[2];
|
|
23
|
+
if (!sessionId || typeof sessionId !== 'string') {
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
runLifecycleCheck(sessionId);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Detached background — best effort. Surfacing errors would have no
|
|
31
|
+
// consumer and the parent hook already logged the spawn.
|
|
32
|
+
}
|
|
@@ -21,6 +21,7 @@ import { isHookEnabled } from './hook-config.js';
|
|
|
21
21
|
import { truncateContent } from './shared/injection-caps.js';
|
|
22
22
|
import { calculateBudget } from './shared/context-budget.js';
|
|
23
23
|
import { approve, approveWithContext, failOpenWithTracking } from './shared/hook-response.js';
|
|
24
|
+
import { escapeAllXmlTags } from './prompt-injection-filter.js';
|
|
24
25
|
// ── 메인 ──
|
|
25
26
|
async function main() {
|
|
26
27
|
const input = await readStdinJSON();
|
|
@@ -39,9 +40,11 @@ async function main() {
|
|
|
39
40
|
console.log(approve());
|
|
40
41
|
return;
|
|
41
42
|
}
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// P1-S2 fix (2026-04-20): 이전에는 `</forgen-notepad>` 리터럴 하나만 치환했지만,
|
|
44
|
+
// notepad 파일에 `<system>`, `<assistant>` 같은 임의 XML 태그가 있으면 그대로
|
|
45
|
+
// LLM에 전달되어 지시 주입 위험. escapeAllXmlTags로 모든 태그를 escape한다.
|
|
46
|
+
const truncated = truncateContent(notepadContent.trim(), calculateBudget(effectiveCwd).notepadMax);
|
|
47
|
+
const safeContent = escapeAllXmlTags(truncated);
|
|
45
48
|
const injection = `<forgen-notepad>\n${safeContent}\n</forgen-notepad>`;
|
|
46
49
|
console.log(approveWithContext(injection, 'UserPromptSubmit'));
|
|
47
50
|
}
|
|
@@ -10,5 +10,13 @@
|
|
|
10
10
|
export declare const SAFE_TOOLS: Set<string>;
|
|
11
11
|
/** autopilot 모드에서도 수동 확인이 필요한 도구 */
|
|
12
12
|
export declare const ALWAYS_CONFIRM_TOOLS: Set<string>;
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* 도구 분류: pass-through 결정 (순수 함수).
|
|
15
|
+
*
|
|
16
|
+
* Audit clarification #4 (2026-04-21): 본 훅은 Claude의 기본 권한 흐름을
|
|
17
|
+
* 가로채지 않는다 — 모든 return 라벨은 "어떤 pass-through 경로인가"를
|
|
18
|
+
* 의미하며, `permissionDecision: 'allow'`를 강제하지 않는다. 과거 라벨
|
|
19
|
+
* `auto-approve-safe`, `autopilot-approve`는 승인으로 오해되어 audit log가
|
|
20
|
+
* 실제 실행 신뢰도와 어긋났다.
|
|
21
|
+
*/
|
|
22
|
+
export declare function classifyTool(toolName: string, isAutopilot: boolean): 'safe-pass-through' | 'autopilot-warn-pass-through' | 'autopilot-pass-through' | 'pass-through';
|
|
@@ -24,15 +24,23 @@ export const SAFE_TOOLS = new Set([
|
|
|
24
24
|
export const ALWAYS_CONFIRM_TOOLS = new Set([
|
|
25
25
|
'Bash', 'Write', 'Edit',
|
|
26
26
|
]);
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* 도구 분류: pass-through 결정 (순수 함수).
|
|
29
|
+
*
|
|
30
|
+
* Audit clarification #4 (2026-04-21): 본 훅은 Claude의 기본 권한 흐름을
|
|
31
|
+
* 가로채지 않는다 — 모든 return 라벨은 "어떤 pass-through 경로인가"를
|
|
32
|
+
* 의미하며, `permissionDecision: 'allow'`를 강제하지 않는다. 과거 라벨
|
|
33
|
+
* `auto-approve-safe`, `autopilot-approve`는 승인으로 오해되어 audit log가
|
|
34
|
+
* 실제 실행 신뢰도와 어긋났다.
|
|
35
|
+
*/
|
|
28
36
|
export function classifyTool(toolName, isAutopilot) {
|
|
29
37
|
if (SAFE_TOOLS.has(toolName))
|
|
30
|
-
return '
|
|
38
|
+
return 'safe-pass-through';
|
|
31
39
|
if (!isAutopilot)
|
|
32
40
|
return 'pass-through';
|
|
33
41
|
if (ALWAYS_CONFIRM_TOOLS.has(toolName))
|
|
34
|
-
return 'autopilot-
|
|
35
|
-
return 'autopilot-
|
|
42
|
+
return 'autopilot-warn-pass-through';
|
|
43
|
+
return 'autopilot-pass-through';
|
|
36
44
|
}
|
|
37
45
|
/** autopilot 모드 활성 여부 확인 */
|
|
38
46
|
function isAutopilotActive() {
|
|
@@ -80,9 +88,17 @@ async function main() {
|
|
|
80
88
|
}
|
|
81
89
|
const toolName = data.tool_name ?? data.toolName ?? '';
|
|
82
90
|
const sessionId = data.session_id ?? 'default';
|
|
83
|
-
//
|
|
91
|
+
// Audit note #4 (2026-04-21): `approve()` / `approveWithWarning()` 둘 다
|
|
92
|
+
// Claude Code hook protocol에서 `permissionDecision: 'allow'`를 설정하지
|
|
93
|
+
// 않는다. 따라서 본 훅은 실제로 도구 실행을 "승인(force-allow)"하지 않고,
|
|
94
|
+
// Claude의 기본 권한 흐름으로 pass-through 시킨다 (systemMessage UI 경고는
|
|
95
|
+
// 선택사항). 과거 로그에서 `auto-approve-safe` / `autopilot-approve` 같은
|
|
96
|
+
// 결정 이름이 실제 효과와 어긋났기에 로그 라벨을 실효에 맞춰 정정했다.
|
|
97
|
+
//
|
|
98
|
+
// SAFE_TOOLS (Read/Glob/Grep 등): Claude 기본 정책상 이미 허용되는 도구이므로
|
|
99
|
+
// 이곳에서 별도 장치 없이 pass-through. 로그는 `safe-pass-through`로 기록.
|
|
84
100
|
if (SAFE_TOOLS.has(toolName)) {
|
|
85
|
-
logPermissionRequest(sessionId, toolName, '
|
|
101
|
+
logPermissionRequest(sessionId, toolName, 'safe-pass-through');
|
|
86
102
|
console.log(approve());
|
|
87
103
|
return;
|
|
88
104
|
}
|
|
@@ -94,18 +110,21 @@ async function main() {
|
|
|
94
110
|
}
|
|
95
111
|
// autopilot 모드 (2차 방어선):
|
|
96
112
|
// pre-tool-use 훅이 위험 패턴(rm -rf, git push --force 등)을 이미 block/warn 처리함.
|
|
97
|
-
// 여기 도달하는 도구는 pre-tool-use를 통과한
|
|
113
|
+
// 여기 도달하는 도구는 pre-tool-use를 통과한 것으로 pass-through + UI 경고.
|
|
114
|
+
// 여전히 Claude의 기본 confirmation은 사용자에게 노출된다 — 본 훅이 전체
|
|
115
|
+
// 승인을 가로채는 게 아니라 추적성을 위한 어노테이션이다.
|
|
98
116
|
if (ALWAYS_CONFIRM_TOOLS.has(toolName)) {
|
|
99
|
-
logPermissionRequest(sessionId, toolName, 'autopilot-
|
|
117
|
+
logPermissionRequest(sessionId, toolName, 'autopilot-warn-pass-through');
|
|
100
118
|
// Bash는 pre-tool-use를 통과했더라도 경고 강도를 높임 (임의 셸 실행 위험)
|
|
101
119
|
const warningLevel = toolName === 'Bash'
|
|
102
|
-
? `[Forgen] ⚠ Autopilot: Bash tool
|
|
103
|
-
: `[Forgen] Autopilot: ${toolName} tool
|
|
120
|
+
? `[Forgen] ⚠ Autopilot: Bash tool — passed pre-tool-use validation. Beware of unexpected commands.`
|
|
121
|
+
: `[Forgen] Autopilot: ${toolName} tool use passed through with warning.`;
|
|
104
122
|
console.log(approveWithWarning(`<compound-permission>\n${warningLevel}\n</compound-permission>`));
|
|
105
123
|
return;
|
|
106
124
|
}
|
|
107
|
-
// 기타 도구: autopilot
|
|
108
|
-
|
|
125
|
+
// 기타 도구: autopilot 모드에서도 pass-through (force-approve 아님).
|
|
126
|
+
// 과거 로그 라벨은 `autopilot-approve`였으나 실제 효과는 pass-through.
|
|
127
|
+
logPermissionRequest(sessionId, toolName, 'autopilot-pass-through');
|
|
109
128
|
console.log(approve());
|
|
110
129
|
}
|
|
111
130
|
main().catch((e) => {
|
|
@@ -334,10 +334,16 @@ async function main() {
|
|
|
334
334
|
log.debug('compound reflection check 실패', e);
|
|
335
335
|
}
|
|
336
336
|
// 활성 모드 리마인더 (10회 호출당 1회 — 결정적 카운터 기반)
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
337
|
+
// P0-4 fix (2026-04-20): 과거에는 getActiveReminders()로 STATE_DIR을 먼저
|
|
338
|
+
// readdir + N회 readFileSync한 뒤에야 shouldShowReminderIO 카운터를 체크했다.
|
|
339
|
+
// 그래서 "리마인더를 보여줄 호출이 아닌" 90%에서도 디렉터리 스캔이 발생.
|
|
340
|
+
// 이제 shouldShowReminderIO를 먼저 체크해 표시 회차일 때만 스캔한다.
|
|
341
|
+
if (shouldShowReminderIO()) {
|
|
342
|
+
const reminders = getActiveReminders();
|
|
343
|
+
if (reminders.length > 0) {
|
|
344
|
+
console.log(approveWithWarning(`<compound-reminder>\n${reminders.join('\n')}\n</compound-reminder>`));
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
341
347
|
}
|
|
342
348
|
console.log(approve());
|
|
343
349
|
}
|
|
@@ -16,6 +16,12 @@ export const SECRET_PATTERNS = [
|
|
|
16
16
|
{ name: 'Password', pattern: /(password|passwd|pwd)\s*[=:]\s*["']?[^\s"']{8,}/i },
|
|
17
17
|
{ name: 'Private Key', pattern: /-----BEGIN (RSA |EC |DSA )?PRIVATE KEY-----/ },
|
|
18
18
|
{ name: 'Connection String', pattern: /(mongodb|postgres|mysql|redis):\/\/\w+:[^@]+@/ },
|
|
19
|
+
// 2026-04-21 follow-up audit #B: vendor-specific prefixes the generic
|
|
20
|
+
// `(sk|pk|api-key)[_-]` pattern does NOT match. Real-world leaks
|
|
21
|
+
// overwhelmingly use these formats.
|
|
22
|
+
{ name: 'GitHub Token', pattern: /\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b/ },
|
|
23
|
+
{ name: 'Google API Key', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
24
|
+
{ name: 'Slack Token', pattern: /\bxox[abpors]-[A-Za-z0-9-]{10,}/ },
|
|
19
25
|
];
|
|
20
26
|
/** 텍스트에서 민감 정보 패턴 감지 (순수 함수) */
|
|
21
27
|
export function detectSecrets(text) {
|
|
@@ -378,7 +378,6 @@ async function main() {
|
|
|
378
378
|
// v1: regex 기반 패턴 학습(prompt-learner) 제거. Evidence 기반으로 전환됨.
|
|
379
379
|
// Compound v3: Run lifecycle check once per day
|
|
380
380
|
try {
|
|
381
|
-
const lifecycleModulePath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'engine', 'compound-lifecycle.js');
|
|
382
381
|
const lastLifecyclePath = path.join(STATE_DIR, 'last-lifecycle.json');
|
|
383
382
|
let shouldRun = true;
|
|
384
383
|
try {
|
|
@@ -390,13 +389,22 @@ async function main() {
|
|
|
390
389
|
}
|
|
391
390
|
catch { /* last-lifecycle.json parse failure — run lifecycle check anyway */ }
|
|
392
391
|
if (shouldRun) {
|
|
393
|
-
// B-4: detached background spawn
|
|
392
|
+
// B-4: detached background spawn — hook timeout 초과 방지.
|
|
393
|
+
//
|
|
394
|
+
// Audit fix #5 (2026-04-21): prior invocation interpolated
|
|
395
|
+
// `sessionId` into a `-e` template literal
|
|
396
|
+
// `import('${path}').then(m => m.runLifecycleCheck('${sessionId}'))`
|
|
397
|
+
// which created a code-injection surface (a crafted sessionId
|
|
398
|
+
// could break out of the single quotes and execute arbitrary JS
|
|
399
|
+
// under the user's Claude-Code privileges). The runner was moved
|
|
400
|
+
// to a dedicated script file and the id is now passed via argv —
|
|
401
|
+
// no shell, no eval, no interpolation.
|
|
402
|
+
const runnerPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'internal', 'run-lifecycle-check.js');
|
|
394
403
|
const { spawn: spawnLifecycle } = await import('node:child_process');
|
|
395
|
-
const lifecycleRunner = spawnLifecycle('node', [
|
|
396
|
-
|
|
397
|
-
'
|
|
398
|
-
|
|
399
|
-
], { detached: true, stdio: 'ignore' });
|
|
404
|
+
const lifecycleRunner = spawnLifecycle('node', [runnerPath, sessionId], {
|
|
405
|
+
detached: true,
|
|
406
|
+
stdio: 'ignore',
|
|
407
|
+
});
|
|
400
408
|
lifecycleRunner.unref();
|
|
401
409
|
const { atomicWriteJSON: writeJSON } = await import('./shared/atomic-write.js');
|
|
402
410
|
writeJSON(lastLifecyclePath, { lastRun: new Date().toISOString() });
|
|
@@ -29,8 +29,6 @@ export declare function approveWithWarning(warning: string): string;
|
|
|
29
29
|
export declare function deny(reason: string): string;
|
|
30
30
|
/** 사용자 확인 요청 (PreToolUse 전용) */
|
|
31
31
|
export declare function ask(reason: string): string;
|
|
32
|
-
/** fail-open: 에러 시 안전하게 통과 */
|
|
33
|
-
export declare function failOpen(): string;
|
|
34
32
|
/**
|
|
35
33
|
* fail-open with error tracking: 에러 시 안전하게 통과하되, 실패 정보를 기록.
|
|
36
34
|
* forgen doctor의 Hook Health 섹션에서 실패 이력을 표시할 수 있도록 JSONL 로그에 기록.
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* 모델에 컨텍스트를 주입하려면 반드시 additionalContext를 사용해야 함.
|
|
15
15
|
*/
|
|
16
16
|
import * as fs from 'node:fs';
|
|
17
|
-
import * as os from 'node:os';
|
|
18
17
|
import * as path from 'node:path';
|
|
18
|
+
import { STATE_DIR } from '../../core/paths.js';
|
|
19
19
|
/** 통과 응답 (컨텍스트 없음, 모든 이벤트 공통) */
|
|
20
20
|
export function approve() {
|
|
21
21
|
return JSON.stringify({ continue: true });
|
|
@@ -59,10 +59,6 @@ export function ask(reason) {
|
|
|
59
59
|
},
|
|
60
60
|
});
|
|
61
61
|
}
|
|
62
|
-
/** fail-open: 에러 시 안전하게 통과 */
|
|
63
|
-
export function failOpen() {
|
|
64
|
-
return JSON.stringify({ continue: true });
|
|
65
|
-
}
|
|
66
62
|
/**
|
|
67
63
|
* fail-open with error tracking: 에러 시 안전하게 통과하되, 실패 정보를 기록.
|
|
68
64
|
* forgen doctor의 Hook Health 섹션에서 실패 이력을 표시할 수 있도록 JSONL 로그에 기록.
|
|
@@ -71,9 +67,8 @@ export function failOpen() {
|
|
|
71
67
|
*/
|
|
72
68
|
export function failOpenWithTracking(hookName) {
|
|
73
69
|
try {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const logPath = path.join(stateDir, 'hook-errors.jsonl');
|
|
70
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
71
|
+
const logPath = path.join(STATE_DIR, 'hook-errors.jsonl');
|
|
77
72
|
const entry = JSON.stringify({ hook: hookName, at: Date.now() });
|
|
78
73
|
fs.appendFileSync(logPath, entry + '\n');
|
|
79
74
|
}
|
|
@@ -9,13 +9,22 @@ import * as path from 'node:path';
|
|
|
9
9
|
import { STATE_DIR } from '../../core/paths.js';
|
|
10
10
|
const TIMING_LOG = path.join(STATE_DIR, 'hook-timing.jsonl');
|
|
11
11
|
const MAX_LINES = 500;
|
|
12
|
+
// P0-2 fix (2026-04-20): rotate를 size gate로 보호. 이전에는 매 hook 완료마다
|
|
13
|
+
// full-file read + length split + write까지 실행해 steady-state(500줄 근처)에서
|
|
14
|
+
// 매 tool call당 ~40KB의 불필요 I/O가 발생했다. statSync 한 번으로 크기만 보고
|
|
15
|
+
// threshold 이하면 read/write 둘 다 skip한다. threshold는 ~80바이트/엔트리 기준
|
|
16
|
+
// MAX_LINES × 1.5 여유를 둠.
|
|
17
|
+
const ROTATE_SIZE_BYTES = MAX_LINES * 80 * 2; // ~80KB
|
|
12
18
|
export function recordHookTiming(hookName, durationMs, event) {
|
|
13
19
|
try {
|
|
14
20
|
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
15
21
|
const entry = JSON.stringify({ hook: hookName, ms: durationMs, event, at: Date.now() });
|
|
16
22
|
fs.appendFileSync(TIMING_LOG, entry + '\n');
|
|
17
|
-
// Rotate if too large
|
|
23
|
+
// Rotate if too large — size-gated (statSync only, skip read/write 대부분의 호출)
|
|
18
24
|
try {
|
|
25
|
+
const size = fs.statSync(TIMING_LOG).size;
|
|
26
|
+
if (size < ROTATE_SIZE_BYTES)
|
|
27
|
+
return;
|
|
19
28
|
const content = fs.readFileSync(TIMING_LOG, 'utf-8');
|
|
20
29
|
const lines = content.trim().split('\n');
|
|
21
30
|
if (lines.length > MAX_LINES) {
|
|
@@ -7,6 +7,27 @@
|
|
|
7
7
|
*
|
|
8
8
|
* knowledge-comes-to-you 원칙: 필요한 지식은 찾아와야 한다
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* Minimum relevance thresholds by fitness state (2026-04-21 gate sweep).
|
|
12
|
+
*
|
|
13
|
+
* Motivation: a flat 0.3 floor gave 100% precision but 60% recall on a
|
|
14
|
+
* synthetic 40-query workload — 10 legitimate matches that scored
|
|
15
|
+
* 0.25-0.30 were blocked alongside noise. A pure 0.25 floor pushed recall
|
|
16
|
+
* to 84% but stripped noise protection for unverified solutions.
|
|
17
|
+
*
|
|
18
|
+
* Champion-aware solution: trust graduates more. Solutions whose fitness
|
|
19
|
+
* classification is `champion` or `active` (accept/correct ratio has
|
|
20
|
+
* survived ≥5 injections under the v0.3.2 gates) earn a lower 0.25
|
|
21
|
+
* injection floor; everything else stays at 0.3. On the sweep this hit
|
|
22
|
+
* precision 95.5% / recall 84% / off-topic specificity 100% — best
|
|
23
|
+
* trade in the variant set.
|
|
24
|
+
*
|
|
25
|
+
* If fitness data is unavailable (fresh install, empty outcomes/),
|
|
26
|
+
* every solution falls into the default 0.3 bucket — identical to the
|
|
27
|
+
* pre-0.3.2 gate. No cold-start regression.
|
|
28
|
+
*/
|
|
29
|
+
export declare const MIN_INJECT_RELEVANCE = 0.3;
|
|
30
|
+
export declare const MIN_INJECT_RELEVANCE_TRUSTED = 0.25;
|
|
10
31
|
interface SessionCacheCommitResult {
|
|
11
32
|
/**
|
|
12
33
|
* commit 상태:
|
|
@@ -30,6 +30,27 @@ import { STATE_DIR } from '../core/paths.js';
|
|
|
30
30
|
import { recordHookTiming } from './shared/hook-timing.js';
|
|
31
31
|
import { appendPending, flushAccept } from '../engine/solution-outcomes.js';
|
|
32
32
|
const MAX_SOLUTIONS_PER_SESSION = 10;
|
|
33
|
+
/**
|
|
34
|
+
* Minimum relevance thresholds by fitness state (2026-04-21 gate sweep).
|
|
35
|
+
*
|
|
36
|
+
* Motivation: a flat 0.3 floor gave 100% precision but 60% recall on a
|
|
37
|
+
* synthetic 40-query workload — 10 legitimate matches that scored
|
|
38
|
+
* 0.25-0.30 were blocked alongside noise. A pure 0.25 floor pushed recall
|
|
39
|
+
* to 84% but stripped noise protection for unverified solutions.
|
|
40
|
+
*
|
|
41
|
+
* Champion-aware solution: trust graduates more. Solutions whose fitness
|
|
42
|
+
* classification is `champion` or `active` (accept/correct ratio has
|
|
43
|
+
* survived ≥5 injections under the v0.3.2 gates) earn a lower 0.25
|
|
44
|
+
* injection floor; everything else stays at 0.3. On the sweep this hit
|
|
45
|
+
* precision 95.5% / recall 84% / off-topic specificity 100% — best
|
|
46
|
+
* trade in the variant set.
|
|
47
|
+
*
|
|
48
|
+
* If fitness data is unavailable (fresh install, empty outcomes/),
|
|
49
|
+
* every solution falls into the default 0.3 bucket — identical to the
|
|
50
|
+
* pre-0.3.2 gate. No cold-start regression.
|
|
51
|
+
*/
|
|
52
|
+
export const MIN_INJECT_RELEVANCE = 0.3;
|
|
53
|
+
export const MIN_INJECT_RELEVANCE_TRUSTED = 0.25;
|
|
33
54
|
/** 세션별 이미 주입된 솔루션 추적 (중복 방지) */
|
|
34
55
|
function getSessionCachePath(sessionId) {
|
|
35
56
|
return path.join(STATE_DIR, `solution-cache-${sanitizeId(sessionId)}.json`);
|
|
@@ -299,12 +320,50 @@ async function main() {
|
|
|
299
320
|
console.log(approve());
|
|
300
321
|
return;
|
|
301
322
|
}
|
|
302
|
-
// 어댑티브 프롬프트당 솔루션 수 제한, experiment는 1개
|
|
323
|
+
// 어댑티브 프롬프트당 솔루션 수 제한, experiment는 1개 제한.
|
|
324
|
+
// 2026-04-21: MIN_INJECT_RELEVANCE 게이트 추가. 과거 0.15~0.21짜리 저신뢰 매칭이
|
|
325
|
+
// 거의 모든 세션에 주입되어 error 귀속의 80%를 차지했음.
|
|
326
|
+
//
|
|
327
|
+
// 2026-04-21 (precision audit follow-up): 단일 태그 매칭은 주입 차단.
|
|
328
|
+
// ~/.forgen/state/match-eval-log.jsonl 7406 queries 분석 결과, top-1의
|
|
329
|
+
// 33.5%가 "forgen", "type", "file" 같은 공통 단어 한 개로 매칭되어 희귀
|
|
330
|
+
// 태그 BM25 boost로 0.5~0.8 점수를 받고 사용자 컨텍스트를 오염시켰다.
|
|
331
|
+
// Matcher는 top-5 recall 유지를 위해 permissive 하게 두고 (bootstrap eval
|
|
332
|
+
// 호환), 주입 직전에만 엄격히:
|
|
333
|
+
// - identifier match ≥ 1 (함수/파일 이름 리터럴 매칭 — 강한 신호) OR
|
|
334
|
+
// - matched tags ≥ 2 (의도 교차점 2개 이상)
|
|
335
|
+
// 둘 중 하나를 만족해야 주입.
|
|
336
|
+
// 2026-04-21 (champion-aware gate): fitness 상태가 champion/active인 솔루션은
|
|
337
|
+
// 검증된 신호가 있으므로 임계값 0.25로 완화. draft/underperform 은 0.3 그대로.
|
|
338
|
+
// Fitness 데이터가 없으면 전체 default 0.3 (cold-start 회귀 없음).
|
|
339
|
+
// Gate sweep 결과: precision 95.5% / recall 84% / off-topic specificity 100%.
|
|
340
|
+
const fitnessStateMap = new Map();
|
|
341
|
+
try {
|
|
342
|
+
const { computeFitness } = await import('../engine/solution-fitness.js');
|
|
343
|
+
for (const r of computeFitness()) {
|
|
344
|
+
fitnessStateMap.set(r.solution, r.state);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
catch (e) {
|
|
348
|
+
log.debug('fitness state load 실패 — default 0.3 적용', e);
|
|
349
|
+
}
|
|
350
|
+
function minRelevanceFor(name) {
|
|
351
|
+
const state = fitnessStateMap.get(name);
|
|
352
|
+
return (state === 'champion' || state === 'active')
|
|
353
|
+
? MIN_INJECT_RELEVANCE_TRUSTED
|
|
354
|
+
: MIN_INJECT_RELEVANCE;
|
|
355
|
+
}
|
|
303
356
|
let experimentCount = 0;
|
|
304
357
|
const toInject = [];
|
|
305
358
|
for (const sol of matches) {
|
|
306
359
|
if (injected.has(sol.name))
|
|
307
360
|
continue;
|
|
361
|
+
if (sol.relevance < minRelevanceFor(sol.name))
|
|
362
|
+
continue;
|
|
363
|
+
const idMatches = sol.matchedIdentifiers?.length ?? 0;
|
|
364
|
+
const tagMatches = Math.max(0, sol.matchedTags.length - idMatches);
|
|
365
|
+
if (idMatches < 1 && tagMatches < 2)
|
|
366
|
+
continue;
|
|
308
367
|
if (sol.status === 'experiment') {
|
|
309
368
|
if (experimentCount >= 1)
|
|
310
369
|
continue;
|
|
@@ -53,6 +53,8 @@ export interface SolutionDetail {
|
|
|
53
53
|
}
|
|
54
54
|
export interface SolutionStats {
|
|
55
55
|
total: number;
|
|
56
|
+
retiredCount: number;
|
|
57
|
+
extractionPrecision: number | null;
|
|
56
58
|
byStatus: Record<SolutionStatus, number>;
|
|
57
59
|
byType: Record<SolutionType, number>;
|
|
58
60
|
byScope: Record<'me' | 'team' | 'project' | 'universal', number>;
|
|
@@ -18,7 +18,7 @@ import * as path from 'node:path';
|
|
|
18
18
|
import { ME_SOLUTIONS, PACKS_DIR } from '../core/paths.js';
|
|
19
19
|
import { logMatchDecision } from '../engine/match-eval-log.js';
|
|
20
20
|
import { maskBlockedTokens } from '../engine/phrase-blocklist.js';
|
|
21
|
-
import { expandCompoundTags, expandQueryBigrams, extractTags, parseSolutionV3, } from '../engine/solution-format.js';
|
|
21
|
+
import { expandCompoundTags, expandQueryBigrams, extractTags, parseFrontmatterOnly, parseSolutionV3, } from '../engine/solution-format.js';
|
|
22
22
|
import { getOrBuildIndex } from '../engine/solution-index.js';
|
|
23
23
|
import { calculateRelevance, shouldRejectByR4T3Rules } from '../engine/solution-matcher.js';
|
|
24
24
|
import { mutateSolutionFile } from '../engine/solution-writer.js';
|
|
@@ -257,8 +257,30 @@ export function readSolution(name, options) {
|
|
|
257
257
|
export function getSolutionStats(options) {
|
|
258
258
|
const dirs = options?.dirs ?? defaultSolutionDirs();
|
|
259
259
|
const index = getOrBuildIndex(dirs);
|
|
260
|
+
// retired 카운트: 인덱스에서 제외되므로 디렉토리를 직접 스캔
|
|
261
|
+
let retiredCount = 0;
|
|
262
|
+
for (const { dir } of dirs) {
|
|
263
|
+
if (!fs.existsSync(dir))
|
|
264
|
+
continue;
|
|
265
|
+
try {
|
|
266
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
|
|
267
|
+
for (const file of files) {
|
|
268
|
+
try {
|
|
269
|
+
const content = fs.readFileSync(path.join(dir, file), 'utf-8');
|
|
270
|
+
const fm = parseFrontmatterOnly(content);
|
|
271
|
+
if (fm?.status === 'retired')
|
|
272
|
+
retiredCount++;
|
|
273
|
+
}
|
|
274
|
+
catch { /* ignore */ }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
catch { /* ignore */ }
|
|
278
|
+
}
|
|
279
|
+
// extractionPrecision: verified+mature / (total active + retired)
|
|
260
280
|
const stats = {
|
|
261
281
|
total: index.entries.length,
|
|
282
|
+
retiredCount,
|
|
283
|
+
extractionPrecision: null,
|
|
262
284
|
// retired는 인덱스에서 제외되므로 항상 0 (solution-index.ts:73)
|
|
263
285
|
byStatus: { experiment: 0, candidate: 0, verified: 0, mature: 0, retired: 0 },
|
|
264
286
|
byType: {
|
|
@@ -279,5 +301,10 @@ export function getSolutionStats(options) {
|
|
|
279
301
|
if (entry.scope in stats.byScope)
|
|
280
302
|
stats.byScope[entry.scope]++;
|
|
281
303
|
}
|
|
304
|
+
const highConfidence = stats.byStatus.verified + stats.byStatus.mature;
|
|
305
|
+
const denominator = index.entries.length + retiredCount;
|
|
306
|
+
if (denominator > 0) {
|
|
307
|
+
stats.extractionPrecision = Math.round((highConfidence / denominator) * 100);
|
|
308
|
+
}
|
|
282
309
|
return stats;
|
|
283
310
|
}
|
package/dist/mcp/tools.js
CHANGED
|
@@ -158,7 +158,10 @@ export function registerTools(server) {
|
|
|
158
158
|
dirs: defaultSolutionDirs(getCwd()),
|
|
159
159
|
});
|
|
160
160
|
const lines = [
|
|
161
|
-
`Total solutions: ${stats.total}`,
|
|
161
|
+
`Total solutions: ${stats.total} active + ${stats.retiredCount} retired`,
|
|
162
|
+
stats.extractionPrecision !== null
|
|
163
|
+
? `Extraction precision: ${stats.extractionPrecision}%`
|
|
164
|
+
: '',
|
|
162
165
|
'',
|
|
163
166
|
'By status:',
|
|
164
167
|
...Object.entries(stats.byStatus)
|
|
@@ -174,7 +177,7 @@ export function registerTools(server) {
|
|
|
174
177
|
...Object.entries(stats.byScope)
|
|
175
178
|
.filter(([, count]) => count > 0)
|
|
176
179
|
.map(([scope, count]) => ` ${scope}: ${count}`),
|
|
177
|
-
];
|
|
180
|
+
].filter((l) => l !== undefined);
|
|
178
181
|
return {
|
|
179
182
|
content: [{
|
|
180
183
|
type: 'text',
|
|
@@ -76,8 +76,18 @@ export function computeEffectiveTrust(desired, runtime) {
|
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
78
|
if (runtimeRank > desiredRank) {
|
|
79
|
-
// runtime > desired →
|
|
80
|
-
|
|
79
|
+
// runtime > desired → 에스컬레이션.
|
|
80
|
+
//
|
|
81
|
+
// Audit fix #3 (2026-04-21): 이전에는 `warning: null`로 조용히 진행했다.
|
|
82
|
+
// 사용자가 `가드레일 우선`을 선택했는데 runtime에서 `--dangerously-skip-
|
|
83
|
+
// permissions`가 주입되면 effective가 `완전 신뢰 실행`로 무경고 상승해
|
|
84
|
+
// audit 로그와 대시보드가 실제 실행 신뢰도와 어긋났다. 이제는 상승 이유를
|
|
85
|
+
// warning으로 반환해 session state에 기록하고 사용자에게 표시한다.
|
|
86
|
+
return {
|
|
87
|
+
effective: runtimeTrust,
|
|
88
|
+
warning: `Trust 상승: desired=${desired}, runtime=${runtimeTrust} (${runtime.permission_mode}) ` +
|
|
89
|
+
`— runtime 권한이 더 관대합니다. --dangerously-skip-permissions나 config가 이번 세션을 덮어썼습니다.`,
|
|
90
|
+
};
|
|
81
91
|
}
|
|
82
92
|
return { effective: desired, warning: null };
|
|
83
93
|
}
|
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
import * as fs from 'node:fs';
|
|
8
8
|
import * as path from 'node:path';
|
|
9
9
|
import * as crypto from 'node:crypto';
|
|
10
|
-
import {
|
|
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
13
|
function evidencePath(evidenceId) {
|
|
14
|
-
return path.join(
|
|
14
|
+
return path.join(ME_BEHAVIOR, `${evidenceId}.json`);
|
|
15
15
|
}
|
|
16
16
|
export function createEvidence(params) {
|
|
17
17
|
return {
|
|
@@ -34,13 +34,13 @@ export function loadEvidence(evidenceId) {
|
|
|
34
34
|
return safeReadJSON(evidencePath(evidenceId), null);
|
|
35
35
|
}
|
|
36
36
|
export function loadAllEvidence() {
|
|
37
|
-
if (!fs.existsSync(
|
|
37
|
+
if (!fs.existsSync(ME_BEHAVIOR))
|
|
38
38
|
return [];
|
|
39
39
|
const items = [];
|
|
40
|
-
for (const file of fs.readdirSync(
|
|
40
|
+
for (const file of fs.readdirSync(ME_BEHAVIOR)) {
|
|
41
41
|
if (!file.endsWith('.json'))
|
|
42
42
|
continue;
|
|
43
|
-
const ev = safeReadJSON(path.join(
|
|
43
|
+
const ev = safeReadJSON(path.join(ME_BEHAVIOR, file), null);
|
|
44
44
|
if (ev)
|
|
45
45
|
items.push(ev);
|
|
46
46
|
}
|
|
@@ -7,6 +7,15 @@
|
|
|
7
7
|
import type { Profile, QualityPack, AutonomyPack, JudgmentPack, CommunicationPack, TrustPolicy } from './types.js';
|
|
8
8
|
export declare function createProfile(userId: string, qualityPack: QualityPack, autonomyPack: AutonomyPack, trustPolicy: TrustPolicy, trustSource: Profile['trust_preferences']['source'], judgmentPack?: JudgmentPack, communicationPack?: CommunicationPack): Profile;
|
|
9
9
|
export declare function loadProfile(): Profile | null;
|
|
10
|
+
export declare function loadProfileRaw(): unknown;
|
|
10
11
|
export declare function saveProfile(profile: Profile): void;
|
|
12
|
+
/**
|
|
13
|
+
* File existence probe. NOTE: this returns `true` even if the on-disk
|
|
14
|
+
* file is legacy/invalid — callers that need "valid v1 profile present"
|
|
15
|
+
* should combine this with `loadProfile() !== null`. The raw existence
|
|
16
|
+
* check is kept for bootstrap logic that explicitly differentiates
|
|
17
|
+
* "file exists but legacy" from "no file at all" (e.g. to decide
|
|
18
|
+
* whether to run `runLegacyCutover`).
|
|
19
|
+
*/
|
|
11
20
|
export declare function profileExists(): boolean;
|
|
12
21
|
export declare function isV1Profile(data: unknown): data is Profile;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Authoritative schema: docs/plans/2026-04-03-forgen-data-model-storage-spec.md §2
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from 'node:fs';
|
|
8
|
-
import {
|
|
8
|
+
import { FORGE_PROFILE } from '../core/paths.js';
|
|
9
9
|
import { atomicWriteJSON, safeReadJSON } from '../hooks/shared/atomic-write.js';
|
|
10
10
|
import { qualityCentroid, autonomyCentroid, judgmentCentroid, communicationCentroid, } from '../preset/facet-catalog.js';
|
|
11
11
|
const MODEL_VERSION = '2.0';
|
|
@@ -36,14 +36,35 @@ export function createProfile(userId, qualityPack, autonomyPack, trustPolicy, tr
|
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
38
|
export function loadProfile() {
|
|
39
|
-
|
|
39
|
+
const raw = safeReadJSON(FORGE_PROFILE, null);
|
|
40
|
+
if (raw === null)
|
|
41
|
+
return null;
|
|
42
|
+
// Audit fix #6 (2026-04-21): 이전에는 disk 내용을 그대로 Profile로
|
|
43
|
+
// 타입 단언해 반환 → legacy-shaped JSON (model_version 없음 / 1.x / 잘못된 모양)
|
|
44
|
+
// 이 downstream으로 흘러들어가 facets/trust_preferences 접근 시 undefined
|
|
45
|
+
// 참조가 되었다. isV1Profile 가드를 통과한 경우에만 반환, 아니면 null로
|
|
46
|
+
// 취급하여 v1-bootstrap이 cutover 흐름을 재실행하게 한다.
|
|
47
|
+
if (!isV1Profile(raw))
|
|
48
|
+
return null;
|
|
49
|
+
return raw;
|
|
50
|
+
}
|
|
51
|
+
export function loadProfileRaw() {
|
|
52
|
+
return safeReadJSON(FORGE_PROFILE, null);
|
|
40
53
|
}
|
|
41
54
|
export function saveProfile(profile) {
|
|
42
55
|
profile.metadata.updated_at = new Date().toISOString();
|
|
43
|
-
atomicWriteJSON(
|
|
56
|
+
atomicWriteJSON(FORGE_PROFILE, profile, { pretty: true });
|
|
44
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* File existence probe. NOTE: this returns `true` even if the on-disk
|
|
60
|
+
* file is legacy/invalid — callers that need "valid v1 profile present"
|
|
61
|
+
* should combine this with `loadProfile() !== null`. The raw existence
|
|
62
|
+
* check is kept for bootstrap logic that explicitly differentiates
|
|
63
|
+
* "file exists but legacy" from "no file at all" (e.g. to decide
|
|
64
|
+
* whether to run `runLegacyCutover`).
|
|
65
|
+
*/
|
|
45
66
|
export function profileExists() {
|
|
46
|
-
return fs.existsSync(
|
|
67
|
+
return fs.existsSync(FORGE_PROFILE);
|
|
47
68
|
}
|
|
48
69
|
export function isV1Profile(data) {
|
|
49
70
|
if (!data || typeof data !== 'object')
|