@wooojin/forgen 0.4.0 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +30 -0
- package/README.ja.md +58 -1
- package/README.ko.md +58 -1
- package/README.md +83 -15
- package/README.zh.md +26 -0
- package/dist/checks/conclusion-verification-ratio.d.ts +37 -0
- package/dist/checks/conclusion-verification-ratio.js +86 -0
- package/dist/checks/fact-vs-agreement.d.ts +47 -0
- package/dist/checks/fact-vs-agreement.js +92 -0
- package/dist/checks/self-score-deflation.d.ts +38 -0
- package/dist/checks/self-score-deflation.js +108 -0
- package/dist/cli.js +22 -2
- package/dist/core/auto-compound-runner.js +75 -11
- package/dist/core/dashboard.js +9 -2
- package/dist/core/doctor.js +26 -5
- package/dist/core/extraction-notice.d.ts +18 -0
- package/dist/core/extraction-notice.js +64 -0
- package/dist/core/init-cli.d.ts +26 -0
- package/dist/core/init-cli.js +104 -0
- package/dist/core/init.js +17 -0
- package/dist/core/inspect-cli.js +1 -2
- package/dist/core/migrate-cli.d.ts +10 -0
- package/dist/core/migrate-cli.js +34 -0
- package/dist/core/paths.d.ts +8 -1
- package/dist/core/paths.js +11 -2
- package/dist/core/recall-cli.d.ts +26 -0
- package/dist/core/recall-cli.js +125 -0
- package/dist/core/recall-reference-detector.d.ts +43 -0
- package/dist/core/recall-reference-detector.js +65 -0
- package/dist/core/stats-cli.d.ts +21 -0
- package/dist/core/stats-cli.js +121 -10
- package/dist/core/uninstall.js +2 -1
- package/dist/engine/compound-cli.js +1 -0
- package/dist/engine/compound-export.js +8 -3
- package/dist/engine/learn-cli.js +1 -4
- package/dist/engine/lifecycle/lifecycle-cli.js +4 -4
- package/dist/engine/lifecycle/meta-reclassifier.js +3 -3
- package/dist/engine/lifecycle/orchestrator.js +2 -2
- package/dist/engine/lifecycle/signals.js +6 -6
- package/dist/engine/meta-learning/session-quality-scorer.d.ts +1 -6
- package/dist/engine/meta-learning/session-quality-scorer.js +2 -21
- package/dist/engine/skill-promoter.js +3 -6
- package/dist/hooks/context-guard.js +1 -1
- package/dist/hooks/dangerous-patterns.json +3 -3
- package/dist/hooks/db-guard.js +18 -2
- package/dist/hooks/intent-classifier.js +1 -1
- package/dist/hooks/keyword-detector.js +1 -1
- package/dist/hooks/notepad-injector.js +1 -1
- package/dist/hooks/permission-handler.js +1 -1
- package/dist/hooks/post-tool-failure.js +1 -1
- package/dist/hooks/post-tool-use.d.ts +6 -0
- package/dist/hooks/post-tool-use.js +37 -19
- package/dist/hooks/pre-compact.js +1 -1
- package/dist/hooks/pre-tool-use.d.ts +7 -0
- package/dist/hooks/pre-tool-use.js +24 -6
- package/dist/hooks/rate-limiter.js +1 -1
- package/dist/hooks/secret-filter.js +1 -1
- package/dist/hooks/session-recovery.js +1 -1
- package/dist/hooks/shared/command-parser.d.ts +44 -0
- package/dist/hooks/shared/command-parser.js +50 -0
- package/dist/hooks/shared/hook-response.d.ts +12 -2
- package/dist/hooks/shared/hook-response.js +30 -3
- package/dist/hooks/skill-injector.js +1 -1
- package/dist/hooks/slop-detector.js +2 -2
- package/dist/hooks/solution-injector.d.ts +9 -0
- package/dist/hooks/solution-injector.js +48 -5
- package/dist/hooks/stop-guard.js +137 -13
- package/dist/hooks/subagent-tracker.js +1 -1
- package/dist/i18n/index.js +3 -5
- package/dist/store/evidence-store.js +11 -0
- package/dist/store/implicit-feedback-store.d.ts +59 -0
- package/dist/store/implicit-feedback-store.js +153 -0
- package/dist/store/rule-store.js +8 -0
- package/package.json +2 -2
- package/plugin.json +1 -1
package/dist/core/stats-cli.js
CHANGED
|
@@ -6,13 +6,15 @@
|
|
|
6
6
|
* working between Claude sessions.
|
|
7
7
|
*/
|
|
8
8
|
import * as fs from 'node:fs';
|
|
9
|
-
import * as os from 'node:os';
|
|
10
9
|
import * as path from 'node:path';
|
|
11
10
|
import { loadAllRules } from '../store/rule-store.js';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
import { loadAllEvidence } from '../store/evidence-store.js';
|
|
12
|
+
import { STATE_DIR, ME_DIR } from './paths.js';
|
|
13
|
+
// v0.4.1 격리 fix: 이전에는 os.homedir() 직접 사용해서 FORGEN_HOME env 로
|
|
14
|
+
// 홈 격리해도 이 파일의 경로는 여전히 실 홈 가리켰음. paths.ts 상수 import.
|
|
15
|
+
const ENFORCEMENT_DIR = path.join(STATE_DIR, 'enforcement');
|
|
16
|
+
const LIFECYCLE_DIR = path.join(STATE_DIR, 'lifecycle');
|
|
17
|
+
const SOLUTIONS_DIR = path.join(ME_DIR, 'solutions');
|
|
16
18
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
17
19
|
function readJsonl(p) {
|
|
18
20
|
if (!fs.existsSync(p))
|
|
@@ -61,12 +63,23 @@ function readLifecycleRetired(days) {
|
|
|
61
63
|
return n;
|
|
62
64
|
}
|
|
63
65
|
function readLastExtraction() {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
// v0.4.1 파일명 정합: auto-compound-runner 는 last-auto-compound.json 에 기록.
|
|
67
|
+
// 이전 코드가 last-extraction.json 을 찾아 "never" 가 만성적으로 표시됨 —
|
|
68
|
+
// 실은 매 auto-compound 세션마다 값이 업데이트되고 있는데도 stats 에 반영 X.
|
|
69
|
+
const candidates = ['last-auto-compound.json', 'last-extraction.json'];
|
|
70
|
+
let p = null;
|
|
71
|
+
for (const name of candidates) {
|
|
72
|
+
const candidate = path.join(STATE_DIR, name);
|
|
73
|
+
if (fs.existsSync(candidate)) {
|
|
74
|
+
p = candidate;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!p)
|
|
66
79
|
return 'never';
|
|
67
80
|
try {
|
|
68
81
|
const data = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
69
|
-
const ts = data.timestamp ?? data.date;
|
|
82
|
+
const ts = data.completedAt ?? data.timestamp ?? data.date;
|
|
70
83
|
if (!ts)
|
|
71
84
|
return 'never';
|
|
72
85
|
const diffDays = Math.floor((Date.now() - Date.parse(ts)) / MS_PER_DAY);
|
|
@@ -81,17 +94,90 @@ function readLastExtraction() {
|
|
|
81
94
|
return 'unknown';
|
|
82
95
|
}
|
|
83
96
|
}
|
|
97
|
+
/** H3: 오늘 (local midnight ~ now) 기준 assist 카운트. */
|
|
98
|
+
function computeAssistToday() {
|
|
99
|
+
const startOfDay = new Date();
|
|
100
|
+
startOfDay.setHours(0, 0, 0, 0);
|
|
101
|
+
const cutoffMs = startOfDay.getTime();
|
|
102
|
+
// recall hits: match-eval-log 의 오늘 entries
|
|
103
|
+
const matchLog = readJsonl(path.join(STATE_DIR, 'match-eval-log.jsonl'));
|
|
104
|
+
let recallHits = 0;
|
|
105
|
+
for (const e of matchLog) {
|
|
106
|
+
const ts = typeof e.ts === 'string' ? Date.parse(e.ts) : NaN;
|
|
107
|
+
if (Number.isFinite(ts) && ts >= cutoffMs)
|
|
108
|
+
recallHits++;
|
|
109
|
+
}
|
|
110
|
+
// surfaced + referenced — 같은 스트림 1회 loop 로.
|
|
111
|
+
const feedback = readJsonl(path.join(STATE_DIR, 'implicit-feedback.jsonl'));
|
|
112
|
+
let surfaced = 0;
|
|
113
|
+
let referenced = 0;
|
|
114
|
+
for (const e of feedback) {
|
|
115
|
+
const ts = typeof e.at === 'string' ? Date.parse(e.at) : NaN;
|
|
116
|
+
if (!Number.isFinite(ts) || ts < cutoffMs)
|
|
117
|
+
continue;
|
|
118
|
+
if (e.type === 'recommendation_surfaced')
|
|
119
|
+
surfaced++;
|
|
120
|
+
else if (e.type === 'recall_referenced')
|
|
121
|
+
referenced++;
|
|
122
|
+
}
|
|
123
|
+
// extracted today: solutions dir 에서 오늘 mtime 인 .md 파일
|
|
124
|
+
let extractedToday = 0;
|
|
125
|
+
try {
|
|
126
|
+
if (fs.existsSync(SOLUTIONS_DIR)) {
|
|
127
|
+
for (const f of fs.readdirSync(SOLUTIONS_DIR)) {
|
|
128
|
+
if (!f.endsWith('.md'))
|
|
129
|
+
continue;
|
|
130
|
+
const stat = fs.statSync(path.join(SOLUTIONS_DIR, f));
|
|
131
|
+
if (stat.mtimeMs >= cutoffMs)
|
|
132
|
+
extractedToday++;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch { /* fail-open */ }
|
|
137
|
+
return { recallHits, surfaced, referenced, extractedToday };
|
|
138
|
+
}
|
|
139
|
+
/** v0.4.1: forge-profile 에서 고도화 지표 추출. 파일 없거나 깨지면 undefined. */
|
|
140
|
+
function computePhilosophy() {
|
|
141
|
+
try {
|
|
142
|
+
const profilePath = path.join(ME_DIR, 'forge-profile.json');
|
|
143
|
+
if (!fs.existsSync(profilePath))
|
|
144
|
+
return undefined;
|
|
145
|
+
const d = JSON.parse(fs.readFileSync(profilePath, 'utf-8'));
|
|
146
|
+
const axisScores = {};
|
|
147
|
+
for (const [k, v] of Object.entries(d.axes ?? {})) {
|
|
148
|
+
if (v && typeof v.score === 'number')
|
|
149
|
+
axisScores[k] = v.score;
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
basePacks: Object.values(d.base_packs ?? {}),
|
|
153
|
+
trustPolicy: d.trust_preferences?.desired_policy ?? 'unknown',
|
|
154
|
+
axisScores,
|
|
155
|
+
lastReclassification: d.metadata?.last_reclassification_at ?? null,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
84
162
|
export function computeStats() {
|
|
85
163
|
const rules = loadAllRules();
|
|
86
164
|
const activeRules = rules.filter((r) => r.status === 'active').length;
|
|
87
165
|
const suppressedRules = rules.filter((r) => r.status === 'suppressed').length;
|
|
88
|
-
|
|
166
|
+
// v0.4.1 정확도 수정: loadRecentEvidence(500) 제한은 "Corrections (total)"
|
|
167
|
+
// 라벨과 모순 — 실 behavior 618건 중 118건 누락됐음. 전체 evidence 스캔으로
|
|
168
|
+
// 교체. explicit_correction 만 filter 이므로 memory overhead 는 N * ~1KB 수준.
|
|
169
|
+
const evidence = loadAllEvidence();
|
|
89
170
|
const corrections = evidence.filter((e) => e.type === 'explicit_correction');
|
|
90
171
|
const correctionsTotal = corrections.length;
|
|
91
172
|
const cutoff7d = Date.now() - 7 * MS_PER_DAY;
|
|
92
173
|
const corrections7d = corrections.filter((e) => Date.parse(e.timestamp) >= cutoff7d).length;
|
|
93
174
|
const violations = readJsonl(path.join(ENFORCEMENT_DIR, 'violations.jsonl'));
|
|
94
|
-
|
|
175
|
+
// v0.4.1 historical false-positive 제거: pre-0.4.1 bypass-detector 가 Write/Edit
|
|
176
|
+
// content 의 quote 본문까지 raw 매칭해서 bypass 로 오기록. 실 관찰: L1-no-rm-rf
|
|
177
|
+
// -unconfirmed bypass 20건 중 Write/Edit 15건. stats 표시는 **실 실행 맥락** 인
|
|
178
|
+
// Bash/Agent/기타만 집계 — 앞으로의 시계열 일관성 + 과거 noise 제거.
|
|
179
|
+
const bypassRaw = readJsonl(path.join(ENFORCEMENT_DIR, 'bypass.jsonl'));
|
|
180
|
+
const bypass = bypassRaw.filter((e) => e.tool !== 'Write' && e.tool !== 'Edit');
|
|
95
181
|
const drift = readJsonl(path.join(ENFORCEMENT_DIR, 'drift.jsonl'));
|
|
96
182
|
const acks = readJsonl(path.join(ENFORCEMENT_DIR, 'acknowledgments.jsonl'));
|
|
97
183
|
// R9-PA2: violations 는 'block' (stop-guard/post-tool) + 'deny' (pre-tool Mech-A)
|
|
@@ -109,6 +195,8 @@ export function computeStats() {
|
|
|
109
195
|
drift7d: countWithin(drift, 7),
|
|
110
196
|
retired7d: readLifecycleRetired(7),
|
|
111
197
|
lastExtraction: readLastExtraction(),
|
|
198
|
+
assistToday: computeAssistToday(),
|
|
199
|
+
philosophy: computePhilosophy(),
|
|
112
200
|
};
|
|
113
201
|
}
|
|
114
202
|
function padNum(n, width = 4) {
|
|
@@ -133,6 +221,29 @@ export function renderStats(s) {
|
|
|
133
221
|
lines.push(` Drift events ${padNum(s.drift7d)} — stuck-loop force-approves`);
|
|
134
222
|
lines.push(` Retired rules ${padNum(s.retired7d)} — superseded or timed out`);
|
|
135
223
|
lines.push('');
|
|
224
|
+
// H3: Assist 축 — enforcement 옆에 나란히 가시화.
|
|
225
|
+
lines.push(' Today (assist)');
|
|
226
|
+
lines.push(` Recall hits ${padNum(s.assistToday.recallHits)} — compound 매칭 시도 수`);
|
|
227
|
+
lines.push(` Surfaced ${padNum(s.assistToday.surfaced)} — 실제 주입된 솔루션 수`);
|
|
228
|
+
const ratio = s.assistToday.surfaced > 0
|
|
229
|
+
? ` (${Math.round(100 * s.assistToday.referenced / s.assistToday.surfaced)}% referenced)`
|
|
230
|
+
: '';
|
|
231
|
+
lines.push(` Referenced ${padNum(s.assistToday.referenced)} — Claude 응답에 인용됨${ratio}`);
|
|
232
|
+
lines.push(` Extracted ${padNum(s.assistToday.extractedToday)} — 오늘 새로 저장된 패턴`);
|
|
233
|
+
lines.push('');
|
|
234
|
+
// v0.4.1 철학 고도화 단면 — "개인화가 어디까지 학습됐나" 1섹션.
|
|
235
|
+
if (s.philosophy) {
|
|
236
|
+
lines.push(' Philosophy (learned)');
|
|
237
|
+
lines.push(` Base packs ${s.philosophy.basePacks.join(' / ')}`);
|
|
238
|
+
lines.push(` Trust policy ${s.philosophy.trustPolicy}`);
|
|
239
|
+
const scores = Object.entries(s.philosophy.axisScores)
|
|
240
|
+
.map(([k, v]) => `${k}:${v.toFixed(2)}`)
|
|
241
|
+
.join(' ');
|
|
242
|
+
if (scores)
|
|
243
|
+
lines.push(` Axis scores ${scores}`);
|
|
244
|
+
lines.push(` Last reclass ${s.philosophy.lastReclassification ?? 'never'}`);
|
|
245
|
+
lines.push('');
|
|
246
|
+
}
|
|
136
247
|
lines.push(` Last extraction: ${s.lastExtraction}`);
|
|
137
248
|
lines.push('');
|
|
138
249
|
return lines.join('\n');
|
package/dist/core/uninstall.js
CHANGED
|
@@ -321,7 +321,8 @@ export async function handleUninstall(cwd, options) {
|
|
|
321
321
|
cleanPluginArtifacts();
|
|
322
322
|
if (options.purge) {
|
|
323
323
|
try {
|
|
324
|
-
const
|
|
324
|
+
const { FORGEN_HOME } = await import('./paths.js');
|
|
325
|
+
const forgenHome = FORGEN_HOME;
|
|
325
326
|
if (fs.existsSync(forgenHome)) {
|
|
326
327
|
fs.rmSync(forgenHome, { recursive: true, force: true });
|
|
327
328
|
console.log(' ✓ Deleted ~/.forgen/ (all rules, state, solutions, behavior)');
|
|
@@ -65,6 +65,7 @@ export function listSolutions() {
|
|
|
65
65
|
}
|
|
66
66
|
const order = ['mature', 'verified', 'candidate', 'experiment', 'retired'];
|
|
67
67
|
console.log('\n Compound Entries\n');
|
|
68
|
+
console.log(' (inj: 누적 주입, ref: 명시 참조, neg: 부정 피드백 — ref 측정은 v0.4.1+ 부터 시작됨)\n');
|
|
68
69
|
let total = 0;
|
|
69
70
|
for (const status of order) {
|
|
70
71
|
const group = groups[status];
|
|
@@ -33,14 +33,19 @@ export function isPathInside(parentWithSep, candidate) {
|
|
|
33
33
|
return resolved.startsWith(parentWithSep) && resolved !== parentWithSep;
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
|
-
* Count
|
|
37
|
-
*
|
|
36
|
+
* Count knowledge files in a directory (non-recursive). `.md` (solutions) + `.json`
|
|
37
|
+
* (rules, behavior, evidence) 둘 다 포함.
|
|
38
|
+
*
|
|
39
|
+
* v0.4.1 (2026-04-24): 이전에는 `.md` 만 세서 rules/*.json 전부가 count=0, export
|
|
40
|
+
* 대상에서도 제외됐다. 실증: `forgen compound export` 결과 rules 0, behavior 18
|
|
41
|
+
* (618건의 .json 누락). 사용자 철학의 **핵심**인 rules 가 하네스에서 빠진 상태
|
|
42
|
+
* 라 "나와 같은 데이터 하네스" 비전이 반쪽이었다.
|
|
38
43
|
*/
|
|
39
44
|
function countFiles(dir) {
|
|
40
45
|
try {
|
|
41
46
|
if (!fs.existsSync(dir))
|
|
42
47
|
return 0;
|
|
43
|
-
return fs.readdirSync(dir).filter(f => f.endsWith('.md')).length;
|
|
48
|
+
return fs.readdirSync(dir).filter(f => f.endsWith('.md') || f.endsWith('.json')).length;
|
|
44
49
|
}
|
|
45
50
|
catch {
|
|
46
51
|
return 0;
|
package/dist/engine/learn-cli.js
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
-
import * as os from 'node:os';
|
|
4
3
|
import { fixupSolutions } from './solution-fixup.js';
|
|
5
4
|
import { listQuarantined, pruneQuarantine } from './solution-quarantine.js';
|
|
6
5
|
import { computeFitness } from './solution-fitness.js';
|
|
7
6
|
import { buildWeaknessReport, saveWeaknessReport } from './solution-weakness.js';
|
|
8
7
|
import { listCandidates, promoteCandidate, rollbackSince } from './solution-candidate.js';
|
|
9
|
-
|
|
10
|
-
const STATE_DIR = path.join(os.homedir(), '.forgen', 'state');
|
|
11
|
-
const OUTCOMES_DIR = path.join(STATE_DIR, 'outcomes');
|
|
8
|
+
import { ME_SOLUTIONS, OUTCOMES_DIR } from '../core/paths.js';
|
|
12
9
|
export async function handleLearn(args) {
|
|
13
10
|
const sub = args[0];
|
|
14
11
|
if (sub === 'fix-up')
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* default dry-run, --apply 시 rule 파일에 상태 전이 반영.
|
|
6
6
|
*/
|
|
7
7
|
import * as path from 'node:path';
|
|
8
|
-
import * as os from 'node:os';
|
|
9
8
|
import { loadAllRules, saveRule } from '../../store/rule-store.js';
|
|
9
|
+
import { STATE_DIR } from '../../core/paths.js';
|
|
10
10
|
import { collectSignals, readJsonlSafe } from './signals.js';
|
|
11
11
|
import { detect as detectT2 } from './trigger-t2-violation.js';
|
|
12
12
|
import { detect as detectT3 } from './trigger-t3-bypass.js';
|
|
@@ -14,7 +14,7 @@ import { detect as detectT4 } from './trigger-t4-decay.js';
|
|
|
14
14
|
import { detect as detectT5 } from './trigger-t5-conflict.js';
|
|
15
15
|
import { scanDriftForDemotion, applyDemotion, scanSignalsForPromotion, applyPromotion, readDriftEntries, appendLifecycleEvents, } from './meta-reclassifier.js';
|
|
16
16
|
import { foldEvents } from './orchestrator.js';
|
|
17
|
-
const LIFECYCLE_DIR = path.join(
|
|
17
|
+
const LIFECYCLE_DIR = path.join(STATE_DIR, 'lifecycle');
|
|
18
18
|
export async function handleLifecycleScan(args) {
|
|
19
19
|
const apply = args.includes('--apply');
|
|
20
20
|
const now = Date.now();
|
|
@@ -23,8 +23,8 @@ export async function handleLifecycleScan(args) {
|
|
|
23
23
|
console.log('\n No rules in ~/.forgen/me/rules. Nothing to scan.\n');
|
|
24
24
|
return;
|
|
25
25
|
}
|
|
26
|
-
const violations = readJsonlSafe(path.join(
|
|
27
|
-
const bypass = readJsonlSafe(path.join(
|
|
26
|
+
const violations = readJsonlSafe(path.join(STATE_DIR, 'enforcement', 'violations.jsonl'));
|
|
27
|
+
const bypass = readJsonlSafe(path.join(STATE_DIR, 'enforcement', 'bypass.jsonl'));
|
|
28
28
|
const drift = readDriftEntries();
|
|
29
29
|
const signals = new Map();
|
|
30
30
|
for (const r of rules)
|
|
@@ -16,12 +16,12 @@
|
|
|
16
16
|
* - 30 일 쿨다운.
|
|
17
17
|
*/
|
|
18
18
|
import * as fs from 'node:fs';
|
|
19
|
-
import * as os from 'node:os';
|
|
20
19
|
import * as path from 'node:path';
|
|
21
20
|
import { initLifecycle } from '../../store/rule-lifecycle.js';
|
|
22
21
|
import { loadAllRules, saveRule } from '../../store/rule-store.js';
|
|
23
|
-
|
|
24
|
-
const
|
|
22
|
+
import { STATE_DIR } from '../../core/paths.js';
|
|
23
|
+
const DRIFT_LOG_PATH = path.join(STATE_DIR, 'enforcement', 'drift.jsonl');
|
|
24
|
+
const LIFECYCLE_DIR = path.join(STATE_DIR, 'lifecycle');
|
|
25
25
|
const DEFAULT_WINDOW_DAYS = 7;
|
|
26
26
|
const DEFAULT_THRESHOLD = 3;
|
|
27
27
|
/** R8-A1: Meta 재분류 쿨다운 (일). 최근 이 기간 내 변경된 rule 은 다시 재분류 금지. */
|
|
@@ -21,15 +21,15 @@
|
|
|
21
21
|
* promote/demote_mech → phase 유지, meta_promotions 는 meta-reclassifier 가 직접 기록
|
|
22
22
|
*/
|
|
23
23
|
import * as fs from 'node:fs';
|
|
24
|
-
import * as os from 'node:os';
|
|
25
24
|
import * as path from 'node:path';
|
|
25
|
+
import { STATE_DIR } from '../../core/paths.js';
|
|
26
26
|
/**
|
|
27
27
|
* R5-B1: rule 이 inactive 상태로 전이될 때 block-count 디렉터리의 잔여 파일 정리.
|
|
28
28
|
* phantom stuck-loop (retired 된 rule 이 다시 GC 전까지 counter 에 반영되는 문제) 차단.
|
|
29
29
|
*/
|
|
30
30
|
function sweepBlockCountsForRule(ruleId) {
|
|
31
31
|
try {
|
|
32
|
-
const dir = path.join(
|
|
32
|
+
const dir = path.join(STATE_DIR, 'enforcement', 'block-count');
|
|
33
33
|
if (!fs.existsSync(dir))
|
|
34
34
|
return;
|
|
35
35
|
const safeRuleId = String(ruleId).replace(/[^a-zA-Z0-9_.-]/g, '_');
|
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
* 모든 IO 는 이 파일에 한정. 트리거들은 pure — collectSignals() 결과를 받아 detect().
|
|
10
10
|
*/
|
|
11
11
|
import * as fs from 'node:fs';
|
|
12
|
-
import * as os from 'node:os';
|
|
13
12
|
import * as path from 'node:path';
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
const
|
|
13
|
+
import { STATE_DIR as FORGEN_STATE_DIR } from '../../core/paths.js';
|
|
14
|
+
const ENFORCEMENT_DIR = path.join(FORGEN_STATE_DIR, 'enforcement');
|
|
15
|
+
const VIOLATIONS_PATH = path.join(ENFORCEMENT_DIR, 'violations.jsonl');
|
|
16
|
+
const BYPASS_PATH = path.join(ENFORCEMENT_DIR, 'bypass.jsonl');
|
|
17
17
|
const ROLLING_N = 20;
|
|
18
18
|
const VIOLATION_WINDOW_DAYS = 30;
|
|
19
19
|
const BYPASS_WINDOW_DAYS = 7;
|
|
@@ -59,7 +59,7 @@ export function readJsonlSafe(p) {
|
|
|
59
59
|
}
|
|
60
60
|
export function recordViolation(entry) {
|
|
61
61
|
try {
|
|
62
|
-
fs.mkdirSync(
|
|
62
|
+
fs.mkdirSync(ENFORCEMENT_DIR, { recursive: true });
|
|
63
63
|
rotateIfBig(VIOLATIONS_PATH);
|
|
64
64
|
const full = { at: new Date().toISOString(), ...entry };
|
|
65
65
|
fs.appendFileSync(VIOLATIONS_PATH, JSON.stringify(full) + '\n');
|
|
@@ -73,7 +73,7 @@ export function recordViolation(entry) {
|
|
|
73
73
|
}
|
|
74
74
|
export function recordBypass(entry) {
|
|
75
75
|
try {
|
|
76
|
-
fs.mkdirSync(
|
|
76
|
+
fs.mkdirSync(ENFORCEMENT_DIR, { recursive: true });
|
|
77
77
|
rotateIfBig(BYPASS_PATH);
|
|
78
78
|
const full = { at: new Date().toISOString(), ...entry };
|
|
79
79
|
fs.appendFileSync(BYPASS_PATH, JSON.stringify(full) + '\n');
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* - state/sessions/{sessionId}.json → session metadata
|
|
13
13
|
*/
|
|
14
14
|
import type { SessionQualityScore } from './types.js';
|
|
15
|
+
import { type ImplicitFeedbackEntry } from '../../store/implicit-feedback-store.js';
|
|
15
16
|
interface InjectionCacheData {
|
|
16
17
|
injected: string[];
|
|
17
18
|
totalInjectedChars: number;
|
|
@@ -29,12 +30,6 @@ interface DriftState {
|
|
|
29
30
|
lastCriticalAt: number | null;
|
|
30
31
|
hardCapReached: boolean;
|
|
31
32
|
}
|
|
32
|
-
interface ImplicitFeedbackEntry {
|
|
33
|
-
type: string;
|
|
34
|
-
sessionId?: string;
|
|
35
|
-
at: string;
|
|
36
|
-
[key: string]: unknown;
|
|
37
|
-
}
|
|
38
33
|
export declare function loadInjectionCache(sessionId: string): InjectionCacheData | null;
|
|
39
34
|
export declare function loadDriftState(sessionId: string): DriftState | null;
|
|
40
35
|
export declare function loadImplicitFeedback(sessionId: string): ImplicitFeedbackEntry[];
|
|
@@ -15,6 +15,7 @@ import * as fs from 'node:fs';
|
|
|
15
15
|
import * as path from 'node:path';
|
|
16
16
|
import { ME_BEHAVIOR, STATE_DIR } from '../../core/paths.js';
|
|
17
17
|
import { safeReadJSON } from '../../hooks/shared/atomic-write.js';
|
|
18
|
+
import { loadImplicitFeedback as loadImplicitFeedbackFromStore, } from '../../store/implicit-feedback-store.js';
|
|
18
19
|
function sanitizeId(id) {
|
|
19
20
|
return id.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
20
21
|
}
|
|
@@ -32,27 +33,7 @@ export function loadDriftState(sessionId) {
|
|
|
32
33
|
return data?.drift ?? null;
|
|
33
34
|
}
|
|
34
35
|
export function loadImplicitFeedback(sessionId) {
|
|
35
|
-
|
|
36
|
-
try {
|
|
37
|
-
if (!fs.existsSync(logPath))
|
|
38
|
-
return [];
|
|
39
|
-
const lines = fs.readFileSync(logPath, 'utf-8').split('\n').filter(Boolean);
|
|
40
|
-
const entries = [];
|
|
41
|
-
for (const line of lines) {
|
|
42
|
-
try {
|
|
43
|
-
const entry = JSON.parse(line);
|
|
44
|
-
if (entry.sessionId === sessionId)
|
|
45
|
-
entries.push(entry);
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
48
|
-
/* skip malformed lines */
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
return entries;
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
return [];
|
|
55
|
-
}
|
|
36
|
+
return loadImplicitFeedbackFromStore(sessionId);
|
|
56
37
|
}
|
|
57
38
|
export function loadSessionCorrections(sessionId) {
|
|
58
39
|
try {
|
|
@@ -6,15 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from 'node:fs';
|
|
8
8
|
import * as path from 'node:path';
|
|
9
|
-
import * as os from 'node:os';
|
|
10
9
|
import { parseSolutionV3 } from './solution-format.js';
|
|
11
10
|
import { createLogger } from '../core/logger.js';
|
|
11
|
+
import { ME_SOLUTIONS, ME_SKILLS, CLAUDE_DIR } from '../core/paths.js';
|
|
12
12
|
const log = createLogger('skill-promoter');
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
const ME_SKILLS = path.join(FORGEN_HOME, 'me', 'skills');
|
|
16
|
-
// Claude Code가 자동 인식하는 글로벌 스킬 경로
|
|
17
|
-
const CLAUDE_SKILLS = path.join(os.homedir(), '.claude', 'skills');
|
|
13
|
+
// Claude Code가 자동 인식하는 글로벌 스킬 경로 (~/.claude/skills)
|
|
14
|
+
const CLAUDE_SKILLS = path.join(CLAUDE_DIR, 'skills');
|
|
18
15
|
// 일반적인 태그 제외 (트리거로 부적합)
|
|
19
16
|
const GENERIC_TAGS = new Set([
|
|
20
17
|
'typescript', 'javascript', 'react', 'node', 'error', 'fix', 'code',
|
|
@@ -408,6 +408,6 @@ function saveHandoff(sessionId, reason, detail) {
|
|
|
408
408
|
if (process.argv[1] && fs.realpathSync(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
|
|
409
409
|
main().catch((e) => {
|
|
410
410
|
process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
|
|
411
|
-
console.log(failOpenWithTracking('context-guard'));
|
|
411
|
+
console.log(failOpenWithTracking('context-guard', e));
|
|
412
412
|
});
|
|
413
413
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
[
|
|
2
|
-
{ "pattern": "rm\\s+(-rf|-fr)\\s+
|
|
2
|
+
{ "pattern": "rm\\s+(-rf|-fr)\\s+(\\/(?!tmp\\b|var\\/folders\\b|var\\/tmp\\b)|~)", "description": "rm -rf on root/home path", "severity": "block" },
|
|
3
3
|
{ "pattern": "rm\\s+(-rf|-fr)\\s+\\.\\s", "description": "rm -rf on current directory", "severity": "block" },
|
|
4
4
|
{ "pattern": "git\\s+push\\s+.*--force(?!-)", "description": "git push --force", "severity": "warn" },
|
|
5
5
|
{ "pattern": "git\\s+reset\\s+--hard", "description": "git reset --hard", "severity": "warn" },
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
{ "pattern": ">\\s*\\/dev\\/sd[a-z]", "description": "write to block device", "severity": "block" },
|
|
10
10
|
{ "pattern": "mkfs\\s", "description": "mkfs (format filesystem)", "severity": "block" },
|
|
11
11
|
{ "pattern": ":\\(\\)\\s*\\{\\s*:\\|:&\\s*\\}\\s*;:", "description": "fork bomb", "severity": "block" },
|
|
12
|
-
{ "pattern": "\\beval\\s+[\"'`]", "description": "eval with string (injection risk)", "severity": "warn" },
|
|
12
|
+
{ "pattern": "\\beval\\s+[\"'`]", "description": "eval with string (injection risk)", "severity": "warn", "match_target": "raw" },
|
|
13
13
|
{ "pattern": "curl\\s+.*\\|\\s*(ba)?sh", "description": "curl pipe to shell", "severity": "block" },
|
|
14
14
|
{ "pattern": "wget\\s+.*\\|\\s*(ba)?sh", "description": "wget pipe to shell", "severity": "block" },
|
|
15
|
-
{ "pattern": "python[23]?\\s+-c\\s+['\"].*(?:import\\s+os|subprocess|exec|eval)", "description": "python -c with dangerous imports", "severity": "warn" },
|
|
15
|
+
{ "pattern": "python[23]?\\s+-c\\s+['\"].*(?:import\\s+os|subprocess|exec|eval)", "description": "python -c with dangerous imports", "severity": "warn", "match_target": "raw" },
|
|
16
16
|
{ "pattern": "\\bchmod\\s+[0-7]*777\\b", "description": "chmod 777 (overly permissive)", "severity": "warn" },
|
|
17
17
|
{ "pattern": "\\bdd\\s+.*of=\\/dev\\/", "description": "dd write to device", "severity": "block" }
|
|
18
18
|
]
|
package/dist/hooks/db-guard.js
CHANGED
|
@@ -11,6 +11,7 @@ import { atomicWriteJSON } from './shared/atomic-write.js';
|
|
|
11
11
|
import { isHookEnabled } from './hook-config.js';
|
|
12
12
|
import { approve, approveWithWarning, deny, failOpenWithTracking } from './shared/hook-response.js';
|
|
13
13
|
import { STATE_DIR } from '../core/paths.js';
|
|
14
|
+
import { preprocessForMatch } from './shared/command-parser.js';
|
|
14
15
|
const FAIL_COUNTER_PATH = path.join(STATE_DIR, 'db-guard-fail-counter.json');
|
|
15
16
|
const FAIL_CLOSE_THRESHOLD = 3;
|
|
16
17
|
export const DANGEROUS_SQL_PATTERNS = [
|
|
@@ -27,8 +28,23 @@ export function checkDangerousSql(toolName, toolInput) {
|
|
|
27
28
|
const command = typeof toolInput === 'string'
|
|
28
29
|
? toolInput
|
|
29
30
|
: (toolInput.command ?? '');
|
|
31
|
+
// TEST-6 확장 (2026-04-24): DB CLI allowlist 기반 quote-aware 전처리.
|
|
32
|
+
//
|
|
33
|
+
// 결함: 이전에는 raw command 를 직접 매칭해 `git commit -m "... DROP TABLE ..."`
|
|
34
|
+
// 같은 quote 안 SQL 키워드까지 block (실증: 이번 세션 내 release 커밋 메시지 차단).
|
|
35
|
+
//
|
|
36
|
+
// 단순히 masked 만 쓰면 `psql -c "DROP TABLE users"` 같은 실 DB 실행의 True-Positive
|
|
37
|
+
// 까지 놓친다. 해법: masked 처리 후에도 **DB CLI 토큰** 이 보이면 진짜 실행 의도
|
|
38
|
+
// 라고 판단해 raw 를 검사, 아니면 masked 를 검사.
|
|
39
|
+
// - `psql -c "DROP TABLE"` → masked: `psql -c ""` → psql 존재 → raw 검사 → block
|
|
40
|
+
// - `git commit -m "DROP TABLE"` → masked: `git commit -m ""` → psql 없음 → masked 검사 → pass
|
|
41
|
+
// - `DROP DATABASE production` (direct SQL) → masked 그대로 (quote 없음) → block
|
|
42
|
+
const maskedCommand = preprocessForMatch(command, 'masked');
|
|
43
|
+
const dbCliRe = /\b(psql|mysql|sqlite3?|pg_restore|mongosh|mysqldump|cockroach\s+sql|redis-cli)\b/i;
|
|
44
|
+
const hasDbCli = dbCliRe.test(maskedCommand);
|
|
45
|
+
const scanCommand = hasDbCli ? command : maskedCommand;
|
|
30
46
|
// 주석 제거 후 SQL에 대해 패턴 매칭 (주석 안 키워드 오차단 방지)
|
|
31
|
-
const sqlWithoutComments =
|
|
47
|
+
const sqlWithoutComments = scanCommand
|
|
32
48
|
.replace(/--[^\n]*/g, '') // 라인 주석 제거
|
|
33
49
|
.replace(/\/\*[\s\S]*?\*\//g, ''); // 블록 주석 제거
|
|
34
50
|
for (const { pattern, description, severity } of DANGEROUS_SQL_PATTERNS) {
|
|
@@ -101,5 +117,5 @@ async function main() {
|
|
|
101
117
|
}
|
|
102
118
|
main().catch((e) => {
|
|
103
119
|
process.stderr.write(`[ch-hook] DB Guard error: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
104
|
-
console.log(failOpenWithTracking('db-guard'));
|
|
120
|
+
console.log(failOpenWithTracking('db-guard', e));
|
|
105
121
|
});
|
|
@@ -83,5 +83,5 @@ async function main() {
|
|
|
83
83
|
}
|
|
84
84
|
main().catch((e) => {
|
|
85
85
|
process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
|
|
86
|
-
console.log(failOpenWithTracking('intent-classifier'));
|
|
86
|
+
console.log(failOpenWithTracking('intent-classifier', e));
|
|
87
87
|
});
|
|
@@ -308,6 +308,6 @@ async function main() {
|
|
|
308
308
|
if (process.argv[1] && fs.realpathSync(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
|
|
309
309
|
main().catch((e) => {
|
|
310
310
|
process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
|
|
311
|
-
console.log(failOpenWithTracking('keyword-detector'));
|
|
311
|
+
console.log(failOpenWithTracking('keyword-detector', e));
|
|
312
312
|
});
|
|
313
313
|
}
|
|
@@ -129,5 +129,5 @@ async function main() {
|
|
|
129
129
|
}
|
|
130
130
|
main().catch((e) => {
|
|
131
131
|
process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
|
|
132
|
-
console.log(failOpenWithTracking('permission-handler'));
|
|
132
|
+
console.log(failOpenWithTracking('permission-handler', e));
|
|
133
133
|
});
|
|
@@ -127,5 +127,5 @@ main().catch((e) => {
|
|
|
127
127
|
hookName: 'post-tool-failure', eventType: 'PostToolUseFailure', cause: e,
|
|
128
128
|
});
|
|
129
129
|
process.stderr.write(`[ch-hook] ${hookErr.name}: ${hookErr.message}\n`);
|
|
130
|
-
console.log(failOpenWithTracking('post-tool-failure'));
|
|
130
|
+
console.log(failOpenWithTracking('post-tool-failure', e));
|
|
131
131
|
});
|
|
@@ -18,6 +18,12 @@ interface ModifiedFilesState {
|
|
|
18
18
|
recentWrites?: Record<string, string[]>;
|
|
19
19
|
/** Drift detection state */
|
|
20
20
|
drift?: DriftState;
|
|
21
|
+
/**
|
|
22
|
+
* TEST-2 support: 최근 N개 tool 이름 (가장 최근이 마지막). 세션 시작 이래 누적된
|
|
23
|
+
* 도구 이름을 그대로 끝까지 보관하면 메모리 낭비이므로 slice window.
|
|
24
|
+
* stop-guard 가 "측정 도구 호출 수" 를 빠르게 계산.
|
|
25
|
+
*/
|
|
26
|
+
recentToolNames?: string[];
|
|
21
27
|
}
|
|
22
28
|
export declare const ERROR_PATTERNS: Array<{
|
|
23
29
|
pattern: RegExp;
|