@wooojin/forgen 0.4.13 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +94 -0
- package/README.ja.md +4 -4
- package/README.md +89 -27
- package/README.zh.md +4 -4
- package/assets/claude/commands/forge-loop.md +7 -1
- package/assets/claude/commands/ship.md +18 -1
- package/assets/opencode/forgen.ts +52 -0
- package/dist/checks/_shared/meta-guard-dispatch.d.ts +7 -0
- package/dist/checks/_shared/meta-guard-dispatch.js +12 -2
- package/dist/checks/_shared/model-profile.d.ts +25 -0
- package/dist/checks/_shared/model-profile.js +63 -0
- package/dist/cli.js +99 -153
- package/dist/core/auto-compound-runner.d.ts +0 -11
- package/dist/core/auto-compound-runner.js +127 -79
- package/dist/core/compound-consent.d.ts +15 -0
- package/dist/core/compound-consent.js +48 -0
- package/dist/core/compound-sweep-cli.d.ts +57 -0
- package/dist/core/compound-sweep-cli.js +354 -0
- package/dist/core/config-injector.d.ts +16 -1
- package/dist/core/config-injector.js +47 -28
- package/dist/core/dashboard-cli.js +40 -16
- package/dist/core/dashboard.d.ts +3 -4
- package/dist/core/dashboard.js +15 -34
- package/dist/core/dev-cli.d.ts +13 -0
- package/dist/core/dev-cli.js +70 -0
- package/dist/core/doctor.d.ts +16 -5
- package/dist/core/doctor.js +161 -176
- package/dist/core/drift-score.d.ts +2 -0
- package/dist/core/drift-score.js +9 -1
- package/dist/core/harness.js +115 -43
- package/dist/core/health-cli.d.ts +2 -0
- package/dist/core/health-cli.js +6 -1
- package/dist/core/host-detect.d.ts +3 -1
- package/dist/core/host-detect.js +26 -1
- package/dist/core/migrate-cli.js +15 -0
- package/dist/core/migrate-evidence-host.d.ts +2 -1
- package/dist/core/migrate-tenetx.d.ts +50 -0
- package/dist/core/migrate-tenetx.js +262 -0
- package/dist/core/probe-workflow-cli.d.ts +3 -3
- package/dist/core/probe-workflow-cli.js +13 -13
- package/dist/core/recall-cli.js +1 -1
- package/dist/core/regress-map-cli.js +1 -1
- package/dist/core/rendered-rules-manifest.d.ts +29 -0
- package/dist/core/rendered-rules-manifest.js +60 -0
- package/dist/core/session-store.js +14 -3
- package/dist/core/settings-injector.d.ts +3 -0
- package/dist/core/settings-injector.js +12 -16
- package/dist/core/spawn.d.ts +11 -1
- package/dist/core/spawn.js +79 -7
- package/dist/core/state-gc.js +1 -0
- package/dist/core/status-cli.d.ts +20 -0
- package/dist/core/status-cli.js +100 -0
- package/dist/core/statusline-cli.d.ts +7 -0
- package/dist/core/statusline-cli.js +63 -19
- package/dist/core/transcript-summary.d.ts +18 -0
- package/dist/core/transcript-summary.js +81 -0
- package/dist/core/trust-layer-intent.d.ts +21 -1
- package/dist/core/trust-layer-intent.js +7 -0
- package/dist/core/types.d.ts +3 -2
- package/dist/core/uninstall.js +12 -0
- package/dist/core/usage-telemetry.d.ts +7 -1
- package/dist/core/usage-telemetry.js +7 -9
- package/dist/core/v1-bootstrap.js +1 -1
- package/dist/core/watch-cli.js +1 -1
- package/dist/engine/compound-extractor.js +10 -0
- package/dist/engine/compound-loop.js +35 -6
- package/dist/engine/compound-share.d.ts +85 -0
- package/dist/engine/compound-share.js +606 -0
- package/dist/engine/correction-cluster-runner.d.ts +38 -0
- package/dist/engine/correction-cluster-runner.js +188 -0
- package/dist/engine/correction-clustering.d.ts +80 -0
- package/dist/engine/correction-clustering.js +167 -0
- package/dist/engine/enforce-classifier.d.ts +10 -1
- package/dist/engine/enforce-classifier.js +111 -22
- package/dist/engine/extraction-session.js +10 -3
- package/dist/engine/private-filter.d.ts +36 -0
- package/dist/engine/private-filter.js +100 -0
- package/dist/engine/ranking-pipeline.js +4 -2
- package/dist/engine/relevance-gate.d.ts +12 -0
- package/dist/engine/relevance-gate.js +12 -0
- package/dist/engine/roi-demotion.d.ts +79 -0
- package/dist/engine/roi-demotion.js +159 -0
- package/dist/engine/solution-format.d.ts +1 -0
- package/dist/engine/solution-format.js +26 -0
- package/dist/engine/solution-matcher.js +10 -1
- package/dist/fgx.js +7 -6
- package/dist/forge/cli.js +8 -2
- package/dist/hooks/compound-reflection.js +6 -1
- package/dist/hooks/context-guard.d.ts +16 -1
- package/dist/hooks/context-guard.js +92 -46
- package/dist/hooks/post-tool-use.js +2 -3
- package/dist/hooks/pre-compact.js +14 -0
- package/dist/hooks/pre-tool-use.js +5 -1
- package/dist/hooks/shared/stop-triggers.d.ts +29 -2
- package/dist/hooks/shared/stop-triggers.js +35 -2
- package/dist/hooks/solution-injector.d.ts +28 -0
- package/dist/hooks/solution-injector.js +126 -28
- package/dist/hooks/stop-guard.js +5 -2
- package/dist/hooks/subagent-stop-guard.js +4 -1
- package/dist/host/capabilities-claude.js +1 -0
- package/dist/host/capabilities-codex.js +1 -0
- package/dist/host/capabilities-opencode.d.ts +26 -0
- package/dist/host/capabilities-opencode.js +78 -0
- package/dist/host/capabilities-registry.d.ts +7 -0
- package/dist/host/capabilities-registry.js +14 -0
- package/dist/host/exec-host.d.ts +4 -3
- package/dist/host/exec-host.js +2 -0
- package/dist/host/host-binding.d.ts +27 -0
- package/dist/host/host-binding.js +11 -0
- package/dist/host/host-runtime.js +18 -0
- package/dist/host/install-codex.d.ts +8 -0
- package/dist/host/install-codex.js +2 -2
- package/dist/host/install-opencode.d.ts +38 -0
- package/dist/host/install-opencode.js +148 -0
- package/dist/host/install-orchestrator.d.ts +4 -1
- package/dist/host/install-orchestrator.js +20 -1
- package/dist/host/invoke-agent.d.ts +3 -2
- package/dist/host/opencode/context-cli.d.ts +15 -0
- package/dist/host/opencode/context-cli.js +25 -0
- package/dist/host/opencode/guard-cli.d.ts +13 -0
- package/dist/host/opencode/guard-cli.js +39 -0
- package/dist/host/opencode/plugin/forgen.d.ts +31 -0
- package/dist/host/opencode/plugin/forgen.js +60 -0
- package/dist/host/opencode/translate.d.ts +49 -0
- package/dist/host/opencode/translate.js +96 -0
- package/dist/host/parity-harness.d.ts +10 -2
- package/dist/host/projection.js +11 -0
- package/dist/mcp/tools.js +17 -4
- package/dist/store/evidence-store.d.ts +2 -6
- package/dist/store/evidence-store.js +68 -14
- package/dist/store/host-mismatch.d.ts +2 -1
- package/dist/store/host-mismatch.js +8 -8
- package/dist/store/profile-store.d.ts +3 -2
- package/dist/store/types.d.ts +9 -2
- package/package.json +3 -2
- package/plugin.json +2 -2
- package/skills/forge-loop/SKILL.md +7 -1
- package/skills/ship/SKILL.md +18 -1
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen — Compound Pattern Share (패턴별 export/import)
|
|
3
|
+
*
|
|
4
|
+
* `compound-export.ts`의 `export`/`import`는 `~/.forgen/me/` 전체를 tar.gz로
|
|
5
|
+
* 통짜 백업/이관한다. 이 모듈은 그와 달리 **이름 지정된 패턴 단위**로 신뢰도
|
|
6
|
+
* (confidence/status/evidence)와 provenance를 함께 담은 JSON 번들을 만들고,
|
|
7
|
+
* 받는 쪽에서 안전하게 병합한다 (ECC `/instinct-import/export` 대응, OSS gap #1).
|
|
8
|
+
*
|
|
9
|
+
* 핵심 설계:
|
|
10
|
+
* - 번들은 스키마 버전 고정 JSON. 최상위/패턴 필드 모두 화이트리스트 검증 —
|
|
11
|
+
* 예상 못한 필드가 있으면 통째로 reject (실행 가능한 콘텐츠가 섞여 들어올
|
|
12
|
+
* 여지 자체를 차단).
|
|
13
|
+
* - 패턴마다 contentHash(sha256)를 동봉 — import 시 재계산해 일치하지 않으면
|
|
14
|
+
* reject (변조/손상 탐지).
|
|
15
|
+
* - 이름 충돌 시: 로컬 콘텐츠 해시가 같으면 "동일 패턴 재발견"으로 간주해
|
|
16
|
+
* `reExtracted` 카운터만 증가(기존 신뢰도는 건드리지 않음 — 이미 solution-writer.ts
|
|
17
|
+
* 의 dual-path 금지 불변식과 일치). 다르면 절대 덮어쓰지 않고 suffix된
|
|
18
|
+
* 이름으로 새로 생성.
|
|
19
|
+
* - 신규 생성되는 패턴은 항상 probation: status='experiment', confidence는
|
|
20
|
+
* 신규 솔루션 표준 베이스라인(statusConfidence('experiment')=0.3, 참고:
|
|
21
|
+
* extraction-persistence.ts saveExtractedSolution)을 상한으로 export 시점
|
|
22
|
+
* confidence의 절반만 반영. evidence는 전부 0으로 리셋 — exporter의 로컬
|
|
23
|
+
* 사용 이력을 이 머신이 검증 없이 물려받지 않는다. 이후 승급은 기존
|
|
24
|
+
* compound-lifecycle.ts의 단일 경로(runLifecycleCheck)로만 진행된다.
|
|
25
|
+
*/
|
|
26
|
+
import * as fs from 'node:fs';
|
|
27
|
+
import * as os from 'node:os';
|
|
28
|
+
import * as path from 'node:path';
|
|
29
|
+
import { createHash } from 'node:crypto';
|
|
30
|
+
import { parseSolutionV3, serializeSolutionV3, validateFrontmatter, diagnoseFrontmatter, DEFAULT_EVIDENCE, } from './solution-format.js';
|
|
31
|
+
import { statusConfidence } from './compound-lifecycle.js';
|
|
32
|
+
import { loadRoiDemotions, isRoiQuarantined } from './roi-demotion.js';
|
|
33
|
+
import { mutateSolutionFile } from './solution-writer.js';
|
|
34
|
+
import { detectSecrets } from '../hooks/secret-filter.js';
|
|
35
|
+
import { atomicWriteText } from '../hooks/shared/atomic-write.js';
|
|
36
|
+
import { ME_SOLUTIONS, ME_RULES } from '../core/paths.js';
|
|
37
|
+
// ── Schema ──
|
|
38
|
+
export const SHARE_BUNDLE_SCHEMA_VERSION = 1;
|
|
39
|
+
/** 번들 하나에 담을 수 있는 최대 패턴 수 — 전체 스토어 이관은 tar.gz export의 몫. */
|
|
40
|
+
const MAX_PATTERNS_PER_BUNDLE = 50;
|
|
41
|
+
/** 번들 원본 JSON 텍스트의 최대 바이트 수 (파싱 전에 먼저 확인 — DoS 가드). */
|
|
42
|
+
const MAX_BUNDLE_BYTES = 2 * 1024 * 1024;
|
|
43
|
+
/** 패턴 하나의 context/content 필드 최대 길이 (문자 수). */
|
|
44
|
+
const MAX_FIELD_LENGTH = 20000;
|
|
45
|
+
/** probation confidence 계산 시 exporter confidence에 곱하는 비율. */
|
|
46
|
+
const PROBATION_SCALE = 0.5;
|
|
47
|
+
const BUNDLE_TOP_KEYS = ['schemaVersion', 'exportedAt', 'originHash', 'patterns'];
|
|
48
|
+
const PATTERN_KEYS = ['name', 'category', 'frontmatter', 'context', 'content', 'contentHash'];
|
|
49
|
+
const FRONTMATTER_KEYS = [
|
|
50
|
+
'name', 'version', 'status', 'confidence', 'type', 'scope', 'tags',
|
|
51
|
+
'identifiers', 'evidence', 'created', 'updated', 'supersedes', 'extractedBy',
|
|
52
|
+
];
|
|
53
|
+
const EVIDENCE_KEYS = ['injected', 'reflected', 'negative', 'sessions', 'reExtracted'];
|
|
54
|
+
function hasOnlyKeys(obj, allowed) {
|
|
55
|
+
return Object.keys(obj).every(k => allowed.includes(k));
|
|
56
|
+
}
|
|
57
|
+
// ── Hashing ──
|
|
58
|
+
function computePatternContentHash(fm, context, content) {
|
|
59
|
+
const stable = JSON.stringify({
|
|
60
|
+
type: fm.type,
|
|
61
|
+
tags: [...fm.tags].sort(),
|
|
62
|
+
identifiers: [...fm.identifiers].sort(),
|
|
63
|
+
context,
|
|
64
|
+
content,
|
|
65
|
+
});
|
|
66
|
+
return createHash('sha256').update(stable, 'utf-8').digest('hex');
|
|
67
|
+
}
|
|
68
|
+
function computeOriginHash() {
|
|
69
|
+
let raw;
|
|
70
|
+
try {
|
|
71
|
+
raw = `${os.hostname()}:${os.userInfo().username}`;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
raw = `${os.hostname()}:unknown`;
|
|
75
|
+
}
|
|
76
|
+
return createHash('sha256').update(raw, 'utf-8').digest('hex').slice(0, 16);
|
|
77
|
+
}
|
|
78
|
+
/** 이름으로 category 디렉터리 안의 솔루션/룰 파일을 찾는다 (symlink 무시). */
|
|
79
|
+
function findLocalSolution(category, name) {
|
|
80
|
+
const dir = category === 'solution' ? ME_SOLUTIONS : ME_RULES;
|
|
81
|
+
if (!fs.existsSync(dir))
|
|
82
|
+
return null;
|
|
83
|
+
// fast path: 파일명이 곧 name.md인 관례를 먼저 시도
|
|
84
|
+
const fastPath = path.join(dir, `${name}.md`);
|
|
85
|
+
const tryRead = (filePath) => {
|
|
86
|
+
try {
|
|
87
|
+
if (fs.lstatSync(filePath).isSymbolicLink())
|
|
88
|
+
return null;
|
|
89
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
90
|
+
const solution = parseSolutionV3(content);
|
|
91
|
+
if (solution && solution.frontmatter.name === name)
|
|
92
|
+
return { filePath, solution };
|
|
93
|
+
}
|
|
94
|
+
catch { /* 다음 후보로 */ }
|
|
95
|
+
return null;
|
|
96
|
+
};
|
|
97
|
+
if (fs.existsSync(fastPath)) {
|
|
98
|
+
const hit = tryRead(fastPath);
|
|
99
|
+
if (hit)
|
|
100
|
+
return hit;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
|
|
104
|
+
for (const file of files) {
|
|
105
|
+
const hit = tryRead(path.join(dir, file));
|
|
106
|
+
if (hit)
|
|
107
|
+
return hit;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch { /* dir read 실패 — not found로 취급 */ }
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
/** import 시 태그로 남기는 원본 번들 해시 표식. */
|
|
114
|
+
const IMPORT_HASH_TAG_PREFIX = 'import-hash:';
|
|
115
|
+
function importHashTag(contentHash) {
|
|
116
|
+
return `${IMPORT_HASH_TAG_PREFIX}${contentHash.slice(0, 16)}`;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* 같은 원본 패턴이 이미 수입됐는지를 `import-hash:` 태그로 찾는다.
|
|
120
|
+
*
|
|
121
|
+
* import는 provenance(태그/노트)를 덧붙여 저장하므로 로컬 재계산 해시가 원본
|
|
122
|
+
* 번들 해시와 달라진다 — 콘텐츠 해시 비교만으로는 재import를 인식하지 못해
|
|
123
|
+
* 같은 번들을 다시 들이면 매번 suffix 사본이 늘었다 (리뷰 #10 SEV-3 실증).
|
|
124
|
+
* 수입 시점의 *원본* 해시를 태그로 보존해두고 여기서 우선 조회한다.
|
|
125
|
+
*/
|
|
126
|
+
function findLocalByImportHash(category, contentHash) {
|
|
127
|
+
const dir = category === 'solution' ? ME_SOLUTIONS : ME_RULES;
|
|
128
|
+
if (!fs.existsSync(dir))
|
|
129
|
+
return null;
|
|
130
|
+
const tag = importHashTag(contentHash);
|
|
131
|
+
try {
|
|
132
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
|
|
133
|
+
for (const file of files) {
|
|
134
|
+
const filePath = path.join(dir, file);
|
|
135
|
+
try {
|
|
136
|
+
if (fs.lstatSync(filePath).isSymbolicLink())
|
|
137
|
+
continue;
|
|
138
|
+
const solution = parseSolutionV3(fs.readFileSync(filePath, 'utf-8'));
|
|
139
|
+
if (solution && solution.frontmatter.tags.includes(tag))
|
|
140
|
+
return { filePath, solution };
|
|
141
|
+
}
|
|
142
|
+
catch { /* 다음 후보로 */ }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch { /* dir read 실패 — not found로 취급 */ }
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
function findSolutionOrRule(name) {
|
|
149
|
+
const sol = findLocalSolution('solution', name);
|
|
150
|
+
if (sol)
|
|
151
|
+
return { ...sol, category: 'solution' };
|
|
152
|
+
const rule = findLocalSolution('rule', name);
|
|
153
|
+
if (rule)
|
|
154
|
+
return { ...rule, category: 'rule' };
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
function pickAvailableName(category, baseName) {
|
|
158
|
+
const dir = category === 'solution' ? ME_SOLUTIONS : ME_RULES;
|
|
159
|
+
let n = 1;
|
|
160
|
+
let candidate = `${baseName}-import`;
|
|
161
|
+
while (fs.existsSync(path.join(dir, `${candidate}.md`)) || findLocalSolution(category, candidate)) {
|
|
162
|
+
n++;
|
|
163
|
+
candidate = `${baseName}-import-${n}`;
|
|
164
|
+
}
|
|
165
|
+
return candidate;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* 이름 목록으로 패턴 번들 생성.
|
|
169
|
+
*
|
|
170
|
+
* 시크릿 감지된 패턴은 조용히 스킵하지 않고 `rejectedSecrets`로 보고하되,
|
|
171
|
+
* 번들 자체는 나머지 clean한 패턴으로 계속 진행한다 (부분 실패 허용).
|
|
172
|
+
*/
|
|
173
|
+
export function buildShareBundle(names) {
|
|
174
|
+
const patterns = [];
|
|
175
|
+
const notFound = [];
|
|
176
|
+
const rejectedSecrets = [];
|
|
177
|
+
for (const rawName of names) {
|
|
178
|
+
const name = rawName.trim();
|
|
179
|
+
if (!name)
|
|
180
|
+
continue;
|
|
181
|
+
const found = findSolutionOrRule(name);
|
|
182
|
+
if (!found) {
|
|
183
|
+
notFound.push(name);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const { category, solution } = found;
|
|
187
|
+
// 시크릿 스캔은 패턴의 *직렬화 전체*에 건다 — context/content만 검사하면
|
|
188
|
+
// frontmatter(identifiers/tags 등 auto-추출 필드)에 붙여넣기된 토큰이
|
|
189
|
+
// 공유 번들로 그대로 유출된다 (리뷰 #10 SEV-2 실증: identifiers 안의
|
|
190
|
+
// AWS 키가 번들에 포함됨). 번들에 실리는 바이트 = 스캔되는 바이트.
|
|
191
|
+
const serialized = JSON.stringify({
|
|
192
|
+
frontmatter: solution.frontmatter,
|
|
193
|
+
context: solution.context,
|
|
194
|
+
content: solution.content,
|
|
195
|
+
});
|
|
196
|
+
const secretHits = detectSecrets(serialized);
|
|
197
|
+
if (secretHits.length > 0) {
|
|
198
|
+
const kinds = [...new Set(secretHits.map(h => h.name))].join(', ');
|
|
199
|
+
rejectedSecrets.push(`${name} (${kinds})`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
patterns.push({
|
|
203
|
+
name: solution.frontmatter.name,
|
|
204
|
+
category,
|
|
205
|
+
frontmatter: solution.frontmatter,
|
|
206
|
+
context: solution.context,
|
|
207
|
+
content: solution.content,
|
|
208
|
+
contentHash: computePatternContentHash(solution.frontmatter, solution.context, solution.content),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
bundle: {
|
|
213
|
+
schemaVersion: SHARE_BUNDLE_SCHEMA_VERSION,
|
|
214
|
+
exportedAt: new Date().toISOString(),
|
|
215
|
+
originHash: computeOriginHash(),
|
|
216
|
+
patterns,
|
|
217
|
+
},
|
|
218
|
+
notFound,
|
|
219
|
+
rejectedSecrets,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* 번들 검증: 크기 캡 → 최상위 필드 화이트리스트 → 패턴별 필드 화이트리스트 →
|
|
224
|
+
* frontmatter 정합성 → contentHash 재계산 일치. 하나라도 실패하면 번들 전체를
|
|
225
|
+
* reject한다 (부분 신뢰 없음 — 손상/변조된 번들은 통째로 버린다).
|
|
226
|
+
*/
|
|
227
|
+
export function validateShareBundle(raw, rawSize) {
|
|
228
|
+
const errors = [];
|
|
229
|
+
if (rawSize > MAX_BUNDLE_BYTES) {
|
|
230
|
+
return { ok: false, bundle: null, errors: [`bundle too large: ${rawSize} bytes (max ${MAX_BUNDLE_BYTES})`] };
|
|
231
|
+
}
|
|
232
|
+
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
233
|
+
return { ok: false, bundle: null, errors: ['bundle root must be an object'] };
|
|
234
|
+
}
|
|
235
|
+
const root = raw;
|
|
236
|
+
if (!hasOnlyKeys(root, BUNDLE_TOP_KEYS)) {
|
|
237
|
+
const extra = Object.keys(root).filter(k => !BUNDLE_TOP_KEYS.includes(k));
|
|
238
|
+
errors.push(`unexpected top-level fields: ${extra.join(', ')}`);
|
|
239
|
+
}
|
|
240
|
+
if (root.schemaVersion !== SHARE_BUNDLE_SCHEMA_VERSION) {
|
|
241
|
+
errors.push(`unsupported schemaVersion: ${String(root.schemaVersion)}`);
|
|
242
|
+
}
|
|
243
|
+
if (typeof root.exportedAt !== 'string' || Number.isNaN(new Date(root.exportedAt).getTime())) {
|
|
244
|
+
errors.push('exportedAt must be a valid ISO date string');
|
|
245
|
+
}
|
|
246
|
+
if (typeof root.originHash !== 'string' || !/^[0-9a-f]{16}$/.test(root.originHash)) {
|
|
247
|
+
errors.push('originHash must be a 16-char hex string');
|
|
248
|
+
}
|
|
249
|
+
if (!Array.isArray(root.patterns)) {
|
|
250
|
+
errors.push('patterns must be an array');
|
|
251
|
+
return { ok: false, bundle: null, errors };
|
|
252
|
+
}
|
|
253
|
+
if (root.patterns.length === 0) {
|
|
254
|
+
errors.push('patterns must not be empty');
|
|
255
|
+
}
|
|
256
|
+
if (root.patterns.length > MAX_PATTERNS_PER_BUNDLE) {
|
|
257
|
+
errors.push(`too many patterns: ${root.patterns.length} (max ${MAX_PATTERNS_PER_BUNDLE})`);
|
|
258
|
+
}
|
|
259
|
+
const patterns = [];
|
|
260
|
+
for (let i = 0; i < root.patterns.length; i++) {
|
|
261
|
+
const rawPattern = root.patterns[i];
|
|
262
|
+
if (rawPattern == null || typeof rawPattern !== 'object' || Array.isArray(rawPattern)) {
|
|
263
|
+
errors.push(`pattern[${i}]: must be an object`);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const p = rawPattern;
|
|
267
|
+
if (!hasOnlyKeys(p, PATTERN_KEYS)) {
|
|
268
|
+
const extra = Object.keys(p).filter(k => !PATTERN_KEYS.includes(k));
|
|
269
|
+
errors.push(`pattern[${i}]: unexpected fields ${extra.join(', ')}`);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (typeof p.name !== 'string' || !/^[a-z0-9가-힣-]{1,60}$/.test(p.name)) {
|
|
273
|
+
errors.push(`pattern[${i}]: invalid name`);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (p.category !== 'solution' && p.category !== 'rule') {
|
|
277
|
+
errors.push(`pattern[${i}]: category must be solution|rule`);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (typeof p.context !== 'string' || p.context.length > MAX_FIELD_LENGTH) {
|
|
281
|
+
errors.push(`pattern[${i}]: context invalid or too long`);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (typeof p.content !== 'string' || p.content.length > MAX_FIELD_LENGTH) {
|
|
285
|
+
errors.push(`pattern[${i}]: content invalid or too long`);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (typeof p.contentHash !== 'string' || !/^[0-9a-f]{64}$/.test(p.contentHash)) {
|
|
289
|
+
errors.push(`pattern[${i}]: invalid contentHash`);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (p.frontmatter == null || typeof p.frontmatter !== 'object' || Array.isArray(p.frontmatter)) {
|
|
293
|
+
errors.push(`pattern[${i}]: frontmatter must be an object`);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const fm = p.frontmatter;
|
|
297
|
+
if (!hasOnlyKeys(fm, FRONTMATTER_KEYS)) {
|
|
298
|
+
errors.push(`pattern[${i}]: frontmatter has unexpected fields`);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (fm.evidence != null && typeof fm.evidence === 'object'
|
|
302
|
+
&& !hasOnlyKeys(fm.evidence, EVIDENCE_KEYS)) {
|
|
303
|
+
errors.push(`pattern[${i}]: frontmatter.evidence has unexpected fields`);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (!validateFrontmatter(fm)) {
|
|
307
|
+
errors.push(`pattern[${i}] (${String(p.name)}): frontmatter invalid — ${diagnoseFrontmatter(fm).join('; ')}`);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const typedFm = fm;
|
|
311
|
+
const recomputed = computePatternContentHash(typedFm, p.context, p.content);
|
|
312
|
+
if (recomputed !== p.contentHash) {
|
|
313
|
+
errors.push(`pattern[${i}] (${p.name}): contentHash mismatch — bundle corrupted or tampered`);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
patterns.push({
|
|
317
|
+
name: p.name,
|
|
318
|
+
category: p.category,
|
|
319
|
+
frontmatter: typedFm,
|
|
320
|
+
context: p.context,
|
|
321
|
+
content: p.content,
|
|
322
|
+
contentHash: p.contentHash,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
if (errors.length > 0) {
|
|
326
|
+
return { ok: false, bundle: null, errors };
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
ok: true,
|
|
330
|
+
errors: [],
|
|
331
|
+
bundle: {
|
|
332
|
+
schemaVersion: SHARE_BUNDLE_SCHEMA_VERSION,
|
|
333
|
+
exportedAt: root.exportedAt,
|
|
334
|
+
originHash: root.originHash,
|
|
335
|
+
patterns,
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
// ── Import ──
|
|
340
|
+
/**
|
|
341
|
+
* Import된 패턴의 probation confidence.
|
|
342
|
+
*
|
|
343
|
+
* status는 항상 'experiment'로 시작해 승급 게이트(age/evidence)를 이 머신에서
|
|
344
|
+
* 처음부터 다시 통과해야 한다. confidence 상한은 신규 솔루션 표준 베이스라인
|
|
345
|
+
* (statusConfidence('experiment')=0.3, extraction-persistence.ts의
|
|
346
|
+
* saveExtractedSolution과 동일 기준)이고, exporter confidence의 절반을 반영해
|
|
347
|
+
* "완전 무근거는 아니다"라는 정보만 남긴다. 하한은 0.05
|
|
348
|
+
* (compound-lifecycle.ts의 STATUS_CONFIDENCE_MIN.experiment)보다 위로 둬서
|
|
349
|
+
* 다음 lifecycle 체크에서 곧바로 retired되는 것을 막는다.
|
|
350
|
+
*/
|
|
351
|
+
function computeProbationConfidence(sourceConfidence) {
|
|
352
|
+
const ceiling = statusConfidence('experiment');
|
|
353
|
+
const scaled = sourceConfidence * PROBATION_SCALE;
|
|
354
|
+
return Math.min(ceiling, Math.max(0.05, scaled));
|
|
355
|
+
}
|
|
356
|
+
/** dry-run과 실제 실행이 공유하는 계획 수립 — 파일시스템에 아무것도 쓰지 않는다. */
|
|
357
|
+
export function planShareImport(bundle) {
|
|
358
|
+
const actions = [];
|
|
359
|
+
for (const pattern of bundle.patterns) {
|
|
360
|
+
// 재import 인식이 이름/콘텐츠 비교보다 우선한다 — 수입본은 provenance가
|
|
361
|
+
// 덧붙어 해시가 달라지므로, 원본 해시 태그로 먼저 찾는다 (suffix sprawl 방지).
|
|
362
|
+
const priorImport = findLocalByImportHash(pattern.category, pattern.contentHash);
|
|
363
|
+
if (priorImport) {
|
|
364
|
+
actions.push({
|
|
365
|
+
sourceName: pattern.name,
|
|
366
|
+
category: pattern.category,
|
|
367
|
+
action: 'merge-reextract',
|
|
368
|
+
targetName: priorImport.solution.frontmatter.name,
|
|
369
|
+
detail: '이미 수입된 패턴(import-hash 일치) — reExtracted 카운터만 증가',
|
|
370
|
+
});
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
const existing = findLocalSolution(pattern.category, pattern.name);
|
|
374
|
+
if (!existing) {
|
|
375
|
+
actions.push({
|
|
376
|
+
sourceName: pattern.name,
|
|
377
|
+
category: pattern.category,
|
|
378
|
+
action: 'create',
|
|
379
|
+
targetName: pattern.name,
|
|
380
|
+
detail: '신규 — probation 신뢰도(experiment)로 생성',
|
|
381
|
+
});
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const localHash = computePatternContentHash(existing.solution.frontmatter, existing.solution.context, existing.solution.content);
|
|
385
|
+
if (localHash === pattern.contentHash) {
|
|
386
|
+
actions.push({
|
|
387
|
+
sourceName: pattern.name,
|
|
388
|
+
category: pattern.category,
|
|
389
|
+
action: 'merge-reextract',
|
|
390
|
+
targetName: pattern.name,
|
|
391
|
+
detail: '동일 콘텐츠 — reExtracted 카운터만 증가 (기존 신뢰도 유지)',
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
const targetName = pickAvailableName(pattern.category, pattern.name);
|
|
396
|
+
actions.push({
|
|
397
|
+
sourceName: pattern.name,
|
|
398
|
+
category: pattern.category,
|
|
399
|
+
action: 'create-suffixed',
|
|
400
|
+
targetName,
|
|
401
|
+
detail: `이름 충돌, 콘텐츠 다름 — 기존 파일을 덮어쓰지 않고 "${targetName}"로 생성 (probation)`,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return actions;
|
|
406
|
+
}
|
|
407
|
+
export function executeShareImport(bundle, opts = {}) {
|
|
408
|
+
const actions = planShareImport(bundle);
|
|
409
|
+
if (opts.dryRun) {
|
|
410
|
+
return { dryRun: true, actions };
|
|
411
|
+
}
|
|
412
|
+
for (const action of actions) {
|
|
413
|
+
const pattern = bundle.patterns.find(p => p.name === action.sourceName && p.category === action.category);
|
|
414
|
+
if (!pattern)
|
|
415
|
+
continue;
|
|
416
|
+
if (action.action === 'merge-reextract') {
|
|
417
|
+
const existing = findLocalSolution(action.category, action.targetName);
|
|
418
|
+
if (existing) {
|
|
419
|
+
mutateSolutionFile(existing.filePath, sol => {
|
|
420
|
+
sol.frontmatter.evidence.reExtracted += 1;
|
|
421
|
+
return true;
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const today = new Date().toISOString().split('T')[0];
|
|
427
|
+
// 프로버넌스 태그는 forge(이 코드)만 부여한다 — incoming 번들이 위조
|
|
428
|
+
// `import-hash:`/`origin:`/`imported` 태그를 로컬 스토어에 심지 못하게
|
|
429
|
+
// 스트립 후 재부여 (리뷰 #11 defense-in-depth: 위조 태그 기반 merge
|
|
430
|
+
// hijack 메커니즘 원천 제거).
|
|
431
|
+
const incomingTags = pattern.frontmatter.tags.filter(t => t !== 'imported' && !t.startsWith('origin:') && !t.startsWith(IMPORT_HASH_TAG_PREFIX));
|
|
432
|
+
const tags = Array.from(new Set([
|
|
433
|
+
...incomingTags,
|
|
434
|
+
'imported',
|
|
435
|
+
`origin:${bundle.originHash}`,
|
|
436
|
+
// 원본 번들 해시 표식 — 재import 시 findLocalByImportHash가 이 태그로
|
|
437
|
+
// 기존 수입본을 찾아 merge로 라우팅한다 (suffix 사본 무한 증식 방지).
|
|
438
|
+
importHashTag(pattern.contentHash),
|
|
439
|
+
]));
|
|
440
|
+
const provenanceNote = `[imported: origin=${bundle.originHash} exportedAt=${bundle.exportedAt} `
|
|
441
|
+
+ `sourceName=${pattern.name} sourceConfidence=${pattern.frontmatter.confidence.toFixed(2)} `
|
|
442
|
+
+ `sourceStatus=${pattern.frontmatter.status}]`;
|
|
443
|
+
const solution = {
|
|
444
|
+
frontmatter: {
|
|
445
|
+
...pattern.frontmatter,
|
|
446
|
+
name: action.targetName,
|
|
447
|
+
status: 'experiment',
|
|
448
|
+
confidence: computeProbationConfidence(pattern.frontmatter.confidence),
|
|
449
|
+
tags,
|
|
450
|
+
evidence: { ...DEFAULT_EVIDENCE },
|
|
451
|
+
created: today,
|
|
452
|
+
updated: today,
|
|
453
|
+
supersedes: null,
|
|
454
|
+
extractedBy: 'manual',
|
|
455
|
+
},
|
|
456
|
+
context: [pattern.context, provenanceNote].filter(Boolean).join('\n\n'),
|
|
457
|
+
content: pattern.content,
|
|
458
|
+
};
|
|
459
|
+
const dir = action.category === 'solution' ? ME_SOLUTIONS : ME_RULES;
|
|
460
|
+
const filePath = path.join(dir, `${action.targetName}.md`);
|
|
461
|
+
// 덮어쓰기 절대 금지 불변식: race로 그새 같은 이름이 생겼으면 스킵.
|
|
462
|
+
if (fs.existsSync(filePath))
|
|
463
|
+
continue;
|
|
464
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
465
|
+
atomicWriteText(filePath, serializeSolutionV3(solution));
|
|
466
|
+
}
|
|
467
|
+
return { dryRun: false, actions };
|
|
468
|
+
}
|
|
469
|
+
// ── File-type sniffing (compound-loop.ts 디스패처가 export/import 대상을
|
|
470
|
+
// tar.gz(통짜 백업) vs JSON 번들(패턴 공유)로 구분할 때 사용) ──
|
|
471
|
+
export function looksLikeShareBundle(filePath) {
|
|
472
|
+
if (filePath.endsWith('.tar.gz') || filePath.endsWith('.tgz'))
|
|
473
|
+
return false;
|
|
474
|
+
if (filePath.endsWith('.json'))
|
|
475
|
+
return true;
|
|
476
|
+
try {
|
|
477
|
+
const fd = fs.openSync(filePath, 'r');
|
|
478
|
+
try {
|
|
479
|
+
const buf = Buffer.alloc(1);
|
|
480
|
+
const bytesRead = fs.readSync(fd, buf, 0, 1, 0);
|
|
481
|
+
return bytesRead > 0 && buf[0] === 0x7b; // '{'
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
fs.closeSync(fd);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
// ── CLI handlers ──
|
|
492
|
+
export async function handleShareExport(args) {
|
|
493
|
+
const outIdx = args.indexOf('--out');
|
|
494
|
+
const outputPath = outIdx !== -1 ? args[outIdx + 1] : undefined;
|
|
495
|
+
const names = args.filter((a, i) => {
|
|
496
|
+
if (a.startsWith('--'))
|
|
497
|
+
return false;
|
|
498
|
+
if (outIdx !== -1 && i === outIdx + 1)
|
|
499
|
+
return false;
|
|
500
|
+
return true;
|
|
501
|
+
});
|
|
502
|
+
if (names.length === 0) {
|
|
503
|
+
console.log(' Usage: forgen compound export <name...> [--out <file>]\n');
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const { bundle, notFound, rejectedSecrets } = buildShareBundle(names);
|
|
507
|
+
if (bundle.patterns.length === 0) {
|
|
508
|
+
console.log('\n 내보낼 패턴이 없습니다.');
|
|
509
|
+
if (notFound.length)
|
|
510
|
+
console.log(` 찾을 수 없음: ${notFound.join(', ')}`);
|
|
511
|
+
if (rejectedSecrets.length)
|
|
512
|
+
console.log(` 비밀정보 감지로 제외: ${rejectedSecrets.join(', ')}`);
|
|
513
|
+
console.log();
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const date = new Date().toISOString().split('T')[0];
|
|
517
|
+
const resolved = outputPath ?? path.join(process.cwd(), `forgen-share-${date}.json`);
|
|
518
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
519
|
+
atomicWriteText(resolved, JSON.stringify(bundle, null, 2));
|
|
520
|
+
console.log('\n Compound Pattern Export\n');
|
|
521
|
+
console.log(` Output: ${resolved}`);
|
|
522
|
+
console.log(` Exported: ${bundle.patterns.length}`);
|
|
523
|
+
for (const p of bundle.patterns) {
|
|
524
|
+
console.log(` + ${p.name} [${p.category}] (confidence ${p.frontmatter.confidence.toFixed(2)}, status ${p.frontmatter.status})`);
|
|
525
|
+
}
|
|
526
|
+
if (notFound.length)
|
|
527
|
+
console.log(` Not found: ${notFound.join(', ')}`);
|
|
528
|
+
if (rejectedSecrets.length)
|
|
529
|
+
console.log(` Excluded (secrets detected): ${rejectedSecrets.join(', ')}`);
|
|
530
|
+
// 죽은 패턴을 조용히 공유하지 않는다 — 받는 쪽 probation을 거치더라도,
|
|
531
|
+
// 보내는 쪽이 이미 은퇴(status)·격리(ROI 저장소)시킨 지식임을 명시 경고
|
|
532
|
+
// (리뷰 #10 관찰. quarantine은 SolutionStatus가 아니라 roi-demotions 저장소 소관).
|
|
533
|
+
const roiDemotions = loadRoiDemotions();
|
|
534
|
+
const dead = bundle.patterns.filter(p => {
|
|
535
|
+
if (p.frontmatter.status === 'retired')
|
|
536
|
+
return true;
|
|
537
|
+
const roiEntry = roiDemotions[p.name];
|
|
538
|
+
return roiEntry ? isRoiQuarantined(roiEntry) : false;
|
|
539
|
+
});
|
|
540
|
+
if (dead.length) {
|
|
541
|
+
console.log(` ⚠ Warning: ${dead.map(p => p.name).join(', ')} — 로컬에서 retired 또는 ROI-격리 상태인 패턴입니다. 공유 전 유효성을 확인하세요.`);
|
|
542
|
+
}
|
|
543
|
+
console.log();
|
|
544
|
+
}
|
|
545
|
+
export async function handleShareImport(args) {
|
|
546
|
+
const filteredArgs = args.filter(a => !a.startsWith('--'));
|
|
547
|
+
const bundlePath = filteredArgs[0];
|
|
548
|
+
const dryRun = args.includes('--dry-run');
|
|
549
|
+
if (!bundlePath) {
|
|
550
|
+
console.log(' Usage: forgen compound import <bundle.json> [--dry-run]\n');
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const resolved = path.resolve(bundlePath);
|
|
554
|
+
if (!fs.existsSync(resolved)) {
|
|
555
|
+
console.log(`\n Bundle not found: ${resolved}\n`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
// 크기컷은 read/parse *전에* — 번들은 외부 수신 산출물이라 악성 대용량
|
|
559
|
+
// 파일을 통째로 메모리에 올린 뒤 거부하면 이미 늦다 (리뷰 #10 SEV-3).
|
|
560
|
+
let rawSize;
|
|
561
|
+
try {
|
|
562
|
+
rawSize = fs.statSync(resolved).size;
|
|
563
|
+
}
|
|
564
|
+
catch (e) {
|
|
565
|
+
console.log(`\n Bundle stat failed: ${e.message}\n`);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (rawSize > MAX_BUNDLE_BYTES) {
|
|
569
|
+
console.log(`\n Bundle too large: ${rawSize} bytes (max ${MAX_BUNDLE_BYTES})\n`);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
let raw;
|
|
573
|
+
try {
|
|
574
|
+
const text = fs.readFileSync(resolved, 'utf-8');
|
|
575
|
+
// TOCTOU 봉쇄: stat과 read 사이 파일이 커졌을 수 있다 — 실제 읽은 길이로 재검
|
|
576
|
+
// (리뷰 #11). rawSize도 실측치로 갱신해 validateShareBundle이 stale 크기를
|
|
577
|
+
// 신뢰하지 않게 한다.
|
|
578
|
+
rawSize = Buffer.byteLength(text, 'utf-8');
|
|
579
|
+
if (rawSize > MAX_BUNDLE_BYTES) {
|
|
580
|
+
console.log(`\n Bundle too large: ${rawSize} bytes (max ${MAX_BUNDLE_BYTES})\n`);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
raw = JSON.parse(text);
|
|
584
|
+
}
|
|
585
|
+
catch (e) {
|
|
586
|
+
console.log(`\n Bundle read/parse failed: ${e.message}\n`);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const validated = validateShareBundle(raw, rawSize);
|
|
590
|
+
if (!validated.ok || !validated.bundle) {
|
|
591
|
+
console.log('\n Bundle validation failed:\n');
|
|
592
|
+
for (const err of validated.errors)
|
|
593
|
+
console.log(` - ${err}`);
|
|
594
|
+
console.log();
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const summary = executeShareImport(validated.bundle, { dryRun });
|
|
598
|
+
console.log(`\n Compound Pattern Import${dryRun ? ' (dry-run)' : ''}\n`);
|
|
599
|
+
console.log(` Bundle: ${resolved}`);
|
|
600
|
+
console.log(` Origin: ${validated.bundle.originHash} Exported: ${validated.bundle.exportedAt}\n`);
|
|
601
|
+
for (const a of summary.actions) {
|
|
602
|
+
const icon = a.action === 'merge-reextract' ? '~' : a.action === 'create-suffixed' ? '+*' : '+';
|
|
603
|
+
console.log(` ${icon} ${a.sourceName} → ${a.targetName} (${a.detail})`);
|
|
604
|
+
}
|
|
605
|
+
console.log(`\n ${summary.actions.length} pattern(s) ${dryRun ? 'would be processed (dry-run — nothing written)' : 'processed'}.\n`);
|
|
606
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
export interface ClusterMergeResult {
|
|
15
|
+
/** 통합 rule_id. */
|
|
16
|
+
mergedRuleId: string;
|
|
17
|
+
/** 통합된 원본 rule_id 들. */
|
|
18
|
+
memberIds: string[];
|
|
19
|
+
category: string;
|
|
20
|
+
strength: string;
|
|
21
|
+
confidence: number;
|
|
22
|
+
policy: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 세션종료 클러스터링 실행. 통합 발생 시 각 통합 요약 배열 반환(빈 배열이면 no-op).
|
|
26
|
+
* 자동 실행이므로 조용하지 않게 — 호출측이 결과를 stderr/알림으로 노출한다.
|
|
27
|
+
*/
|
|
28
|
+
export declare function runCorrectionClustering(): Promise<ClusterMergeResult[]>;
|
|
29
|
+
export interface UnmergeResult {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
restored: string[];
|
|
32
|
+
reason?: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 통합 취소: 통합 rule 을 removed 처리하고 원본 룰을 active 복원, 조합을 억제목록에 추가.
|
|
36
|
+
* 억제로 인해 다음 세션종료에 같은 조합이 재통합되지 않는다.
|
|
37
|
+
*/
|
|
38
|
+
export declare function unmergeCluster(mergedRuleId: string): UnmergeResult;
|