@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/hooks/stop-guard.js
CHANGED
|
@@ -22,7 +22,15 @@ import * as fs from 'node:fs';
|
|
|
22
22
|
import * as path from 'node:path';
|
|
23
23
|
import * as os from 'node:os';
|
|
24
24
|
import { readStdinJSON } from './shared/read-stdin.js';
|
|
25
|
-
import { approve, blockStop, failOpenWithTracking } from './shared/hook-response.js';
|
|
25
|
+
import { approve, approveWithWarning, blockStop, failOpenWithTracking } from './shared/hook-response.js';
|
|
26
|
+
import { takeLastExtractionNotice } from '../core/extraction-notice.js';
|
|
27
|
+
import { checkConclusionVerificationRatio } from '../checks/conclusion-verification-ratio.js';
|
|
28
|
+
import { checkSelfScoreInflation } from '../checks/self-score-deflation.js';
|
|
29
|
+
import { STATE_DIR } from '../core/paths.js';
|
|
30
|
+
import { sanitizeId } from './shared/sanitize-id.js';
|
|
31
|
+
import { detectRecallReferences } from '../core/recall-reference-detector.js';
|
|
32
|
+
import { appendImplicitFeedback } from '../store/implicit-feedback-store.js';
|
|
33
|
+
import { atomicWriteJSON } from './shared/atomic-write.js';
|
|
26
34
|
import { recordHookTiming } from './shared/hook-timing.js';
|
|
27
35
|
import { isHookEnabled } from './hook-config.js';
|
|
28
36
|
import { loadActiveRules } from '../store/rule-store.js';
|
|
@@ -38,9 +46,9 @@ import { DEFAULT_STOP_TRIGGER_RE, DEFAULT_STOP_EXCLUDE_RE } from './shared/stop-
|
|
|
38
46
|
* ADR-002 Meta 트리거(규칙 자동 강등)로 연결한다.
|
|
39
47
|
*/
|
|
40
48
|
const STUCK_LOOP_THRESHOLD = 3;
|
|
41
|
-
const BLOCK_COUNT_DIR = path.join(
|
|
42
|
-
const DRIFT_LOG = path.join(
|
|
43
|
-
const ACK_LOG = path.join(
|
|
49
|
+
const BLOCK_COUNT_DIR = path.join(STATE_DIR, 'enforcement', 'block-count');
|
|
50
|
+
const DRIFT_LOG = path.join(STATE_DIR, 'enforcement', 'drift.jsonl');
|
|
51
|
+
const ACK_LOG = path.join(STATE_DIR, 'enforcement', 'acknowledgments.jsonl');
|
|
44
52
|
/**
|
|
45
53
|
* Spike scenarios.json 로더 — FORGEN_SPIKE_RULES 명시 시에만 로드.
|
|
46
54
|
* H1 (2026-04-22): 이전에는 process.cwd()/tests/spike/... 를 기본 폴백했으나,
|
|
@@ -213,7 +221,7 @@ function evaluateVerifier(rule) {
|
|
|
213
221
|
* 루트 밖 경로는 존재 여부와 무관하게 false 반환.
|
|
214
222
|
*/
|
|
215
223
|
function artifactFresh(relOrAbs, maxAgeS) {
|
|
216
|
-
const homeBase =
|
|
224
|
+
const homeBase = STATE_DIR;
|
|
217
225
|
const projectBase = path.resolve(process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd(), '.forgen', 'state');
|
|
218
226
|
const allowedRoots = [homeBase, projectBase];
|
|
219
227
|
let p = relOrAbs;
|
|
@@ -397,22 +405,138 @@ export function getStuckLoopThreshold() {
|
|
|
397
405
|
return env;
|
|
398
406
|
return STUCK_LOOP_THRESHOLD;
|
|
399
407
|
}
|
|
408
|
+
/**
|
|
409
|
+
* H4 완결 (2026-04-24): recall_referenced emit 경로.
|
|
410
|
+
* Stop hook 에서 Claude 의 직전 응답 텍스트와 이 세션의 injection-cache 를 대조해,
|
|
411
|
+
* 주입된 솔루션 이름이 응답에 등장하면 `recall_referenced` 이벤트 기록. 동일
|
|
412
|
+
* 솔루션 중복 emit 방지용으로 injection-cache 의 해당 엔트리에 `_referenced: true`
|
|
413
|
+
* 플래그 세팅 후 atomic 재기록. fail-open — 어떤 단계든 throw 시 스킵.
|
|
414
|
+
*/
|
|
415
|
+
function emitRecallReferencesFailOpen(sessionId, lastMessage) {
|
|
416
|
+
try {
|
|
417
|
+
const cachePath = path.join(STATE_DIR, `injection-cache-${sanitizeId(sessionId)}.json`);
|
|
418
|
+
if (!fs.existsSync(cachePath))
|
|
419
|
+
return;
|
|
420
|
+
const raw = fs.readFileSync(cachePath, 'utf-8');
|
|
421
|
+
const cache = JSON.parse(raw);
|
|
422
|
+
const sols = Array.isArray(cache.solutions) ? cache.solutions : [];
|
|
423
|
+
if (sols.length === 0)
|
|
424
|
+
return;
|
|
425
|
+
const { newlyReferenced } = detectRecallReferences(lastMessage, sols);
|
|
426
|
+
if (newlyReferenced.length === 0)
|
|
427
|
+
return;
|
|
428
|
+
const now = new Date().toISOString();
|
|
429
|
+
for (const name of newlyReferenced) {
|
|
430
|
+
appendImplicitFeedback({
|
|
431
|
+
type: 'recall_referenced',
|
|
432
|
+
category: 'positive',
|
|
433
|
+
solution: name,
|
|
434
|
+
at: now,
|
|
435
|
+
sessionId,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
// 중복 emit 방지 — cache 에 플래그 저장 후 재기록
|
|
439
|
+
const refSet = new Set(newlyReferenced);
|
|
440
|
+
const updated = {
|
|
441
|
+
...cache,
|
|
442
|
+
solutions: sols.map((s) => (refSet.has(s.name) ? { ...s, _referenced: true } : s)),
|
|
443
|
+
updatedAt: now,
|
|
444
|
+
};
|
|
445
|
+
atomicWriteJSON(cachePath, updated, { mode: 0o600, dirMode: 0o700 });
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
/* fail-open */
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* TEST-2 support: post-tool-use 가 저장한 modified-files-{sessionId}.json 에서
|
|
453
|
+
* recentToolNames 윈도우를 로드. 파일이 없거나 깨져도 빈 배열로 fail-open.
|
|
454
|
+
*/
|
|
455
|
+
function loadRecentToolNames(sessionId) {
|
|
456
|
+
try {
|
|
457
|
+
const p = path.join(STATE_DIR, `modified-files-${sanitizeId(sessionId)}.json`);
|
|
458
|
+
if (!fs.existsSync(p))
|
|
459
|
+
return [];
|
|
460
|
+
const raw = fs.readFileSync(p, 'utf-8');
|
|
461
|
+
const data = JSON.parse(raw);
|
|
462
|
+
if (Array.isArray(data.recentToolNames)) {
|
|
463
|
+
return data.recentToolNames.filter((n) => typeof n === 'string');
|
|
464
|
+
}
|
|
465
|
+
return [];
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
return [];
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* H2: Stop hook approve 시 이전 세션의 auto-compound 추출 결과를 1회만 surface.
|
|
473
|
+
* takeLastExtractionNotice 가 null 이면 일반 approve, 아니면 systemMessage 포함.
|
|
474
|
+
*/
|
|
475
|
+
function approveWithOptionalExtractionNotice() {
|
|
476
|
+
const notice = takeLastExtractionNotice();
|
|
477
|
+
if (notice)
|
|
478
|
+
return approveWithWarning(notice);
|
|
479
|
+
return approve();
|
|
480
|
+
}
|
|
400
481
|
export async function main() {
|
|
401
482
|
const started = Date.now();
|
|
402
483
|
try {
|
|
403
484
|
if (!isHookEnabled(HOOK_NAME)) {
|
|
404
|
-
console.log(
|
|
485
|
+
console.log(approveWithOptionalExtractionNotice());
|
|
405
486
|
return;
|
|
406
487
|
}
|
|
407
488
|
const input = await readStdinJSON();
|
|
408
489
|
const lastMessage = readLastAssistantMessage(input);
|
|
409
490
|
if (!lastMessage) {
|
|
410
|
-
console.log(
|
|
491
|
+
console.log(approveWithOptionalExtractionNotice());
|
|
411
492
|
return;
|
|
412
493
|
}
|
|
494
|
+
// H4 완결: 응답 텍스트와 injection-cache 대조해 recall_referenced emit.
|
|
495
|
+
// block/approve 어느 경로이든 동일하게 기록 (참조는 응답 내용이 결정).
|
|
496
|
+
const sessionIdForRef = input?.session_id ?? 'unknown';
|
|
497
|
+
emitRecallReferencesFailOpen(sessionIdForRef, lastMessage);
|
|
498
|
+
// TEST-2/3: rule-free meta guards — FORGEN_USER_CONFIRMED=1 우회 공통.
|
|
499
|
+
if (process.env.FORGEN_USER_CONFIRMED !== '1') {
|
|
500
|
+
const sessionId = input?.session_id ?? 'unknown';
|
|
501
|
+
// TEST-2 (자가 점수 인플레이션): 숫자 점수 상승 선언 + 측정 도구 0회 → block.
|
|
502
|
+
// TEST-3 보다 강한 신호라 먼저 평가.
|
|
503
|
+
const recentTools = loadRecentToolNames(sessionId);
|
|
504
|
+
const score = checkSelfScoreInflation({ text: lastMessage, recentTools });
|
|
505
|
+
if (score.block) {
|
|
506
|
+
recordViolation({
|
|
507
|
+
rule_id: 'builtin:self-score-inflation',
|
|
508
|
+
session_id: sessionId,
|
|
509
|
+
source: 'stop-guard',
|
|
510
|
+
kind: 'block',
|
|
511
|
+
message_preview: lastMessage.slice(0, 120),
|
|
512
|
+
});
|
|
513
|
+
const reasonText = `[forgen:stop-guard/self-score-inflation] ${score.reason}
|
|
514
|
+
|
|
515
|
+
(Override this turn: set FORGEN_USER_CONFIRMED=1 (audited).)`;
|
|
516
|
+
console.log(blockStop(reasonText, 'rule:TEST-2 — self-score inflation'));
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
// TEST-3: 결론/검증 비율 — Claude 가 실제 측정 도구는 돌렸지만 서술이
|
|
520
|
+
// 결론-편향이면 여전히 block.
|
|
521
|
+
const ratio = checkConclusionVerificationRatio({ text: lastMessage });
|
|
522
|
+
if (ratio.block) {
|
|
523
|
+
recordViolation({
|
|
524
|
+
rule_id: 'builtin:conclusion-verification-ratio',
|
|
525
|
+
session_id: sessionId,
|
|
526
|
+
source: 'stop-guard',
|
|
527
|
+
kind: 'block',
|
|
528
|
+
message_preview: lastMessage.slice(0, 120),
|
|
529
|
+
});
|
|
530
|
+
const reasonText = `[forgen:stop-guard/conclusion-ratio] ${ratio.reason}
|
|
531
|
+
|
|
532
|
+
(Override this turn: set FORGEN_USER_CONFIRMED=1 (audited).)`;
|
|
533
|
+
console.log(blockStop(reasonText, 'rule:TEST-3 — conclusion/verification ratio'));
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
413
537
|
const rules = loadStopRules();
|
|
414
538
|
if (rules.length === 0) {
|
|
415
|
-
console.log(
|
|
539
|
+
console.log(approveWithOptionalExtractionNotice());
|
|
416
540
|
return;
|
|
417
541
|
}
|
|
418
542
|
const result = evaluateStop(lastMessage, rules);
|
|
@@ -421,7 +545,7 @@ export async function main() {
|
|
|
421
545
|
// R9-PA2: 같은 session 에 pending block 이 있었다면 retract→pass 루프가
|
|
422
546
|
// 실제 작동한 것 — acknowledgment 이벤트로 기록. block-count 는 cleanup.
|
|
423
547
|
acknowledgeSessionBlocks(sessionId);
|
|
424
|
-
console.log(
|
|
548
|
+
console.log(approveWithOptionalExtractionNotice());
|
|
425
549
|
return;
|
|
426
550
|
}
|
|
427
551
|
const { hit, reason } = result;
|
|
@@ -433,7 +557,7 @@ export async function main() {
|
|
|
433
557
|
kind: 'correction',
|
|
434
558
|
message_preview: `[FORGEN_USER_CONFIRMED=1 bypass] ${lastMessage.slice(0, 100)}`,
|
|
435
559
|
});
|
|
436
|
-
console.log(
|
|
560
|
+
console.log(approveWithOptionalExtractionNotice());
|
|
437
561
|
return;
|
|
438
562
|
}
|
|
439
563
|
// T2 signal: block 은 rule 위반 증거 — violations.jsonl 에 기록.
|
|
@@ -464,13 +588,13 @@ export async function main() {
|
|
|
464
588
|
message_preview: lastMessage.slice(0, 120),
|
|
465
589
|
});
|
|
466
590
|
resetBlockCount(sessionId, hit.id);
|
|
467
|
-
console.log(
|
|
591
|
+
console.log(approveWithOptionalExtractionNotice());
|
|
468
592
|
return;
|
|
469
593
|
}
|
|
470
594
|
console.log(blockStop(reasonWithHint, hit.system_tag));
|
|
471
595
|
}
|
|
472
|
-
catch {
|
|
473
|
-
console.log(failOpenWithTracking(HOOK_NAME));
|
|
596
|
+
catch (e) {
|
|
597
|
+
console.log(failOpenWithTracking(HOOK_NAME, e));
|
|
474
598
|
}
|
|
475
599
|
finally {
|
|
476
600
|
recordHookTiming(HOOK_NAME, Date.now() - started, 'Stop');
|
package/dist/i18n/index.js
CHANGED
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
* 사용자 대면 출력만 로케일에 따라 전환.
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from 'node:fs';
|
|
8
|
-
import
|
|
9
|
-
import * as os from 'node:os';
|
|
8
|
+
import { GLOBAL_CONFIG } from '../core/paths.js';
|
|
10
9
|
// ── Pack Display Names ──
|
|
11
10
|
const QUALITY_NAMES = {
|
|
12
11
|
ko: { '보수형': '보수형', '균형형': '균형형', '속도형': '속도형' },
|
|
@@ -215,10 +214,9 @@ export function getLocale() { return _currentLocale; }
|
|
|
215
214
|
/** GlobalConfig에서 locale을 읽어 설정. 없으면 'en' 기본값. */
|
|
216
215
|
export function initLocaleFromConfig() {
|
|
217
216
|
try {
|
|
218
|
-
|
|
219
|
-
if (!fs.existsSync(configPath))
|
|
217
|
+
if (!fs.existsSync(GLOBAL_CONFIG))
|
|
220
218
|
return;
|
|
221
|
-
const config = JSON.parse(fs.readFileSync(
|
|
219
|
+
const config = JSON.parse(fs.readFileSync(GLOBAL_CONFIG, 'utf-8'));
|
|
222
220
|
if (config.locale === 'ko' || config.locale === 'en') {
|
|
223
221
|
_currentLocale = config.locale;
|
|
224
222
|
}
|
|
@@ -31,7 +31,18 @@ export function createEvidence(params) {
|
|
|
31
31
|
raw_payload: params.raw_payload ?? {},
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
|
+
/** TEST-4 / RC4: behavior_observation 의 summary 가 의미있는 내용을 담아야 분석 가능. */
|
|
35
|
+
const MIN_BEHAVIOR_OBSERVATION_LEN = 20;
|
|
34
36
|
export function saveEvidence(evidence) {
|
|
37
|
+
// TEST-4 / RC4: 빈/짧은 behavior_observation 은 저장 거부.
|
|
38
|
+
// 결함: ~/.forgen/me/behavior/*.json 다수에 summary="" 가 누적되어 학습 데이터가
|
|
39
|
+
// 분석 불가능한 형태로 쌓임. saveEvidence 가 마지막 게이트라 여기서 거른다.
|
|
40
|
+
// 다른 evidence type (explicit_correction, session_summary) 은 backward compat.
|
|
41
|
+
if (evidence.type === 'behavior_observation') {
|
|
42
|
+
const len = (evidence.summary ?? '').trim().length;
|
|
43
|
+
if (len < MIN_BEHAVIOR_OBSERVATION_LEN)
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
35
46
|
atomicWriteJSON(evidencePath(evidence.evidence_id), evidence, { pretty: true });
|
|
36
47
|
}
|
|
37
48
|
/**
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen v0.4.1 — Implicit Feedback Store (TEST-5)
|
|
3
|
+
*
|
|
4
|
+
* `~/.forgen/state/implicit-feedback.jsonl` 의 append/read.
|
|
5
|
+
*
|
|
6
|
+
* TEST-5 / RC5: 누적된 엔트리들이 `type` 문자열만 가지고 category 없이 섞여 있어
|
|
7
|
+
* - drift_critical / drift_warning / revert_detected / repeated_edit / agent_* 가 한 스트림에 섞여
|
|
8
|
+
* - 집계/쿼리 시 카테고리 enum 부재로 휴리스틱 문자열 매칭에 의존
|
|
9
|
+
* - 스키마 검증이 없어 빈/잘못된 필드로 쓰여도 나중에 분석 불가
|
|
10
|
+
* 이 모듈은 category 필드를 **필수화**하고, 기존 레거시 라인은 read 시 `type→category`
|
|
11
|
+
* 백필로 보정한다. 새 write 는 category 없으면 drift/revert 계열은 **거부**한다.
|
|
12
|
+
*/
|
|
13
|
+
export declare const IMPLICIT_FEEDBACK_LOG: string;
|
|
14
|
+
/**
|
|
15
|
+
* TEST-5/H4: 카테고리 enum.
|
|
16
|
+
* - drift / revert: 네거티브 signal (schema 강제)
|
|
17
|
+
* - edit / agent: 네거티브-ish signal (휴리스틱)
|
|
18
|
+
* - positive: H4 양수 신호 — assist (recommendation_surfaced, recall_referenced)
|
|
19
|
+
*/
|
|
20
|
+
export type ImplicitFeedbackCategory = 'drift' | 'revert' | 'edit' | 'agent' | 'positive';
|
|
21
|
+
export interface ImplicitFeedbackEntry {
|
|
22
|
+
type: string;
|
|
23
|
+
category: ImplicitFeedbackCategory;
|
|
24
|
+
sessionId?: string;
|
|
25
|
+
at: string;
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
/** 호출지 입력 — category 선택적 (schema 가 허용하면 inference 로 채움). */
|
|
29
|
+
export interface ImplicitFeedbackInput {
|
|
30
|
+
type: string;
|
|
31
|
+
category?: ImplicitFeedbackCategory;
|
|
32
|
+
sessionId?: string;
|
|
33
|
+
at: string;
|
|
34
|
+
[key: string]: unknown;
|
|
35
|
+
}
|
|
36
|
+
/** type → category 추론. 레거시 엔트리 마이그레이션과 호출지 기본값 계산에 공용. */
|
|
37
|
+
export declare function inferCategoryFromType(type: string): ImplicitFeedbackCategory | null;
|
|
38
|
+
/**
|
|
39
|
+
* TEST-5 메인 라이터. 내부에서 스키마 검증 후 append.
|
|
40
|
+
* drift/revert 스키마 위반 시 silent drop (hot path 에서 throw 금지).
|
|
41
|
+
* 반환값: 실제로 기록되었는지 (테스트 검증용).
|
|
42
|
+
*/
|
|
43
|
+
export declare function appendImplicitFeedback(entry: ImplicitFeedbackInput): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* TEST-5 리더. 세션 필터링 + 레거시 라인에 대한 lazy 마이그레이션 (category 백필).
|
|
46
|
+
* 디스크 상 파일은 건드리지 않고 읽기 시점에만 category 를 보정한다 — atomic-write
|
|
47
|
+
* 없이 append-only 로그를 rewrite 하면 race 위험이 있기 때문.
|
|
48
|
+
* 영구 백필은 `migrateImplicitFeedbackLog()` 를 명시적으로 호출한다.
|
|
49
|
+
*/
|
|
50
|
+
export declare function loadImplicitFeedback(sessionId: string): ImplicitFeedbackEntry[];
|
|
51
|
+
/**
|
|
52
|
+
* 영구 마이그레이션 — 레거시 로그 파일을 읽어 category 백필 후 원자적으로 재기록.
|
|
53
|
+
* 마이그레이션 불가 라인 (type 도 category 도 없거나 inference 실패) 은 drop.
|
|
54
|
+
* 반환: { migrated: 백필된 라인 수, dropped: 버려진 라인 수 }
|
|
55
|
+
*/
|
|
56
|
+
export declare function migrateImplicitFeedbackLog(): {
|
|
57
|
+
migrated: number;
|
|
58
|
+
dropped: number;
|
|
59
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen v0.4.1 — Implicit Feedback Store (TEST-5)
|
|
3
|
+
*
|
|
4
|
+
* `~/.forgen/state/implicit-feedback.jsonl` 의 append/read.
|
|
5
|
+
*
|
|
6
|
+
* TEST-5 / RC5: 누적된 엔트리들이 `type` 문자열만 가지고 category 없이 섞여 있어
|
|
7
|
+
* - drift_critical / drift_warning / revert_detected / repeated_edit / agent_* 가 한 스트림에 섞여
|
|
8
|
+
* - 집계/쿼리 시 카테고리 enum 부재로 휴리스틱 문자열 매칭에 의존
|
|
9
|
+
* - 스키마 검증이 없어 빈/잘못된 필드로 쓰여도 나중에 분석 불가
|
|
10
|
+
* 이 모듈은 category 필드를 **필수화**하고, 기존 레거시 라인은 read 시 `type→category`
|
|
11
|
+
* 백필로 보정한다. 새 write 는 category 없으면 drift/revert 계열은 **거부**한다.
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from 'node:fs';
|
|
14
|
+
import * as path from 'node:path';
|
|
15
|
+
import { STATE_DIR } from '../core/paths.js';
|
|
16
|
+
export const IMPLICIT_FEEDBACK_LOG = path.join(STATE_DIR, 'implicit-feedback.jsonl');
|
|
17
|
+
/** type → category 추론. 레거시 엔트리 마이그레이션과 호출지 기본값 계산에 공용. */
|
|
18
|
+
export function inferCategoryFromType(type) {
|
|
19
|
+
if (type === 'drift_critical' || type === 'drift_warning')
|
|
20
|
+
return 'drift';
|
|
21
|
+
if (type === 'revert_detected')
|
|
22
|
+
return 'revert';
|
|
23
|
+
if (type === 'repeated_edit')
|
|
24
|
+
return 'edit';
|
|
25
|
+
if (type.startsWith('agent_'))
|
|
26
|
+
return 'agent';
|
|
27
|
+
// H4: 양수 assist 신호 — 솔루션이 사용자에게 노출/참조되었음을 기록.
|
|
28
|
+
if (type === 'recommendation_surfaced' || type === 'recall_referenced')
|
|
29
|
+
return 'positive';
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* TEST-5 스키마 검증 — drift/revert 계열은 category 누락 시 쓰기 거부.
|
|
34
|
+
* agent/edit 은 fail-open (기존 호출지가 빠뜨려도 로깅 자체는 보존), 대신 inference
|
|
35
|
+
* 가 가능하면 자동 보정.
|
|
36
|
+
*/
|
|
37
|
+
function validateAndNormalize(entry) {
|
|
38
|
+
if (!entry.type || !entry.at)
|
|
39
|
+
return null;
|
|
40
|
+
const inferred = inferCategoryFromType(entry.type);
|
|
41
|
+
const category = entry.category ?? inferred;
|
|
42
|
+
// drift/revert/positive 는 schema 강제: 명시든 추론이든 올바른 카테고리여야 함.
|
|
43
|
+
if (entry.type === 'drift_critical' || entry.type === 'drift_warning') {
|
|
44
|
+
if (category !== 'drift')
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
if (entry.type === 'revert_detected') {
|
|
48
|
+
if (category !== 'revert')
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
if (entry.type === 'recommendation_surfaced' || entry.type === 'recall_referenced') {
|
|
52
|
+
if (category !== 'positive')
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (!category)
|
|
56
|
+
return null;
|
|
57
|
+
return { ...entry, category };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* TEST-5 메인 라이터. 내부에서 스키마 검증 후 append.
|
|
61
|
+
* drift/revert 스키마 위반 시 silent drop (hot path 에서 throw 금지).
|
|
62
|
+
* 반환값: 실제로 기록되었는지 (테스트 검증용).
|
|
63
|
+
*/
|
|
64
|
+
export function appendImplicitFeedback(entry) {
|
|
65
|
+
const normalized = validateAndNormalize(entry);
|
|
66
|
+
if (!normalized)
|
|
67
|
+
return false;
|
|
68
|
+
try {
|
|
69
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
70
|
+
fs.appendFileSync(IMPLICIT_FEEDBACK_LOG, JSON.stringify(normalized) + '\n');
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// fail-open: implicit feedback recording must not throw.
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* TEST-5 리더. 세션 필터링 + 레거시 라인에 대한 lazy 마이그레이션 (category 백필).
|
|
80
|
+
* 디스크 상 파일은 건드리지 않고 읽기 시점에만 category 를 보정한다 — atomic-write
|
|
81
|
+
* 없이 append-only 로그를 rewrite 하면 race 위험이 있기 때문.
|
|
82
|
+
* 영구 백필은 `migrateImplicitFeedbackLog()` 를 명시적으로 호출한다.
|
|
83
|
+
*/
|
|
84
|
+
export function loadImplicitFeedback(sessionId) {
|
|
85
|
+
try {
|
|
86
|
+
if (!fs.existsSync(IMPLICIT_FEEDBACK_LOG))
|
|
87
|
+
return [];
|
|
88
|
+
const lines = fs.readFileSync(IMPLICIT_FEEDBACK_LOG, 'utf-8').split('\n').filter(Boolean);
|
|
89
|
+
const entries = [];
|
|
90
|
+
for (const line of lines) {
|
|
91
|
+
try {
|
|
92
|
+
const raw = JSON.parse(line);
|
|
93
|
+
if (raw.sessionId !== sessionId)
|
|
94
|
+
continue;
|
|
95
|
+
if (!raw.type || !raw.at)
|
|
96
|
+
continue;
|
|
97
|
+
const category = raw.category ?? inferCategoryFromType(raw.type);
|
|
98
|
+
if (!category)
|
|
99
|
+
continue;
|
|
100
|
+
entries.push({ ...raw, category });
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
/* skip malformed lines */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return entries;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* 영구 마이그레이션 — 레거시 로그 파일을 읽어 category 백필 후 원자적으로 재기록.
|
|
114
|
+
* 마이그레이션 불가 라인 (type 도 category 도 없거나 inference 실패) 은 drop.
|
|
115
|
+
* 반환: { migrated: 백필된 라인 수, dropped: 버려진 라인 수 }
|
|
116
|
+
*/
|
|
117
|
+
export function migrateImplicitFeedbackLog() {
|
|
118
|
+
if (!fs.existsSync(IMPLICIT_FEEDBACK_LOG))
|
|
119
|
+
return { migrated: 0, dropped: 0 };
|
|
120
|
+
const lines = fs.readFileSync(IMPLICIT_FEEDBACK_LOG, 'utf-8').split('\n').filter(Boolean);
|
|
121
|
+
const out = [];
|
|
122
|
+
let migrated = 0;
|
|
123
|
+
let dropped = 0;
|
|
124
|
+
for (const line of lines) {
|
|
125
|
+
try {
|
|
126
|
+
const raw = JSON.parse(line);
|
|
127
|
+
if (!raw.type || !raw.at) {
|
|
128
|
+
dropped++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (raw.category) {
|
|
132
|
+
out.push(JSON.stringify(raw));
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const inferred = inferCategoryFromType(raw.type);
|
|
136
|
+
if (!inferred) {
|
|
137
|
+
dropped++;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const repaired = { ...raw, category: inferred };
|
|
141
|
+
out.push(JSON.stringify(repaired));
|
|
142
|
+
migrated++;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
dropped++;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// atomic replace via temp file
|
|
149
|
+
const tmp = `${IMPLICIT_FEEDBACK_LOG}.migrate.${process.pid}`;
|
|
150
|
+
fs.writeFileSync(tmp, out.length > 0 ? out.join('\n') + '\n' : '');
|
|
151
|
+
fs.renameSync(tmp, IMPLICIT_FEEDBACK_LOG);
|
|
152
|
+
return { migrated, dropped };
|
|
153
|
+
}
|
package/dist/store/rule-store.js
CHANGED
|
@@ -33,6 +33,14 @@ export function createRule(params) {
|
|
|
33
33
|
}
|
|
34
34
|
export function saveRule(rule) {
|
|
35
35
|
rule.updated_at = new Date().toISOString();
|
|
36
|
+
// v0.4.1 audit-trail 불변식: rule 저장 시 lifecycle state 가 null/undefined 이면
|
|
37
|
+
// active phase 기본값 주입. 이전에는 old rule 파일이 lifecycle 없이 존재해 쌤 이후
|
|
38
|
+
// audit trail (phase/violation_count/meta_promotions) 추적 불가 — 이번 세션에서
|
|
39
|
+
// suppressed rule 의 lifecycle=null 발견. initLifecycle 은 기존 값이 있으면 normalize,
|
|
40
|
+
// 없으면 phase='active' + counters=0 초기화.
|
|
41
|
+
if (!rule.lifecycle) {
|
|
42
|
+
rule.lifecycle = initLifecycle(rule);
|
|
43
|
+
}
|
|
36
44
|
atomicWriteJSON(rulePath(rule.rule_id), rule, { pretty: true });
|
|
37
45
|
}
|
|
38
46
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wooojin/forgen",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"preferGlobal": true,
|
|
5
5
|
"main": "dist/lib.js",
|
|
6
6
|
"types": "./dist/lib.d.ts",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"postinstall": "node scripts/postinstall.js",
|
|
31
31
|
"prepare": "npm run build",
|
|
32
32
|
"prepublishOnly": "npm test",
|
|
33
|
-
"prepack": "node scripts/prepack-hooks.cjs"
|
|
33
|
+
"prepack": "npm run build && node scripts/prepack-hooks.cjs"
|
|
34
34
|
},
|
|
35
35
|
"author": "jang-ujin",
|
|
36
36
|
"license": "MIT",
|
package/plugin.json
CHANGED