@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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +109 -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 +19 -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,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* forgen compound-sweep — 시간-기반 compound backstop (ADR-011).
|
|
3
|
+
*
|
|
4
|
+
* 이벤트 훅(Stop/PreCompact)이 fast path 라면, 이 sweep 는 **보장**이다:
|
|
5
|
+
* 최근 수정된 Claude transcript 중 세션별로 오래(stale) compound 안 된 것을 쓸어담아
|
|
6
|
+
* auto-compound-runner 를 돌린다. 세션 경계(재시작)에 의존하는 기존 backstop 의 갭
|
|
7
|
+
* (재시작 않는 긴 세션 유실)을 시간-트리거로 메운다. cron(시간당 권장)에서 호출.
|
|
8
|
+
*
|
|
9
|
+
* 순수 선택 로직(selectSweepCandidates)과 IO(runCompoundSweep) 분리 — 전자는 테스트 가능.
|
|
10
|
+
* 멱등·fail-open: 실패해도 세션은 다음 sweep 에서 재시도.
|
|
11
|
+
*/
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import * as fs from 'node:fs';
|
|
14
|
+
import * as os from 'node:os';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { STATE_DIR } from './paths.js';
|
|
18
|
+
import { runAutoCompound } from './spawn.js';
|
|
19
|
+
import { createLogger } from './logger.js';
|
|
20
|
+
const log = createLogger('compound-sweep');
|
|
21
|
+
export const DEFAULT_SWEEP_OPTS = {
|
|
22
|
+
windowMs: 24 * 60 * 60 * 1000,
|
|
23
|
+
staleMs: 2 * 60 * 60 * 1000,
|
|
24
|
+
minPrompts: 10,
|
|
25
|
+
maxPerRun: 5,
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* 순수 선택: 최근·충분한 대화·stale(오래 안 쓸린) 세션을, oldest-swept 우선으로 maxPerRun 만큼.
|
|
29
|
+
*/
|
|
30
|
+
export function selectSweepCandidates(transcripts, sweepState, opts) {
|
|
31
|
+
return transcripts
|
|
32
|
+
.filter((t) => opts.nowMs - t.mtimeMs <= opts.windowMs)
|
|
33
|
+
.filter((t) => t.promptCount >= opts.minPrompts)
|
|
34
|
+
.filter((t) => {
|
|
35
|
+
const s = sweepState[t.sessionId];
|
|
36
|
+
return !s || opts.nowMs - s.sweptAt >= opts.staleMs;
|
|
37
|
+
})
|
|
38
|
+
.sort((a, b) => (sweepState[a.sessionId]?.sweptAt ?? 0) - (sweepState[b.sessionId]?.sweptAt ?? 0))
|
|
39
|
+
.slice(0, Math.max(0, opts.maxPerRun));
|
|
40
|
+
}
|
|
41
|
+
function claudeProjectsDir() {
|
|
42
|
+
const base = process.env.CLAUDE_CONFIG_DIR
|
|
43
|
+
? process.env.CLAUDE_CONFIG_DIR
|
|
44
|
+
: path.join(os.homedir(), '.claude');
|
|
45
|
+
return path.join(base, 'projects');
|
|
46
|
+
}
|
|
47
|
+
/** user 턴 판정 — tool_result 캐리어(type:'user'지만 content 가 tool_result)는 제외.
|
|
48
|
+
* (리뷰 SEV-2: tool_result 가 promptCount 를 3~5배 부풀려 ≥10 게이트를 무력화). */
|
|
49
|
+
function isRealUserTurn(e) {
|
|
50
|
+
if (e.type !== 'user' && e.role !== 'user' && e.message?.role !== 'user')
|
|
51
|
+
return false;
|
|
52
|
+
const content = e.message?.content;
|
|
53
|
+
if (Array.isArray(content) && content.some((c) => c?.type === 'tool_result')) {
|
|
54
|
+
return false; // 도구 결과 반환 — 실제 사용자 프롬프트 아님
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
/** transcript 에서 cwd + 실제 user 프롬프트 수 추출. cwd 는 라인의 cwd 필드 우선, 없으면 dir 명 역산. */
|
|
59
|
+
function readTranscriptMeta(transcriptPath, projectDirName) {
|
|
60
|
+
let cwd = projectDirName.replace(/-/g, '/'); // fallback: sanitized dir → cwd (lossy, cwd 필드 없을 때만)
|
|
61
|
+
let promptCount = 0;
|
|
62
|
+
try {
|
|
63
|
+
const lines = fs.readFileSync(transcriptPath, 'utf-8').split('\n');
|
|
64
|
+
let cwdFound = false;
|
|
65
|
+
for (const line of lines) {
|
|
66
|
+
if (!line.trim())
|
|
67
|
+
continue;
|
|
68
|
+
try {
|
|
69
|
+
const e = JSON.parse(line);
|
|
70
|
+
if (!cwdFound && typeof e.cwd === 'string' && e.cwd) {
|
|
71
|
+
cwd = e.cwd;
|
|
72
|
+
cwdFound = true;
|
|
73
|
+
}
|
|
74
|
+
if (isRealUserTurn(e))
|
|
75
|
+
promptCount += 1;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
/* skip malformed line */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* unreadable — leave defaults */
|
|
84
|
+
}
|
|
85
|
+
return { cwd, promptCount };
|
|
86
|
+
}
|
|
87
|
+
/** Claude projects 하위의 최근 transcript 를 열거해 메타 구성. */
|
|
88
|
+
export function enumerateTranscripts(windowMs, nowMs) {
|
|
89
|
+
const root = claudeProjectsDir();
|
|
90
|
+
let projDirs;
|
|
91
|
+
try {
|
|
92
|
+
projDirs = fs.readdirSync(root);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return []; // root 없음/접근불가 — fail-open
|
|
96
|
+
}
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const projDir of projDirs) {
|
|
99
|
+
const abs = path.join(root, projDir);
|
|
100
|
+
let files;
|
|
101
|
+
try {
|
|
102
|
+
if (!fs.statSync(abs).isDirectory())
|
|
103
|
+
continue;
|
|
104
|
+
files = fs.readdirSync(abs).filter((f) => f.endsWith('.jsonl'));
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
for (const f of files) {
|
|
110
|
+
const fp = path.join(abs, f);
|
|
111
|
+
let mtimeMs;
|
|
112
|
+
try {
|
|
113
|
+
mtimeMs = fs.statSync(fp).mtimeMs;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (nowMs - mtimeMs > windowMs)
|
|
119
|
+
continue; // 창 밖은 파싱 비용도 아낌
|
|
120
|
+
const { cwd, promptCount } = readTranscriptMeta(fp, projDir);
|
|
121
|
+
out.push({ sessionId: f.replace(/\.jsonl$/, ''), transcriptPath: fp, cwd, mtimeMs, promptCount });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
function loadSweepState() {
|
|
127
|
+
try {
|
|
128
|
+
return JSON.parse(fs.readFileSync(path.join(STATE_DIR, 'compound-sweep-state.json'), 'utf-8'));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return {};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** sweep-state 저장. 실패 시 true 반환(호출측이 무인 재과금 위험을 경고). */
|
|
135
|
+
function saveSweepState(state) {
|
|
136
|
+
try {
|
|
137
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
138
|
+
fs.writeFileSync(path.join(STATE_DIR, 'compound-sweep-state.json'), JSON.stringify(state, null, 2));
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
log.debug('sweep-state 저장 실패', e);
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** window 밖으로 사라진 세션 엔트리 정리 (무한 증가 방지, 리뷰 SEV-3). */
|
|
147
|
+
function pruneSweepState(state, liveSessionIds, nowMs, windowMs) {
|
|
148
|
+
for (const sid of Object.keys(state)) {
|
|
149
|
+
if (!liveSessionIds.has(sid) && nowMs - state[sid].sweptAt > windowMs)
|
|
150
|
+
delete state[sid];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* cron/CLI 진입점. dryRun 이면 실행 없이 후보만 보고.
|
|
155
|
+
* 무인 실행 안전장치: (1) 동시 실행 lock, (2) 실제 spawn('spawned') 만 sweptAt 기록,
|
|
156
|
+
* (3) state 저장 실패 시 경고 플래그.
|
|
157
|
+
*/
|
|
158
|
+
export function runCompoundSweep(opts = {}) {
|
|
159
|
+
const nowMs = Date.now();
|
|
160
|
+
const full = { nowMs, ...DEFAULT_SWEEP_OPTS, ...opts };
|
|
161
|
+
// 동시 cron fire 겹침 방지 (리뷰 SEV-3): stale(>1h) lock 은 무시.
|
|
162
|
+
const lockPath = path.join(STATE_DIR, 'compound-sweep.lock');
|
|
163
|
+
if (!opts.dryRun) {
|
|
164
|
+
try {
|
|
165
|
+
const raw = fs.readFileSync(lockPath, 'utf-8');
|
|
166
|
+
const { at } = JSON.parse(raw);
|
|
167
|
+
if (Number.isFinite(at) && nowMs - at < 60 * 60 * 1000) {
|
|
168
|
+
return { scanned: 0, eligible: 0, swept: [] }; // 다른 sweep 진행 중
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
/* lock 없음/손상 — 진행 */
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
176
|
+
fs.writeFileSync(lockPath, JSON.stringify({ at: nowMs, pid: process.pid }));
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
/* lock 기록 실패 — 진행(단일 실행 가정) */
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const transcripts = enumerateTranscripts(full.windowMs, nowMs);
|
|
184
|
+
const state = loadSweepState();
|
|
185
|
+
const candidates = selectSweepCandidates(transcripts, state, full);
|
|
186
|
+
const swept = [];
|
|
187
|
+
for (const c of candidates) {
|
|
188
|
+
if (opts.dryRun) {
|
|
189
|
+
swept.push(c.sessionId);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
// 실제 spawn 된 경우만 sweptAt 기록 (리뷰 SEV-2: dedup-skip/실패를 완료로 오기록 금지).
|
|
193
|
+
const status = runAutoCompound(c.cwd, c.transcriptPath, c.sessionId, c.promptCount);
|
|
194
|
+
if (status === 'spawned') {
|
|
195
|
+
state[c.sessionId] = { sweptAt: nowMs };
|
|
196
|
+
swept.push(c.sessionId);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
let stateSaveFailed = false;
|
|
200
|
+
if (!opts.dryRun) {
|
|
201
|
+
pruneSweepState(state, new Set(transcripts.map((t) => t.sessionId)), nowMs, full.windowMs);
|
|
202
|
+
stateSaveFailed = !saveSweepState(state);
|
|
203
|
+
}
|
|
204
|
+
return { scanned: transcripts.length, eligible: candidates.length, swept, stateSaveFailed };
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
log.debug('compound-sweep 실행 실패 (fail-open)', e);
|
|
208
|
+
return { scanned: 0, eligible: 0, swept: [] };
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
if (!opts.dryRun)
|
|
212
|
+
fs.rmSync(lockPath, { force: true });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// ── cron 자동설치 (ADR-011 §Consequences) ────────────────────────────────────
|
|
216
|
+
const CRON_MARKER = '# forgen-compound-sweep (ADR-011)';
|
|
217
|
+
const CRON_SCHEDULE = '17 * * * *'; // 매시 17분 — 정각 몰림 회피
|
|
218
|
+
/** cron 은 각 라인을 /bin/sh 로 실행 → 공백/메타문자 경로가 word-split/injection 되지 않게 single-quote. */
|
|
219
|
+
function shq(s) {
|
|
220
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
221
|
+
}
|
|
222
|
+
/** crontab 라인 구성 (순수 — 테스트 가능). 경로는 shell-quote(리뷰 SEV-2). */
|
|
223
|
+
export function buildCronLine(nodePath, cliPath, logPath, schedule = CRON_SCHEDULE) {
|
|
224
|
+
return `${schedule} ${shq(nodePath)} ${shq(cliPath)} compound sweep >> ${shq(logPath)} 2>&1 ${CRON_MARKER}`;
|
|
225
|
+
}
|
|
226
|
+
/** 기존 crontab 에서 forgen 라인을 upsert/제거 (순수 — 테스트 가능). */
|
|
227
|
+
export function upsertCronText(existing, line) {
|
|
228
|
+
const kept = existing
|
|
229
|
+
.split('\n')
|
|
230
|
+
.filter((l) => l.trim() && !l.includes(CRON_MARKER));
|
|
231
|
+
if (line)
|
|
232
|
+
kept.push(line);
|
|
233
|
+
return `${kept.join('\n')}\n`;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* 현재 crontab 읽기. 리뷰 SEV-1: `crontab -l` 실패를 무조건 ''(빈 crontab)로 취급하면
|
|
237
|
+
* 일시적 read 실패 시 기존 사용자 job 전체를 덮어써 파괴한다. "no crontab" 만 ''로 보고,
|
|
238
|
+
* 그 외 실 오류는 throw 하여 install/uninstall 이 **쓰지 않고 중단**하게 한다.
|
|
239
|
+
*/
|
|
240
|
+
function readCrontab() {
|
|
241
|
+
try {
|
|
242
|
+
return execFileSync('crontab', ['-l'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
243
|
+
}
|
|
244
|
+
catch (e) {
|
|
245
|
+
const err = e;
|
|
246
|
+
const stderr = String(err.stderr ?? '');
|
|
247
|
+
// "no crontab for <user>" = 정상적으로 비어있음 → '' (생성 안전).
|
|
248
|
+
if (/no crontab for/i.test(stderr))
|
|
249
|
+
return '';
|
|
250
|
+
// 그 외(권한/일시장애 등)는 알 수 없음 → 파괴적 쓰기 방지 위해 throw.
|
|
251
|
+
throw new Error(`crontab -l 실패 (기존 job 보호 위해 중단): ${stderr.trim() || e.message}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function distCliPath() {
|
|
255
|
+
// 이 파일 = dist/core/compound-sweep-cli.js → dist/cli.js
|
|
256
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'cli.js');
|
|
257
|
+
}
|
|
258
|
+
export function installCron() {
|
|
259
|
+
const nodePath = process.execPath;
|
|
260
|
+
const cliPath = distCliPath();
|
|
261
|
+
const logPath = path.join(STATE_DIR, 'compound-sweep.log');
|
|
262
|
+
const line = buildCronLine(nodePath, cliPath, logPath);
|
|
263
|
+
if (process.platform === 'win32') {
|
|
264
|
+
return {
|
|
265
|
+
ok: false,
|
|
266
|
+
message: 'Windows 는 crontab 미지원 — Task Scheduler 에 시간당 작업을 수동 등록하세요:\n' +
|
|
267
|
+
` 프로그램: ${nodePath}\n 인수: "${cliPath}" compound sweep`,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
let existing;
|
|
271
|
+
try {
|
|
272
|
+
existing = readCrontab(); // read 실패(‘no crontab’ 제외) 시 throw → 아래서 중단
|
|
273
|
+
}
|
|
274
|
+
catch (e) {
|
|
275
|
+
return { ok: false, message: `${e.message}\n수동 등록: crontab -e 에 아래 추가:\n ${line}` };
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
execFileSync('crontab', ['-'], { input: upsertCronText(existing, line), encoding: 'utf-8' });
|
|
279
|
+
return { ok: true, message: `compound sweep cron 설치됨 (매시 17분):\n ${line}` };
|
|
280
|
+
}
|
|
281
|
+
catch (e) {
|
|
282
|
+
return {
|
|
283
|
+
ok: false,
|
|
284
|
+
message: `crontab 쓰기 실패: ${e.message}\n수동 등록: crontab -e 에 아래 추가:\n ${line}`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
export function uninstallCron() {
|
|
289
|
+
if (process.platform === 'win32')
|
|
290
|
+
return { ok: false, message: 'Windows: Task Scheduler 에서 수동 제거하세요.' };
|
|
291
|
+
let existing;
|
|
292
|
+
try {
|
|
293
|
+
existing = readCrontab();
|
|
294
|
+
}
|
|
295
|
+
catch (e) {
|
|
296
|
+
return { ok: false, message: `${e.message} (제거 중단 — 기존 crontab 보호)` };
|
|
297
|
+
}
|
|
298
|
+
if (!existing.includes(CRON_MARKER))
|
|
299
|
+
return { ok: true, message: 'compound sweep cron 없음 (이미 미설치).' };
|
|
300
|
+
try {
|
|
301
|
+
execFileSync('crontab', ['-'], { input: upsertCronText(existing, null), encoding: 'utf-8' });
|
|
302
|
+
return { ok: true, message: 'compound sweep cron 제거됨.' };
|
|
303
|
+
}
|
|
304
|
+
catch (e) {
|
|
305
|
+
return { ok: false, message: `crontab 쓰기 실패: ${e.message}` };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
export function cronStatus() {
|
|
309
|
+
if (process.platform === 'win32')
|
|
310
|
+
return { ok: false, message: 'Windows: Task Scheduler 확인.' };
|
|
311
|
+
try {
|
|
312
|
+
return readCrontab().includes(CRON_MARKER)
|
|
313
|
+
? { ok: true, message: '설치됨 (매시 17분).' }
|
|
314
|
+
: { ok: false, message: '미설치. `forgen compound sweep --install-cron` 로 설치.' };
|
|
315
|
+
}
|
|
316
|
+
catch (e) {
|
|
317
|
+
return { ok: false, message: e.message };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/** CLI: forgen compound sweep [--dry-run|--install-cron|--uninstall-cron|--cron-status] [--window-hours N] [--stale-hours M] */
|
|
321
|
+
export function compoundSweepCli(args) {
|
|
322
|
+
// cron 관리 플래그 (상호배타 — 우선 처리)
|
|
323
|
+
if (args.includes('--install-cron')) {
|
|
324
|
+
const r = installCron();
|
|
325
|
+
console.log(`[forgen compound sweep] ${r.message}`);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (args.includes('--uninstall-cron')) {
|
|
329
|
+
console.log(`[forgen compound sweep] ${uninstallCron().message}`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (args.includes('--cron-status')) {
|
|
333
|
+
console.log(`[forgen compound sweep] cron: ${cronStatus().message}`);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const dryRun = args.includes('--dry-run');
|
|
337
|
+
const num = (flag) => {
|
|
338
|
+
const i = args.indexOf(flag);
|
|
339
|
+
return i >= 0 && args[i + 1] ? Number(args[i + 1]) : undefined;
|
|
340
|
+
};
|
|
341
|
+
const wh = num('--window-hours');
|
|
342
|
+
const sh = num('--stale-hours');
|
|
343
|
+
const r = runCompoundSweep({
|
|
344
|
+
dryRun,
|
|
345
|
+
...(wh ? { windowMs: wh * 3600_000 } : {}),
|
|
346
|
+
...(sh ? { staleMs: sh * 3600_000 } : {}),
|
|
347
|
+
});
|
|
348
|
+
console.log(`[forgen compound sweep] scanned=${r.scanned} eligible=${r.eligible} ` +
|
|
349
|
+
`${dryRun ? 'dry-run' : 'swept'}=${r.swept.length}${r.swept.length ? ` (${r.swept.map((s) => s.slice(0, 8)).join(', ')})` : ''}`);
|
|
350
|
+
if (r.stateSaveFailed) {
|
|
351
|
+
console.error(' ⚠ sweep-state 저장 실패 — 다음 cron 이 동일 세션을 재-compound 할 수 있음(Haiku 재과금). ' +
|
|
352
|
+
'~/.forgen/state 쓰기 권한을 확인하세요.');
|
|
353
|
+
}
|
|
354
|
+
}
|
|
@@ -8,11 +8,26 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import type { RuntimeHost } from './types.js';
|
|
10
10
|
/** 보안 규칙 (정적 — v1 GLOBAL_SAFETY_RULES와 동일 맥락) */
|
|
11
|
+
/**
|
|
12
|
+
* ADR-010 W2-3: 보안 규칙 prose → 2줄 포인터로 축약.
|
|
13
|
+
* 실제 강제는 훅(secret-filter, db-guard)이 결정적으로 수행하며,
|
|
14
|
+
* prose 는 순수 토큰 비용이었다 (native /doctor 도 "코드에서 유추 가능한
|
|
15
|
+
* 규칙"으로 트리밍 제안하는 부류). 차단 발생 시 확인: `forgen explain`.
|
|
16
|
+
*/
|
|
11
17
|
export declare function generateSecurityRules(): string;
|
|
12
|
-
/**
|
|
18
|
+
/** ADR-010 W2-3: prose 제거 — 포인터는 generateSecurityRules 로 통합됨 */
|
|
13
19
|
export declare function generateAntiPatternRules(): string;
|
|
14
20
|
/** compound loop + 개인 규칙 (me/rules) 로드 */
|
|
15
21
|
export declare function generateCompoundRules(cwd: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* ADR-010 W1-2: 캡처 사이드 공유 판정 — 첫 유효 라인이 Claude-voice 에코인가.
|
|
24
|
+
*
|
|
25
|
+
* auto-compound-runner 가 behavior 파일을 디스크에 쓰기 전에 호출한다.
|
|
26
|
+
* 렌더 타임 필터(위)만으로는 오염 데이터가 스토어에 계속 쌓이므로,
|
|
27
|
+
* 저장 자체를 차단하는 것이 우선이다. 판정은 첫 유효 라인 기준 —
|
|
28
|
+
* 에코는 항상 assistant 발화로 시작하고, 진짜 패턴 요약은 지시문으로 시작한다.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isSelfReferentialEcho(text: string): boolean;
|
|
16
31
|
/**
|
|
17
32
|
* Strip formatting that already exists in the source line BEFORE the
|
|
18
33
|
* renderer adds its own prefix/suffix. Without this, a behavior file
|
|
@@ -108,40 +108,24 @@ function loadProjectMapSummary(cwd) {
|
|
|
108
108
|
}
|
|
109
109
|
// ── v1 Static Rules ──
|
|
110
110
|
/** 보안 규칙 (정적 — v1 GLOBAL_SAFETY_RULES와 동일 맥락) */
|
|
111
|
+
/**
|
|
112
|
+
* ADR-010 W2-3: 보안 규칙 prose → 2줄 포인터로 축약.
|
|
113
|
+
* 실제 강제는 훅(secret-filter, db-guard)이 결정적으로 수행하며,
|
|
114
|
+
* prose 는 순수 토큰 비용이었다 (native /doctor 도 "코드에서 유추 가능한
|
|
115
|
+
* 규칙"으로 트리밍 제안하는 부류). 차단 발생 시 확인: `forgen explain`.
|
|
116
|
+
*/
|
|
111
117
|
export function generateSecurityRules() {
|
|
112
118
|
return [
|
|
113
|
-
'# Forgen — Security
|
|
119
|
+
'# Forgen — Security & Anti-Pattern',
|
|
114
120
|
'',
|
|
115
|
-
'
|
|
116
|
-
'-
|
|
117
|
-
'- Double confirmation required for production environment access',
|
|
118
|
-
'',
|
|
119
|
-
'## Secret Key Protection',
|
|
120
|
-
'- Do not commit sensitive information such as `.env`, `credentials.json`, API keys',
|
|
121
|
-
'- Manage through environment variables or a secrets manager',
|
|
122
|
-
'- Detect hardcoded secrets during code review',
|
|
121
|
+
'- 보안/위험명령/안티패턴 강제는 forgen 훅이 수행: secret-filter(비밀키 커밋 차단), db-guard(위험 SQL/rm -rf 확인), slop-detector(AI 슬롭 감지).',
|
|
122
|
+
'- 차단이 발생하면 `forgen status --blocks` 로 규칙·사유·해결책 확인.',
|
|
123
123
|
'',
|
|
124
124
|
].join('\n');
|
|
125
125
|
}
|
|
126
|
-
/**
|
|
126
|
+
/** ADR-010 W2-3: prose 제거 — 포인터는 generateSecurityRules 로 통합됨 */
|
|
127
127
|
export function generateAntiPatternRules() {
|
|
128
|
-
return
|
|
129
|
-
'# Forgen — Anti-Pattern Detection',
|
|
130
|
-
'',
|
|
131
|
-
'## Repeated Edit Warning',
|
|
132
|
-
'- Stop immediately when editing the same file 3+ times → full structure redesign required',
|
|
133
|
-
'- For 5+ edits, always check current state with Read before replacing with a single Write',
|
|
134
|
-
'',
|
|
135
|
-
'## Error Suppression Warning',
|
|
136
|
-
'- No empty catch blocks — at minimum log or re-throw',
|
|
137
|
-
'- Minimize suppression comments like eslint-disable, @ts-ignore',
|
|
138
|
-
'',
|
|
139
|
-
'## Excessive Complexity Warning',
|
|
140
|
-
'- Consider splitting single functions exceeding 50 lines',
|
|
141
|
-
'- Apply early return pattern when nesting depth exceeds 4',
|
|
142
|
-
'- No unnecessary abstraction — implement only what is currently needed',
|
|
143
|
-
'',
|
|
144
|
-
].join('\n');
|
|
128
|
+
return '';
|
|
145
129
|
}
|
|
146
130
|
/** compound loop + 개인 규칙 (me/rules) 로드 */
|
|
147
131
|
export function generateCompoundRules(cwd) {
|
|
@@ -230,11 +214,38 @@ const SELF_REFERENTIAL_PATTERNS = Object.freeze([
|
|
|
230
214
|
/^I['\u2019]?ll\s+(analyze|review|check|update|add|create|run|fix)/i,
|
|
231
215
|
/^Let me\s+(analyze|check|look|verify|update|add)/i,
|
|
232
216
|
/^I['\u2019]?ve\s+(added|updated|created|fixed|completed)/i,
|
|
217
|
+
// ADR-010 W1-2 (2026-07-16): \uc2e4\uc720\ucd9c 60\uac74(behavior-echoes \ubc31\uc5c5 = fixture \uc18c\uc2a4)\uc5d0\uc11c
|
|
218
|
+
// \ud655\uc778\ub41c \ucd94\uac00 \uc5d0\ucf54 \ud615\ud0dc. \uc804\ubd80 line-start \uc575\ucee4 \u2014 H-2 \uc624\ud0d0 \uaddc\uce59 \uc900\uc218.
|
|
219
|
+
// \uc774 \ubaa9\ub85d\uc740 \ubcf4\uc870 \ubc29\uc5b4\ub2e4: \uc8fc\ub825\uc740 observedCount >= 2 \uac8c\uc774\ud2b8 (\uc544\ub798 \ub80c\ub354 \ub8e8\ud504).
|
|
220
|
+
/^I (see|notice|understand)\b/i,
|
|
221
|
+
/^I['\u2019]?m ready\b/i,
|
|
222
|
+
/^Understood\b/i,
|
|
223
|
+
/^Got it\b/i,
|
|
224
|
+
/^[\u26a0\u2705\u274c\u{1f534}\u{1f4cb}]/u, // \u26a0 \u2705 \u274c \ud83d\udd34 \ud83d\udccb \uc120\ub450 \u2014 assistant \uc0c1\ud0dc \ub9c8\ucee4
|
|
225
|
+
/^(\uc774\ud574\ud588\uc2b5\ub2c8\ub2e4|\uc54c\uaca0\uc2b5\ub2c8\ub2e4|\ud655\uc778\ud588\uc2b5\ub2c8\ub2e4|\ud655\uc778\ud558\uaca0\uc2b5\ub2c8\ub2e4|\ud30c\uc545\ud588\uc2b5\ub2c8\ub2e4)/,
|
|
226
|
+
// "\ud604\uc7ac \uc0c1\ud669"/"\uc900\ube44 \uc644\ub8cc"\ub294 Claude-voice \ubb38\uc7a5 \uc644\uacb0\ud615\ub9cc \ub9e4\uce58 \u2014 \uc0ac\uc6a9\uc790 \uc9c0\uc2dc\ubb38
|
|
227
|
+
// "\ud604\uc7ac \uc0c1\ud669 \ud30c\uc545 \ud6c4 \uc791\uc5c5 \uc2dc\uc791", "\uc900\ube44 \uc644\ub8cc\ub418\uba74 \uc54c\ub824\uc8fc\uc138\uc694"\ub294 \ud1b5\uacfc\ud574\uc57c \ud55c\ub2e4 (H-2).
|
|
228
|
+
/^(\uc791\uc5c5 \uc0c1\ud0dc\ub97c \ud655\uc778|\uc0c1\ud669\uc744 \ud30c\uc545\ud588|\uc548\ub155\ud558\uc138\uc694)/,
|
|
229
|
+
/^\ud604\uc7ac \uc0c1\ud669(:|\uc774|\uc740|\uc744 (\ud30c\uc545|\ud655\uc778|\uc815\ub9ac))/,
|
|
230
|
+
/^\uc900\ube44 \uc644\ub8cc(\uc785\ub2c8\ub2e4|\ud588\uc2b5\ub2c8\ub2e4|[.!])/,
|
|
231
|
+
/^\ubc31\uadf8\ub77c\uc6b4\ub4dc .*(\uc644\ub8cc|\uc911\ub2e8)/,
|
|
233
232
|
// Object.freeze is defense-in-depth: the readonly type is compile-time
|
|
234
233
|
// only. Freezing prevents runtime mutation by any other module loaded
|
|
235
234
|
// in the same process from silently disabling the filter by pushing
|
|
236
235
|
// an over-broad pattern or emptying the array.
|
|
237
236
|
]);
|
|
237
|
+
/**
|
|
238
|
+
* ADR-010 W1-2: 캡처 사이드 공유 판정 — 첫 유효 라인이 Claude-voice 에코인가.
|
|
239
|
+
*
|
|
240
|
+
* auto-compound-runner 가 behavior 파일을 디스크에 쓰기 전에 호출한다.
|
|
241
|
+
* 렌더 타임 필터(위)만으로는 오염 데이터가 스토어에 계속 쌓이므로,
|
|
242
|
+
* 저장 자체를 차단하는 것이 우선이다. 판정은 첫 유효 라인 기준 —
|
|
243
|
+
* 에코는 항상 assistant 발화로 시작하고, 진짜 패턴 요약은 지시문으로 시작한다.
|
|
244
|
+
*/
|
|
245
|
+
export function isSelfReferentialEcho(text) {
|
|
246
|
+
const firstLine = text.split('\n').map((l) => l.trim()).find((l) => l.length > 0) ?? '';
|
|
247
|
+
return SELF_REFERENTIAL_PATTERNS.some((re) => re.test(firstLine));
|
|
248
|
+
}
|
|
238
249
|
/**
|
|
239
250
|
* Strip formatting that already exists in the source line BEFORE the
|
|
240
251
|
* renderer adds its own prefix/suffix. Without this, a behavior file
|
|
@@ -284,6 +295,14 @@ function generateBehavioralRules() {
|
|
|
284
295
|
const countMatch = fm.match(/^observedCount:\s*(\d+)/m);
|
|
285
296
|
const kind = kindMatch?.[1]?.trim().replace(/^["']|["']$/g, '') ?? '';
|
|
286
297
|
const observedCount = countMatch ? parseInt(countMatch[1], 10) : 0;
|
|
298
|
+
// ADR-010 W1-2 주력 방어 — 1회 관찰 항목은 렌더하지 않는다 (모든 kind).
|
|
299
|
+
// 실측(2026-07-16): 오염된 behavior 엔트리 49/49(100%)가 observedCount=1.
|
|
300
|
+
// 진짜 사용자 패턴은 재관찰되어 mergeOrCreateBehavior 가 count 를 누적한다.
|
|
301
|
+
// 주의(정직한 한계): count 는 50% word-overlap merge 로도 오르므로, regex
|
|
302
|
+
// 3중 방어(캡처/렌더)를 모두 뚫는 novel echo 2건이 서로 merge 되면 이 게이트도
|
|
303
|
+
// 뚫린다 — 알려진 60건 형태는 캡처 사이드에서 차단되므로 잔존 리스크는 낮음.
|
|
304
|
+
if (observedCount < 2)
|
|
305
|
+
continue;
|
|
287
306
|
const contentIdx = body.indexOf('## Content');
|
|
288
307
|
const contentBody = contentIdx >= 0 ? body.slice(contentIdx + '## Content'.length) : body;
|
|
289
308
|
const rawDesc = contentBody.split('\n').find(l => {
|
|
@@ -414,7 +433,7 @@ export function generateClaudeRules(cwd, v1RenderedRules) {
|
|
|
414
433
|
export async function registerTmuxBindings() {
|
|
415
434
|
const { execFileSync } = await import('node:child_process');
|
|
416
435
|
try {
|
|
417
|
-
execFileSync('tmux', ['bind-key', 'T', 'run-shell', 'forgen
|
|
436
|
+
execFileSync('tmux', ['bind-key', 'T', 'run-shell', 'forgen status --profile'], { stdio: 'ignore' });
|
|
418
437
|
}
|
|
419
438
|
catch (e) {
|
|
420
439
|
log.debug('tmux 키바인딩 등��� 실패', e);
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import * as fs from 'node:fs';
|
|
8
8
|
import * as path from 'node:path';
|
|
9
9
|
import { getUsageStats } from './usage-telemetry.js';
|
|
10
|
+
import { loadRoiDemotions, isRoiQuarantined } from '../engine/roi-demotion.js';
|
|
10
11
|
import { classifySolutions } from './lifecycle-classifier.js';
|
|
11
12
|
import { STATE_DIR } from './paths.js';
|
|
12
13
|
import { FORGEN_HOME } from './paths.js';
|
|
@@ -22,7 +23,7 @@ const C = {
|
|
|
22
23
|
red: isTTY ? '\x1b[31m' : '',
|
|
23
24
|
};
|
|
24
25
|
const BOX_WIDTH = 66;
|
|
25
|
-
function
|
|
26
|
+
function _bar(pct, width = 10) {
|
|
26
27
|
const filled = Math.round(pct * width);
|
|
27
28
|
const empty = width - filled;
|
|
28
29
|
return '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, empty));
|
|
@@ -47,13 +48,28 @@ function boxBottom() {
|
|
|
47
48
|
}
|
|
48
49
|
function collectData() {
|
|
49
50
|
const now = new Date();
|
|
50
|
-
// usage
|
|
51
|
+
// usage (deprecated — W2-2): 과거 축적분만 읽힘, 신규 기록 없음
|
|
51
52
|
const usage = (() => {
|
|
52
53
|
try {
|
|
53
|
-
return getUsageStats();
|
|
54
|
+
return { ...getUsageStats(), deprecated: true };
|
|
54
55
|
}
|
|
55
56
|
catch {
|
|
56
|
-
return { hour5: { claude: 0, codex: 0, total: 0 }, week: { claude: 0, codex: 0, total: 0 } };
|
|
57
|
+
return { hour5: { claude: 0, codex: 0, total: 0 }, week: { claude: 0, codex: 0, total: 0 }, deprecated: true };
|
|
58
|
+
}
|
|
59
|
+
})();
|
|
60
|
+
// ROI 강등 (W3-1): surfaced-but-ignored top-5
|
|
61
|
+
const roiDemoted = (() => {
|
|
62
|
+
try {
|
|
63
|
+
return Object.values(loadRoiDemotions())
|
|
64
|
+
.sort((a, b) => b.surfaced - a.surfaced)
|
|
65
|
+
.slice(0, 5)
|
|
66
|
+
.map(e => ({
|
|
67
|
+
id: e.solutionId, surfaced: e.surfaced, actedOn: e.actedOn,
|
|
68
|
+
windowCount: e.windowCount, quarantined: isRoiQuarantined(e),
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return [];
|
|
57
73
|
}
|
|
58
74
|
})();
|
|
59
75
|
// today extracted: me/solutions/*.md mtime이 오늘인 것 (또는 last-extraction.json)
|
|
@@ -137,6 +153,7 @@ function collectData() {
|
|
|
137
153
|
return {
|
|
138
154
|
timestamp: now.toISOString(),
|
|
139
155
|
usage,
|
|
156
|
+
roiDemoted,
|
|
140
157
|
todayExtracted,
|
|
141
158
|
solutions: {
|
|
142
159
|
total: classified.length,
|
|
@@ -152,16 +169,23 @@ function renderTTY(data) {
|
|
|
152
169
|
const lines = [];
|
|
153
170
|
lines.push(boxTop());
|
|
154
171
|
lines.push(boxEmpty());
|
|
155
|
-
// Usage
|
|
172
|
+
// Usage — ADR-010 W2-2: recordToolCall no-op 이후 이 카운트는 영구 0 으로
|
|
173
|
+
// 수렴한다. 침묵 열화 대신 이관 안내를 표시 (JSON 은 deprecated 플래그).
|
|
156
174
|
lines.push(boxLine(`${C.bold}Usage${C.reset}`));
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
175
|
+
lines.push(boxLine(` ${C.dim}→ native /usage 로 이동 (plan limit 을 skill/subagent별 분해)${C.reset}`));
|
|
176
|
+
lines.push(boxEmpty());
|
|
177
|
+
// Surfaced-but-ignored (W3-1): 저 ROI 주입 top-5 — δ 는 injection 에서 오므로
|
|
178
|
+
// "노출되는데 안 쓰이는" 솔루션이 곧 낭비되는 컨텍스트다.
|
|
179
|
+
lines.push(boxLine(`${C.bold}Surfaced but ignored (low ROI)${C.reset}`));
|
|
180
|
+
if (data.roiDemoted.length === 0) {
|
|
181
|
+
lines.push(boxLine(` ${C.dim}none — 주입되는 솔루션이 실제로 쓰이고 있음${C.reset}`));
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
for (const e of data.roiDemoted) {
|
|
185
|
+
const mark = e.quarantined ? `${C.red}⛔ quarantined` : `${C.yellow}↓ demoted`;
|
|
186
|
+
lines.push(boxLine(` ${mark}${C.reset} ${e.id} (surfaced ${e.surfaced}, acted ${e.actedOn})`));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
165
189
|
lines.push(boxEmpty());
|
|
166
190
|
// Today compound
|
|
167
191
|
lines.push(boxLine(`${C.bold}Today's compound${C.reset}`));
|
|
@@ -181,7 +205,7 @@ function renderTTY(data) {
|
|
|
181
205
|
lines.push(boxLine(`${C.bold}Top ${data.solutions.topHot.length} hot (90d)${C.reset}`));
|
|
182
206
|
for (const h of data.solutions.topHot) {
|
|
183
207
|
const pctStr = `${Math.round(h.rate * 100)}%`;
|
|
184
|
-
const idShort = h.id.length > 36 ? h.id.slice(0, 33)
|
|
208
|
+
const idShort = h.id.length > 36 ? `${h.id.slice(0, 33)}...` : h.id;
|
|
185
209
|
lines.push(boxLine(` · ${C.yellow}${idShort}${C.reset} surf=${h.surfaced} acted=${h.acted} (${pctStr})`));
|
|
186
210
|
}
|
|
187
211
|
lines.push(boxEmpty());
|
|
@@ -203,14 +227,14 @@ export async function runDashboard(opts) {
|
|
|
203
227
|
if (opts.json) {
|
|
204
228
|
// Strip classified array (verbose) from JSON output for cleaner schema
|
|
205
229
|
const { solutions: { classified: _c, ...solutionStats }, ...rest } = data;
|
|
206
|
-
process.stdout.write(JSON.stringify({ ...rest, solutions: solutionStats }, null, 2)
|
|
230
|
+
process.stdout.write(`${JSON.stringify({ ...rest, solutions: solutionStats }, null, 2)}\n`);
|
|
207
231
|
return;
|
|
208
232
|
}
|
|
209
233
|
const output = renderTTY(data);
|
|
210
234
|
if (opts.watch) {
|
|
211
235
|
process.stdout.write('\x1b[2J\x1b[H'); // clear screen
|
|
212
236
|
}
|
|
213
|
-
process.stdout.write(output
|
|
237
|
+
process.stdout.write(`${output}\n`);
|
|
214
238
|
};
|
|
215
239
|
render();
|
|
216
240
|
if (opts.watch) {
|
package/dist/core/dashboard.d.ts
CHANGED
|
@@ -96,18 +96,17 @@ export declare function collectMultiHostData(): MultiHostData;
|
|
|
96
96
|
export interface LearningCurve {
|
|
97
97
|
correctionsLast7d: number;
|
|
98
98
|
correctionsPrev7d: number;
|
|
99
|
-
|
|
99
|
+
/** 교정 카운트 델타의 관찰 (인과 효과 아님 — down=count 감소일 뿐). */
|
|
100
|
+
correctionTrend: 'down' | 'flat' | 'up';
|
|
100
101
|
evidenceTotalDays: number;
|
|
101
102
|
sessionsAnalyzed: number;
|
|
102
|
-
estimatedMinutesSaved: number;
|
|
103
103
|
topCorrectionAxes: Array<{
|
|
104
104
|
axis: string;
|
|
105
105
|
count: number;
|
|
106
106
|
}>;
|
|
107
107
|
}
|
|
108
108
|
/**
|
|
109
|
-
*
|
|
110
|
-
* evidence 파일(교정 기록)과 compound 활용률을 교차 분석하여 "쓸수록 나아진다"를 정량화.
|
|
109
|
+
* 교정·활동 요약 수집 (관찰). evidence 파일(교정 기록) 기반. 인과 효과/절약은 미주장.
|
|
111
110
|
*/
|
|
112
111
|
export declare function collectLearningCurve(): LearningCurve;
|
|
113
112
|
export declare function renderDashboard(): string;
|