@wooojin/forgen 0.4.12 → 0.5.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 (139) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +109 -0
  3. package/README.ja.md +4 -4
  4. package/README.md +89 -27
  5. package/README.zh.md +4 -4
  6. package/assets/claude/commands/forge-loop.md +7 -1
  7. package/assets/claude/commands/ship.md +18 -1
  8. package/assets/opencode/forgen.ts +52 -0
  9. package/dist/checks/_shared/meta-guard-dispatch.d.ts +7 -0
  10. package/dist/checks/_shared/meta-guard-dispatch.js +12 -2
  11. package/dist/checks/_shared/model-profile.d.ts +25 -0
  12. package/dist/checks/_shared/model-profile.js +63 -0
  13. package/dist/cli.js +99 -153
  14. package/dist/core/auto-compound-runner.d.ts +0 -11
  15. package/dist/core/auto-compound-runner.js +127 -79
  16. package/dist/core/compound-consent.d.ts +15 -0
  17. package/dist/core/compound-consent.js +48 -0
  18. package/dist/core/compound-sweep-cli.d.ts +57 -0
  19. package/dist/core/compound-sweep-cli.js +354 -0
  20. package/dist/core/config-injector.d.ts +16 -1
  21. package/dist/core/config-injector.js +47 -28
  22. package/dist/core/dashboard-cli.js +40 -16
  23. package/dist/core/dashboard.d.ts +3 -4
  24. package/dist/core/dashboard.js +15 -34
  25. package/dist/core/dev-cli.d.ts +13 -0
  26. package/dist/core/dev-cli.js +70 -0
  27. package/dist/core/doctor.d.ts +16 -5
  28. package/dist/core/doctor.js +161 -176
  29. package/dist/core/drift-score.d.ts +2 -0
  30. package/dist/core/drift-score.js +9 -1
  31. package/dist/core/harness.js +115 -43
  32. package/dist/core/health-cli.d.ts +2 -0
  33. package/dist/core/health-cli.js +6 -1
  34. package/dist/core/host-detect.d.ts +3 -1
  35. package/dist/core/host-detect.js +26 -1
  36. package/dist/core/migrate-cli.js +15 -0
  37. package/dist/core/migrate-evidence-host.d.ts +2 -1
  38. package/dist/core/migrate-tenetx.d.ts +50 -0
  39. package/dist/core/migrate-tenetx.js +262 -0
  40. package/dist/core/probe-workflow-cli.d.ts +3 -3
  41. package/dist/core/probe-workflow-cli.js +13 -13
  42. package/dist/core/recall-cli.js +1 -1
  43. package/dist/core/regress-map-cli.js +1 -1
  44. package/dist/core/rendered-rules-manifest.d.ts +29 -0
  45. package/dist/core/rendered-rules-manifest.js +60 -0
  46. package/dist/core/session-store.js +14 -3
  47. package/dist/core/settings-injector.d.ts +3 -0
  48. package/dist/core/settings-injector.js +12 -16
  49. package/dist/core/spawn.d.ts +11 -1
  50. package/dist/core/spawn.js +79 -7
  51. package/dist/core/state-gc.js +1 -0
  52. package/dist/core/status-cli.d.ts +20 -0
  53. package/dist/core/status-cli.js +100 -0
  54. package/dist/core/statusline-cli.d.ts +7 -0
  55. package/dist/core/statusline-cli.js +63 -19
  56. package/dist/core/transcript-summary.d.ts +18 -0
  57. package/dist/core/transcript-summary.js +81 -0
  58. package/dist/core/trust-layer-intent.d.ts +21 -1
  59. package/dist/core/trust-layer-intent.js +7 -0
  60. package/dist/core/types.d.ts +3 -2
  61. package/dist/core/uninstall.js +12 -0
  62. package/dist/core/usage-telemetry.d.ts +7 -1
  63. package/dist/core/usage-telemetry.js +7 -9
  64. package/dist/core/v1-bootstrap.js +1 -1
  65. package/dist/core/watch-cli.js +1 -1
  66. package/dist/engine/compound-extractor.js +10 -0
  67. package/dist/engine/compound-loop.js +35 -6
  68. package/dist/engine/compound-share.d.ts +85 -0
  69. package/dist/engine/compound-share.js +606 -0
  70. package/dist/engine/correction-cluster-runner.d.ts +38 -0
  71. package/dist/engine/correction-cluster-runner.js +188 -0
  72. package/dist/engine/correction-clustering.d.ts +80 -0
  73. package/dist/engine/correction-clustering.js +167 -0
  74. package/dist/engine/enforce-classifier.d.ts +10 -1
  75. package/dist/engine/enforce-classifier.js +111 -22
  76. package/dist/engine/extraction-session.js +10 -3
  77. package/dist/engine/private-filter.d.ts +36 -0
  78. package/dist/engine/private-filter.js +100 -0
  79. package/dist/engine/ranking-pipeline.js +4 -2
  80. package/dist/engine/relevance-gate.d.ts +12 -0
  81. package/dist/engine/relevance-gate.js +12 -0
  82. package/dist/engine/roi-demotion.d.ts +79 -0
  83. package/dist/engine/roi-demotion.js +159 -0
  84. package/dist/engine/solution-format.d.ts +1 -0
  85. package/dist/engine/solution-format.js +26 -0
  86. package/dist/engine/solution-matcher.js +10 -1
  87. package/dist/fgx.js +19 -6
  88. package/dist/forge/cli.js +8 -2
  89. package/dist/hooks/compound-reflection.js +6 -1
  90. package/dist/hooks/context-guard.d.ts +16 -1
  91. package/dist/hooks/context-guard.js +92 -46
  92. package/dist/hooks/post-tool-use.js +2 -3
  93. package/dist/hooks/pre-compact.js +14 -0
  94. package/dist/hooks/pre-tool-use.js +5 -1
  95. package/dist/hooks/shared/stop-triggers.d.ts +29 -2
  96. package/dist/hooks/shared/stop-triggers.js +35 -2
  97. package/dist/hooks/solution-injector.d.ts +28 -0
  98. package/dist/hooks/solution-injector.js +126 -28
  99. package/dist/hooks/stop-guard.js +5 -2
  100. package/dist/hooks/subagent-stop-guard.js +4 -1
  101. package/dist/host/capabilities-claude.js +1 -0
  102. package/dist/host/capabilities-codex.js +1 -0
  103. package/dist/host/capabilities-opencode.d.ts +26 -0
  104. package/dist/host/capabilities-opencode.js +78 -0
  105. package/dist/host/capabilities-registry.d.ts +7 -0
  106. package/dist/host/capabilities-registry.js +14 -0
  107. package/dist/host/exec-host.d.ts +4 -3
  108. package/dist/host/exec-host.js +2 -0
  109. package/dist/host/host-binding.d.ts +27 -0
  110. package/dist/host/host-binding.js +11 -0
  111. package/dist/host/host-runtime.js +18 -0
  112. package/dist/host/install-codex.d.ts +8 -0
  113. package/dist/host/install-codex.js +2 -2
  114. package/dist/host/install-opencode.d.ts +38 -0
  115. package/dist/host/install-opencode.js +148 -0
  116. package/dist/host/install-orchestrator.d.ts +4 -1
  117. package/dist/host/install-orchestrator.js +20 -1
  118. package/dist/host/invoke-agent.d.ts +3 -2
  119. package/dist/host/opencode/context-cli.d.ts +15 -0
  120. package/dist/host/opencode/context-cli.js +25 -0
  121. package/dist/host/opencode/guard-cli.d.ts +13 -0
  122. package/dist/host/opencode/guard-cli.js +39 -0
  123. package/dist/host/opencode/plugin/forgen.d.ts +31 -0
  124. package/dist/host/opencode/plugin/forgen.js +60 -0
  125. package/dist/host/opencode/translate.d.ts +49 -0
  126. package/dist/host/opencode/translate.js +96 -0
  127. package/dist/host/parity-harness.d.ts +10 -2
  128. package/dist/host/projection.js +11 -0
  129. package/dist/mcp/tools.js +17 -4
  130. package/dist/store/evidence-store.d.ts +2 -6
  131. package/dist/store/evidence-store.js +68 -14
  132. package/dist/store/host-mismatch.d.ts +2 -1
  133. package/dist/store/host-mismatch.js +8 -8
  134. package/dist/store/profile-store.d.ts +3 -2
  135. package/dist/store/types.d.ts +9 -2
  136. package/package.json +3 -2
  137. package/plugin.json +2 -2
  138. package/skills/forge-loop/SKILL.md +7 -1
  139. package/skills/ship/SKILL.md +18 -1
@@ -0,0 +1,188 @@
1
+ /**
2
+ * correction-cluster-runner — W3-2 클러스터링 실행 (파일 IO / 룰 저장 / 훅 연결).
3
+ * 순수 로직은 correction-clustering.ts. 여기서만 rule-store 를 건드린다.
4
+ *
5
+ * 흐름 (세션종료 시 auto-compound-runner:promoteSessionCandidates 직후 호출):
6
+ * 1. me-scope active explicit_correction 룰(비-hard) 로드
7
+ * 2. 억제목록 로드 → clusterCorrectionRules
8
+ * 3. 각 클러스터: T5 내부 모순이면 스킵(conflict 우선), 아니면 통합 rule 생성 +
9
+ * 원본 superseded + clustered_into 링크
10
+ * 4. 통합 요약 반환(호출측이 알림)
11
+ *
12
+ * unmerge: 통합 rule 제거 + 원본 active 복원 + 조합을 억제목록에 추가(재통합 방지).
13
+ */
14
+ import * as crypto from 'node:crypto';
15
+ import * as fs from 'node:fs';
16
+ import * as path from 'node:path';
17
+ import { createLogger } from '../core/logger.js';
18
+ import { STATE_DIR } from '../core/paths.js';
19
+ import { atomicWriteJSON, safeReadJSON } from '../hooks/shared/atomic-write.js';
20
+ import { withFileLock } from '../hooks/shared/file-lock.js';
21
+ import { createRule, loadAllRules, loadRule, saveRule } from '../store/rule-store.js';
22
+ import { clusterCorrectionRules, clusterKey, } from './correction-clustering.js';
23
+ const log = createLogger('correction-cluster');
24
+ const SUPPRESSION_PATH = path.join(STATE_DIR, 'cluster-suppression.json');
25
+ /** 동시 세션종료 클러스터링 직렬화용 락 대상 경로(SEV-3 #4). */
26
+ const CLUSTER_LOCK_PATH = path.join(STATE_DIR, 'cluster-run');
27
+ function loadSuppression() {
28
+ const st = safeReadJSON(SUPPRESSION_PATH, null);
29
+ return new Set(st?.keys ?? []);
30
+ }
31
+ function saveSuppression(keys) {
32
+ try {
33
+ fs.mkdirSync(STATE_DIR, { recursive: true });
34
+ atomicWriteJSON(SUPPRESSION_PATH, { keys: [...keys] });
35
+ }
36
+ catch (e) {
37
+ log.debug('cluster-suppression 저장 실패', e);
38
+ }
39
+ }
40
+ function toClusterable(r) {
41
+ return {
42
+ rule_id: r.rule_id,
43
+ category: r.category,
44
+ policy: r.policy,
45
+ strength: r.strength,
46
+ evidence_refs: r.evidence_refs ?? [],
47
+ };
48
+ }
49
+ /**
50
+ * render_key for a merged cluster rule: `category.cluster.<slug>-<hash>`.
51
+ * 리뷰 SEV-3: slug(30자 접두)만으로는 두 별개 클러스터가 같은 slug 를 가질 때
52
+ * dedupeByRenderKey 가 하나를 조용히 드롭(교정 소실)할 수 있다. clusterKey 해시를
53
+ * 접미해 통합룰 render_key 를 무조건 유니크화 → 충돌 원천봉쇄(가독성 유지).
54
+ */
55
+ function mergedRenderKey(cluster) {
56
+ const category = cluster.members[0]?.category ?? 'workflow';
57
+ const slug = cluster.representativePolicy
58
+ .toLowerCase()
59
+ .replace(/[^가-힣a-z0-9\s]/g, '')
60
+ .trim()
61
+ .replace(/\s+/g, '-')
62
+ .slice(0, 30);
63
+ const hash = crypto
64
+ .createHash('sha1')
65
+ .update(clusterKey(cluster.members))
66
+ .digest('hex')
67
+ .slice(0, 8);
68
+ return `${category}.cluster.${slug}-${hash}`;
69
+ }
70
+ /** 통합(cluster-merge) 룰인지 — render_key 의 `.cluster.` 인픽스로 식별(내부 생성만 사용). */
71
+ function isClusterMergedRule(r) {
72
+ return r.render_key.includes('.cluster.');
73
+ }
74
+ /**
75
+ * 세션종료 클러스터링 실행. 통합 발생 시 각 통합 요약 배열 반환(빈 배열이면 no-op).
76
+ * 자동 실행이므로 조용하지 않게 — 호출측이 결과를 stderr/알림으로 노출한다.
77
+ */
78
+ export async function runCorrectionClustering() {
79
+ // 리뷰 SEV-3 #4: auto-compound 는 세션별 detached 실행이라 두 세션 동시 종료 시
80
+ // 병렬 진입 가능 → 같은 후보로 각자 통합룰 생성/last-write-wins 불일치. 파일락으로
81
+ // 클러스터링 임계구역을 직렬화한다. fail-open(락 실패는 예외 → 호출측 catch).
82
+ fs.mkdirSync(STATE_DIR, { recursive: true }); // 락 파일 생성 전 상태 디렉터리 보장
83
+ return withFileLock(CLUSTER_LOCK_PATH, () => runCorrectionClusteringLocked());
84
+ }
85
+ async function runCorrectionClusteringLocked() {
86
+ const all = loadAllRules();
87
+ const candidates = all.filter((r) => r.scope === 'me' &&
88
+ r.status === 'active' &&
89
+ r.source === 'explicit_correction' &&
90
+ r.strength !== 'hard');
91
+ if (candidates.length < 2)
92
+ return [];
93
+ const suppressed = loadSuppression();
94
+ const clusters = clusterCorrectionRules(candidates.map(toClusterable), suppressed);
95
+ if (clusters.length === 0)
96
+ return [];
97
+ // T5 내부 모순 확인용 detector (동적 import — 순환 의존 회피).
98
+ const { detect: detectT5 } = await import('./lifecycle/trigger-t5-conflict.js');
99
+ const results = [];
100
+ for (const cluster of clusters) {
101
+ const memberRules = cluster.members
102
+ .map((m) => candidates.find((c) => c.rule_id === m.rule_id))
103
+ .filter((r) => Boolean(r));
104
+ if (memberRules.length < 2)
105
+ continue;
106
+ // 클러스터 내부에 T5 모순(상반 교정)이 있으면 통합하지 않는다 — conflict 해소 우선.
107
+ const conflicts = detectT5({ rules: memberRules });
108
+ if (conflicts.length > 0) {
109
+ log.debug(`클러스터 통합 스킵(T5 모순): ${clusterKey(cluster.members)}`);
110
+ continue;
111
+ }
112
+ const category = memberRules[0].category;
113
+ // 리뷰 SEV-3 #2: 클러스터에 이미 통합룰이 있으면 새 M2 를 만들지 않고 *기존 통합룰에
114
+ // 흡수*한다(supersession 체인 방지). evidence/강도/policy 를 갱신하고 신규 멤버만
115
+ // superseded. 없으면 새 통합룰 생성.
116
+ const existingMerged = memberRules.filter(isClusterMergedRule);
117
+ let mergedId;
118
+ let absorbed;
119
+ if (existingMerged.length > 0) {
120
+ const absorber = existingMerged[0];
121
+ absorber.status = 'active';
122
+ absorber.clustered_into = undefined;
123
+ absorber.policy = cluster.representativePolicy;
124
+ absorber.strength = cluster.strength;
125
+ absorber.evidence_refs = cluster.evidenceRefs;
126
+ saveRule(absorber);
127
+ mergedId = absorber.rule_id;
128
+ absorbed = memberRules.filter((r) => r.rule_id !== absorber.rule_id);
129
+ }
130
+ else {
131
+ const merged = createRule({
132
+ category,
133
+ scope: 'me',
134
+ trigger: memberRules[0].trigger,
135
+ policy: cluster.representativePolicy,
136
+ strength: cluster.strength,
137
+ source: 'explicit_correction',
138
+ evidence_refs: cluster.evidenceRefs,
139
+ render_key: mergedRenderKey(cluster),
140
+ });
141
+ saveRule(merged);
142
+ mergedId = merged.rule_id;
143
+ absorbed = memberRules;
144
+ }
145
+ // 흡수된 원본은 삭제하지 않고 superseded + clustered_into 링크(unmerge 복원 가능).
146
+ for (const orig of absorbed) {
147
+ orig.status = 'superseded';
148
+ orig.clustered_into = mergedId;
149
+ saveRule(orig);
150
+ }
151
+ results.push({
152
+ mergedRuleId: mergedId,
153
+ memberIds: absorbed.map((r) => r.rule_id),
154
+ category,
155
+ strength: cluster.strength,
156
+ confidence: cluster.confidence,
157
+ policy: cluster.representativePolicy,
158
+ });
159
+ log.debug(`클러스터 통합: +${absorbed.length}룰 → ${mergedId} (${cluster.strength})`);
160
+ }
161
+ return results;
162
+ }
163
+ /**
164
+ * 통합 취소: 통합 rule 을 removed 처리하고 원본 룰을 active 복원, 조합을 억제목록에 추가.
165
+ * 억제로 인해 다음 세션종료에 같은 조합이 재통합되지 않는다.
166
+ */
167
+ export function unmergeCluster(mergedRuleId) {
168
+ const merged = loadRule(mergedRuleId);
169
+ if (!merged)
170
+ return { ok: false, restored: [], reason: `통합 rule 없음: ${mergedRuleId}` };
171
+ const all = loadAllRules();
172
+ const members = all.filter((r) => r.clustered_into === mergedRuleId);
173
+ if (members.length === 0) {
174
+ return { ok: false, restored: [], reason: '이 통합 rule 에 연결된 원본이 없음' };
175
+ }
176
+ for (const orig of members) {
177
+ orig.status = 'active';
178
+ orig.clustered_into = undefined;
179
+ saveRule(orig);
180
+ }
181
+ merged.status = 'removed';
182
+ saveRule(merged);
183
+ // 이 조합을 억제목록에 추가 → 재통합 방지.
184
+ const suppressed = loadSuppression();
185
+ suppressed.add(clusterKey(members));
186
+ saveSuppression(suppressed);
187
+ return { ok: true, restored: members.map((r) => r.rule_id) };
188
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * correction-clustering — 교정 룰 클러스터링 → 통합 승급 (Wave 3 W3-2).
3
+ * 설계: reports/feature-audit/w3-2-design.md
4
+ *
5
+ * 문제: 같은 원칙을 문구·target 만 바꿔 N회 교정하면, promoteSessionCandidates 가
6
+ * render_key(정확 중복)만 dedup 하므로 N개 별도 `default` 룰로 흩어진다. "사용자가
7
+ * N번 교정했다"는 강한 개인화 신호(강도↑)가 소실된다.
8
+ *
9
+ * 델타 (기존 파이프라인엔 없는 2개만):
10
+ * D1. 의미 클러스터링 — 같은 axis 내 교정 룰을 유사도로 묶는다.
11
+ * D2. 반복 → 강도 — 클러스터 크기 N 을 Laplace 계승법칙 confidence 로 환산해 강도 tier 결정.
12
+ *
13
+ * 근거 (임의 상수 아님):
14
+ * - τ=0.3: `solution-injector.MIN_INJECT_RELEVANCE`(동일 relevance-scorer 표현에 2026-04-21
15
+ * gate sweep 으로 실측 튜닝된 게이트)을 상속.
16
+ * - confidence=(s+1)/(s+f+2): Laplace 계승법칙(1814) / Beta-Bernoulli 사후평균.
17
+ *
18
+ * 이 모듈은 *순수 로직*만 담는다 — 파일 IO/룰 저장/훅 연결은 호출측(correction-cluster-runner).
19
+ */
20
+ import type { RuleStrength } from '../store/types.js';
21
+ /** τ — 클러스터 편입 유사도 임계. 정준 게이트(RELEVANCE_MATCH_GATE=0.3)를 코드로 상속. */
22
+ export declare const CLUSTER_SIMILARITY_TAU = 0.3;
23
+ /** 클러스터링 입력: 룰의 최소 표현(순수 로직이 파일/스토어에 의존하지 않도록). */
24
+ export interface ClusterableRule {
25
+ rule_id: string;
26
+ /** 클러스터는 같은 category(=axis) 안에서만 형성된다. */
27
+ category: string;
28
+ policy: string;
29
+ strength: RuleStrength;
30
+ evidence_refs: string[];
31
+ }
32
+ export interface CorrectionCluster {
33
+ /** 통합 대상 룰들(크기 ≥ 2). */
34
+ members: ClusterableRule[];
35
+ /** 대표 정책(가장 긴 policy — 보통 가장 서술적). 통합 룰의 policy 후보. */
36
+ representativePolicy: string;
37
+ /** Laplace 계승법칙 confidence = (N+1)/(N+2). */
38
+ confidence: number;
39
+ /** confidence → 강도 tier. hard 로는 절대 자동 도달하지 않는다. */
40
+ strength: RuleStrength;
41
+ /** 통합 룰의 evidence_refs(멤버 union). */
42
+ evidenceRefs: string[];
43
+ }
44
+ /**
45
+ * 두 정책 텍스트의 대칭 유사도. calculateRelevance 는 비대칭(prompt→solution)이라
46
+ * 양방향 평균으로 대칭화한다. confidence 인자는 1(순수 태그 매치만 보고 싶음).
47
+ */
48
+ export declare function policySimilarity(a: string, b: string): number;
49
+ /**
50
+ * Laplace 계승법칙 (rule of succession, 1814).
51
+ * s = 일관 교정 횟수, f = 모순(반박) 횟수. 지속 선호일 사후평균 = (s+1)/(s+f+2).
52
+ * N회 일관·모순0 → (N+1)/(N+2): N=1→0.67, N=2→0.75, N=3→0.80, N=5→0.86.
53
+ */
54
+ export declare function laplaceConfidence(successes: number, failures?: number): number;
55
+ /**
56
+ * confidence → 강도 tier. statusConfidence 밴드(verified=0.75)에 매핑.
57
+ * confidence ≥ 0.75 (= N≥2 일관교정) → strong. 그 미만 → default.
58
+ * hard 는 confidence 로 자동 도달하지 않는다 — L1 안전룰 전용(오탐 시 세션 차단 위험).
59
+ */
60
+ export declare function strengthForConfidence(confidence: number): RuleStrength;
61
+ /**
62
+ * 같은 category(axis) 내 룰들을 유사도 그래프의 connected component 로 클러스터링.
63
+ * 크기 ≥ 2 인 클러스터만 반환. hard 룰은 후보에서 제외(안전룰은 통합 대상 아님).
64
+ *
65
+ * @param rules 후보 룰들(호출측이 me-scope active explicit_correction 로 필터해서 전달)
66
+ * @param suppressed 억제 조합 키 집합(unmerge 된 조합 재통합 방지) — 정렬된 rule_id join
67
+ */
68
+ export declare function clusterCorrectionRules(rules: ClusterableRule[], suppressed?: ReadonlySet<string>): CorrectionCluster[];
69
+ /** 클러스터 멤버 집합의 안정 키(정렬된 rule_id join) — 억제 목록/식별용. */
70
+ export declare function clusterKey(members: Array<{
71
+ rule_id: string;
72
+ }>): string;
73
+ /**
74
+ * 이 클러스터가 억제 대상인지 — 정확 일치뿐 아니라 부분집합/상위집합 관계도 억제한다.
75
+ * 사용자가 거부한 조합(억제키)이 현재 클러스터에 포함(S⊆cur)되거나 현재 클러스터가
76
+ * 거부 조합의 일부(cur⊆S)면 재통합하지 않는다. 데이터 손실 없음(안 묶으면 흩어진 채 유지).
77
+ */
78
+ export declare function isSuppressedCluster(members: Array<{
79
+ rule_id: string;
80
+ }>, suppressed: ReadonlySet<string>): boolean;
@@ -0,0 +1,167 @@
1
+ /**
2
+ * correction-clustering — 교정 룰 클러스터링 → 통합 승급 (Wave 3 W3-2).
3
+ * 설계: reports/feature-audit/w3-2-design.md
4
+ *
5
+ * 문제: 같은 원칙을 문구·target 만 바꿔 N회 교정하면, promoteSessionCandidates 가
6
+ * render_key(정확 중복)만 dedup 하므로 N개 별도 `default` 룰로 흩어진다. "사용자가
7
+ * N번 교정했다"는 강한 개인화 신호(강도↑)가 소실된다.
8
+ *
9
+ * 델타 (기존 파이프라인엔 없는 2개만):
10
+ * D1. 의미 클러스터링 — 같은 axis 내 교정 룰을 유사도로 묶는다.
11
+ * D2. 반복 → 강도 — 클러스터 크기 N 을 Laplace 계승법칙 confidence 로 환산해 강도 tier 결정.
12
+ *
13
+ * 근거 (임의 상수 아님):
14
+ * - τ=0.3: `solution-injector.MIN_INJECT_RELEVANCE`(동일 relevance-scorer 표현에 2026-04-21
15
+ * gate sweep 으로 실측 튜닝된 게이트)을 상속.
16
+ * - confidence=(s+1)/(s+f+2): Laplace 계승법칙(1814) / Beta-Bernoulli 사후평균.
17
+ *
18
+ * 이 모듈은 *순수 로직*만 담는다 — 파일 IO/룰 저장/훅 연결은 호출측(correction-cluster-runner).
19
+ */
20
+ import { statusConfidence } from './compound-lifecycle.js';
21
+ import { RELEVANCE_MATCH_GATE } from './relevance-gate.js';
22
+ import { calculateRelevance } from './relevance-scorer.js';
23
+ import { extractTags } from './solution-format.js';
24
+ /** τ — 클러스터 편입 유사도 임계. 정준 게이트(RELEVANCE_MATCH_GATE=0.3)를 코드로 상속. */
25
+ export const CLUSTER_SIMILARITY_TAU = RELEVANCE_MATCH_GATE;
26
+ /** 강도 승급 컷오프 — statusConfidence('verified')=0.75 를 코드로 상속(리터럴 복제 아님). */
27
+ const STRONG_CONFIDENCE_CUTOFF = statusConfidence('verified');
28
+ /** 클러스터 대상이 되는 최소 정책 텍스트 길이(너무 짧으면 tag 신호 부족). */
29
+ const MIN_POLICY_LEN = 10;
30
+ /**
31
+ * 두 정책 텍스트의 대칭 유사도. calculateRelevance 는 비대칭(prompt→solution)이라
32
+ * 양방향 평균으로 대칭화한다. confidence 인자는 1(순수 태그 매치만 보고 싶음).
33
+ */
34
+ export function policySimilarity(a, b) {
35
+ const tagsA = extractTags(a);
36
+ const tagsB = extractTags(b);
37
+ if (tagsA.length === 0 || tagsB.length === 0)
38
+ return 0;
39
+ const ab = calculateRelevance(tagsA, tagsB, 1);
40
+ const ba = calculateRelevance(tagsB, tagsA, 1);
41
+ return (ab.relevance + ba.relevance) / 2;
42
+ }
43
+ /**
44
+ * Laplace 계승법칙 (rule of succession, 1814).
45
+ * s = 일관 교정 횟수, f = 모순(반박) 횟수. 지속 선호일 사후평균 = (s+1)/(s+f+2).
46
+ * N회 일관·모순0 → (N+1)/(N+2): N=1→0.67, N=2→0.75, N=3→0.80, N=5→0.86.
47
+ */
48
+ export function laplaceConfidence(successes, failures = 0) {
49
+ return (successes + 1) / (successes + failures + 2);
50
+ }
51
+ /**
52
+ * confidence → 강도 tier. statusConfidence 밴드(verified=0.75)에 매핑.
53
+ * confidence ≥ 0.75 (= N≥2 일관교정) → strong. 그 미만 → default.
54
+ * hard 는 confidence 로 자동 도달하지 않는다 — L1 안전룰 전용(오탐 시 세션 차단 위험).
55
+ */
56
+ export function strengthForConfidence(confidence) {
57
+ return confidence >= STRONG_CONFIDENCE_CUTOFF ? 'strong' : 'default';
58
+ }
59
+ /**
60
+ * 같은 category(axis) 내 룰들을 유사도 그래프의 connected component 로 클러스터링.
61
+ * 크기 ≥ 2 인 클러스터만 반환. hard 룰은 후보에서 제외(안전룰은 통합 대상 아님).
62
+ *
63
+ * @param rules 후보 룰들(호출측이 me-scope active explicit_correction 로 필터해서 전달)
64
+ * @param suppressed 억제 조합 키 집합(unmerge 된 조합 재통합 방지) — 정렬된 rule_id join
65
+ */
66
+ export function clusterCorrectionRules(rules, suppressed = new Set()) {
67
+ const eligible = rules.filter((r) => r.strength !== 'hard' && (r.policy?.length ?? 0) >= MIN_POLICY_LEN);
68
+ // category 별 그룹핑 — 클러스터는 axis 경계를 넘지 않는다.
69
+ const byCategory = new Map();
70
+ for (const r of eligible) {
71
+ const arr = byCategory.get(r.category) ?? [];
72
+ arr.push(r);
73
+ byCategory.set(r.category, arr);
74
+ }
75
+ const clusters = [];
76
+ for (const group of byCategory.values()) {
77
+ if (group.length < 2)
78
+ continue;
79
+ // Union-Find over the group by pairwise similarity ≥ τ.
80
+ const parent = group.map((_, i) => i);
81
+ const find = (x) => {
82
+ while (parent[x] !== x) {
83
+ parent[x] = parent[parent[x]];
84
+ x = parent[x];
85
+ }
86
+ return x;
87
+ };
88
+ const union = (a, b) => {
89
+ const ra = find(a);
90
+ const rb = find(b);
91
+ if (ra !== rb)
92
+ parent[ra] = rb;
93
+ };
94
+ for (let i = 0; i < group.length; i++) {
95
+ for (let j = i + 1; j < group.length; j++) {
96
+ if (policySimilarity(group[i].policy, group[j].policy) >= CLUSTER_SIMILARITY_TAU) {
97
+ union(i, j);
98
+ }
99
+ }
100
+ }
101
+ // component 수집
102
+ const components = new Map();
103
+ for (let i = 0; i < group.length; i++) {
104
+ const root = find(i);
105
+ const arr = components.get(root) ?? [];
106
+ arr.push(group[i]);
107
+ components.set(root, arr);
108
+ }
109
+ for (const members of components.values()) {
110
+ if (members.length < 2)
111
+ continue;
112
+ // 억제된 조합이면 스킵. 정확 일치뿐 아니라 *부분집합/상위집합*도 억제 —
113
+ // {a,b,c} 를 unmerge 했는데 유사교정 d 도착으로 {a,b,c,d} 가 재통합되는
114
+ // whack-a-mole 방지 (리뷰 SEV-3 #3).
115
+ if (isSuppressedCluster(members, suppressed))
116
+ continue;
117
+ const representativePolicy = members
118
+ .map((m) => m.policy)
119
+ .reduce((a, b) => (b.length > a.length ? b : a));
120
+ const evidenceRefs = Array.from(new Set(members.flatMap((m) => m.evidence_refs ?? [])));
121
+ // Laplace 의 N = *교정 관측 횟수* = 고유 evidence 수 (룰 객체 수가 아님).
122
+ // raw 교정 룰은 evidence 1개씩이라 base case 는 members.length 와 동일하지만,
123
+ // 이미 통합된 룰이 재-클러스터될 때(evidence 다수 보유) 반복 횟수를 정확히 누적한다.
124
+ const observationCount = Math.max(evidenceRefs.length, members.length);
125
+ const confidence = laplaceConfidence(observationCount);
126
+ clusters.push({
127
+ members,
128
+ representativePolicy,
129
+ confidence,
130
+ strength: strengthForConfidence(confidence),
131
+ evidenceRefs,
132
+ });
133
+ }
134
+ }
135
+ return clusters;
136
+ }
137
+ /** 클러스터 멤버 집합의 안정 키(정렬된 rule_id join) — 억제 목록/식별용. */
138
+ export function clusterKey(members) {
139
+ return members
140
+ .map((m) => m.rule_id)
141
+ .sort()
142
+ .join('|');
143
+ }
144
+ function isSubset(a, b) {
145
+ for (const x of a)
146
+ if (!b.has(x))
147
+ return false;
148
+ return true;
149
+ }
150
+ /**
151
+ * 이 클러스터가 억제 대상인지 — 정확 일치뿐 아니라 부분집합/상위집합 관계도 억제한다.
152
+ * 사용자가 거부한 조합(억제키)이 현재 클러스터에 포함(S⊆cur)되거나 현재 클러스터가
153
+ * 거부 조합의 일부(cur⊆S)면 재통합하지 않는다. 데이터 손실 없음(안 묶으면 흩어진 채 유지).
154
+ */
155
+ export function isSuppressedCluster(members, suppressed) {
156
+ if (suppressed.size === 0)
157
+ return false;
158
+ const cur = new Set(members.map((m) => m.rule_id));
159
+ for (const key of suppressed) {
160
+ if (!key)
161
+ continue;
162
+ const s = new Set(key.split('|'));
163
+ if (isSubset(s, cur) || isSubset(cur, s))
164
+ return true;
165
+ }
166
+ return false;
167
+ }
@@ -6,7 +6,10 @@
6
6
  *
7
7
  * 휴리스틱 (ADR-001 §Migration heuristics):
8
8
  * - trigger/policy 에 `rm|force|DROP|credentials|\.env` → Mech-A PreToolUse + tool_arg_regex
9
- * - trigger/policy 에 `완료|complete|done|e2e|mock|verify` → Mech-A Stop + artifact_check
9
+ * - trigger/policy 에 `완료|complete|done|e2e|mock|verify` + 명시적 증거 경로(*.json)
10
+ * → Mech-A Stop + artifact_check. 경로 미명시 → Mech-B self_check_prompt
11
+ * (무경로 디폴트 .forgen/state/e2e-result.json 은 e2e 게이트 폐지(ADR-010 W0) 후
12
+ * 죽은 경로가 되어 완료 선언을 영구 차단하는 룰을 재생산했다 — 리뷰 발견)
10
13
  * - strength ∈ {strong, hard} + 문체/응답 맥락 → Mech-B UserPromptSubmit + self_check_prompt
11
14
  * - 그 외 soft/default → Mech-C (drift 측정)
12
15
  *
@@ -25,6 +28,12 @@ export interface EnforceProposal {
25
28
  }
26
29
  export declare function classify(rule: Rule): EnforceProposal;
27
30
  export declare function classifyAll(rules: Rule[]): EnforceProposal[];
31
+ /**
32
+ * critic-review 룰인데 baked Stop 트리거가 skip-review 시그널을 안 가지는지 (리뷰 SEV-3 (c)).
33
+ * true 면 `forgen rule classify --apply --force` 재-bake 로 갱신해야 갭이 닫힌다.
34
+ * doctor nudge 용 — "고쳤는데 기존 유저에겐 안 닿는" 상태를 발견 가능하게 한다.
35
+ */
36
+ export declare function needsCriticTriggerMigration(rule: Rule): boolean;
28
37
  /** 제안을 적용해 새 Rule 을 반환 (pure). 이미 enforce_via 가 있으면 force=false 에서 건너뜀. */
29
38
  export declare function applyProposal(rule: Rule, proposal: EnforceProposal, options?: {
30
39
  force?: boolean;
@@ -6,7 +6,10 @@
6
6
  *
7
7
  * 휴리스틱 (ADR-001 §Migration heuristics):
8
8
  * - trigger/policy 에 `rm|force|DROP|credentials|\.env` → Mech-A PreToolUse + tool_arg_regex
9
- * - trigger/policy 에 `완료|complete|done|e2e|mock|verify` → Mech-A Stop + artifact_check
9
+ * - trigger/policy 에 `완료|complete|done|e2e|mock|verify` + 명시적 증거 경로(*.json)
10
+ * → Mech-A Stop + artifact_check. 경로 미명시 → Mech-B self_check_prompt
11
+ * (무경로 디폴트 .forgen/state/e2e-result.json 은 e2e 게이트 폐지(ADR-010 W0) 후
12
+ * 죽은 경로가 되어 완료 선언을 영구 차단하는 룰을 재생산했다 — 리뷰 발견)
10
13
  * - strength ∈ {strong, hard} + 문체/응답 맥락 → Mech-B UserPromptSubmit + self_check_prompt
11
14
  * - 그 외 soft/default → Mech-C (drift 측정)
12
15
  *
@@ -18,11 +21,49 @@
18
21
  const DESTRUCTIVE_PATTERN = /\b(rm\s+-rf|rm\s+-fr|force|DROP\s+TABLE|credentials|\.env|sudo|mkfs|dd\s+if=)/i;
19
22
  const COMPLETION_PATTERN = /(완료|complete|done|ready|shipped|finished|e2e|mock|verify|검증|배포)/i;
20
23
  const STYLE_PATTERN = /(문체|응답|설명|톤|어투|장황|간결|verbose|tone|style)/i;
24
+ /** rule 텍스트에 명시된 증거 파일 경로 (예: ~/.forgen/state/e2e-result.json). */
25
+ const ARTIFACT_PATH_PATTERN = /(?:~\/)?[\w.-]+(?:\/[\w.-]+)*\.json/;
26
+ /**
27
+ * 폐지/완화 성격의 룰 감지 — "X를 더 이상 요구하지 않는다" 류 교정은 기존 게이트를
28
+ * *해제*하는 룰인데, 텍스트에 완료 키워드+경로가 남아 있어 완료 게이트로 오분류됐다
29
+ * (실사례: e2e 게이트 폐지 교정 룰이 폐지 대상 게이트를 스스로 강제).
30
+ * 금지문("~하지 마라")이 함께 있으면 폐지가 아니라 강제 룰이다 — 금지문이 우선한다
31
+ * (리뷰 #9: "완화 없이 검증 완료를 선언하지 마라"가 repeal 로 오탐되던 케이스).
32
+ */
33
+ const REPEAL_PATTERN = /(더\s*이상.{0,60}(않는다|않음|안\s*한다)|폐지|완화|선택\s*사항|필요\s*없|optional|no\s+longer\s+require)/i;
34
+ const PROHIBITION_PATTERN = /(하지\s*마라|하지\s*말|말\s*것|금지)/;
35
+ /**
36
+ * 증거 맥락 감지 — artifact_check 는 경로가 증거 아티팩트로 보일 때만 부착한다.
37
+ * 부수적 .json 언급(package.json 버전 확인 등)에 게이트를 걸면 ~/.forgen 상대해석
38
+ * 으로 파일이 영원히 부재 → 완료 영구 차단 (리뷰 #9 구성 실증).
39
+ */
40
+ const EVIDENCE_CONTEXT_PATTERN = /(증거|evidence|검증\s*결과|e2e-result|smoke-report)/i;
21
41
  // R6-F2: shared single source of truth — stop-guard 와 동일 regex 재사용.
22
- import { DEFAULT_STOP_TRIGGER_RE as STOP_COMPLETION_TRIGGER, DEFAULT_STOP_EXCLUDE_RE as STOP_COMPLETION_EXCLUDE, MOCK_TRIGGER_RE as STOP_MOCK_TRIGGER, MOCK_EXCLUDE_RE as STOP_MOCK_EXCLUDE, } from '../hooks/shared/stop-triggers.js';
42
+ import { DEFAULT_STOP_TRIGGER_RE as STOP_COMPLETION_TRIGGER, DEFAULT_STOP_EXCLUDE_RE as STOP_COMPLETION_EXCLUDE, MOCK_TRIGGER_RE as STOP_MOCK_TRIGGER, MOCK_EXCLUDE_RE as STOP_MOCK_EXCLUDE, CRITIC_STOP_TRIGGER_RE, CRITIC_STOP_EXCLUDE_RE, } from '../hooks/shared/stop-triggers.js';
43
+ /**
44
+ * critic-review 룰 감지 (2026-07-22, 리뷰 SEV-2 #3): "비판 리뷰/critic 을 돌리고 다음으로
45
+ * 넘어감" 정책만 skip-review 트리거를 받는다. e2e·mock 등 다른 완료룰은 DEFAULT(완료 전용)
46
+ * 유지 → semantic 오염 방지.
47
+ */
48
+ // 검토 동사목록을 리뷰와 대칭화(진행|수행|돌리 추가) — 리뷰 SEV-3 (b): "검토를 진행하라" 미탐 수정.
49
+ const CRITIC_REVIEW_PATTERN = /(비판\s*리뷰|fresh-context|critic|리뷰를?\s*(돌리|수행|진행|후)|검토를?\s*(하고|후|없이|생략|진행|수행|돌리))/i;
23
50
  export function classify(rule) {
24
51
  const reasoning = [];
25
52
  const proposed = [];
53
+ // ADR-013 (critic 재검증 #2): 채굴 교정(behavior_inference)은 **data-level invariant** 로
54
+ // advisory-only. classify 자체가 여기서 빈 proposal 을 반환하므로, promote 든
55
+ // `forgen classify-enforce --apply` 든 어떤 경로로도 채굴 룰이 Mech-A 차단을 얻지 못한다.
56
+ // (enforce_via=[] 를 "미분류"로 보고 재분류하던 CLI 우회 경로 근절.)
57
+ if (rule.source === 'behavior_inference') {
58
+ reasoning.push('behavior_inference (auto-mined) → advisory-only, no enforcement (ADR-013)');
59
+ return {
60
+ rule_id: rule.rule_id,
61
+ trigger_preview: rule.trigger.slice(0, 60),
62
+ current_enforce_via: rule.enforce_via ?? null,
63
+ proposed: [],
64
+ reasoning,
65
+ };
66
+ }
26
67
  const text = `${rule.trigger}\n${rule.policy}`;
27
68
  const isDestructive = DESTRUCTIVE_PATTERN.test(text);
28
69
  const isCompletion = COMPLETION_PATTERN.test(text);
@@ -53,28 +94,62 @@ export function classify(rule) {
53
94
  });
54
95
  reasoning.push(`destructive literal "${matchedLiteral}" → Mech-A PreToolUse+tool_arg_regex ${pattern}`);
55
96
  }
56
- // Mech-A Stop — 완료 선언 + 증거 요구 (destructive 와 독립적으로 평가: 하나의 rule 이 둘 다 해당 가능)
57
- if (isCompletion) {
97
+ // Mech-A Stop — 완료 선언 + 증거 요구 (destructive 와 독립적으로 평가: 하나의 rule 이 둘 다 해당 가능).
98
+ // artifact_check 는 rule 텍스트가 증거 경로를 명시했을 때만 제안한다. 과거의 무경로
99
+ // 디폴트(.forgen/state/e2e-result.json)는 e2e 게이트 폐지 후 죽은 경로가 되어,
100
+ // "완료" 키워드가 있는 모든 신규 룰이 영구 차단 게이트를 물려받는 버그를 낳았다.
101
+ let completionSelfCheck = false;
102
+ const isRepeal = REPEAL_PATTERN.test(text) && !PROHIBITION_PATTERN.test(text);
103
+ if (isRepeal && isCompletion) {
104
+ reasoning.push('repeal/relaxation phrasing → 완료 게이트 제안 생략 (게이트 해제 룰이 게이트를 강제하는 오분류 방지)');
105
+ }
106
+ if (isCompletion && !isRepeal) {
58
107
  const mockAsProof = /mock|stub|fake/i.test(text);
59
- // 증거 파일 경로는 v0.4.0 최종 구현에서 rule.policy 에서 추출; 지금은 default 사용
60
- proposed.push({
61
- mech: 'A',
62
- hook: 'Stop',
63
- verifier: {
64
- kind: 'artifact_check',
65
- params: { path: '.forgen/state/e2e-result.json', max_age_s: 3600 },
66
- },
67
- block_message: `${rule.rule_id.slice(0, 8)}: ${rule.policy.slice(0, 120)}`,
68
- trigger_keywords_regex: mockAsProof ? STOP_MOCK_TRIGGER : STOP_COMPLETION_TRIGGER,
69
- trigger_exclude_regex: mockAsProof ? STOP_MOCK_EXCLUDE : STOP_COMPLETION_EXCLUDE,
70
- system_tag: `rule:${rule.rule_id.slice(0, 8)} — ${mockAsProof ? 'no-mock-as-proof' : 'e2e-before-done'}`,
71
- });
72
- reasoning.push(mockAsProof
73
- ? 'completion + mock keyword → Mech-A Stop+artifact_check (mock trigger)'
74
- : 'completion keyword → Mech-A Stop+artifact_check (completion trigger)');
108
+ const criticReview = CRITIC_REVIEW_PATTERN.test(text);
109
+ // 트리거 선택: mock > critic-review > 완료-전용. critic 만 skip-review 시그널 포함.
110
+ const stopTrigger = mockAsProof ? STOP_MOCK_TRIGGER : criticReview ? CRITIC_STOP_TRIGGER_RE : STOP_COMPLETION_TRIGGER;
111
+ const stopExclude = mockAsProof ? STOP_MOCK_EXCLUDE : criticReview ? CRITIC_STOP_EXCLUDE_RE : STOP_COMPLETION_EXCLUDE;
112
+ const pathMatch = text.match(ARTIFACT_PATH_PATTERN)?.[0];
113
+ // 증거로 보이는 경로만 게이트화: .forgen/ 하위이거나 텍스트에 증거 맥락어가 있을 때.
114
+ const explicitArtifact = pathMatch && (pathMatch.includes('.forgen/') || EVIDENCE_CONTEXT_PATTERN.test(text))
115
+ ? pathMatch : undefined;
116
+ if (explicitArtifact) {
117
+ proposed.push({
118
+ mech: 'A',
119
+ hook: 'Stop',
120
+ verifier: {
121
+ kind: 'artifact_check',
122
+ // stop-guard 는 home 기준 상대경로로 평가 — `~/` 접두는 벗겨서 저장
123
+ params: { path: explicitArtifact.replace(/^~\//, ''), max_age_s: 3600 },
124
+ },
125
+ block_message: `${rule.rule_id.slice(0, 8)}: ${rule.policy.slice(0, 120)}`,
126
+ trigger_keywords_regex: stopTrigger,
127
+ trigger_exclude_regex: stopExclude,
128
+ system_tag: `rule:${rule.rule_id.slice(0, 8)} — ${mockAsProof ? 'no-mock-as-proof' : 'evidence-before-done'}`,
129
+ });
130
+ reasoning.push(`completion + explicit artifact "${explicitArtifact}" → Mech-A Stop+artifact_check`);
131
+ }
132
+ else {
133
+ completionSelfCheck = true;
134
+ proposed.push({
135
+ mech: 'B',
136
+ hook: 'Stop',
137
+ verifier: {
138
+ kind: 'self_check_prompt',
139
+ params: {
140
+ question: `직전 응답이 다음 규칙을 위반했는지 자가점검하라: "${rule.policy.slice(0, 120)}". 위반 시 구체적 근거와 함께 수정해 재응답하라.`,
141
+ },
142
+ },
143
+ trigger_keywords_regex: stopTrigger,
144
+ trigger_exclude_regex: stopExclude,
145
+ system_tag: `rule:${rule.rule_id.slice(0, 8)} — completion-self-check`,
146
+ });
147
+ reasoning.push('completion keyword, no explicit artifact path → Mech-B Stop+self_check_prompt (dead e2e default removed)');
148
+ }
75
149
  }
76
- // Mech-B — 문체/응답 관련 또는 strong/hard 정책이지만 기계 판정 어려운 경우
77
- if ((isStyle || (isStrong && !isDestructive && !isCompletion))) {
150
+ // Mech-B — 문체/응답 관련 또는 strong/hard 정책이지만 기계 판정 어려운 경우.
151
+ // completion self-check 를 이미 제안했다면 동일 훅에 중복 self-check 를 얹지 않는다.
152
+ if (((isStyle && !completionSelfCheck) || (isStrong && !isDestructive && !isCompletion))) {
78
153
  proposed.push({
79
154
  mech: 'B',
80
155
  hook: 'Stop',
@@ -110,6 +185,20 @@ export function classify(rule) {
110
185
  export function classifyAll(rules) {
111
186
  return rules.map(classify);
112
187
  }
188
+ /**
189
+ * critic-review 룰인데 baked Stop 트리거가 skip-review 시그널을 안 가지는지 (리뷰 SEV-3 (c)).
190
+ * true 면 `forgen rule classify --apply --force` 재-bake 로 갱신해야 갭이 닫힌다.
191
+ * doctor nudge 용 — "고쳤는데 기존 유저에겐 안 닿는" 상태를 발견 가능하게 한다.
192
+ */
193
+ export function needsCriticTriggerMigration(rule) {
194
+ const text = `${rule.trigger}\n${rule.policy}`;
195
+ if (!CRITIC_REVIEW_PATTERN.test(text))
196
+ return false;
197
+ const stopSpec = rule.enforce_via?.find((s) => s.hook === 'Stop');
198
+ if (!stopSpec)
199
+ return false;
200
+ return !/생략|넘어가|스킵|패스/.test(stopSpec.trigger_keywords_regex ?? '');
201
+ }
113
202
  /** 제안을 적용해 새 Rule 을 반환 (pure). 이미 enforce_via 가 있으면 force=false 에서 건너뜀. */
114
203
  export function applyProposal(rule, proposal, options = {}) {
115
204
  if (rule.enforce_via && rule.enforce_via.length > 0 && !options.force) {