@wooojin/forgen 0.3.1 → 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.
Files changed (125) hide show
  1. package/.claude-plugin/plugin.json +7 -2
  2. package/CHANGELOG.md +164 -0
  3. package/README.ja.md +90 -7
  4. package/README.ko.md +44 -1
  5. package/README.md +128 -9
  6. package/README.zh.md +90 -7
  7. package/dist/cli.js +140 -8
  8. package/dist/core/auto-compound-runner.js +16 -5
  9. package/dist/core/dashboard.js +11 -4
  10. package/dist/core/doctor.d.ts +6 -1
  11. package/dist/core/doctor.js +85 -11
  12. package/dist/core/global-config.d.ts +2 -2
  13. package/dist/core/global-config.js +6 -14
  14. package/dist/core/harness.d.ts +3 -5
  15. package/dist/core/harness.js +34 -338
  16. package/dist/core/inspect-cli.js +65 -5
  17. package/dist/core/installer.d.ts +10 -0
  18. package/dist/core/installer.js +185 -0
  19. package/dist/core/paths.d.ts +0 -34
  20. package/dist/core/paths.js +0 -35
  21. package/dist/core/settings-injector.d.ts +13 -0
  22. package/dist/core/settings-injector.js +167 -0
  23. package/dist/core/settings-lock.d.ts +35 -2
  24. package/dist/core/settings-lock.js +65 -7
  25. package/dist/core/spawn.js +100 -39
  26. package/dist/core/state-gc.d.ts +49 -0
  27. package/dist/core/state-gc.js +163 -0
  28. package/dist/core/stats-cli.d.ts +15 -0
  29. package/dist/core/stats-cli.js +143 -0
  30. package/dist/core/uninstall.d.ts +1 -0
  31. package/dist/core/uninstall.js +36 -5
  32. package/dist/core/v1-bootstrap.js +11 -3
  33. package/dist/engine/classify-enforce-cli.d.ts +8 -0
  34. package/dist/engine/classify-enforce-cli.js +61 -0
  35. package/dist/engine/compound-cli.d.ts +27 -2
  36. package/dist/engine/compound-cli.js +69 -16
  37. package/dist/engine/compound-export.d.ts +15 -0
  38. package/dist/engine/compound-export.js +32 -5
  39. package/dist/engine/compound-loop.js +3 -2
  40. package/dist/engine/enforce-classifier.d.ts +31 -0
  41. package/dist/engine/enforce-classifier.js +123 -0
  42. package/dist/engine/learn-cli.js +52 -0
  43. package/dist/engine/lifecycle/bypass-detector.d.ts +34 -0
  44. package/dist/engine/lifecycle/bypass-detector.js +82 -0
  45. package/dist/engine/lifecycle/lifecycle-cli.d.ts +7 -0
  46. package/dist/engine/lifecycle/lifecycle-cli.js +102 -0
  47. package/dist/engine/lifecycle/meta-cli.d.ts +4 -0
  48. package/dist/engine/lifecycle/meta-cli.js +7 -0
  49. package/dist/engine/lifecycle/meta-reclassifier.d.ts +78 -0
  50. package/dist/engine/lifecycle/meta-reclassifier.js +351 -0
  51. package/dist/engine/lifecycle/orchestrator.d.ts +32 -0
  52. package/dist/engine/lifecycle/orchestrator.js +131 -0
  53. package/dist/engine/lifecycle/signals.d.ts +30 -0
  54. package/dist/engine/lifecycle/signals.js +142 -0
  55. package/dist/engine/lifecycle/trigger-t1-correction.d.ts +23 -0
  56. package/dist/engine/lifecycle/trigger-t1-correction.js +78 -0
  57. package/dist/engine/lifecycle/trigger-t2-violation.d.ts +18 -0
  58. package/dist/engine/lifecycle/trigger-t2-violation.js +42 -0
  59. package/dist/engine/lifecycle/trigger-t3-bypass.d.ts +17 -0
  60. package/dist/engine/lifecycle/trigger-t3-bypass.js +39 -0
  61. package/dist/engine/lifecycle/trigger-t4-decay.d.ts +18 -0
  62. package/dist/engine/lifecycle/trigger-t4-decay.js +40 -0
  63. package/dist/engine/lifecycle/trigger-t5-conflict.d.ts +16 -0
  64. package/dist/engine/lifecycle/trigger-t5-conflict.js +78 -0
  65. package/dist/engine/lifecycle/types.d.ts +52 -0
  66. package/dist/engine/lifecycle/types.js +7 -0
  67. package/dist/engine/match-eval-log.js +45 -0
  68. package/dist/engine/rule-toggle-cli.d.ts +13 -0
  69. package/dist/engine/rule-toggle-cli.js +76 -0
  70. package/dist/engine/solution-format.d.ts +0 -2
  71. package/dist/engine/solution-format.js +0 -4
  72. package/dist/engine/solution-matcher.d.ts +8 -0
  73. package/dist/engine/solution-matcher.js +7 -4
  74. package/dist/engine/solution-outcomes.d.ts +4 -0
  75. package/dist/engine/solution-outcomes.js +174 -97
  76. package/dist/engine/solution-writer.d.ts +8 -5
  77. package/dist/engine/solution-writer.js +43 -19
  78. package/dist/fgx.js +9 -2
  79. package/dist/forge/cli.js +7 -7
  80. package/dist/forge/evidence-processor.js +10 -2
  81. package/dist/hooks/context-guard.js +86 -1
  82. package/dist/hooks/hook-config.d.ts +9 -1
  83. package/dist/hooks/hook-config.js +25 -3
  84. package/dist/hooks/internal/run-lifecycle-check.d.ts +2 -0
  85. package/dist/hooks/internal/run-lifecycle-check.js +32 -0
  86. package/dist/hooks/notepad-injector.js +6 -3
  87. package/dist/hooks/permission-handler.d.ts +10 -2
  88. package/dist/hooks/permission-handler.js +31 -12
  89. package/dist/hooks/post-tool-use.js +62 -0
  90. package/dist/hooks/pre-tool-use.js +67 -5
  91. package/dist/hooks/secret-filter.d.ts +10 -0
  92. package/dist/hooks/secret-filter.js +26 -0
  93. package/dist/hooks/session-recovery.js +15 -7
  94. package/dist/hooks/shared/atomic-write.d.ts +8 -1
  95. package/dist/hooks/shared/atomic-write.js +17 -3
  96. package/dist/hooks/shared/hook-response.d.ts +11 -2
  97. package/dist/hooks/shared/hook-response.js +20 -7
  98. package/dist/hooks/shared/hook-timing.js +10 -1
  99. package/dist/hooks/shared/safe-regex.d.ts +25 -0
  100. package/dist/hooks/shared/safe-regex.js +50 -0
  101. package/dist/hooks/shared/stop-triggers.d.ts +19 -0
  102. package/dist/hooks/shared/stop-triggers.js +19 -0
  103. package/dist/hooks/solution-injector.d.ts +21 -0
  104. package/dist/hooks/solution-injector.js +60 -1
  105. package/dist/hooks/stop-guard.d.ts +84 -0
  106. package/dist/hooks/stop-guard.js +482 -0
  107. package/dist/mcp/solution-reader.d.ts +2 -0
  108. package/dist/mcp/solution-reader.js +28 -1
  109. package/dist/mcp/tools.js +24 -4
  110. package/dist/preset/preset-manager.js +12 -2
  111. package/dist/store/evidence-store.d.ts +15 -0
  112. package/dist/store/evidence-store.js +55 -6
  113. package/dist/store/profile-store.d.ts +9 -0
  114. package/dist/store/profile-store.js +25 -4
  115. package/dist/store/rule-lifecycle.d.ts +23 -0
  116. package/dist/store/rule-lifecycle.js +63 -0
  117. package/dist/store/rule-store.d.ts +21 -0
  118. package/dist/store/rule-store.js +133 -13
  119. package/dist/store/types.d.ts +83 -0
  120. package/dist/store/types.js +7 -1
  121. package/hooks/hook-registry.json +1 -0
  122. package/hooks/hooks.json +6 -1
  123. package/package.json +10 -2
  124. package/plugin.json +7 -2
  125. package/scripts/postinstall.js +52 -5
@@ -7,11 +7,15 @@
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 { V1_EVIDENCE_DIR } from '../core/paths.js';
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
- return path.join(V1_EVIDENCE_DIR, `${evidenceId}.json`);
18
+ return path.join(ME_BEHAVIOR, `${evidenceId}.json`);
15
19
  }
16
20
  export function createEvidence(params) {
17
21
  return {
@@ -30,17 +34,56 @@ 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
  }
36
79
  export function loadAllEvidence() {
37
- if (!fs.existsSync(V1_EVIDENCE_DIR))
80
+ if (!fs.existsSync(ME_BEHAVIOR))
38
81
  return [];
39
82
  const items = [];
40
- for (const file of fs.readdirSync(V1_EVIDENCE_DIR)) {
83
+ for (const file of fs.readdirSync(ME_BEHAVIOR)) {
41
84
  if (!file.endsWith('.json'))
42
85
  continue;
43
- const ev = safeReadJSON(path.join(V1_EVIDENCE_DIR, file), null);
86
+ const ev = safeReadJSON(path.join(ME_BEHAVIOR, file), null);
44
87
  if (ev)
45
88
  items.push(ev);
46
89
  }
@@ -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
- const rule = createRule({
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++;
@@ -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 { V1_PROFILE } from '../core/paths.js';
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
- return safeReadJSON(V1_PROFILE, null);
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(V1_PROFILE, profile, { pretty: true });
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(V1_PROFILE);
67
+ return fs.existsSync(FORGE_PROFILE);
47
68
  }
48
69
  export function isV1Profile(data) {
49
70
  if (!data || typeof data !== 'object')
@@ -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;
@@ -0,0 +1,63 @@
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
+ /** safe non-negative integer normalization — 파일 corruption / 다중 writer race 방어. */
13
+ export function safeCount(n) {
14
+ return typeof n === 'number' && Number.isFinite(n) && n >= 0 ? n : 0;
15
+ }
16
+ /**
17
+ * Rule 에 대해 정규화된 LifecycleState 반환 (pure — rule 을 변경하지 않음).
18
+ * 기존 lifecycle 이 있으면 카운터를 safeCount 로 정규화한 사본, 없으면 초기 상태.
19
+ */
20
+ export function initLifecycle(rule) {
21
+ const existing = rule.lifecycle;
22
+ if (existing) {
23
+ return {
24
+ phase: existing.phase,
25
+ first_active_at: existing.first_active_at,
26
+ last_inject_at: existing.last_inject_at,
27
+ last_violation_at: existing.last_violation_at,
28
+ inject_count: safeCount(existing.inject_count),
29
+ accept_count: safeCount(existing.accept_count),
30
+ violation_count: safeCount(existing.violation_count),
31
+ bypass_count: safeCount(existing.bypass_count),
32
+ conflict_refs: Array.isArray(existing.conflict_refs) ? [...existing.conflict_refs] : [],
33
+ merged_into: existing.merged_into,
34
+ superseded_by: existing.superseded_by,
35
+ meta_promotions: Array.isArray(existing.meta_promotions) ? [...existing.meta_promotions] : [],
36
+ };
37
+ }
38
+ return {
39
+ phase: 'active',
40
+ first_active_at: rule.created_at,
41
+ inject_count: 0,
42
+ accept_count: 0,
43
+ violation_count: 0,
44
+ bypass_count: 0,
45
+ conflict_refs: [],
46
+ meta_promotions: [],
47
+ };
48
+ }
49
+ /** inject count + last_inject_at 을 한 단계 증가 — markRulesInjected 의 공통 로직. */
50
+ export function bumpInject(lifecycle, nowIso) {
51
+ return {
52
+ ...lifecycle,
53
+ inject_count: lifecycle.inject_count + 1,
54
+ last_inject_at: nowIso,
55
+ };
56
+ }
57
+ /** meta_promotions 에 새 entry append (immutable). */
58
+ export function appendMetaPromotion(lifecycle, promotion) {
59
+ return {
60
+ ...lifecycle,
61
+ meta_promotions: [...lifecycle.meta_promotions, promotion],
62
+ };
63
+ }
@@ -16,9 +16,30 @@ export declare function createRule(params: {
16
16
  render_key: string;
17
17
  }): Rule;
18
18
  export declare function saveRule(rule: Rule): void;
19
+ /**
20
+ * ADR-002 T5 — rule 저장 + 기존 active rules 와 자연어 충돌 감지 + 양쪽 conflict_refs 기록.
21
+ *
22
+ * saveRule 과의 차이:
23
+ * - 저장 직후 T5 detect 실행 → 충돌 발견 시 신규 rule + 반대편 rule 모두 conflict_refs 업데이트.
24
+ * - auto-merge 안 함 (ADR-002 §Risks — 사용자 수동 해소).
25
+ * - T5 감지 실패는 저장 자체를 막지 않음 (fail-open).
26
+ *
27
+ * 반환: 저장된 rule + 감지된 충돌 rule_id 목록.
28
+ */
29
+ export declare function appendRule(rule: Rule): Promise<{
30
+ saved: true;
31
+ conflicts_with: string[];
32
+ }>;
19
33
  export declare function loadRule(ruleId: string): Rule | null;
20
34
  export declare function loadAllRules(): Rule[];
21
35
  export declare function loadActiveRules(): Rule[];
36
+ /**
37
+ * ADR-002 Meta signal — rule 들이 프롬프트에 inject 되었음을 기록.
38
+ * rule.lifecycle.inject_count +1, last_inject_at = now.
39
+ * lifecycle 없던 rule 은 auto-init 하고 phase='active'.
40
+ * Meta promotion (B→A) 의 rolling window 집계가 이 카운터를 소비한다.
41
+ */
42
+ export declare function markRulesInjected(ruleIds: string[], nowIso?: string): void;
22
43
  export declare function updateRuleStatus(ruleId: string, status: RuleStatus): boolean;
23
44
  /**
24
45
  * 현재 세션 ID와 다른 scope:'session' 규칙을 비활성화.
@@ -7,10 +7,12 @@
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 { V1_RULES_DIR } from '../core/paths.js';
10
+ import { ME_RULES } from '../core/paths.js';
11
11
  import { atomicWriteJSON, safeReadJSON } from '../hooks/shared/atomic-write.js';
12
+ import { CURRENT_RULE_SCHEMA_VERSION } from './types.js';
13
+ import { initLifecycle, bumpInject } from './rule-lifecycle.js';
12
14
  function rulePath(ruleId) {
13
- return path.join(V1_RULES_DIR, `${ruleId}.json`);
15
+ return path.join(ME_RULES, `${ruleId}.json`);
14
16
  }
15
17
  export function createRule(params) {
16
18
  const now = new Date().toISOString();
@@ -33,25 +35,143 @@ export function saveRule(rule) {
33
35
  rule.updated_at = new Date().toISOString();
34
36
  atomicWriteJSON(rulePath(rule.rule_id), rule, { pretty: true });
35
37
  }
38
+ /**
39
+ * ADR-002 T5 — rule 저장 + 기존 active rules 와 자연어 충돌 감지 + 양쪽 conflict_refs 기록.
40
+ *
41
+ * saveRule 과의 차이:
42
+ * - 저장 직후 T5 detect 실행 → 충돌 발견 시 신규 rule + 반대편 rule 모두 conflict_refs 업데이트.
43
+ * - auto-merge 안 함 (ADR-002 §Risks — 사용자 수동 해소).
44
+ * - T5 감지 실패는 저장 자체를 막지 않음 (fail-open).
45
+ *
46
+ * 반환: 저장된 rule + 감지된 충돌 rule_id 목록.
47
+ */
48
+ export async function appendRule(rule) {
49
+ saveRule(rule);
50
+ try {
51
+ const [{ detect: detectT5 }, { appendLifecycleEvents }] = await Promise.all([
52
+ import('../engine/lifecycle/trigger-t5-conflict.js'),
53
+ import('../engine/lifecycle/meta-reclassifier.js'),
54
+ ]);
55
+ const all = loadAllRules();
56
+ const events = detectT5({ rules: all });
57
+ const relevant = events.filter((e) => e.evidence?.refs?.includes(rule.rule_id));
58
+ if (relevant.length === 0)
59
+ return { saved: true, conflicts_with: [] };
60
+ // conflict_refs 양방향 업데이트
61
+ const affected = new Set();
62
+ for (const ev of relevant)
63
+ affected.add(ev.rule_id);
64
+ for (const id of affected) {
65
+ const target = all.find((r) => r.rule_id === id);
66
+ if (!target)
67
+ continue;
68
+ const refs = relevant
69
+ .filter((ev) => ev.rule_id === id)
70
+ .flatMap((ev) => (ev.evidence?.refs ?? []).filter((r) => r !== id));
71
+ const currentConflicts = target.lifecycle?.conflict_refs ?? [];
72
+ const merged = [...new Set([...currentConflicts, ...refs])];
73
+ const lifecycle = target.lifecycle ?? {
74
+ phase: 'active',
75
+ first_active_at: target.created_at,
76
+ inject_count: 0, accept_count: 0, violation_count: 0, bypass_count: 0,
77
+ conflict_refs: [], meta_promotions: [],
78
+ };
79
+ saveRule({ ...target, lifecycle: { ...lifecycle, conflict_refs: merged } });
80
+ }
81
+ appendLifecycleEvents(relevant);
82
+ const conflicts_with = [
83
+ ...new Set(relevant
84
+ .filter((e) => e.rule_id === rule.rule_id)
85
+ .flatMap((e) => (e.evidence?.refs ?? []).filter((r) => r !== rule.rule_id))),
86
+ ];
87
+ return { saved: true, conflicts_with };
88
+ }
89
+ catch {
90
+ return { saved: true, conflicts_with: [] };
91
+ }
92
+ }
36
93
  export function loadRule(ruleId) {
37
94
  return safeReadJSON(rulePath(ruleId), null);
38
95
  }
39
96
  export function loadAllRules() {
40
- if (!fs.existsSync(V1_RULES_DIR))
41
- return [];
42
97
  const rules = [];
43
- for (const file of fs.readdirSync(V1_RULES_DIR)) {
44
- if (!file.endsWith('.json'))
45
- continue;
46
- const rule = safeReadJSON(path.join(V1_RULES_DIR, file), null);
47
- if (rule)
48
- rules.push(rule);
98
+ // 1) 사용자 개인 rules: ~/.forgen/me/rules
99
+ if (fs.existsSync(ME_RULES)) {
100
+ for (const file of fs.readdirSync(ME_RULES)) {
101
+ if (!file.endsWith('.json'))
102
+ continue;
103
+ const rule = safeReadJSON(path.join(ME_RULES, file), null);
104
+ if (rule && isCompatibleSchema(rule, file))
105
+ rules.push(rule);
106
+ }
107
+ }
108
+ // 2) 프로젝트 로컬 rules: <cwd>/.forgen/rules
109
+ // ADR-003 Phase 1 Dogfood — 팀/프로젝트가 git 에 committed 한 L1 정책을 자동 로드.
110
+ // 같은 rule_id 가 me 와 project 양쪽에 있으면 project 가 우선 (git 소스가 정책 진실).
111
+ const projectRulesDir = resolveProjectRulesDir();
112
+ if (projectRulesDir && fs.existsSync(projectRulesDir)) {
113
+ for (const file of fs.readdirSync(projectRulesDir)) {
114
+ if (!file.endsWith('.json'))
115
+ continue;
116
+ const rule = safeReadJSON(path.join(projectRulesDir, file), null);
117
+ if (!rule || !isCompatibleSchema(rule, file))
118
+ continue;
119
+ const existingIdx = rules.findIndex((r) => r.rule_id === rule.rule_id);
120
+ if (existingIdx >= 0)
121
+ rules[existingIdx] = rule; // project override
122
+ else
123
+ rules.push(rule);
124
+ }
49
125
  }
50
126
  return rules;
51
127
  }
128
+ /**
129
+ * R5-B3: schema_version 호환성 체크.
130
+ * - undefined / 0 / CURRENT_RULE_SCHEMA_VERSION: OK
131
+ * - > CURRENT: 상위 버전 → graceful skip (downgrade 시 silent corruption 방지)
132
+ */
133
+ function isCompatibleSchema(rule, filename) {
134
+ const v = rule.schema_version;
135
+ if (v == null || v <= CURRENT_RULE_SCHEMA_VERSION)
136
+ return true;
137
+ if (process.env.FORGEN_DEBUG_SIGNALS === '1') {
138
+ process.stderr.write(`[forgen:rule-store] ${filename} schema_version=${v} > supported ${CURRENT_RULE_SCHEMA_VERSION} — skipped\n`);
139
+ }
140
+ return false;
141
+ }
142
+ /**
143
+ * 현재 프로젝트 cwd 의 `.forgen/rules/` 경로. FORGEN_CWD/COMPOUND_CWD 우선.
144
+ * 테스트 / CI 에서 프로젝트 스코프 로딩을 비활성화하려면 FORGEN_DISABLE_PROJECT_RULES=1.
145
+ */
146
+ function resolveProjectRulesDir() {
147
+ if (process.env.FORGEN_DISABLE_PROJECT_RULES === '1')
148
+ return null;
149
+ const cwd = process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd();
150
+ return path.join(cwd, '.forgen', 'rules');
151
+ }
52
152
  export function loadActiveRules() {
53
153
  return loadAllRules().filter(r => r.status === 'active');
54
154
  }
155
+ /**
156
+ * ADR-002 Meta signal — rule 들이 프롬프트에 inject 되었음을 기록.
157
+ * rule.lifecycle.inject_count +1, last_inject_at = now.
158
+ * lifecycle 없던 rule 은 auto-init 하고 phase='active'.
159
+ * Meta promotion (B→A) 의 rolling window 집계가 이 카운터를 소비한다.
160
+ */
161
+ export function markRulesInjected(ruleIds, nowIso = new Date().toISOString()) {
162
+ for (const id of ruleIds) {
163
+ const rule = loadRule(id);
164
+ if (!rule)
165
+ continue;
166
+ // R6-F1: initLifecycle 이 정규화(카운터 ≥0, 배열 보장) 까지 포함.
167
+ const lifecycle = initLifecycle(rule);
168
+ const updated = {
169
+ ...rule,
170
+ lifecycle: bumpInject(lifecycle, nowIso),
171
+ };
172
+ atomicWriteJSON(rulePath(rule.rule_id), updated, { pretty: true });
173
+ }
174
+ }
55
175
  export function updateRuleStatus(ruleId, status) {
56
176
  const rule = loadRule(ruleId);
57
177
  if (!rule)
@@ -65,13 +185,13 @@ export function updateRuleStatus(ruleId, status) {
65
185
  * 이전 세션의 임시 규칙이 새 세션에서 영향을 미치지 않도록 정리.
66
186
  */
67
187
  export function cleanupStaleSessionRules(_currentSessionId) {
68
- if (!fs.existsSync(V1_RULES_DIR))
188
+ if (!fs.existsSync(ME_RULES))
69
189
  return 0;
70
190
  let cleaned = 0;
71
- for (const file of fs.readdirSync(V1_RULES_DIR)) {
191
+ for (const file of fs.readdirSync(ME_RULES)) {
72
192
  if (!file.endsWith('.json'))
73
193
  continue;
74
- const filePath = path.join(V1_RULES_DIR, file);
194
+ const filePath = path.join(ME_RULES, file);
75
195
  const rule = safeReadJSON(filePath, null);
76
196
  if (rule && rule.scope === 'session' && rule.status === 'active') {
77
197
  rule.status = 'suppressed';
@@ -14,7 +14,79 @@ export type RuleScope = 'me' | 'session';
14
14
  export type RuleStrength = 'soft' | 'default' | 'strong' | 'hard';
15
15
  export type RuleSource = 'onboarding' | 'explicit_correction' | 'behavior_inference' | 'pack_overlay';
16
16
  export type RuleStatus = 'active' | 'suppressed' | 'removed' | 'superseded';
17
+ export type EnforcementMech = 'A' | 'B' | 'C';
18
+ export type HookPoint = 'PreToolUse' | 'PostToolUse' | 'Stop' | 'UserPromptSubmit';
19
+ export type VerifierKind = 'file_exists' | 'pattern_match' | 'tool_arg_regex' | 'artifact_check' | 'self_check_prompt';
20
+ export interface VerifierSpec {
21
+ kind: VerifierKind;
22
+ /** 각 kind 별로 의미가 다름 — 예: self_check_prompt 는 `question`, artifact_check 는 `path`+`max_age_s`. */
23
+ params: Record<string, string | number | boolean>;
24
+ }
25
+ export interface EnforceSpec {
26
+ mech: EnforcementMech;
27
+ hook: HookPoint;
28
+ /** Mech-A/B 에서 필수, Mech-C 에서는 미사용. */
29
+ verifier?: VerifierSpec;
30
+ /** Mech-A BLOCK / Mech-B self-check 시 Claude 에게 전달할 reason. */
31
+ block_message?: string;
32
+ /** Mech-C drift-score.ts 키 — 정량 판정 불가 규칙의 장기 누적 편향 축. */
33
+ drift_key?: string;
34
+ /**
35
+ * Stop hook 전용: 어시스턴트 응답 텍스트에서 이 규칙을 발화시킬 정규식.
36
+ * 미지정 시 shared default (완료 선언 키워드 regex) 사용.
37
+ */
38
+ trigger_keywords_regex?: string;
39
+ /**
40
+ * Stop hook 전용: trigger 가 매칭되더라도 이 regex 가 매칭되면 발화 안 함.
41
+ * retraction/meta/테스트-맥락 등 false-positive 컨텍스트 차단용.
42
+ */
43
+ trigger_exclude_regex?: string;
44
+ /**
45
+ * UI 표시용 한 줄 태그 (Stop hook 의 `systemMessage` 로 전달).
46
+ * 예: "rule:R-B1 — e2e-before-done"
47
+ */
48
+ system_tag?: string;
49
+ }
50
+ export type LifecyclePhase = 'active' | 'flagged' | 'suppressed' | 'retired' | 'merged' | 'superseded';
51
+ export interface MetaPromotion {
52
+ at: string;
53
+ from_mech: EnforcementMech;
54
+ to_mech: EnforcementMech;
55
+ reason: 'consistent_adherence' | 'repeated_violation' | 'user_override' | 'stuck_loop_force_approve';
56
+ trigger_stats: {
57
+ window_n: number;
58
+ adherence_rate?: number;
59
+ violation_count?: number;
60
+ };
61
+ }
62
+ export interface LifecycleState {
63
+ phase: LifecyclePhase;
64
+ first_active_at: string;
65
+ last_inject_at?: string;
66
+ last_violation_at?: string;
67
+ inject_count: number;
68
+ accept_count: number;
69
+ violation_count: number;
70
+ /** T3: 사용자가 rule 과 반대로 행동한 횟수 */
71
+ bypass_count: number;
72
+ /** T5: 충돌하는 rule_id 목록 */
73
+ conflict_refs: string[];
74
+ /** T5: 이 rule 이 흡수된 대상 rule_id */
75
+ merged_into?: string;
76
+ /** T1: 이 rule 을 교체한 rule_id */
77
+ superseded_by?: string;
78
+ /** Meta: mech 변경 이력 */
79
+ meta_promotions: MetaPromotion[];
80
+ }
81
+ /**
82
+ * Rule JSON schema version. v0.4.0 introduces `enforce_via` + `lifecycle` — 이들을
83
+ * 포함하는 schema 의 공식 버전은 1. 누락된 rule 파일은 pre-v0.4.0 으로 취급 (optional fields
84
+ * 만 비어있을 뿐 로드 가능). 미래 breaking change 시 이 값을 증가시키고 `migrate()` 체인으로 흡수.
85
+ */
86
+ export declare const CURRENT_RULE_SCHEMA_VERSION = 1;
17
87
  export interface Rule {
88
+ /** R5-B3: 미래 breaking schema change 를 위한 version 필드. 없으면 v0 (pre-0.4.0) 으로 취급. */
89
+ schema_version?: number;
18
90
  rule_id: string;
19
91
  category: RuleCategory;
20
92
  scope: RuleScope;
@@ -27,6 +99,17 @@ export interface Rule {
27
99
  render_key: string;
28
100
  created_at: string;
29
101
  updated_at: string;
102
+ /**
103
+ * 이 rule 이 어떤 hook/verifier 로 강제되는가. optional — 기존 rule 은 null.
104
+ * `forgen classify-enforce` 명령이 기존 rule 을 자동 분류하여 채운다.
105
+ * ADR-001 §Data Model.
106
+ */
107
+ enforce_via?: EnforceSpec[];
108
+ /**
109
+ * Lifecycle 상태. optional — 기존 rule 은 load 시 phase='active' 로 auto-initialize.
110
+ * ADR-002 §Data Model.
111
+ */
112
+ lifecycle?: LifecycleState;
30
113
  }
31
114
  export type EvidenceType = 'explicit_correction' | 'behavior_observation' | 'session_summary';
32
115
  export interface Evidence {
@@ -4,4 +4,10 @@
4
4
  * Authoritative source: docs/plans/2026-04-03-forgen-data-model-storage-spec.md
5
5
  * Runtime contracts: docs/plans/2026-04-03-forgen-component-interface-design.md
6
6
  */
7
- export {};
7
+ // ── Rule ────────────────────────────────────────────────────────────────────
8
+ /**
9
+ * Rule JSON schema version. v0.4.0 introduces `enforce_via` + `lifecycle` — 이들을
10
+ * 포함하는 schema 의 공식 버전은 1. 누락된 rule 파일은 pre-v0.4.0 으로 취급 (optional fields
11
+ * 만 비어있을 뿐 로드 가능). 미래 breaking change 시 이 값을 증가시키고 `migrate()` 체인으로 흡수.
12
+ */
13
+ export const CURRENT_RULE_SCHEMA_VERSION = 1;
@@ -5,6 +5,7 @@
5
5
  { "name": "post-tool-use", "tier": "compound-core", "event": "PostToolUse", "matcher": "*", "script": "hooks/post-tool-use.js", "timeout": 3, "compoundCritical": true },
6
6
  { "name": "pre-compact", "tier": "compound-core", "event": "PreCompact", "matcher": "*", "script": "hooks/pre-compact.js", "timeout": 3, "compoundCritical": false },
7
7
  { "name": "context-guard-stop", "tier": "compound-core", "event": "Stop", "matcher": "*", "script": "hooks/context-guard.js", "timeout": 5, "compoundCritical": false },
8
+ { "name": "stop-guard", "tier": "compound-core", "event": "Stop", "matcher": "*", "script": "hooks/stop-guard.js", "timeout": 10, "compoundCritical": true },
8
9
  { "name": "pre-tool-use", "tier": "compound-core", "event": "PreToolUse", "matcher": "*", "script": "hooks/pre-tool-use.js", "timeout": 3, "compoundCritical": true },
9
10
  { "name": "secret-filter", "tier": "safety", "event": "PostToolUse", "matcher": "Write|Edit|Bash", "script": "hooks/secret-filter.js", "timeout": 3, "compoundCritical": false },
10
11
  { "name": "slop-detector", "tier": "safety", "event": "PostToolUse", "matcher": "Write|Edit", "script": "hooks/slop-detector.js", "timeout": 3, "compoundCritical": false },
package/hooks/hooks.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "Forgen harness hooks (auto-generated, 19/19 active)",
2
+ "description": "Forgen harness hooks (auto-generated, 20/20 active)",
3
3
  "hooks": {
4
4
  "UserPromptSubmit": [
5
5
  {
@@ -102,6 +102,11 @@
102
102
  "type": "command",
103
103
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hooks/context-guard.js\"",
104
104
  "timeout": 5
105
+ },
106
+ {
107
+ "type": "command",
108
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hooks/stop-guard.js\"",
109
+ "timeout": 10
105
110
  }
106
111
  ]
107
112
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wooojin/forgen",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "preferGlobal": true,
5
5
  "main": "dist/lib.js",
6
6
  "types": "./dist/lib.d.ts",
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "author": "jang-ujin",
36
36
  "license": "MIT",
37
- "description": "Code, forged for you — personalized Claude Code harness",
37
+ "description": "When Claude says 'done', forgen makes it prove it turn-level self-verification + personalized rules, at $0 extra API cost.",
38
38
  "keywords": [
39
39
  "claude-code",
40
40
  "forgen",
@@ -86,5 +86,13 @@
86
86
  "@modelcontextprotocol/sdk": "^1.28.0",
87
87
  "js-yaml": "^4.1.1",
88
88
  "zod": "^4.3.6"
89
+ },
90
+ "peerDependencies": {
91
+ "@anthropic-ai/claude-code": ">=2.0.0"
92
+ },
93
+ "peerDependenciesMeta": {
94
+ "@anthropic-ai/claude-code": {
95
+ "optional": true
96
+ }
89
97
  }
90
98
  }
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://claude.ai/schemas/claude-plugin.json",
3
3
  "name": "forgen",
4
- "version": "5.1.2",
4
+ "version": "0.4.0",
5
5
  "description": "Claude Code harness — the more you use Claude, the better it gets",
6
6
  "author": {
7
7
  "name": "jang-ujin",
@@ -10,7 +10,12 @@
10
10
  "repository": "https://github.com/wooo-jin/forgen",
11
11
  "homepage": "https://github.com/wooo-jin/forgen",
12
12
  "license": "MIT",
13
- "keywords": ["claude-code", "harness", "personalization", "forge"],
13
+ "keywords": [
14
+ "claude-code",
15
+ "harness",
16
+ "personalization",
17
+ "forge"
18
+ ],
14
19
  "skills": "./skills/",
15
20
  "agents": "agents/",
16
21
  "statusLine": {