@wooojin/forgen 0.4.9 → 0.4.11
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 +46 -0
- package/README.md +33 -1
- package/assets/claude/agents/forgen-verify.md +65 -0
- package/assets/claude/workflows/compound-extract.js +136 -0
- package/assets/claude/workflows/evidence-gate-audit.js +107 -0
- package/assets/shared/hook-registry.json +1 -0
- package/dist/checks/_shared/meta-guard-dispatch.d.ts +38 -0
- package/dist/checks/_shared/meta-guard-dispatch.js +80 -0
- package/dist/checks/_shared/text-sanitizer.js +15 -0
- package/dist/cli.js +65 -2
- package/dist/core/changelog-cli.d.ts +7 -0
- package/dist/core/changelog-cli.js +100 -0
- package/dist/core/doctor.d.ts +3 -0
- package/dist/core/doctor.js +75 -1
- package/dist/core/effort-advisory.d.ts +23 -0
- package/dist/core/effort-advisory.js +29 -0
- package/dist/core/explain-cli.d.ts +6 -0
- package/dist/core/explain-cli.js +99 -0
- package/dist/core/git-stats.d.ts +23 -0
- package/dist/core/git-stats.js +50 -0
- package/dist/core/health-cli.d.ts +23 -0
- package/dist/core/health-cli.js +86 -0
- package/dist/core/inspect-cli.js +60 -1
- package/dist/core/probe-workflow-cli.d.ts +72 -0
- package/dist/core/probe-workflow-cli.js +282 -0
- package/dist/core/regress-map-cli.d.ts +7 -0
- package/dist/core/regress-map-cli.js +59 -0
- package/dist/core/stats-cli.d.ts +22 -9
- package/dist/core/stats-cli.js +149 -0
- package/dist/core/watch-cli.d.ts +7 -0
- package/dist/core/watch-cli.js +185 -0
- package/dist/core/workflows-cli.d.ts +26 -0
- package/dist/core/workflows-cli.js +120 -0
- package/dist/engine/compound-export.d.ts +12 -0
- package/dist/engine/compound-export.js +136 -14
- package/dist/engine/compound-extractor.d.ts +12 -43
- package/dist/engine/compound-extractor.js +27 -756
- package/dist/engine/extraction-diff.d.ts +11 -0
- package/dist/engine/extraction-diff.js +105 -0
- package/dist/engine/extraction-gates.d.ts +37 -0
- package/dist/engine/extraction-gates.js +100 -0
- package/dist/engine/extraction-git.d.ts +20 -0
- package/dist/engine/extraction-git.js +75 -0
- package/dist/engine/extraction-persistence.d.ts +27 -0
- package/dist/engine/extraction-persistence.js +140 -0
- package/dist/engine/extraction-session.d.ts +26 -0
- package/dist/engine/extraction-session.js +230 -0
- package/dist/engine/lifecycle/types.d.ts +1 -1
- package/dist/engine/meta-learning/matcher-weight-loader.d.ts +16 -0
- package/dist/engine/meta-learning/matcher-weight-loader.js +45 -0
- package/dist/engine/precision-guards.d.ts +14 -0
- package/dist/engine/precision-guards.js +39 -0
- package/dist/engine/ranking-pipeline.d.ts +45 -0
- package/dist/engine/ranking-pipeline.js +66 -0
- package/dist/engine/relevance-scorer.d.ts +43 -0
- package/dist/engine/relevance-scorer.js +81 -0
- package/dist/engine/scoring-algorithms.d.ts +31 -0
- package/dist/engine/scoring-algorithms.js +109 -0
- package/dist/engine/solution-matcher-eval.d.ts +97 -0
- package/dist/engine/solution-matcher-eval.js +122 -0
- package/dist/engine/solution-matcher.d.ts +21 -380
- package/dist/engine/solution-matcher.js +27 -828
- package/dist/fgx.d.ts +4 -1
- package/dist/fgx.js +46 -10
- package/dist/hooks/notepad-injector.js +7 -0
- package/dist/hooks/post-tool-use.js +8 -1
- package/dist/hooks/shared/preflight-check.d.ts +15 -0
- package/dist/hooks/shared/preflight-check.js +51 -0
- package/dist/hooks/stop-guard.js +19 -60
- package/dist/hooks/subagent-stop-guard.d.ts +23 -0
- package/dist/hooks/subagent-stop-guard.js +158 -0
- package/dist/hooks/subagent-tracker.d.ts +36 -3
- package/dist/hooks/subagent-tracker.js +86 -39
- package/dist/store/evidence-store.js +9 -2
- package/hooks/hooks.json +6 -1
- package/package.json +7 -7
- package/plugin.json +1 -1
- package/scripts/postinstall.js +10 -7
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* forgen health — single-line health score (0-100).
|
|
3
|
+
*
|
|
4
|
+
* Combines:
|
|
5
|
+
* - Solution utilization (7d match rate) 30%
|
|
6
|
+
* - Block→ack effectiveness 25%
|
|
7
|
+
* - Knowledge growth (extractions this week) 20%
|
|
8
|
+
* - Rule coverage (active rules) 15%
|
|
9
|
+
* - Profile completeness 10%
|
|
10
|
+
*/
|
|
11
|
+
import { computeStats } from './stats-cli.js';
|
|
12
|
+
const isTTY = process.stdout.isTTY;
|
|
13
|
+
const C = {
|
|
14
|
+
reset: isTTY ? '\x1b[0m' : '',
|
|
15
|
+
bold: isTTY ? '\x1b[1m' : '',
|
|
16
|
+
dim: isTTY ? '\x1b[2m' : '',
|
|
17
|
+
green: isTTY ? '\x1b[32m' : '',
|
|
18
|
+
yellow: isTTY ? '\x1b[33m' : '',
|
|
19
|
+
red: isTTY ? '\x1b[31m' : '',
|
|
20
|
+
cyan: isTTY ? '\x1b[36m' : '',
|
|
21
|
+
};
|
|
22
|
+
export function computeHealth() {
|
|
23
|
+
const s = computeStats();
|
|
24
|
+
// 1. Utilization (30%): % of solutions matched in last 7d, capped at 100%
|
|
25
|
+
const utilization = Math.min(1, s.solutionHealth.utilization7d) * 30;
|
|
26
|
+
// 2. Effectiveness (25%): if blocks happened, what % were acknowledged
|
|
27
|
+
let effectiveness;
|
|
28
|
+
if (s.blocks7d === 0) {
|
|
29
|
+
effectiveness = 25; // no blocks = no problems
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
effectiveness = (s.acks7d / s.blocks7d) * 25;
|
|
33
|
+
}
|
|
34
|
+
// 3. Growth (20%): extractions this week (1 extraction = 10pts, cap at 20)
|
|
35
|
+
const growth = Math.min(20, s.weeklyTrend.extractionsThisWeek * 10);
|
|
36
|
+
// 4. Coverage (15%): active rules (1 rule = 3pts, cap at 15)
|
|
37
|
+
const coverage = Math.min(15, s.activeRules * 3);
|
|
38
|
+
// 5. Profile (10%): has profile + has philosophy + axis scores populated
|
|
39
|
+
let profile = 0;
|
|
40
|
+
if (s.philosophy) {
|
|
41
|
+
profile += 4; // profile exists
|
|
42
|
+
if (s.philosophy.basePacks.length > 0)
|
|
43
|
+
profile += 3;
|
|
44
|
+
if (Object.keys(s.philosophy.axisScores).length >= 4)
|
|
45
|
+
profile += 3;
|
|
46
|
+
}
|
|
47
|
+
const total = Math.round(utilization + effectiveness + growth + coverage + profile);
|
|
48
|
+
const grade = total >= 80 ? 'A' : total >= 60 ? 'B' : total >= 40 ? 'C' : total >= 20 ? 'D' : 'F';
|
|
49
|
+
return {
|
|
50
|
+
total,
|
|
51
|
+
components: {
|
|
52
|
+
utilization: Math.round(utilization),
|
|
53
|
+
effectiveness: Math.round(effectiveness),
|
|
54
|
+
growth: Math.round(growth),
|
|
55
|
+
coverage: Math.round(coverage),
|
|
56
|
+
profile: Math.round(profile),
|
|
57
|
+
},
|
|
58
|
+
grade,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function gradeColor(grade) {
|
|
62
|
+
if (grade === 'A')
|
|
63
|
+
return C.green;
|
|
64
|
+
if (grade === 'B')
|
|
65
|
+
return C.cyan;
|
|
66
|
+
if (grade === 'C')
|
|
67
|
+
return C.yellow;
|
|
68
|
+
return C.red;
|
|
69
|
+
}
|
|
70
|
+
function bar(value, max, width = 10) {
|
|
71
|
+
const filled = Math.round((value / max) * width);
|
|
72
|
+
return '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, width - filled));
|
|
73
|
+
}
|
|
74
|
+
export async function handleHealth() {
|
|
75
|
+
const h = computeHealth();
|
|
76
|
+
const gc = gradeColor(h.grade);
|
|
77
|
+
console.log('');
|
|
78
|
+
console.log(` ${C.bold}forgen health${C.reset} ${gc}${C.bold}${h.grade}${C.reset} ${gc}${h.total}/100${C.reset}`);
|
|
79
|
+
console.log('');
|
|
80
|
+
console.log(` Utilization ${bar(h.components.utilization, 30)} ${h.components.utilization}/30 ${C.dim}solution match rate (7d)${C.reset}`);
|
|
81
|
+
console.log(` Effectiveness ${bar(h.components.effectiveness, 25)} ${h.components.effectiveness}/25 ${C.dim}block→ack ratio${C.reset}`);
|
|
82
|
+
console.log(` Growth ${bar(h.components.growth, 20)} ${h.components.growth}/20 ${C.dim}extractions this week${C.reset}`);
|
|
83
|
+
console.log(` Coverage ${bar(h.components.coverage, 15)} ${h.components.coverage}/15 ${C.dim}active rules${C.reset}`);
|
|
84
|
+
console.log(` Profile ${bar(h.components.profile, 10)} ${h.components.profile}/10 ${C.dim}personalization depth${C.reset}`);
|
|
85
|
+
console.log('');
|
|
86
|
+
}
|
package/dist/core/inspect-cli.js
CHANGED
|
@@ -8,7 +8,7 @@ import * as fs from 'node:fs';
|
|
|
8
8
|
import * as path from 'node:path';
|
|
9
9
|
import { loadProfile } from '../store/profile-store.js';
|
|
10
10
|
import { loadAllRules, loadActiveRules } from '../store/rule-store.js';
|
|
11
|
-
import { loadRecentEvidence } from '../store/evidence-store.js';
|
|
11
|
+
import { loadRecentEvidence, loadAllEvidence } from '../store/evidence-store.js';
|
|
12
12
|
import { loadRecentSessions } from '../store/session-state-store.js';
|
|
13
13
|
import * as inspect from '../renderer/inspect-renderer.js';
|
|
14
14
|
import { ME_BEHAVIOR, ME_SOLUTIONS, STATE_DIR } from './paths.js';
|
|
@@ -71,6 +71,10 @@ export async function handleInspect(args) {
|
|
|
71
71
|
}
|
|
72
72
|
console.log('');
|
|
73
73
|
}
|
|
74
|
+
// ── Receipts: correction → rule → inject ── (v0.4.10)
|
|
75
|
+
// "내가 forgen 으로 무엇을 얻었나" 한 화면. correction 이 만들어낸 rule 의 lifecycle 을
|
|
76
|
+
// 묶어 표시 — 주입(inject_count), 마지막 주입 시각, host 분포.
|
|
77
|
+
await renderReceipts();
|
|
74
78
|
return;
|
|
75
79
|
}
|
|
76
80
|
if (sub === 'rules') {
|
|
@@ -157,3 +161,58 @@ export async function handleInspect(args) {
|
|
|
157
161
|
forgen inspect bypass [--last N] — 사용자 우회 기록
|
|
158
162
|
forgen inspect drift [--last N] — stuck-loop force-approve 기록`);
|
|
159
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* v0.4.10 Receipts: correction → rule → inject 트리플.
|
|
166
|
+
*
|
|
167
|
+
* 사용자 가치 명제("the more you use, the better it knows") 의 receipt.
|
|
168
|
+
* 1) Top-injected rules (실제로 Claude 에 주입된 룰 N회)
|
|
169
|
+
* 2) Recent rule births (correction → 새 룰 생성된 이력)
|
|
170
|
+
* 3) Host 균형 (claude vs codex)
|
|
171
|
+
*/
|
|
172
|
+
async function renderReceipts() {
|
|
173
|
+
const rules = loadAllRules();
|
|
174
|
+
const evidence = loadAllEvidence();
|
|
175
|
+
const injected = rules
|
|
176
|
+
.filter((r) => r.lifecycle && (r.lifecycle.inject_count ?? 0) > 0)
|
|
177
|
+
.sort((a, b) => (b.lifecycle?.inject_count ?? 0) - (a.lifecycle?.inject_count ?? 0))
|
|
178
|
+
.slice(0, 5);
|
|
179
|
+
console.log('── Receipts (correction → rule → inject) ──');
|
|
180
|
+
if (injected.length === 0) {
|
|
181
|
+
console.log(' No injected rules yet. Trigger a session to start the loop.');
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
console.log(' Top injected rules:');
|
|
185
|
+
for (const r of injected) {
|
|
186
|
+
const count = r.lifecycle?.inject_count ?? 0;
|
|
187
|
+
const last = (r.lifecycle?.last_inject_at ?? '').slice(0, 10) || 'never';
|
|
188
|
+
const trigger = (r.trigger ?? '').slice(0, 48);
|
|
189
|
+
console.log(` ${String(count).padStart(3)}× [${r.category}] ${trigger} (last ${last})`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// Recent rule births: 마지막 7일 내 created_at 인 룰 (T = correction → rule)
|
|
193
|
+
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
194
|
+
const recentRules = rules
|
|
195
|
+
.filter((r) => new Date(r.created_at).getTime() >= sevenDaysAgo)
|
|
196
|
+
.sort((a, b) => b.created_at.localeCompare(a.created_at))
|
|
197
|
+
.slice(0, 3);
|
|
198
|
+
if (recentRules.length > 0) {
|
|
199
|
+
console.log(' New rules (last 7 days):');
|
|
200
|
+
for (const r of recentRules) {
|
|
201
|
+
const dateStr = r.created_at.slice(0, 10);
|
|
202
|
+
console.log(` + [${r.category}/${r.strength}] ${(r.trigger ?? '').slice(0, 48)} (${dateStr})`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Host 균형 — multi-host 가치 명제의 receipt
|
|
206
|
+
const hostCount = { claude: 0, codex: 0 };
|
|
207
|
+
for (const e of evidence) {
|
|
208
|
+
const h = (e.host ?? 'claude');
|
|
209
|
+
hostCount[h] = (hostCount[h] ?? 0) + 1;
|
|
210
|
+
}
|
|
211
|
+
const total = hostCount.claude + hostCount.codex;
|
|
212
|
+
if (total > 0) {
|
|
213
|
+
const cpct = Math.round((hostCount.claude / total) * 100);
|
|
214
|
+
const xpct = 100 - cpct;
|
|
215
|
+
console.log(` Host balance: claude ${cpct}% · codex ${xpct}% (n=${total})`);
|
|
216
|
+
}
|
|
217
|
+
console.log('');
|
|
218
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* forgen probe-workflow — ADR-009 §1 결정적 미확인 변수 실측 도구.
|
|
3
|
+
*
|
|
4
|
+
* 질문: dynamic workflow **내부** 에이전트에 대해 SubagentStart/Stop·PostToolUse
|
|
5
|
+
* 훅이 발화하는가? (워크플로우 런타임은 "대화와 분리된 격리 백그라운드"로
|
|
6
|
+
* 명시되어 있어 발화 여부가 문서로 미확인.)
|
|
7
|
+
*
|
|
8
|
+
* 이 답이 ADR-009 §2(훅 라우트로 워크플로우까지 검증 가능) vs §3(워크플로우
|
|
9
|
+
* 품질은 템플릿 라우트로만 도달)의 선택을 가른다. forgen 프로젝트 룰상 가정
|
|
10
|
+
* 위에 §2 를 구현할 수 없으므로, 실제 실행 증거를 먼저 수집한다.
|
|
11
|
+
*
|
|
12
|
+
* 신호원 (훅이 부작용으로 남기는 state 파일 — forgen 자체 timing 계측의 공백과
|
|
13
|
+
* 무관하게 직접 관측 가능):
|
|
14
|
+
* - SubagentStart/Stop → ~/.forgen/state/active-agents-*.json (agents[])
|
|
15
|
+
* - PostToolUse → ~/.forgen/state/modified-files-*.json (mtime)
|
|
16
|
+
* - (보조) hook-timing.jsonl 의 event 별 엔트리
|
|
17
|
+
*
|
|
18
|
+
* 절차 (2단계 — 런타임이 격리되어 있어 단일 프로세스로는 트리거 불가):
|
|
19
|
+
* 1. `forgen probe-workflow arm` → baseline 마커 기록 + 안내 출력
|
|
20
|
+
* 2. 사용자가 Claude Code 에서 워크플로우 1회 실행 (그 사이 다른 작업 금지)
|
|
21
|
+
* 3. `forgen probe-workflow report` → baseline 이후 신호 수집 → verdict 박제
|
|
22
|
+
*
|
|
23
|
+
* 가정 (detailed-communication): arm~report 사이에 사용자가 **워크플로우만**
|
|
24
|
+
* 실행했다고 전제한다. 일반 Task-tool subagent 를 같이 돌리면 신호가 섞인다.
|
|
25
|
+
*/
|
|
26
|
+
export interface ProbeBaseline {
|
|
27
|
+
armedAtMs: number;
|
|
28
|
+
armedIso: string;
|
|
29
|
+
}
|
|
30
|
+
export interface AgentObservation {
|
|
31
|
+
agentId: string;
|
|
32
|
+
agentType?: string;
|
|
33
|
+
model?: string;
|
|
34
|
+
startedAtMs: number;
|
|
35
|
+
stoppedAtMs?: number;
|
|
36
|
+
}
|
|
37
|
+
export interface ProbeObservations {
|
|
38
|
+
agents: AgentObservation[];
|
|
39
|
+
/** baseline 이후 modified-files-*.json 이 갱신됨 (PostToolUse 발화 신호). */
|
|
40
|
+
postToolUseFired: boolean;
|
|
41
|
+
/** hook-timing.jsonl 에서 baseline 이후 관측된 event 이름들. */
|
|
42
|
+
hookEvents: string[];
|
|
43
|
+
}
|
|
44
|
+
export type ProbeOutcome = 'workflow-hooks-fire' | 'workflow-hooks-absent' | 'inconclusive';
|
|
45
|
+
export interface ProbeVerdict {
|
|
46
|
+
subagentStartStopFired: boolean;
|
|
47
|
+
postToolUseFired: boolean;
|
|
48
|
+
agentCount: number;
|
|
49
|
+
maxConcurrency: number;
|
|
50
|
+
agentTypes: string[];
|
|
51
|
+
outcome: ProbeOutcome;
|
|
52
|
+
recommendation: string;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* 구간 [start, stop) 들의 최대 동시 겹침 수. stoppedAt 미지정(진행 중) 은 +∞ 로 간주.
|
|
56
|
+
* sweep-line: start 이벤트 +1, stop 이벤트 -1 을 시간순 정렬 후 누적 최대.
|
|
57
|
+
* 동일 시각에서는 start(+1) 를 stop(-1) 보다 먼저 처리해 겹침을 과소평가하지 않는다.
|
|
58
|
+
*/
|
|
59
|
+
export declare function maxConcurrency(agents: AgentObservation[]): number;
|
|
60
|
+
/**
|
|
61
|
+
* Pure core — baseline + 관측치 → verdict. IO 없음 (단위 테스트 대상).
|
|
62
|
+
*
|
|
63
|
+
* 판정:
|
|
64
|
+
* - 에이전트 0 → 'workflow-hooks-absent' (단 "워크플로우를 실제로 띄웠는가"
|
|
65
|
+
* 확인 전제 — recommendation 에 명시). §2 는 워크플로우 내부에 도달 못 함.
|
|
66
|
+
* - 에이전트 >0 → 'workflow-hooks-fire'. 동시 ≥11 이면 워크플로우 강한 신호.
|
|
67
|
+
* §2(SubagentStop 검증)가 워크플로우까지 커버 가능.
|
|
68
|
+
*/
|
|
69
|
+
export declare function analyzeProbe(obs: ProbeObservations): ProbeVerdict;
|
|
70
|
+
/** baseline 이후 신호 수집 (IO 셸). */
|
|
71
|
+
export declare function collectObservations(baselineMs: number): ProbeObservations;
|
|
72
|
+
export declare function handleProbeWorkflow(args: string[]): Promise<void>;
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* forgen probe-workflow — ADR-009 §1 결정적 미확인 변수 실측 도구.
|
|
3
|
+
*
|
|
4
|
+
* 질문: dynamic workflow **내부** 에이전트에 대해 SubagentStart/Stop·PostToolUse
|
|
5
|
+
* 훅이 발화하는가? (워크플로우 런타임은 "대화와 분리된 격리 백그라운드"로
|
|
6
|
+
* 명시되어 있어 발화 여부가 문서로 미확인.)
|
|
7
|
+
*
|
|
8
|
+
* 이 답이 ADR-009 §2(훅 라우트로 워크플로우까지 검증 가능) vs §3(워크플로우
|
|
9
|
+
* 품질은 템플릿 라우트로만 도달)의 선택을 가른다. forgen 프로젝트 룰상 가정
|
|
10
|
+
* 위에 §2 를 구현할 수 없으므로, 실제 실행 증거를 먼저 수집한다.
|
|
11
|
+
*
|
|
12
|
+
* 신호원 (훅이 부작용으로 남기는 state 파일 — forgen 자체 timing 계측의 공백과
|
|
13
|
+
* 무관하게 직접 관측 가능):
|
|
14
|
+
* - SubagentStart/Stop → ~/.forgen/state/active-agents-*.json (agents[])
|
|
15
|
+
* - PostToolUse → ~/.forgen/state/modified-files-*.json (mtime)
|
|
16
|
+
* - (보조) hook-timing.jsonl 의 event 별 엔트리
|
|
17
|
+
*
|
|
18
|
+
* 절차 (2단계 — 런타임이 격리되어 있어 단일 프로세스로는 트리거 불가):
|
|
19
|
+
* 1. `forgen probe-workflow arm` → baseline 마커 기록 + 안내 출력
|
|
20
|
+
* 2. 사용자가 Claude Code 에서 워크플로우 1회 실행 (그 사이 다른 작업 금지)
|
|
21
|
+
* 3. `forgen probe-workflow report` → baseline 이후 신호 수집 → verdict 박제
|
|
22
|
+
*
|
|
23
|
+
* 가정 (detailed-communication): arm~report 사이에 사용자가 **워크플로우만**
|
|
24
|
+
* 실행했다고 전제한다. 일반 Task-tool subagent 를 같이 돌리면 신호가 섞인다.
|
|
25
|
+
*/
|
|
26
|
+
import * as fs from 'node:fs';
|
|
27
|
+
import * as path from 'node:path';
|
|
28
|
+
import { STATE_DIR } from './paths.js';
|
|
29
|
+
const isTTY = process.stdout.isTTY;
|
|
30
|
+
const C = {
|
|
31
|
+
reset: isTTY ? '\x1b[0m' : '',
|
|
32
|
+
bold: isTTY ? '\x1b[1m' : '',
|
|
33
|
+
dim: isTTY ? '\x1b[2m' : '',
|
|
34
|
+
green: isTTY ? '\x1b[32m' : '',
|
|
35
|
+
yellow: isTTY ? '\x1b[33m' : '',
|
|
36
|
+
red: isTTY ? '\x1b[31m' : '',
|
|
37
|
+
cyan: isTTY ? '\x1b[36m' : '',
|
|
38
|
+
};
|
|
39
|
+
const BASELINE_PATH = path.join(STATE_DIR, 'probe-workflow.json');
|
|
40
|
+
const RESULT_PATH = path.join(STATE_DIR, 'probe-workflow-result.json');
|
|
41
|
+
/** 일반 subagent 동시 실행 상한(MAX_CONCURRENT_AGENTS=10) 초과 → 워크플로우 강한 신호. */
|
|
42
|
+
const WORKFLOW_CONCURRENCY_HINT = 11;
|
|
43
|
+
/**
|
|
44
|
+
* 구간 [start, stop) 들의 최대 동시 겹침 수. stoppedAt 미지정(진행 중) 은 +∞ 로 간주.
|
|
45
|
+
* sweep-line: start 이벤트 +1, stop 이벤트 -1 을 시간순 정렬 후 누적 최대.
|
|
46
|
+
* 동일 시각에서는 start(+1) 를 stop(-1) 보다 먼저 처리해 겹침을 과소평가하지 않는다.
|
|
47
|
+
*/
|
|
48
|
+
export function maxConcurrency(agents) {
|
|
49
|
+
const events = [];
|
|
50
|
+
for (const a of agents) {
|
|
51
|
+
events.push({ t: a.startedAtMs, delta: 1 });
|
|
52
|
+
events.push({ t: a.stoppedAtMs ?? Number.POSITIVE_INFINITY, delta: -1 });
|
|
53
|
+
}
|
|
54
|
+
events.sort((x, y) => (x.t === y.t ? y.delta - x.delta : x.t - y.t));
|
|
55
|
+
let cur = 0;
|
|
56
|
+
let peak = 0;
|
|
57
|
+
for (const e of events) {
|
|
58
|
+
cur += e.delta;
|
|
59
|
+
if (cur > peak)
|
|
60
|
+
peak = cur;
|
|
61
|
+
}
|
|
62
|
+
return peak;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Pure core — baseline + 관측치 → verdict. IO 없음 (단위 테스트 대상).
|
|
66
|
+
*
|
|
67
|
+
* 판정:
|
|
68
|
+
* - 에이전트 0 → 'workflow-hooks-absent' (단 "워크플로우를 실제로 띄웠는가"
|
|
69
|
+
* 확인 전제 — recommendation 에 명시). §2 는 워크플로우 내부에 도달 못 함.
|
|
70
|
+
* - 에이전트 >0 → 'workflow-hooks-fire'. 동시 ≥11 이면 워크플로우 강한 신호.
|
|
71
|
+
* §2(SubagentStop 검증)가 워크플로우까지 커버 가능.
|
|
72
|
+
*/
|
|
73
|
+
export function analyzeProbe(obs) {
|
|
74
|
+
const agentCount = obs.agents.length;
|
|
75
|
+
const conc = maxConcurrency(obs.agents);
|
|
76
|
+
const types = [...new Set(obs.agents.map((a) => a.agentType).filter((t) => !!t))];
|
|
77
|
+
const subagentFired = agentCount > 0;
|
|
78
|
+
let outcome;
|
|
79
|
+
let recommendation;
|
|
80
|
+
if (!subagentFired) {
|
|
81
|
+
outcome = 'workflow-hooks-absent';
|
|
82
|
+
recommendation =
|
|
83
|
+
'SubagentStart/Stop 신호 0건. 워크플로우를 실제로 실행했다면 → 워크플로우 ' +
|
|
84
|
+
'내부 에이전트는 forgen 훅을 거치지 않음. ADR-009 §2 는 Task-tool/team/swarm ' +
|
|
85
|
+
'subagent 한정으로 제한하고, 워크플로우 품질은 §3 템플릿 라우트로만 달성한다. ' +
|
|
86
|
+
'(워크플로우를 안 띄웠다면 arm 후 재시도 — inconclusive.)';
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
outcome = 'workflow-hooks-fire';
|
|
90
|
+
const strong = conc >= WORKFLOW_CONCURRENCY_HINT;
|
|
91
|
+
recommendation =
|
|
92
|
+
`SubagentStart/Stop 발화 확인(에이전트 ${agentCount}, 최대 동시 ${conc}` +
|
|
93
|
+
`${strong ? ', 워크플로우 동시성 신호 강함' : ', 동시성 낮음 — 일반 subagent 가능성 검토'}). ` +
|
|
94
|
+
`ADR-009 §2(SubagentStop 검증)가 워크플로우 내부까지 커버 가능. ` +
|
|
95
|
+
`PostToolUse=${obs.postToolUseFired ? '발화(§2d per-agent tool 추적 가능)' : '미관측(§2d 거짓양성 리스크 — 추가 확인 필요)'}.`;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
subagentStartStopFired: subagentFired,
|
|
99
|
+
postToolUseFired: obs.postToolUseFired,
|
|
100
|
+
agentCount,
|
|
101
|
+
maxConcurrency: conc,
|
|
102
|
+
agentTypes: types,
|
|
103
|
+
outcome,
|
|
104
|
+
recommendation,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function parseIsoMs(iso) {
|
|
108
|
+
if (!iso)
|
|
109
|
+
return undefined;
|
|
110
|
+
const ms = Date.parse(iso);
|
|
111
|
+
return Number.isFinite(ms) ? ms : undefined;
|
|
112
|
+
}
|
|
113
|
+
/** STATE_DIR 의 prefix-*.json 파일 경로 목록. 실패 시 []. */
|
|
114
|
+
function listStateFiles(prefix) {
|
|
115
|
+
try {
|
|
116
|
+
return fs
|
|
117
|
+
.readdirSync(STATE_DIR)
|
|
118
|
+
.filter((f) => f.startsWith(prefix) && f.endsWith('.json'))
|
|
119
|
+
.map((f) => path.join(STATE_DIR, f));
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** baseline 이후 신호 수집 (IO 셸). */
|
|
126
|
+
export function collectObservations(baselineMs) {
|
|
127
|
+
const agents = [];
|
|
128
|
+
for (const file of listStateFiles('active-agents-')) {
|
|
129
|
+
try {
|
|
130
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
131
|
+
for (const a of data.agents ?? []) {
|
|
132
|
+
const startedAtMs = parseIsoMs(a.startedAt);
|
|
133
|
+
if (startedAtMs === undefined || startedAtMs < baselineMs)
|
|
134
|
+
continue;
|
|
135
|
+
agents.push({
|
|
136
|
+
agentId: a.agentId ?? 'unknown',
|
|
137
|
+
agentType: a.agentType,
|
|
138
|
+
model: a.model,
|
|
139
|
+
startedAtMs,
|
|
140
|
+
stoppedAtMs: parseIsoMs(a.stoppedAt),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
/* skip malformed */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
let postToolUseFired = false;
|
|
149
|
+
for (const file of listStateFiles('modified-files-')) {
|
|
150
|
+
try {
|
|
151
|
+
if (fs.statSync(file).mtimeMs >= baselineMs) {
|
|
152
|
+
postToolUseFired = true;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
/* skip */
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const hookEvents = collectHookEvents(baselineMs);
|
|
161
|
+
return { agents, postToolUseFired, hookEvents };
|
|
162
|
+
}
|
|
163
|
+
/** hook-timing.jsonl 에서 baseline 이후 관측된 distinct event 이름 (보조 신호). */
|
|
164
|
+
function collectHookEvents(baselineMs) {
|
|
165
|
+
const p = path.join(STATE_DIR, 'hook-timing.jsonl');
|
|
166
|
+
try {
|
|
167
|
+
const lines = fs.readFileSync(p, 'utf-8').trim().split('\n');
|
|
168
|
+
const set = new Set();
|
|
169
|
+
for (const line of lines) {
|
|
170
|
+
try {
|
|
171
|
+
const e = JSON.parse(line);
|
|
172
|
+
if (typeof e.at === 'number' && e.at >= baselineMs && e.event)
|
|
173
|
+
set.add(e.event);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
/* skip */
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return [...set];
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function armProbe() {
|
|
186
|
+
const now = Date.now();
|
|
187
|
+
const baseline = { armedAtMs: now, armedIso: new Date(now).toISOString() };
|
|
188
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
189
|
+
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2));
|
|
190
|
+
console.log(`
|
|
191
|
+
${C.bold}forgen probe-workflow — armed${C.reset} ${C.dim}(${baseline.armedIso})${C.reset}
|
|
192
|
+
|
|
193
|
+
${C.cyan}다음을 정확히 순서대로 실행하세요:${C.reset}
|
|
194
|
+
1. Claude Code (v2.1.154+, workflows 활성) 세션에서 ${C.bold}워크플로우 1회만${C.reset} 실행
|
|
195
|
+
예: ${C.dim}Run a workflow to list files under src/${C.reset}
|
|
196
|
+
또는: ${C.dim}/deep-research <질문>${C.reset}
|
|
197
|
+
2. ${C.yellow}그 사이 다른 Task/subagent 작업은 돌리지 마세요${C.reset} (신호 오염 방지)
|
|
198
|
+
3. 워크플로우가 끝나면: ${C.bold}forgen probe-workflow report${C.reset}
|
|
199
|
+
|
|
200
|
+
${C.dim}전제: forgen 의 SubagentStart/Stop·PostToolUse 훅이 설치/활성 상태여야 합니다.
|
|
201
|
+
불확실하면 'forgen config hooks' 로 확인하세요.${C.reset}
|
|
202
|
+
`);
|
|
203
|
+
}
|
|
204
|
+
function loadBaseline() {
|
|
205
|
+
try {
|
|
206
|
+
return JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8'));
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function colorForOutcome(outcome) {
|
|
213
|
+
if (outcome === 'workflow-hooks-fire')
|
|
214
|
+
return C.green;
|
|
215
|
+
if (outcome === 'workflow-hooks-absent')
|
|
216
|
+
return C.yellow;
|
|
217
|
+
return C.dim;
|
|
218
|
+
}
|
|
219
|
+
function reportProbe() {
|
|
220
|
+
const baseline = loadBaseline();
|
|
221
|
+
if (!baseline) {
|
|
222
|
+
console.log(`\n ${C.red}✗ armed 상태가 아닙니다.${C.reset} 먼저 ${C.bold}forgen probe-workflow arm${C.reset} 를 실행하세요.\n`);
|
|
223
|
+
process.exitCode = 1;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const obs = collectObservations(baseline.armedAtMs);
|
|
227
|
+
const verdict = analyzeProbe(obs);
|
|
228
|
+
persistResult(baseline, verdict);
|
|
229
|
+
const oc = colorForOutcome(verdict.outcome);
|
|
230
|
+
console.log(`
|
|
231
|
+
${C.bold}forgen probe-workflow — report${C.reset} ${C.dim}(armed ${baseline.armedIso})${C.reset}
|
|
232
|
+
|
|
233
|
+
SubagentStart/Stop 발화 : ${verdict.subagentStartStopFired ? `${C.green}YES${C.reset}` : `${C.yellow}NO${C.reset}`}
|
|
234
|
+
PostToolUse 발화 : ${verdict.postToolUseFired ? `${C.green}YES${C.reset}` : `${C.yellow}NO${C.reset}`}
|
|
235
|
+
관측 에이전트 : ${verdict.agentCount} (최대 동시 ${verdict.maxConcurrency})
|
|
236
|
+
agentType : ${verdict.agentTypes.length ? verdict.agentTypes.join(', ') : `${C.dim}(없음)${C.reset}`}
|
|
237
|
+
보조 hook 이벤트 : ${obs.hookEvents.length ? obs.hookEvents.join(', ') : `${C.dim}(없음)${C.reset}`}
|
|
238
|
+
|
|
239
|
+
${C.bold}판정:${C.reset} ${oc}${verdict.outcome}${C.reset}
|
|
240
|
+
${verdict.recommendation}
|
|
241
|
+
|
|
242
|
+
${C.dim}결과 박제: ${RESULT_PATH}${C.reset}
|
|
243
|
+
`);
|
|
244
|
+
}
|
|
245
|
+
function persistResult(baseline, verdict) {
|
|
246
|
+
try {
|
|
247
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
248
|
+
fs.writeFileSync(RESULT_PATH, JSON.stringify({ at: new Date().toISOString(), armedIso: baseline.armedIso, verdict }, null, 2));
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
/* best-effort */
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function statusProbe() {
|
|
255
|
+
const baseline = loadBaseline();
|
|
256
|
+
console.log(baseline
|
|
257
|
+
? `\n armed: ${C.cyan}${baseline.armedIso}${C.reset}\n → 워크플로우 실행 후 ${C.bold}forgen probe-workflow report${C.reset}\n`
|
|
258
|
+
: `\n ${C.dim}armed 상태 아님.${C.reset} ${C.bold}forgen probe-workflow arm${C.reset} 로 시작하세요.\n`);
|
|
259
|
+
}
|
|
260
|
+
export async function handleProbeWorkflow(args) {
|
|
261
|
+
const sub = args[0] ?? 'status';
|
|
262
|
+
switch (sub) {
|
|
263
|
+
case 'arm':
|
|
264
|
+
armProbe();
|
|
265
|
+
return;
|
|
266
|
+
case 'report':
|
|
267
|
+
reportProbe();
|
|
268
|
+
return;
|
|
269
|
+
case 'status':
|
|
270
|
+
statusProbe();
|
|
271
|
+
return;
|
|
272
|
+
default:
|
|
273
|
+
console.log(`
|
|
274
|
+
${C.bold}forgen probe-workflow${C.reset} — ADR-009 §1: 워크플로우 훅 발화 실측
|
|
275
|
+
|
|
276
|
+
Usage:
|
|
277
|
+
forgen probe-workflow arm baseline 기록 + 안내 (먼저)
|
|
278
|
+
forgen probe-workflow report 워크플로우 실행 후 신호 수집 → 판정
|
|
279
|
+
forgen probe-workflow status 현재 armed 상태 확인
|
|
280
|
+
`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `forgen regress-map` — fix:feat 비율 워닝을 actionable 로.
|
|
3
|
+
*
|
|
4
|
+
* doctor 의 36% 시그널을 받아 "어느 파일이 진앙인가" 를 한 화면에 보여준다.
|
|
5
|
+
* `--days N` (기본 30), `--top N` (기본 10), `--json` 지원.
|
|
6
|
+
*/
|
|
7
|
+
import { computeRegressMap, formatFixRatio, computeFixFeatRatio } from './git-stats.js';
|
|
8
|
+
function parseIntArg(args, flag, fallback) {
|
|
9
|
+
const i = args.indexOf(flag);
|
|
10
|
+
if (i === -1)
|
|
11
|
+
return fallback;
|
|
12
|
+
const v = Number.parseInt(args[i + 1] ?? '', 10);
|
|
13
|
+
return Number.isFinite(v) && v > 0 ? v : fallback;
|
|
14
|
+
}
|
|
15
|
+
export async function handleRegressMap(args = []) {
|
|
16
|
+
const days = parseIntArg(args, '--days', 30);
|
|
17
|
+
const top = parseIntArg(args, '--top', 10);
|
|
18
|
+
const asJson = args.includes('--json');
|
|
19
|
+
const map = computeRegressMap(process.cwd(), days, top);
|
|
20
|
+
const ratio = computeFixFeatRatio(process.cwd(), 30);
|
|
21
|
+
if (asJson) {
|
|
22
|
+
process.stdout.write(JSON.stringify({ fixFeat: ratio, regress: map }, null, 2));
|
|
23
|
+
process.stdout.write('\n');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (!map.available) {
|
|
27
|
+
console.log('regress-map: git unavailable or no commits in window.');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
console.log(' ┌─ forgen regress-map ───────────────────────────────────┐');
|
|
31
|
+
console.log(` │ window: last ${map.windowDays} days · fix commits: ${map.fixCommits}`.padEnd(60) + '│');
|
|
32
|
+
if (ratio.available) {
|
|
33
|
+
console.log(` │ ${formatFixRatio(ratio)}`.padEnd(60) + '│');
|
|
34
|
+
}
|
|
35
|
+
console.log(' ├────────────────────────────────────────────────────────┤');
|
|
36
|
+
if (map.hotspots.length === 0) {
|
|
37
|
+
console.log(' │ No fix-touched files in window.'.padEnd(60) + '│');
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
console.log(' │ rank hits file (last fix · sha)'.padEnd(60) + '│');
|
|
41
|
+
map.hotspots.forEach((h, i) => {
|
|
42
|
+
const rank = String(i + 1).padStart(2, ' ');
|
|
43
|
+
const hits = String(h.fixHits).padStart(3, ' ');
|
|
44
|
+
const meta = `${h.lastFixDate} ${h.lastFixSha}`;
|
|
45
|
+
const pathBudget = 56 - 4 - 4 - meta.length - 3;
|
|
46
|
+
const shownPath = h.path.length > pathBudget
|
|
47
|
+
? '…' + h.path.slice(-(pathBudget - 1))
|
|
48
|
+
: h.path;
|
|
49
|
+
const line = ` ${rank} ${hits} ${shownPath} (${meta})`;
|
|
50
|
+
console.log(` │ ${line}`.padEnd(60) + '│');
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
console.log(' └────────────────────────────────────────────────────────┘');
|
|
54
|
+
if (ratio.available && ratio.exceedsThreshold) {
|
|
55
|
+
console.log('');
|
|
56
|
+
console.log(' ⚠ fix:feat 비율 초과 — 상위 파일의 invariant/테스트 보강 권장.');
|
|
57
|
+
console.log(' → 같은 파일이 3회 이상 fix 닿았다면 책임 분리 또는 회귀 테스트 우선.');
|
|
58
|
+
}
|
|
59
|
+
}
|
package/dist/core/stats-cli.d.ts
CHANGED
|
@@ -9,27 +9,40 @@ export interface StatsSnapshot {
|
|
|
9
9
|
drift7d: number;
|
|
10
10
|
retired7d: number;
|
|
11
11
|
lastExtraction: string;
|
|
12
|
-
/**
|
|
13
|
-
* H3 / v0.4.1 — assist 축 가시화. enforcement(block/violation) 는 이미 표시되지만
|
|
14
|
-
* assist(recall hit, surface, extraction) 는 v0.4.0 에서 8,000+ 번 작동했음에도
|
|
15
|
-
* 사용자에게 0건 노출되었다. 오늘 기준 숫자로 "지금 학습되고 있다" 를 surface.
|
|
16
|
-
*/
|
|
17
12
|
assistToday: {
|
|
18
13
|
recallHits: number;
|
|
19
14
|
surfaced: number;
|
|
20
15
|
referenced: number;
|
|
21
16
|
extractedToday: number;
|
|
22
17
|
};
|
|
23
|
-
/**
|
|
24
|
-
* v0.4.1 철학 고도화 지표 — forge-profile.json 이 실제로 학습됐는지 한눈에.
|
|
25
|
-
* 값이 있으면 가시화, 없으면 undefined.
|
|
26
|
-
*/
|
|
27
18
|
philosophy?: {
|
|
28
19
|
basePacks: string[];
|
|
29
20
|
trustPolicy: string;
|
|
30
21
|
axisScores: Record<string, number>;
|
|
31
22
|
lastReclassification: string | null;
|
|
32
23
|
};
|
|
24
|
+
/** v0.5.0: solution health — status 분포, 활용률 */
|
|
25
|
+
solutionHealth: {
|
|
26
|
+
total: number;
|
|
27
|
+
byStatus: Record<string, number>;
|
|
28
|
+
avgConfidence: number;
|
|
29
|
+
/** 지난 7일간 match-eval-log에서 한 번이라도 매칭된 솔루션 비율 */
|
|
30
|
+
utilization7d: number;
|
|
31
|
+
};
|
|
32
|
+
/** v0.5.0: 7일간 가장 많이 발동된 규칙 top-3 */
|
|
33
|
+
topRules7d: Array<{
|
|
34
|
+
name: string;
|
|
35
|
+
count: number;
|
|
36
|
+
}>;
|
|
37
|
+
/** v0.5.0: 이번주 vs 지난주 변화량 */
|
|
38
|
+
weeklyTrend: {
|
|
39
|
+
blocksThisWeek: number;
|
|
40
|
+
blocksLastWeek: number;
|
|
41
|
+
recallsThisWeek: number;
|
|
42
|
+
recallsLastWeek: number;
|
|
43
|
+
extractionsThisWeek: number;
|
|
44
|
+
extractionsLastWeek: number;
|
|
45
|
+
};
|
|
33
46
|
}
|
|
34
47
|
export declare function computeStats(): StatsSnapshot;
|
|
35
48
|
export declare function renderStats(s: StatsSnapshot): string;
|